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,6 +1,10 @@
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/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';
@@ -31,7 +35,10 @@ class GroupData {
/// Groups list page using the shared DataGrid component.
class GroupsListPage extends ConsumerStatefulWidget {
const GroupsListPage({super.key});
const GroupsListPage({super.key, this.routerState});
/// Router state for URL deep-linking.
final GoRouterState? routerState;
@override
ConsumerState<GroupsListPage> createState() => _GroupsListPageState();
@@ -40,11 +47,19 @@ class GroupsListPage extends ConsumerStatefulWidget {
class _GroupsListPageState extends ConsumerState<GroupsListPage> {
late final StateNotifierProvider<DataGridController<GroupData>,
DataGridState<GroupData>> _gridProvider;
late PageUrlState _urlState;
Timer? _urlSyncTimer;
bool _isSyncing = false;
@override
void initState() {
super.initState();
// Parse URL state
_urlState = PageUrlState.fromQueryParams(
widget.routerState?.uri.queryParameters ?? {},
);
// Create provider in initState to ensure stable reference
final dio = ref.read(coreApiClientProvider);
final source = CoreApiDataSource<GroupData>(
@@ -53,13 +68,63 @@ class _GroupsListPageState extends ConsumerState<GroupsListPage> {
fromJson: GroupData.fromJson,
);
// Initialize grid with URL state
_gridProvider = dataGridProvider<GroupData>(
source: source,
config: _buildConfig(),
idSelector: (g) => g.id,
initialSearch: _urlState.search,
initialSortColumnIndex: _columnIndexForId(_urlState.sortColumn),
initialSortDescending: _urlState.sortDescending,
);
}
@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 params = PageUrlState(
search: state.searchQuery.isEmpty ? null : state.searchQuery,
sortColumn: state.sortColumnIndex != null
? _columnIdForIndex(state.sortColumnIndex!)
: null,
sortDescending: state.sortDescending,
).toQueryParams();
updateBrowserUrlParams(params);
}
Future<void> _syncFromAuthentik() async {
if (_isSyncing) return;
setState(() => _isSyncing = true);
@@ -96,6 +161,7 @@ class _GroupsListPageState extends ConsumerState<GroupsListPage> {
return DataGridConfig<GroupData>(
columns: [
DataGridColumn<GroupData>(
id: 'name',
header: 'Name',
valueBuilder: (g) => g.name,
sortable: true,
@@ -103,18 +169,21 @@ class _GroupsListPageState extends ConsumerState<GroupsListPage> {
width: const DataGridColumnWidth.flex(2),
),
DataGridColumn<GroupData>(
id: 'members',
header: 'Members',
valueBuilder: (g) => g.memberCount.toString(),
width: const DataGridColumnWidth.fixed(100),
alignment: DataGridColumnAlignment.end,
),
DataGridColumn<GroupData>(
id: 'type',
header: 'Type',
valueBuilder: (g) => g.isSuperuser ? 'Superuser' : 'Standard',
cellBuilder: (context, g) => _GroupTypeBadge(isSuperuser: g.isSuperuser),
width: const DataGridColumnWidth.fixed(120),
),
DataGridColumn<GroupData>(
id: 'parent',
header: 'Parent',
valueBuilder: (g) => g.parentName ?? '-',
width: const DataGridColumnWidth.flex(1),
@@ -129,6 +198,15 @@ class _GroupsListPageState extends ConsumerState<GroupsListPage> {
@override
Widget build(BuildContext context) {
// 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();
}
});
return DataGrid<GroupData>(
provider: _gridProvider,
config: _buildConfig(),