feat: add URL deep-linking for DataGrids
Build and Push / release (push) Successful in 3s
Build and Push / build (push) Successful in 3m5s

- Add PageUrlState utility for URL ↔ state serialization
- Add column `id` field for unique column identification in URLs
- Update idSelector to return String for URL compatibility
- All DataGrid pages now support URL params: search, sort, order, id
- Browser URL updates via replaceState (no GoRouter rebuilds)
- Add FilterPanelSemantics for filter panel semantic IDs
- Add TESTING.md documentation

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Jeroen Schweitzer
2026-01-05 13:59:08 +01:00
co-authored by Claude Opus 4.5
parent a6c51f757a
commit c494ace5d8
19 changed files with 952 additions and 28 deletions
@@ -1,8 +1,12 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:tatlock_ui/core/api/api_client.dart';
import 'package:tatlock_ui/features/control_room/npm/presentation/pages/proxy_host_page.dart';
import 'package:tatlock_ui/features/control_room/npm/presentation/providers/proxy_hosts_provider.dart';
import 'package:tatlock_ui/routing/url_state.dart';
import 'package:tatlock_ui/shared/components/data_grid/adapters/core_api_source.dart';
import 'package:tatlock_ui/shared/components/data_grid/data_grid_exports.dart';
@@ -33,7 +37,10 @@ class DomainData {
/// Page displaying the list of proxy hosts (domains) from NPM.
class ProxyHostsPage extends ConsumerStatefulWidget {
const ProxyHostsPage({super.key});
const ProxyHostsPage({super.key, this.routerState});
/// Router state for URL deep-linking.
final GoRouterState? routerState;
@override
ConsumerState<ProxyHostsPage> createState() => _ProxyHostsPageState();
@@ -42,10 +49,18 @@ class ProxyHostsPage extends ConsumerStatefulWidget {
class _ProxyHostsPageState extends ConsumerState<ProxyHostsPage> {
late final StateNotifierProvider<DataGridController<DomainData>,
DataGridState<DomainData>> _gridProvider;
late PageUrlState _urlState;
Timer? _urlSyncTimer;
@override
void initState() {
super.initState();
// Parse URL state
_urlState = PageUrlState.fromQueryParams(
widget.routerState?.uri.queryParameters ?? {},
);
final dio = ref.read(coreApiClientProvider);
final source = CoreApiDataSource<DomainData>(
dio: dio,
@@ -53,17 +68,80 @@ class _ProxyHostsPageState extends ConsumerState<ProxyHostsPage> {
fromJson: DomainData.fromJson,
);
// Initialize grid with URL state
_gridProvider = dataGridProvider<DomainData>(
source: source,
config: _buildConfig(),
idSelector: (d) => d.proxyHostId.toString(),
initialSearch: _urlState.search,
initialSortColumnIndex: _columnIndexForId(_urlState.sortColumn),
initialSortDescending: _urlState.sortDescending,
);
// Open document from URL if id present
if (_urlState.id != null) {
final id = int.tryParse(_urlState.id!);
if (id != null) {
WidgetsBinding.instance.addPostFrameCallback((_) {
ref.read(selectedProxyHostProvider.notifier).select(id);
});
}
}
}
@override
void dispose() {
_urlSyncTimer?.cancel();
super.dispose();
}
/// Find column index by column ID.
int? _columnIndexForId(String? columnId) {
if (columnId == null) return null;
final columns = _buildConfig().columns;
for (var i = 0; i < columns.length; i++) {
if (columns[i].id == columnId) return i;
}
return null;
}
/// Get column ID by index.
String? _columnIdForIndex(int index) {
final columns = _buildConfig().columns;
if (index >= 0 && index < columns.length) {
return columns[index].id;
}
return null;
}
/// Schedule URL sync with debounce.
void _scheduleUrlSync() {
_urlSyncTimer?.cancel();
_urlSyncTimer = Timer(const Duration(milliseconds: 500), _syncUrlParams);
}
/// Sync current state to URL.
void _syncUrlParams() {
final state = ref.read(_gridProvider);
final selectedId = ref.read(selectedProxyHostProvider);
final params = PageUrlState(
id: selectedId?.toString(),
search: state.searchQuery.isEmpty ? null : state.searchQuery,
sortColumn: state.sortColumnIndex != null
? _columnIdForIndex(state.sortColumnIndex!)
: null,
sortDescending: state.sortDescending,
).toQueryParams();
updateBrowserUrlParams(params);
}
DataGridConfig<DomainData> _buildConfig() {
return DataGridConfig<DomainData>(
columns: [
DataGridColumn<DomainData>(
id: 'domain',
header: 'Domain',
valueBuilder: (d) => d.domain,
sortable: true,
@@ -72,12 +150,14 @@ class _ProxyHostsPageState extends ConsumerState<ProxyHostsPage> {
cellBuilder: (context, d) => _DomainCell(domain: d),
),
DataGridColumn<DomainData>(
id: 'service',
header: 'Service',
valueBuilder: (d) => d.service,
width: const DataGridColumnWidth.flex(1),
cellBuilder: (context, d) => _ServiceCell(service: d.service),
),
DataGridColumn<DomainData>(
id: 'ssl',
header: 'SSL',
valueBuilder: (d) => d.sslEnabled ? 'Enabled' : 'Disabled',
width: const DataGridColumnWidth.fixed(100),
@@ -101,6 +181,8 @@ class _ProxyHostsPageState extends ConsumerState<ProxyHostsPage> {
void _viewDomain(DomainData domain) {
ref.read(selectedProxyHostProvider.notifier).select(domain.proxyHostId);
// Navigate with id to enable back button
context.go('/control-room/proxy-hosts?id=${domain.proxyHostId}');
}
void _createNew() {
@@ -112,6 +194,15 @@ class _ProxyHostsPageState extends ConsumerState<ProxyHostsPage> {
final selectedId = ref.watch(selectedProxyHostProvider);
final isCreating = ref.watch(creatingProxyHostProvider);
// Listen for grid state changes to sync URL
ref.listen(_gridProvider, (previous, next) {
if (previous?.searchQuery != next.searchQuery ||
previous?.sortColumnIndex != next.sortColumnIndex ||
previous?.sortDescending != next.sortDescending) {
_scheduleUrlSync();
}
});
// Show create page if creating new
if (isCreating) {
return ProxyHostPage.create(
@@ -128,6 +219,8 @@ class _ProxyHostsPageState extends ConsumerState<ProxyHostsPage> {
proxyHostId: selectedId,
onClose: () {
ref.read(selectedProxyHostProvider.notifier).clear();
// Clear id from URL
context.go('/control-room/proxy-hosts');
ref.read(_gridProvider.notifier).refresh();
},
);