From c494ace5d88e698aa7fdf3e16f21635f2a4da261 Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Mon, 5 Jan 2026 13:59:08 +0100 Subject: [PATCH] feat: add URL deep-linking for DataGrids MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- CHANGELOG.md | 23 ++ docs/ARCHITECTURE.md | 26 ++ docs/TESTING.md | 368 ++++++++++++++++++ lib/core/semantics/semantic_ids.dart | 11 + .../pages/containers_list_page.dart | 79 +++- .../presentation/pages/proxy_hosts_page.dart | 95 ++++- .../presentation/pages/control_room_page.dart | 28 +- lib/features/control_room/router.dart | 7 +- .../security/groups/groups_list_page.dart | 80 +++- .../presentation/pages/security_page.dart | 13 +- lib/features/security/router.dart | 7 +- .../security/users/users_list_page.dart | 79 +++- lib/routing/url_state.dart | 98 +++++ lib/routing/url_state_stub.dart | 4 + lib/routing/url_state_web.dart | 13 + .../data_grid/data_grid_column.dart | 4 + .../data_grid/data_grid_provider.dart | 41 +- .../components/data_grid/data_grid_state.dart | 2 +- pubspec.yaml | 2 +- 19 files changed, 952 insertions(+), 28 deletions(-) create mode 100644 docs/TESTING.md create mode 100644 lib/routing/url_state.dart create mode 100644 lib/routing/url_state_stub.dart create mode 100644 lib/routing/url_state_web.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index 98e81ee..00d0fc1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,29 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [1.3.0] - 2026-01-05 + +### Added +- **URL deep-linking for DataGrids** - State is now reflected in URL query parameters + - `?search=` - DataGrid search query + - `?sort=` - Column ID for sorting + - `?order=desc` - Sort direction + - `?id=` - Opened document ID (proxy hosts page) +- `PageUrlState` utility class (`lib/routing/url_state.dart`) for URL ↔ state serialization +- Browser URL updates via `replaceState` without triggering GoRouter rebuilds +- `id` field added to `DataGridColumn` for unique column identification in URLs +- `FilterPanelSemantics` class for filter panel semantic IDs +- TESTING.md documentation for semantic widgets and automation testing + +### Changed +- `idSelector` in DataGridController now returns `String` (was `Object`) for URL compatibility +- All DataGrid pages now support URL deep-linking: + - Containers list (`/control-room/containers`) + - Proxy hosts (`/control-room/proxy-hosts`) + - Users (`/security/users`) + - Groups (`/security/groups`) +- Router passes `GoRouterState` to pages for query parameter access + ## [1.2.0] - 2026-01-05 ### Added diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index d9b6621..10376bc 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -20,6 +20,7 @@ lib/ │ ├── auth/ # Authentik OIDC integration │ ├── config/ # Environment configuration │ ├── error/ # Error types and handling +│ ├── semantics/ # Semantic IDs for automation │ └── theme/ # Material 3 theming ├── routing/ # go_router configuration ├── shared/ # Reusable components @@ -544,3 +545,28 @@ import 'package:tatlock_ui/features/containers/domain/entities/container.dart'; // Bad - importing model in presentation import '../data/models/container_model.dart'; // Don't do this ``` + +## Semantic Identifiers for Automation + +All interactive widgets should have semantic identifiers for UI automation. This enables reliable testing with Puppeteer, Appium, and other automation tools. + +### Quick Reference + +```dart +import 'package:tatlock_ui/core/semantics/semantic_ids.dart'; + +// Wrap interactive widgets with Semantics +Semantics( + identifier: DataGridSemantics.row(item.id), + label: 'Select ${item.name}', + child: MyRowWidget(item: item), +) +``` + +### Key Points + +1. **Central ID Registry** - All IDs defined in `lib/core/semantics/semantic_ids.dart` +2. **Naming Convention** - `{area}_{component}_{identifier}` (e.g., `dataGrid_row_abc123`) +3. **Web Enabled** - Semantics tree exposed via `SemanticsBinding.instance.ensureSemantics()` in `main.dart` + +For complete documentation on semantic patterns, automation queries, and best practices, see **[TESTING.md](./TESTING.md)**. diff --git a/docs/TESTING.md b/docs/TESTING.md new file mode 100644 index 0000000..a4d6aba --- /dev/null +++ b/docs/TESTING.md @@ -0,0 +1,368 @@ +# Testing & Automation Guide + +This document covers automated testing patterns for Tatlock UI, focusing on semantic identifiers that enable reliable UI automation. + +## Overview + +Tatlock UI uses Flutter's **Semantics tree** to expose stable identifiers for automated testing. These identifiers are accessible to: + +- **Puppeteer** (via Chrome DevTools accessibility API) +- **Appium** (via accessibility labels) +- **WebDriver** (via ARIA attributes) +- **Flutter integration tests** + +The semantics system is enabled on web in `main.dart`: + +```dart +if (kIsWeb) { + SemanticsBinding.instance.ensureSemantics(); +} +``` + +## Semantic Identifiers + +All semantic IDs are centralized in `lib/core/semantics/semantic_ids.dart`. This provides: + +1. **Stable selectors** - IDs don't change with UI refactoring +2. **Type safety** - Compile-time verification of ID usage +3. **Discoverability** - Single source of truth for automation targets + +### Available ID Classes + +| Class | Purpose | Example IDs | +|-------|---------|-------------| +| `ProfileSemantics` | User profile dropdown | `profile_button`, `profile_menu_settings` | +| `RoomTabSemantics` | Main navigation tabs | `roomTab_frontHall`, `roomTab_controlRoom` | +| `NavSemantics` | Side navigation panel | `nav_panel`, `nav_item_{id}` | +| `DataGridSemantics` | Data tables | `dataGrid_row_{id}`, `dataGrid_search` | +| `DialogSemantics` | Modal dialogs | `dialog_confirm`, `dialog_cancel` | +| `SettingsSemantics` | Settings page | `settings_theme`, `settings_defaultRoom` | +| `StateSemantics` | Loading/error states | `state_auth_loading`, `snackbar_{type}` | + +### ID Naming Convention + +``` +{area}_{component}_{identifier} +``` + +- **area**: Feature or section (e.g., `profile`, `nav`, `dataGrid`) +- **component**: Widget type (e.g., `menu`, `button`, `row`) +- **identifier**: Specific item (e.g., `light`, `settings`, `selectAll`) + +Examples: +- `profile_menu_theme_dark` - Dark theme option in profile menu +- `dataGrid_row_abc123` - Row with ID "abc123" in data grid +- `nav_item_containers` - Containers nav item + +## Adding Semantics to Widgets + +### Method 1: Direct Semantics Widget + +Use Flutter's `Semantics` widget with the `identifier` property: + +```dart +import 'package:tatlock_ui/core/semantics/semantic_ids.dart'; + +Semantics( + identifier: ProfileSemantics.button, + label: 'Open profile menu', + button: true, + child: IconButton( + icon: Icon(Icons.person), + onPressed: () => ..., + ), +) +``` + +### Method 2: SemanticWidget Wrapper + +Use the convenience wrapper from `lib/core/semantics/semantic_widget.dart`: + +```dart +import 'package:tatlock_ui/core/semantics/semantic_ids.dart'; +import 'package:tatlock_ui/core/semantics/semantic_widget.dart'; + +SemanticWidget( + id: DataGridSemantics.search, + label: 'Search data grid', + textField: true, + child: TextField( + decoration: InputDecoration(hintText: 'Search...'), + ), +) +``` + +### Method 3: Extension Method + +Use the `withSemantics` extension for inline wrapping: + +```dart +TextField( + decoration: InputDecoration(hintText: 'Search...'), +).withSemantics( + id: DataGridSemantics.search, + label: 'Search data grid', +) +``` + +### Dynamic IDs + +For lists and grids, use the generator methods: + +```dart +// Row in a data grid +Semantics( + identifier: DataGridSemantics.row(item.id), // "dataGrid_row_abc123" + child: DataGridRow(item: item), +) + +// Navigation item +Semantics( + identifier: NavSemantics.item(route.id), // "nav_item_containers" + child: NavItem(route: route), +) + +// Bulk action button +Semantics( + identifier: DataGridSemantics.bulkAction('delete'), // "dataGrid_bulk_delete" + child: IconButton(icon: Icon(Icons.delete), ...), +) +``` + +## Querying from Puppeteer + +Puppeteer can query semantic identifiers via Chrome's accessibility tree: + +```javascript +// Connect to Chrome with DevTools protocol +const browser = await puppeteer.connect({ + browserURL: 'http://localhost:9222' +}); +const page = await browser.newPage(); + +// Get accessibility snapshot +const snapshot = await page.accessibility.snapshot({ interestingOnly: false }); + +// Find element by semantic identifier +function findBySemanticId(node, id) { + if (node.name === id || node.description === id) { + return node; + } + for (const child of node.children || []) { + const found = findBySemanticId(child, id); + if (found) return found; + } + return null; +} + +// Example: Find profile button +const profileButton = findBySemanticId(snapshot, 'profile_button'); + +// Example: Find a specific data grid row +const row = findBySemanticId(snapshot, 'dataGrid_row_abc123'); +``` + +### Using Chrome DevTools MCP + +With the Chrome DevTools MCP server, you can query semantics directly: + +```javascript +// Take a snapshot (returns accessibility tree) +const snapshot = await mcp__chrome_devtools__take_snapshot(); + +// Click by semantic ID (uid in snapshot) +await mcp__chrome_devtools__click({ uid: 'profile_button' }); + +// Fill input by semantic ID +await mcp__chrome_devtools__fill({ + uid: 'dataGrid_search', + value: 'my search query' +}); +``` + +## Best Practices + +### 1. Add Semantics to Interactive Elements + +Every clickable, tappable, or input element should have a semantic identifier: + +```dart +// Buttons +Semantics( + identifier: 'myFeature_submit', + button: true, + label: 'Submit form', + child: ElevatedButton(...), +) + +// Text fields +Semantics( + identifier: 'myFeature_email', + textField: true, + label: 'Email address', + child: TextField(...), +) + +// Checkboxes +Semantics( + identifier: 'myFeature_rememberMe', + checked: isChecked, + label: 'Remember me', + child: Checkbox(...), +) +``` + +### 2. Use Meaningful Labels + +Labels help both accessibility tools and test debugging: + +```dart +// Good - descriptive label +Semantics( + identifier: DataGridSemantics.rowAction(item.id, 'delete'), + label: 'Delete ${item.name}', + button: true, + child: ..., +) + +// Bad - no context +Semantics( + identifier: 'btn1', + child: ..., +) +``` + +### 3. Register New IDs Centrally + +Always add new semantic IDs to `semantic_ids.dart`: + +```dart +/// My new feature IDs. +abstract class MyFeatureSemantics { + static const submitButton = 'myFeature_submit'; + static const cancelButton = 'myFeature_cancel'; + static const nameField = 'myFeature_name'; + + /// Generate ID for a list item. + static String item(String id) => 'myFeature_item_$id'; +} +``` + +### 4. Test ID Stability + +Semantic IDs should remain stable across releases. When refactoring: + +- Keep existing IDs unchanged +- Add deprecation comments if IDs must change +- Update automation tests when IDs change + +### 5. Exclude Decorative Elements + +Don't add semantic IDs to purely decorative elements: + +```dart +// Decorative icon - no semantics needed +Icon(Icons.star, color: Colors.yellow) + +// Interactive icon - needs semantics +Semantics( + identifier: 'rating_star_3', + button: true, + label: 'Rate 3 stars', + child: IconButton( + icon: Icon(Icons.star), + onPressed: () => rate(3), + ), +) +``` + +## DataGrid Semantic Patterns + +The DataGrid component has comprehensive semantic coverage: + +``` +dataGrid - The grid container +dataGrid_search - Search input field +dataGrid_search_clear - Clear search button +dataGrid_selectAll - Select all checkbox +dataGrid_header_{columnId} - Column header (sortable) +dataGrid_row_{itemId} - Row container +dataGrid_row_{itemId}_checkbox - Row selection checkbox +dataGrid_row_{itemId}_actions - Row actions menu trigger +dataGrid_row_{itemId}_action_{actionId} - Specific row action +dataGrid_bulk_{actionId} - Bulk action button +dataGrid_bulk_clear - Clear selection button +dataGrid_loading - Loading indicator +dataGrid_empty - Empty state message +dataGrid_error - Error state message +dataGrid_refresh - Refresh button +``` + +### Example: Automating DataGrid Selection + +```javascript +// Select all rows +await click('dataGrid_selectAll'); + +// Select specific row +await click('dataGrid_row_abc123_checkbox'); + +// Perform bulk delete +await click('dataGrid_bulk_delete'); + +// Confirm in dialog +await click('dialog_confirm'); +``` + +## Debugging Semantics + +### Flutter DevTools + +1. Open Flutter DevTools +2. Go to "Inspector" tab +3. Enable "Semantics" overlay +4. Click widgets to see their semantic properties + +### Chrome DevTools + +1. Open DevTools (F12) +2. Go to "Accessibility" tab +3. Inspect the accessibility tree +4. Search for semantic identifiers + +### Programmatic Inspection + +```dart +// In a test, dump the semantics tree +debugDumpSemanticsTree(); + +// Check if semantics are enabled +print('Semantics enabled: ${SemanticsBinding.instance.semanticsEnabled}'); +``` + +## Integration with URL Routing + +For deep-linkable test scenarios, semantic IDs work with URL query parameters: + +``` +/control-room/containers?selected=abc123 +``` + +Automation can: +1. Navigate to URL with query params +2. Verify selection state via `dataGrid_row_abc123_checkbox` (checked: true) +3. Interact with selected rows via semantic IDs + +See [URL Routing](#url-routing) section for query parameter patterns. + +## Checklist for New Features + +When adding a new feature, ensure semantic coverage: + +- [ ] Add semantic ID class to `semantic_ids.dart` +- [ ] Wrap all buttons with `Semantics` + `identifier` +- [ ] Wrap all inputs with `Semantics` + `identifier` +- [ ] Wrap list/grid items with dynamic IDs +- [ ] Add labels for accessibility +- [ ] Test that IDs appear in accessibility snapshot +- [ ] Document IDs in this file if they establish new patterns diff --git a/lib/core/semantics/semantic_ids.dart b/lib/core/semantics/semantic_ids.dart index e2fb12b..d7bd213 100644 --- a/lib/core/semantics/semantic_ids.dart +++ b/lib/core/semantics/semantic_ids.dart @@ -103,6 +103,17 @@ abstract class SettingsSemantics { static const defaultRoomDropdown = 'settings_defaultRoom'; } +/// Filter panel IDs. +abstract class FilterPanelSemantics { + static const panel = 'filterPanel'; + static const search = 'filterPanel_search'; + static const searchClear = 'filterPanel_search_clear'; + static const refresh = 'filterPanel_refresh'; + + /// Generate ID for a filter item. + static String item(String id) => 'filterPanel_item_$id'; +} + /// Loading/state indicator IDs. abstract class StateSemantics { static const authLoading = 'state_auth_loading'; diff --git a/lib/features/control_room/containers/presentation/pages/containers_list_page.dart b/lib/features/control_room/containers/presentation/pages/containers_list_page.dart index 1a342a1..198a793 100644 --- a/lib/features/control_room/containers/presentation/pages/containers_list_page.dart +++ b/lib/features/control_room/containers/presentation/pages/containers_list_page.dart @@ -1,9 +1,13 @@ +import 'dart:async'; + import 'package:flutter/material.dart' hide Container; 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/containers/presentation/providers/containers_provider.dart'; import 'package:tatlock_ui/features/control_room/containers/presentation/widgets/container_logs_viewer.dart'; import 'package:tatlock_ui/features/control_room/containers/presentation/widgets/container_status_badge.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'; @@ -101,7 +105,10 @@ class ContainerPort { /// Page displaying the list of containers using DataGrid. class ContainersListPage extends ConsumerStatefulWidget { - const ContainersListPage({super.key}); + const ContainersListPage({super.key, this.routerState}); + + /// Router state for URL deep-linking. + final GoRouterState? routerState; @override ConsumerState createState() => _ContainersListPageState(); @@ -110,10 +117,18 @@ class ContainersListPage extends ConsumerStatefulWidget { class _ContainersListPageState extends ConsumerState { late final StateNotifierProvider, DataGridState> _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( dio: dio, @@ -121,17 +136,68 @@ class _ContainersListPageState extends ConsumerState { fromJson: ContainerData.fromJson, ); + // Initialize grid with URL state _gridProvider = dataGridProvider( source: source, config: _buildConfig(), idSelector: (c) => c.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); + } + DataGridConfig _buildConfig() { return DataGridConfig( columns: [ DataGridColumn( + id: 'container', header: 'Container', valueBuilder: (c) => '${c.name} ${c.image}', sortable: true, @@ -140,12 +206,14 @@ class _ContainersListPageState extends ConsumerState { cellBuilder: (context, c) => _ContainerCell(container: c), ), DataGridColumn( + id: 'ports', header: 'Ports', valueBuilder: (c) => c.ports.map((p) => p.formatted).join(', '), width: const DataGridColumnWidth.flex(1), cellBuilder: (context, c) => _PortsCell(ports: c.ports), ), DataGridColumn( + id: 'status', header: 'Status', valueBuilder: (c) => c.displayStatus, width: const DataGridColumnWidth.fixed(160), @@ -211,6 +279,15 @@ class _ContainersListPageState extends ConsumerState { Widget build(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; + // 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(); + } + }); + // Listen for container action results to show snackbars ref.listen>(containerActionsProvider, (previous, next) { if (previous?.isLoading == true && !next.isLoading) { diff --git a/lib/features/control_room/npm/presentation/pages/proxy_hosts_page.dart b/lib/features/control_room/npm/presentation/pages/proxy_hosts_page.dart index eb79d8d..c579de2 100644 --- a/lib/features/control_room/npm/presentation/pages/proxy_hosts_page.dart +++ b/lib/features/control_room/npm/presentation/pages/proxy_hosts_page.dart @@ -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 createState() => _ProxyHostsPageState(); @@ -42,10 +49,18 @@ class ProxyHostsPage extends ConsumerStatefulWidget { class _ProxyHostsPageState extends ConsumerState { late final StateNotifierProvider, DataGridState> _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( dio: dio, @@ -53,17 +68,80 @@ class _ProxyHostsPageState extends ConsumerState { fromJson: DomainData.fromJson, ); + // Initialize grid with URL state _gridProvider = dataGridProvider( 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 _buildConfig() { return DataGridConfig( columns: [ DataGridColumn( + id: 'domain', header: 'Domain', valueBuilder: (d) => d.domain, sortable: true, @@ -72,12 +150,14 @@ class _ProxyHostsPageState extends ConsumerState { cellBuilder: (context, d) => _DomainCell(domain: d), ), DataGridColumn( + id: 'service', header: 'Service', valueBuilder: (d) => d.service, width: const DataGridColumnWidth.flex(1), cellBuilder: (context, d) => _ServiceCell(service: d.service), ), DataGridColumn( + id: 'ssl', header: 'SSL', valueBuilder: (d) => d.sslEnabled ? 'Enabled' : 'Disabled', width: const DataGridColumnWidth.fixed(100), @@ -101,6 +181,8 @@ class _ProxyHostsPageState extends ConsumerState { 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 { 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 { proxyHostId: selectedId, onClose: () { ref.read(selectedProxyHostProvider.notifier).clear(); + // Clear id from URL + context.go('/control-room/proxy-hosts'); ref.read(_gridProvider.notifier).refresh(); }, ); diff --git a/lib/features/control_room/presentation/pages/control_room_page.dart b/lib/features/control_room/presentation/pages/control_room_page.dart index 17b6c4b..ba0470e 100644 --- a/lib/features/control_room/presentation/pages/control_room_page.dart +++ b/lib/features/control_room/presentation/pages/control_room_page.dart @@ -17,11 +17,15 @@ class ControlRoomPage extends ConsumerWidget { const ControlRoomPage({ super.key, this.nav = ControlRoomNav.containers, + this.routerState, }); /// The current nav item to display. final ControlRoomNav nav; + /// Router state for URL deep-linking (query params). + final GoRouterState? routerState; + @override Widget build(BuildContext context, WidgetRef ref) { final colorScheme = Theme.of(context).colorScheme; @@ -53,7 +57,7 @@ class ControlRoomPage extends ConsumerWidget { ), // Section content Expanded( - child: _SectionContent(nav: nav), + child: _SectionContent(nav: nav, routerState: routerState), ), ], ), @@ -63,15 +67,16 @@ class ControlRoomPage extends ConsumerWidget { /// Renders content for the selected nav item. class _SectionContent extends ConsumerWidget { - const _SectionContent({required this.nav}); + const _SectionContent({required this.nav, this.routerState}); final ControlRoomNav nav; + final GoRouterState? routerState; @override Widget build(BuildContext context, WidgetRef ref) { return switch (nav) { - ControlRoomNav.containers => const _ContainersSection(), - ControlRoomNav.proxyHosts => const ProxyHostsPage(), + ControlRoomNav.containers => _ContainersSection(routerState: routerState), + ControlRoomNav.proxyHosts => ProxyHostsPage(routerState: routerState), _ => _PlaceholderSection(nav: nav), }; } @@ -119,7 +124,9 @@ class _PlaceholderSection extends StatelessWidget { /// Containers section with optional stack filter. class _ContainersSection extends ConsumerWidget { - const _ContainersSection(); + const _ContainersSection({this.routerState}); + + final GoRouterState? routerState; @override Widget build(BuildContext context, WidgetRef ref) { @@ -129,7 +136,7 @@ class _ContainersSection extends ConsumerWidget { return Row( children: [ // Stacks filter panel - const _StacksFilterPanel(), + _StacksFilterPanel(routerState: routerState), // Divider VerticalDivider( width: 1, @@ -139,7 +146,7 @@ class _ContainersSection extends ConsumerWidget { // Main content - containers list or stack detail Expanded( child: selectedStack == null - ? const ContainersListPage() + ? ContainersListPage(routerState: routerState) : StackDetailPage(stackId: selectedStack), ), ], @@ -149,7 +156,9 @@ class _ContainersSection extends ConsumerWidget { /// Stacks filter panel for Containers section (includes "All Containers" option). class _StacksFilterPanel extends ConsumerWidget { - const _StacksFilterPanel(); + const _StacksFilterPanel({this.routerState}); + + final GoRouterState? routerState; @override Widget build(BuildContext context, WidgetRef ref) { @@ -207,6 +216,7 @@ class _StacksFilterPanel extends ConsumerWidget { data: (stacks) => _StacksList( stacks: stacks, selectedStackId: selectedStack, + routerState: routerState, onStackSelected: (id) => ref.read(selectedStackProvider.notifier).select(id), onStackAction: (id, action) => @@ -281,12 +291,14 @@ class _StacksList extends StatefulWidget { required this.selectedStackId, required this.onStackSelected, required this.onStackAction, + this.routerState, }); final List stacks; final String? selectedStackId; final void Function(String) onStackSelected; final void Function(String, String) onStackAction; + final GoRouterState? routerState; @override State<_StacksList> createState() => _StacksListState(); diff --git a/lib/features/control_room/router.dart b/lib/features/control_room/router.dart index 1bf3af6..ebddfbe 100644 --- a/lib/features/control_room/router.dart +++ b/lib/features/control_room/router.dart @@ -63,8 +63,11 @@ List controlRoomRoutes() { GoRoute( path: nav.path, name: 'controlRoom${_capitalize(nav.id.replaceAll('-', '_'))}', - pageBuilder: (context, state) => - noTransitionPage(context, state, ControlRoomPage(nav: nav)), + pageBuilder: (context, state) => noTransitionPage( + context, + state, + ControlRoomPage(nav: nav, routerState: state), + ), ), ]; } diff --git a/lib/features/security/groups/groups_list_page.dart b/lib/features/security/groups/groups_list_page.dart index 94bc574..a0f096c 100644 --- a/lib/features/security/groups/groups_list_page.dart +++ b/lib/features/security/groups/groups_list_page.dart @@ -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 createState() => _GroupsListPageState(); @@ -40,11 +47,19 @@ class GroupsListPage extends ConsumerStatefulWidget { class _GroupsListPageState extends ConsumerState { late final StateNotifierProvider, DataGridState> _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( @@ -53,13 +68,63 @@ class _GroupsListPageState extends ConsumerState { fromJson: GroupData.fromJson, ); + // Initialize grid with URL state _gridProvider = dataGridProvider( 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 _syncFromAuthentik() async { if (_isSyncing) return; setState(() => _isSyncing = true); @@ -96,6 +161,7 @@ class _GroupsListPageState extends ConsumerState { return DataGridConfig( columns: [ DataGridColumn( + id: 'name', header: 'Name', valueBuilder: (g) => g.name, sortable: true, @@ -103,18 +169,21 @@ class _GroupsListPageState extends ConsumerState { width: const DataGridColumnWidth.flex(2), ), DataGridColumn( + id: 'members', header: 'Members', valueBuilder: (g) => g.memberCount.toString(), width: const DataGridColumnWidth.fixed(100), alignment: DataGridColumnAlignment.end, ), DataGridColumn( + id: 'type', header: 'Type', valueBuilder: (g) => g.isSuperuser ? 'Superuser' : 'Standard', cellBuilder: (context, g) => _GroupTypeBadge(isSuperuser: g.isSuperuser), width: const DataGridColumnWidth.fixed(120), ), DataGridColumn( + id: 'parent', header: 'Parent', valueBuilder: (g) => g.parentName ?? '-', width: const DataGridColumnWidth.flex(1), @@ -129,6 +198,15 @@ class _GroupsListPageState extends ConsumerState { @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( provider: _gridProvider, config: _buildConfig(), diff --git a/lib/features/security/presentation/pages/security_page.dart b/lib/features/security/presentation/pages/security_page.dart index 903d90d..d212696 100644 --- a/lib/features/security/presentation/pages/security_page.dart +++ b/lib/features/security/presentation/pages/security_page.dart @@ -11,11 +11,15 @@ class SecurityPage extends ConsumerWidget { const SecurityPage({ super.key, this.nav = SecurityNav.users, + this.routerState, }); /// The current nav item to display. final SecurityNav nav; + /// Router state for URL deep-linking (query params). + final GoRouterState? routerState; + @override Widget build(BuildContext context, WidgetRef ref) { final colorScheme = Theme.of(context).colorScheme; @@ -45,7 +49,7 @@ class SecurityPage extends ConsumerWidget { ), // Section content Expanded( - child: _SectionContent(nav: nav), + child: _SectionContent(nav: nav, routerState: routerState), ), ], ), @@ -55,17 +59,18 @@ class SecurityPage extends ConsumerWidget { /// Renders content for the selected nav item. class _SectionContent extends StatelessWidget { - const _SectionContent({required this.nav}); + const _SectionContent({required this.nav, this.routerState}); final SecurityNav nav; + final GoRouterState? routerState; @override Widget build(BuildContext context) { switch (nav) { case SecurityNav.users: - return const UsersListPage(); + return UsersListPage(routerState: routerState); case SecurityNav.groups: - return const GroupsListPage(); + return GroupsListPage(routerState: routerState); default: return _PlaceholderSection(nav: nav); } diff --git a/lib/features/security/router.dart b/lib/features/security/router.dart index 3ebd06a..764b1fa 100644 --- a/lib/features/security/router.dart +++ b/lib/features/security/router.dart @@ -61,8 +61,11 @@ List securityRoutes() { GoRoute( path: nav.path, name: 'security${_capitalize(nav.id.replaceAll('-', '_'))}', - pageBuilder: (context, state) => - noTransitionPage(context, state, SecurityPage(nav: nav)), + pageBuilder: (context, state) => noTransitionPage( + context, + state, + SecurityPage(nav: nav, routerState: state), + ), ), ]; } diff --git a/lib/features/security/users/users_list_page.dart b/lib/features/security/users/users_list_page.dart index 6d54078..a1302ce 100644 --- a/lib/features/security/users/users_list_page.dart +++ b/lib/features/security/users/users_list_page.dart @@ -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'; @@ -61,7 +65,10 @@ class UserData { /// Users list page using the shared DataGrid component. class UsersListPage extends ConsumerStatefulWidget { - const UsersListPage({super.key}); + const UsersListPage({super.key, this.routerState}); + + /// Router state for URL deep-linking. + final GoRouterState? routerState; @override ConsumerState createState() => _UsersListPageState(); @@ -70,11 +77,19 @@ class UsersListPage extends ConsumerStatefulWidget { class _UsersListPageState extends ConsumerState { late final StateNotifierProvider, DataGridState> _gridProvider; + late PageUrlState _urlState; + Timer? _urlSyncTimer; bool _isSyncing = false; @override void initState() { super.initState(); + + // Parse URL state + _urlState = PageUrlState.fromQueryParams( + widget.routerState?.uri.queryParameters ?? {}, + ); + final dio = ref.read(coreApiClientProvider); final source = CoreApiDataSource( dio: dio, @@ -82,13 +97,63 @@ class _UsersListPageState extends ConsumerState { fromJson: UserData.fromJson, ); + // Initialize grid with URL state _gridProvider = dataGridProvider( source: source, config: _buildConfig(), idSelector: (u) => u.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 _syncFromAuthentik() async { if (_isSyncing) return; setState(() => _isSyncing = true); @@ -125,6 +190,7 @@ class _UsersListPageState extends ConsumerState { return DataGridConfig( columns: [ DataGridColumn( + id: 'user', header: 'User', valueBuilder: (u) => u.name, sortable: true, @@ -133,12 +199,14 @@ class _UsersListPageState extends ConsumerState { cellBuilder: (context, u) => _UserCell(user: u), ), DataGridColumn( + id: 'roles', header: 'Roles', valueBuilder: (u) => u.roles.join(', '), width: const DataGridColumnWidth.flex(1), cellBuilder: (context, u) => _RolesCell(roles: u.roles), ), DataGridColumn( + id: 'lastLogin', header: 'Last Login', valueBuilder: (u) => u.lastLoginFormatted, width: const DataGridColumnWidth.fixed(120), @@ -154,6 +222,15 @@ class _UsersListPageState extends ConsumerState { @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( provider: _gridProvider, config: _buildConfig(), diff --git a/lib/routing/url_state.dart b/lib/routing/url_state.dart new file mode 100644 index 0000000..0640772 --- /dev/null +++ b/lib/routing/url_state.dart @@ -0,0 +1,98 @@ +import 'package:flutter/foundation.dart' show kIsWeb; + +// Conditional import for web-only functionality +import 'url_state_stub.dart' if (dart.library.js_interop) 'url_state_web.dart' + as platform; + +/// Parses and builds URL query parameters for page state. +/// +/// Used to enable deep-linking for DataGrid and filter panel state. +/// Query params are read once on page load and updated via browser +/// replaceState to avoid GoRouter rebuild loops. +class PageUrlState { + const PageUrlState({ + this.id, + this.filter, + this.search, + this.sortColumn, + this.sortDescending = false, + }); + + /// Opened document ID (view/edit mode). + final String? id; + + /// Filter panel search value. + final String? filter; + + /// DataGrid search value. + final String? search; + + /// Column ID for sorting. + final String? sortColumn; + + /// Sort direction (true = descending). + final bool sortDescending; + + /// Parse from query parameters map. + factory PageUrlState.fromQueryParams(Map params) { + return PageUrlState( + id: params['id'], + filter: params['filter'], + search: params['search'], + sortColumn: params['sort'], + sortDescending: params['order'] == 'desc', + ); + } + + /// Convert to query parameter map (omits empty/default values). + Map toQueryParams() { + return { + if (id != null && id!.isNotEmpty) 'id': id!, + if (filter != null && filter!.isNotEmpty) 'filter': filter!, + if (search != null && search!.isNotEmpty) 'search': search!, + if (sortColumn != null && sortColumn!.isNotEmpty) 'sort': sortColumn!, + if (sortDescending) 'order': 'desc', + }; + } + + /// Create a copy with modified values. + PageUrlState copyWith({ + String? id, + String? filter, + String? search, + String? sortColumn, + bool? sortDescending, + bool clearId = false, + }) { + return PageUrlState( + id: clearId ? null : (id ?? this.id), + filter: filter ?? this.filter, + search: search ?? this.search, + sortColumn: sortColumn ?? this.sortColumn, + sortDescending: sortDescending ?? this.sortDescending, + ); + } + + /// Whether any state is present. + bool get isEmpty => + id == null && + (filter == null || filter!.isEmpty) && + (search == null || search!.isEmpty) && + sortColumn == null; + + @override + String toString() => + 'PageUrlState(id: $id, filter: $filter, search: $search, ' + 'sort: $sortColumn, desc: $sortDescending)'; +} + +/// Updates browser URL with query parameters without triggering navigation. +/// +/// Uses browser's replaceState API on web, no-op on other platforms. +/// This allows the URL to stay in sync for bookmarking/sharing without +/// causing Flutter to rebuild. +void updateBrowserUrlParams(Map params) { + if (kIsWeb) { + platform.updateBrowserUrlParams(params); + } +} diff --git a/lib/routing/url_state_stub.dart b/lib/routing/url_state_stub.dart new file mode 100644 index 0000000..b44a9cd --- /dev/null +++ b/lib/routing/url_state_stub.dart @@ -0,0 +1,4 @@ +/// Stub implementation for non-web platforms. +void updateBrowserUrlParams(Map params) { + // No-op on non-web platforms +} diff --git a/lib/routing/url_state_web.dart b/lib/routing/url_state_web.dart new file mode 100644 index 0000000..fb7729c --- /dev/null +++ b/lib/routing/url_state_web.dart @@ -0,0 +1,13 @@ +import 'package:web/web.dart' as web; + +/// Updates browser URL with query parameters without triggering navigation. +/// +/// Uses browser's replaceState API to update the URL bar for bookmarking +/// without causing Flutter/GoRouter to rebuild the page. +void updateBrowserUrlParams(Map params) { + final currentUri = Uri.parse(web.window.location.href); + final newUri = currentUri.replace( + queryParameters: params.isEmpty ? null : params, + ); + web.window.history.replaceState(null, '', newUri.toString()); +} diff --git a/lib/shared/components/data_grid/data_grid_column.dart b/lib/shared/components/data_grid/data_grid_column.dart index e6255b1..e2ffd52 100644 --- a/lib/shared/components/data_grid/data_grid_column.dart +++ b/lib/shared/components/data_grid/data_grid_column.dart @@ -45,6 +45,7 @@ enum DataGridColumnAlignment { /// Column definition for DataGrid. class DataGridColumn { const DataGridColumn({ + required this.id, required this.header, required this.valueBuilder, this.cellBuilder, @@ -58,6 +59,9 @@ class DataGridColumn { this.tooltip, }); + /// Unique identifier for this column (used in URL deep-linking). + final String id; + /// Column header text. final String header; diff --git a/lib/shared/components/data_grid/data_grid_provider.dart b/lib/shared/components/data_grid/data_grid_provider.dart index 4a49bc3..3bd0723 100644 --- a/lib/shared/components/data_grid/data_grid_provider.dart +++ b/lib/shared/components/data_grid/data_grid_provider.dart @@ -15,9 +15,15 @@ class DataGridController extends StateNotifier> { required this.source, required this.config, required this.idSelector, + String? initialSearch, + int? initialSortColumnIndex, + bool? initialSortDescending, }) : super(DataGridState( - sortColumnIndex: config.defaultSortColumn, - sortDescending: config.defaultSortDescending, + searchQuery: initialSearch ?? '', + sortColumnIndex: + initialSortColumnIndex ?? config.defaultSortColumn, + sortDescending: + initialSortDescending ?? config.defaultSortDescending, )) { // Initial load _load(); @@ -29,8 +35,13 @@ class DataGridController extends StateNotifier> { /// Grid configuration. final DataGridConfig config; - /// Function to extract unique ID from an item. - final Object Function(T item) idSelector; + /// Function to extract unique ID from an item (used for selection and URL deep-linking). + /// + /// Returns a unique string identifier for each row. Can be: + /// - A simple ID field: `(item) => item.id.toString()` + /// - A combination of fields: `(item) => '${item.name}_${item.type}'` + /// - Any unique identifier suitable for URLs + final String Function(T item) idSelector; /// Debounce timer for search. Timer? _searchDebounce; @@ -185,7 +196,7 @@ class DataGridController extends StateNotifier> { if (!config.rowsSelectable) return; final id = idSelector(item); - final newSelection = Set.from(state.selectedIds); + final newSelection = Set.from(state.selectedIds); if (newSelection.contains(id)) { newSelection.remove(id); @@ -287,17 +298,35 @@ class DataGridController extends StateNotifier> { /// idSelector: (c) => c.id, /// ); /// ``` +/// +/// For URL deep-linking, pass initial state from URL params: +/// ```dart +/// final gridProvider = dataGridProvider( +/// source: source, +/// config: config, +/// idSelector: (c) => c.id, +/// initialSearch: urlState.search, +/// initialSortColumnIndex: _columnIndexForId(urlState.sortColumn), +/// initialSortDescending: urlState.sortDescending, +/// ); +/// ``` StateNotifierProvider, DataGridState> dataGridProvider({ required DataGridSource source, required DataGridConfig config, - required Object Function(T) idSelector, + required String Function(T) idSelector, + String? initialSearch, + int? initialSortColumnIndex, + bool? initialSortDescending, }) { return StateNotifierProvider, DataGridState>( (ref) => DataGridController( source: source, config: config, idSelector: idSelector, + initialSearch: initialSearch, + initialSortColumnIndex: initialSortColumnIndex, + initialSortDescending: initialSortDescending, ), ); } diff --git a/lib/shared/components/data_grid/data_grid_state.dart b/lib/shared/components/data_grid/data_grid_state.dart index 38688e5..1ed4399 100644 --- a/lib/shared/components/data_grid/data_grid_state.dart +++ b/lib/shared/components/data_grid/data_grid_state.dart @@ -31,7 +31,7 @@ sealed class DataGridState with _$DataGridState { @Default(false) bool sortDescending, /// Currently selected item IDs (if selectable). - @Default({}) Set selectedIds, + @Default({}) Set selectedIds, /// Current page (for paginated mode). @Default(0) int currentPage, diff --git a/pubspec.yaml b/pubspec.yaml index 3fd3657..8f2fd8e 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html # In Windows, build-name is used as the major, minor, and patch parts # of the product and file versions while build-number is used as the build suffix. -version: 1.2.0+1 +version: 1.3.0+1 environment: sdk: ^3.10.4