diff --git a/kell_creations_apps/apps/kell_web/lib/navigation/app_navigation.dart b/kell_creations_apps/apps/kell_web/lib/navigation/app_navigation.dart index b5ba20f..c4f331b 100644 --- a/kell_creations_apps/apps/kell_web/lib/navigation/app_navigation.dart +++ b/kell_creations_apps/apps/kell_web/lib/navigation/app_navigation.dart @@ -20,24 +20,27 @@ abstract final class AppNavigation { // ── Dashboard → feature handoffs ────────────────────────────────────── /// Dashboard KPI "Total Products" / "In Stock" → Inventory page. - static void dashboardToInventory(BuildContext context, {String? filter}) { + static void dashboardToInventory(BuildContext context, {String? filter, String? query}) { navigateTo( context, - NavigationTarget(route: AppRoutes.inventory, arguments: {'filter': ?filter}), + NavigationTarget(route: AppRoutes.inventory, arguments: {'filter': ?filter, 'query': ?query}), ); } /// Dashboard KPI "Draft" → Products page. - static void dashboardToProducts(BuildContext context, {String? filter}) { + static void dashboardToProducts(BuildContext context, {String? filter, String? query}) { navigateTo( context, - NavigationTarget(route: AppRoutes.products, arguments: {'filter': ?filter}), + NavigationTarget(route: AppRoutes.products, arguments: {'filter': ?filter, 'query': ?query}), ); } /// Dashboard KPI "Total Orders" / "Pending" / "Active" → Orders page. - static void dashboardToOrders(BuildContext context, {String? filter}) { - navigateTo(context, NavigationTarget(route: AppRoutes.orders, arguments: {'filter': ?filter})); + static void dashboardToOrders(BuildContext context, {String? filter, String? query}) { + navigateTo( + context, + NavigationTarget(route: AppRoutes.orders, arguments: {'filter': ?filter, 'query': ?query}), + ); } /// Dashboard KPI "Revenue" → Finance page. diff --git a/kell_creations_apps/apps/kell_web/lib/navigation/navigation_target.dart b/kell_creations_apps/apps/kell_web/lib/navigation/navigation_target.dart index 0d2c318..fe18a23 100644 --- a/kell_creations_apps/apps/kell_web/lib/navigation/navigation_target.dart +++ b/kell_creations_apps/apps/kell_web/lib/navigation/navigation_target.dart @@ -32,6 +32,9 @@ class NavigationTarget { /// Convenience: a category filter, if any. String? get category => arguments['category']; + /// Convenience: a search query, if any. + String? get query => arguments['query']; + @override bool operator ==(Object other) => identical(this, other) || diff --git a/kell_creations_apps/apps/kell_web/lib/routing/app_routes.dart b/kell_creations_apps/apps/kell_web/lib/routing/app_routes.dart index 1cb2c19..c8c9c84 100644 --- a/kell_creations_apps/apps/kell_web/lib/routing/app_routes.dart +++ b/kell_creations_apps/apps/kell_web/lib/routing/app_routes.dart @@ -23,6 +23,9 @@ abstract final class AppRoutes { static const String integrations = '/integrations'; static Route onGenerateRoute(RouteSettings settings) { + // Extract navigation arguments passed via NavigationTarget. + final args = _extractArgs(settings); + switch (settings.name) { case dashboard: return _buildRoute(settings, (context) { @@ -49,6 +52,9 @@ abstract final class AppRoutes { child: InventoryPage( repository: AppScope.of(context).inventoryRepository, onViewProduct: (sku) => AppNavigation.inventoryToProduct(context, sku: sku), + initialFilter: args['filter'], + initialQuery: args['query'], + initialSelectedSku: args['selectedSku'], ), ), ); @@ -61,6 +67,9 @@ abstract final class AppRoutes { child: ProductPublishingPage( repository: AppScope.of(context).productPublishingRepository, onViewPolicy: () => AppNavigation.productToPolicy(context), + initialFilter: args['filter'], + initialQuery: args['query'], + initialSelectedSku: args['selectedSku'], ), ), ); @@ -74,6 +83,9 @@ abstract final class AppRoutes { repository: AppScope.of(context).ordersRepository, onViewProduct: (sku) => AppNavigation.orderToProduct(context, sku: sku), onViewInventory: (sku) => AppNavigation.orderToInventory(context, sku: sku), + initialFilter: args['filter'], + initialQuery: args['query'], + initialSelectedId: args['selectedId'], ), ), ); @@ -96,6 +108,9 @@ abstract final class AppRoutes { repository: AppScope.of(context).policyRepository, onViewRelatedPage: (category) => AppNavigation.policyToRelatedPage(context, category: category), + initialCategory: args['category'], + initialQuery: args['query'], + initialSelectedId: args['selectedId'], ), ), ); @@ -129,4 +144,14 @@ abstract final class AppRoutes { ) { return MaterialPageRoute(settings: settings, builder: pageBuilder); } + + /// Safely extracts the `Map` arguments from [RouteSettings]. + /// + /// Returns an empty map when no arguments are present or the type doesn't + /// match, so callers can always index safely. + static Map _extractArgs(RouteSettings settings) { + final raw = settings.arguments; + if (raw is Map) return raw; + return const {}; + } } diff --git a/kell_creations_apps/apps/kell_web/test/navigation/navigation_target_test.dart b/kell_creations_apps/apps/kell_web/test/navigation/navigation_target_test.dart index 7a404af..a565d1e 100644 --- a/kell_creations_apps/apps/kell_web/test/navigation/navigation_target_test.dart +++ b/kell_creations_apps/apps/kell_web/test/navigation/navigation_target_test.dart @@ -23,12 +23,14 @@ void main() { 'selectedId': 'ID-001', 'filter': 'lowStock', 'category': 'Product Compliance', + 'query': 'coaster', }, ); expect(target.selectedSku, 'SKU-001'); expect(target.selectedId, 'ID-001'); expect(target.filter, 'lowStock'); expect(target.category, 'Product Compliance'); + expect(target.query, 'coaster'); }); test('convenience getters return null when key is absent', () { @@ -37,6 +39,7 @@ void main() { expect(target.selectedId, isNull); expect(target.filter, isNull); expect(target.category, isNull); + expect(target.query, isNull); }); test('equality works for same route and arguments', () { @@ -63,5 +66,10 @@ void main() { expect(target.toString(), contains('/inventory')); expect(target.toString(), contains('lowStock')); }); + + test('query argument round-trips through convenience getter', () { + const target = NavigationTarget(route: '/inventory', arguments: {'query': 'bowl cozy'}); + expect(target.query, 'bowl cozy'); + }); }); } diff --git a/kell_creations_apps/packages/feature_inventory/lib/src/application/inventory_controller.dart b/kell_creations_apps/packages/feature_inventory/lib/src/application/inventory_controller.dart index 7e2363c..65047b2 100644 --- a/kell_creations_apps/packages/feature_inventory/lib/src/application/inventory_controller.dart +++ b/kell_creations_apps/packages/feature_inventory/lib/src/application/inventory_controller.dart @@ -1,23 +1,44 @@ import 'package:flutter/foundation.dart'; import '../domain/inventory_item.dart'; +import '../domain/inventory_status.dart'; import 'get_inventory_items.dart'; +/// Controller that manages the inventory workspace state, including +/// filtering by status, free-text search, and item selection. class InventoryController extends ChangeNotifier { final GetInventoryItems _getInventoryItems; InventoryController(this._getInventoryItems); bool isLoading = false; - List items = []; Object? error; + /// All items returned by the repository (unfiltered). + List _allItems = []; + + /// The currently visible items after applying [activeFilter] and [searchQuery]. + List items = []; + + /// The currently selected item, if any. + InventoryItem? selectedItem; + + /// The active status filter label, or `null` for "all". + /// + /// Recognised values: `'inStock'`, `'lowStock'`, `'outOfStock'`, `'draft'`. + String? activeFilter; + + /// The current free-text search query applied to name / SKU. + String searchQuery = ''; + + /// Loads all inventory items and applies any current filter / search. Future load() async { isLoading = true; error = null; notifyListeners(); try { - items = await _getInventoryItems(); + _allItems = await _getInventoryItems(); + _applyFilters(); } catch (e) { error = e; } finally { @@ -25,4 +46,78 @@ class InventoryController extends ChangeNotifier { notifyListeners(); } } + + /// Sets the status filter and recomputes the visible list. + void setFilter(String? filter) { + activeFilter = filter; + _applyFilters(); + notifyListeners(); + } + + /// Sets the search query and recomputes the visible list. + void setSearchQuery(String query) { + searchQuery = query; + _applyFilters(); + notifyListeners(); + } + + /// Selects an item for detail view / highlight. + void selectItem(InventoryItem item) { + selectedItem = item; + notifyListeners(); + } + + /// Attempts to select an item by SKU. Returns `true` if found. + bool selectBySku(String sku) { + final match = _allItems.where((i) => i.sku == sku).firstOrNull; + if (match != null) { + selectedItem = match; + notifyListeners(); + return true; + } + return false; + } + + // ── Private helpers ──────────────────────────────────────────────────── + + void _applyFilters() { + var result = _allItems; + + // Status filter + final status = _parseStatus(activeFilter); + if (status != null) { + result = result.where((i) => i.status == status).toList(); + } + + // Free-text search on name and SKU + if (searchQuery.isNotEmpty) { + final q = searchQuery.toLowerCase(); + result = result.where((i) { + return i.name.toLowerCase().contains(q) || i.sku.toLowerCase().contains(q); + }).toList(); + } + + items = result; + + // Keep selection valid; clear if the selected item is no longer visible. + if (selectedItem != null && !items.contains(selectedItem)) { + selectedItem = null; + } + } + + static InventoryStatus? _parseStatus(String? filter) { + if (filter == null) return null; + switch (filter) { + case 'inStock': + return InventoryStatus.inStock; + case 'lowStock': + return InventoryStatus.lowStock; + case 'outOfStock': + return InventoryStatus.outOfStock; + case 'draft': + return InventoryStatus.draft; + default: + return null; + } + } } diff --git a/kell_creations_apps/packages/feature_inventory/lib/src/presentation/inventory_page.dart b/kell_creations_apps/packages/feature_inventory/lib/src/presentation/inventory_page.dart index fe8f79f..fab2842 100644 --- a/kell_creations_apps/packages/feature_inventory/lib/src/presentation/inventory_page.dart +++ b/kell_creations_apps/packages/feature_inventory/lib/src/presentation/inventory_page.dart @@ -13,7 +13,23 @@ class InventoryPage extends StatefulWidget { /// Provided by the app layer to enable cross-feature handoffs. final void Function(String sku)? onViewProduct; - const InventoryPage({super.key, required this.repository, this.onViewProduct}); + /// Optional initial status filter to apply on first load (e.g. `'lowStock'`). + final String? initialFilter; + + /// Optional initial search query to apply on first load. + final String? initialQuery; + + /// Optional SKU to pre-select on first load (from a navigation handoff). + final String? initialSelectedSku; + + const InventoryPage({ + super.key, + required this.repository, + this.onViewProduct, + this.initialFilter, + this.initialQuery, + this.initialSelectedSku, + }); @override State createState() => _InventoryPageState(); @@ -26,7 +42,21 @@ class _InventoryPageState extends State { void initState() { super.initState(); controller = InventoryController(GetInventoryItems(widget.repository)); - controller.load(); + + // Apply any initial filter / query before loading. + if (widget.initialFilter != null) { + controller.activeFilter = widget.initialFilter; + } + if (widget.initialQuery != null && widget.initialQuery!.isNotEmpty) { + controller.searchQuery = widget.initialQuery!; + } + + controller.load().then((_) { + // After data is loaded, try to pre-select by SKU if requested. + if (widget.initialSelectedSku != null) { + controller.selectBySku(widget.initialSelectedSku!); + } + }); } @override diff --git a/kell_creations_apps/packages/feature_inventory/test/feature_inventory_test.dart b/kell_creations_apps/packages/feature_inventory/test/feature_inventory_test.dart index 6e8f167..fbd261a 100644 --- a/kell_creations_apps/packages/feature_inventory/test/feature_inventory_test.dart +++ b/kell_creations_apps/packages/feature_inventory/test/feature_inventory_test.dart @@ -1,6 +1,8 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:feature_inventory/feature_inventory.dart'; +import 'package:feature_inventory/src/application/get_inventory_items.dart'; +import 'package:feature_inventory/src/application/inventory_controller.dart'; void main() { group('InventoryStatus', () { @@ -36,4 +38,122 @@ void main() { expect(names, contains('Ocean Nightlight')); }); }); + + group('InventoryController', () { + late FakeInventoryRepository repository; + late InventoryController controller; + + setUp(() { + repository = FakeInventoryRepository(); + controller = InventoryController(GetInventoryItems(repository)); + }); + + tearDown(() { + controller.dispose(); + }); + + test('starts with empty state', () { + expect(controller.isLoading, false); + expect(controller.items, isEmpty); + expect(controller.selectedItem, isNull); + expect(controller.activeFilter, isNull); + expect(controller.searchQuery, ''); + expect(controller.error, isNull); + }); + + test('load populates items', () async { + await controller.load(); + + expect(controller.isLoading, false); + expect(controller.items.length, 6); + expect(controller.error, isNull); + }); + + test('setFilter filters by status', () async { + await controller.load(); + + controller.setFilter('lowStock'); + expect(controller.items.length, 2); + expect(controller.items.every((i) => i.status == InventoryStatus.lowStock), true); + }); + + test('setFilter with null shows all items', () async { + await controller.load(); + + controller.setFilter('lowStock'); + expect(controller.items.length, 2); + + controller.setFilter(null); + expect(controller.items.length, 6); + }); + + test('setSearchQuery filters by name', () async { + await controller.load(); + + controller.setSearchQuery('cozy'); + expect(controller.items.length, 1); + expect(controller.items.first.name, 'Floral Bowl Cozy'); + }); + + test('setSearchQuery filters by SKU', () async { + await controller.load(); + + controller.setSearchQuery('BC-FLR'); + expect(controller.items.length, 1); + expect(controller.items.first.sku, 'BC-FLR-001'); + }); + + test('search is case-insensitive', () async { + await controller.load(); + + controller.setSearchQuery('OCEAN'); + expect(controller.items.length, 1); + expect(controller.items.first.name, 'Ocean Nightlight'); + }); + + test('filter and search combine', () async { + await controller.load(); + + controller.setFilter('inStock'); + controller.setSearchQuery('floral'); + expect(controller.items.length, 1); + expect(controller.items.first.name, 'Floral Bowl Cozy'); + }); + + test('selectItem sets selectedItem', () async { + await controller.load(); + + final item = controller.items[2]; + controller.selectItem(item); + expect(controller.selectedItem, item); + }); + + test('selectBySku selects matching item', () async { + await controller.load(); + + final found = controller.selectBySku('CS-CIT-002'); + expect(found, true); + expect(controller.selectedItem!.sku, 'CS-CIT-002'); + }); + + test('selectBySku returns false for unknown SKU', () async { + await controller.load(); + + final found = controller.selectBySku('UNKNOWN'); + expect(found, false); + expect(controller.selectedItem, isNull); + }); + + test('selection is cleared when filtered out', () async { + await controller.load(); + + // Select an inStock item. + controller.selectBySku('BC-FLR-001'); + expect(controller.selectedItem, isNotNull); + + // Filter to lowStock — the selected item should be cleared. + controller.setFilter('lowStock'); + expect(controller.selectedItem, isNull); + }); + }); } diff --git a/kell_creations_apps/packages/feature_orders/build/test_cache/build/b70f9274ac3b5ec9754be8774a3c9a1c.cache.dill.track.dill b/kell_creations_apps/packages/feature_orders/build/test_cache/build/b70f9274ac3b5ec9754be8774a3c9a1c.cache.dill.track.dill index e621673..d162925 100644 Binary files a/kell_creations_apps/packages/feature_orders/build/test_cache/build/b70f9274ac3b5ec9754be8774a3c9a1c.cache.dill.track.dill and b/kell_creations_apps/packages/feature_orders/build/test_cache/build/b70f9274ac3b5ec9754be8774a3c9a1c.cache.dill.track.dill differ diff --git a/kell_creations_apps/packages/feature_orders/lib/src/application/orders_controller.dart b/kell_creations_apps/packages/feature_orders/lib/src/application/orders_controller.dart index 353f4e1..c151fd3 100644 --- a/kell_creations_apps/packages/feature_orders/lib/src/application/orders_controller.dart +++ b/kell_creations_apps/packages/feature_orders/lib/src/application/orders_controller.dart @@ -1,27 +1,46 @@ import 'package:flutter/foundation.dart'; import '../domain/order.dart'; +import '../domain/order_status.dart'; import 'get_orders.dart'; -/// Controller that manages the orders workspace state. +/// Controller that manages the orders workspace state, including +/// filtering by order status, free-text search, and order selection. class OrdersController extends ChangeNotifier { final GetOrders _getOrders; OrdersController(this._getOrders); bool isLoading = false; - List orders = []; - Order? selectedOrder; Object? error; - /// Loads all orders. + /// All orders returned by the repository (unfiltered). + List _allOrders = []; + + /// The currently visible orders after applying [activeFilter] and [searchQuery]. + List orders = []; + + /// The currently selected order for detail view. + Order? selectedOrder; + + /// The active status filter label, or `null` for "all". + /// + /// Recognised values: `'pending'`, `'processing'`, `'shipped'`, + /// `'delivered'`, `'cancelled'`. + String? activeFilter; + + /// The current free-text search query applied to customer name / order ID. + String searchQuery = ''; + + /// Loads all orders and applies any current filter / search. Future load() async { isLoading = true; error = null; notifyListeners(); try { - orders = await _getOrders(); + _allOrders = await _getOrders(); + _applyFilters(); // Auto-select the first order if nothing is selected. selectedOrder ??= orders.isNotEmpty ? orders.first : null; } catch (e) { @@ -32,9 +51,79 @@ class OrdersController extends ChangeNotifier { } } + /// Sets the status filter and recomputes the visible list. + void setFilter(String? filter) { + activeFilter = filter; + _applyFilters(); + notifyListeners(); + } + + /// Sets the search query and recomputes the visible list. + void setSearchQuery(String query) { + searchQuery = query; + _applyFilters(); + notifyListeners(); + } + /// Selects an order for detail view. void selectOrder(Order order) { selectedOrder = order; notifyListeners(); } + + /// Attempts to select an order by ID. Returns `true` if found. + bool selectById(String id) { + final match = _allOrders.where((o) => o.id == id).firstOrNull; + if (match != null) { + selectedOrder = match; + notifyListeners(); + return true; + } + return false; + } + + // ── Private helpers ──────────────────────────────────────────────────── + + void _applyFilters() { + var result = _allOrders; + + // Status filter + final status = _parseStatus(activeFilter); + if (status != null) { + result = result.where((o) => o.status == status).toList(); + } + + // Free-text search on customer name and order ID + if (searchQuery.isNotEmpty) { + final q = searchQuery.toLowerCase(); + result = result.where((o) { + return o.customerName.toLowerCase().contains(q) || o.id.toLowerCase().contains(q); + }).toList(); + } + + orders = result; + + // Keep selection valid; clear if the selected order is no longer visible. + if (selectedOrder != null && !orders.any((o) => o.id == selectedOrder!.id)) { + selectedOrder = null; + } + } + + static OrderStatus? _parseStatus(String? filter) { + if (filter == null) return null; + switch (filter) { + case 'pending': + return OrderStatus.pending; + case 'processing': + return OrderStatus.processing; + case 'shipped': + return OrderStatus.shipped; + case 'delivered': + return OrderStatus.delivered; + case 'cancelled': + return OrderStatus.cancelled; + default: + return null; + } + } } diff --git a/kell_creations_apps/packages/feature_orders/lib/src/presentation/orders_page.dart b/kell_creations_apps/packages/feature_orders/lib/src/presentation/orders_page.dart index b19d735..98979db 100644 --- a/kell_creations_apps/packages/feature_orders/lib/src/presentation/orders_page.dart +++ b/kell_creations_apps/packages/feature_orders/lib/src/presentation/orders_page.dart @@ -20,7 +20,24 @@ class OrdersPage extends StatefulWidget { /// Optional callback to navigate to the Inventory page for a given SKU. final void Function(String sku)? onViewInventory; - const OrdersPage({super.key, required this.repository, this.onViewProduct, this.onViewInventory}); + /// Optional initial status filter to apply on first load (e.g. `'pending'`). + final String? initialFilter; + + /// Optional initial search query to apply on first load. + final String? initialQuery; + + /// Optional order ID to pre-select on first load (from a navigation handoff). + final String? initialSelectedId; + + const OrdersPage({ + super.key, + required this.repository, + this.onViewProduct, + this.onViewInventory, + this.initialFilter, + this.initialQuery, + this.initialSelectedId, + }); @override State createState() => _OrdersPageState(); @@ -33,7 +50,21 @@ class _OrdersPageState extends State { void initState() { super.initState(); controller = OrdersController(GetOrders(widget.repository)); - controller.load(); + + // Apply any initial filter / query before loading. + if (widget.initialFilter != null) { + controller.activeFilter = widget.initialFilter; + } + if (widget.initialQuery != null && widget.initialQuery!.isNotEmpty) { + controller.searchQuery = widget.initialQuery!; + } + + controller.load().then((_) { + // After data is loaded, try to pre-select by ID if requested. + if (widget.initialSelectedId != null) { + controller.selectById(widget.initialSelectedId!); + } + }); } @override diff --git a/kell_creations_apps/packages/feature_orders/test/orders_controller_test.dart b/kell_creations_apps/packages/feature_orders/test/orders_controller_test.dart index 8029a47..7aa7106 100644 --- a/kell_creations_apps/packages/feature_orders/test/orders_controller_test.dart +++ b/kell_creations_apps/packages/feature_orders/test/orders_controller_test.dart @@ -22,6 +22,8 @@ void main() { expect(controller.isLoading, false); expect(controller.orders, isEmpty); expect(controller.selectedOrder, isNull); + expect(controller.activeFilter, isNull); + expect(controller.searchQuery, ''); expect(controller.error, isNull); }); @@ -43,5 +45,75 @@ void main() { expect(controller.selectedOrder!.id, third.id); }); + + test('setFilter filters by order status', () async { + await controller.load(); + + controller.setFilter('pending'); + expect(controller.orders.length, 2); + expect(controller.orders.every((o) => o.status == OrderStatus.pending), true); + }); + + test('setFilter with null shows all orders', () async { + await controller.load(); + + controller.setFilter('cancelled'); + expect(controller.orders.length, 1); + + controller.setFilter(null); + expect(controller.orders.length, 6); + }); + + test('setSearchQuery filters by customer name', () async { + await controller.load(); + + controller.setSearchQuery('sarah'); + expect(controller.orders.length, 1); + expect(controller.orders.first.customerName, 'Sarah Mitchell'); + }); + + test('setSearchQuery filters by order ID', () async { + await controller.load(); + + controller.setSearchQuery('KC-1003'); + expect(controller.orders.length, 1); + expect(controller.orders.first.id, 'KC-1003'); + }); + + test('filter and search combine', () async { + await controller.load(); + + controller.setFilter('pending'); + controller.setSearchQuery('david'); + expect(controller.orders.length, 1); + expect(controller.orders.first.customerName, 'David Park'); + }); + + test('selectById selects matching order', () async { + await controller.load(); + + final found = controller.selectById('KC-1004'); + expect(found, true); + expect(controller.selectedOrder!.id, 'KC-1004'); + }); + + test('selectById returns false for unknown ID', () async { + await controller.load(); + + final found = controller.selectById('UNKNOWN'); + expect(found, false); + }); + + test('selection is cleared when filtered out', () async { + await controller.load(); + + // Select a delivered order. + controller.selectById('KC-1001'); + expect(controller.selectedOrder, isNotNull); + + // Filter to pending — the selected order should be cleared. + controller.setFilter('pending'); + expect(controller.selectedOrder, isNull); + }); }); } diff --git a/kell_creations_apps/packages/feature_policy/lib/src/application/policy_controller.dart b/kell_creations_apps/packages/feature_policy/lib/src/application/policy_controller.dart index d6ecb3a..038d622 100644 --- a/kell_creations_apps/packages/feature_policy/lib/src/application/policy_controller.dart +++ b/kell_creations_apps/packages/feature_policy/lib/src/application/policy_controller.dart @@ -3,25 +3,43 @@ import 'package:flutter/foundation.dart'; import '../domain/policy_check_result.dart'; import 'get_policy_checks.dart'; -/// Controller that manages the policy-checks workspace state. +/// Controller that manages the policy-checks workspace state, including +/// filtering by category, free-text search, and check selection. class PolicyController extends ChangeNotifier { final GetPolicyChecks _getPolicyChecks; PolicyController(this._getPolicyChecks); bool isLoading = false; - List checks = []; - PolicyCheckResult? selectedCheck; Object? error; - /// Loads all policy checks. + /// All checks returned by the repository (unfiltered). + List _allChecks = []; + + /// The currently visible checks after applying [activeCategory] and [searchQuery]. + List checks = []; + + /// The currently selected check for detail view. + PolicyCheckResult? selectedCheck; + + /// The active category filter, or `null` for "all". + /// + /// Matches [PolicyCheckResult.category] values such as + /// `'Product Compliance'`, `'Inventory Governance'`, etc. + String? activeCategory; + + /// The current free-text search query applied to title / summary. + String searchQuery = ''; + + /// Loads all policy checks and applies any current filter / search. Future load() async { isLoading = true; error = null; notifyListeners(); try { - checks = await _getPolicyChecks(); + _allChecks = await _getPolicyChecks(); + _applyFilters(); // Auto-select the first check if nothing is selected. selectedCheck ??= checks.isNotEmpty ? checks.first : null; } catch (e) { @@ -32,9 +50,60 @@ class PolicyController extends ChangeNotifier { } } + /// Sets the category filter and recomputes the visible list. + void setCategory(String? category) { + activeCategory = category; + _applyFilters(); + notifyListeners(); + } + + /// Sets the search query and recomputes the visible list. + void setSearchQuery(String query) { + searchQuery = query; + _applyFilters(); + notifyListeners(); + } + /// Selects a policy check for detail view. void selectCheck(PolicyCheckResult check) { selectedCheck = check; notifyListeners(); } + + /// Attempts to select a check by ID. Returns `true` if found. + bool selectById(String id) { + final match = _allChecks.where((c) => c.id == id).firstOrNull; + if (match != null) { + selectedCheck = match; + notifyListeners(); + return true; + } + return false; + } + + // ── Private helpers ──────────────────────────────────────────────────── + + void _applyFilters() { + var result = _allChecks; + + // Category filter + if (activeCategory != null && activeCategory!.isNotEmpty) { + result = result.where((c) => c.category == activeCategory).toList(); + } + + // Free-text search on title and summary + if (searchQuery.isNotEmpty) { + final q = searchQuery.toLowerCase(); + result = result.where((c) { + return c.title.toLowerCase().contains(q) || c.summary.toLowerCase().contains(q); + }).toList(); + } + + checks = result; + + // Keep selection valid; clear if the selected check is no longer visible. + if (selectedCheck != null && !checks.any((c) => c.id == selectedCheck!.id)) { + selectedCheck = null; + } + } } diff --git a/kell_creations_apps/packages/feature_policy/lib/src/presentation/policy_page.dart b/kell_creations_apps/packages/feature_policy/lib/src/presentation/policy_page.dart index a957573..af01266 100644 --- a/kell_creations_apps/packages/feature_policy/lib/src/presentation/policy_page.dart +++ b/kell_creations_apps/packages/feature_policy/lib/src/presentation/policy_page.dart @@ -18,7 +18,24 @@ class PolicyPage extends StatefulWidget { /// the policy check's category. Provided by the app layer. final void Function(String category)? onViewRelatedPage; - const PolicyPage({super.key, required this.repository, this.onViewRelatedPage}); + /// Optional initial category filter to apply on first load + /// (e.g. `'Product Compliance'`). + final String? initialCategory; + + /// Optional initial search query to apply on first load. + final String? initialQuery; + + /// Optional check ID to pre-select on first load (from a navigation handoff). + final String? initialSelectedId; + + const PolicyPage({ + super.key, + required this.repository, + this.onViewRelatedPage, + this.initialCategory, + this.initialQuery, + this.initialSelectedId, + }); @override State createState() => _PolicyPageState(); @@ -31,7 +48,21 @@ class _PolicyPageState extends State { void initState() { super.initState(); controller = PolicyController(GetPolicyChecks(widget.repository)); - controller.load(); + + // Apply any initial category / query before loading. + if (widget.initialCategory != null) { + controller.activeCategory = widget.initialCategory; + } + if (widget.initialQuery != null && widget.initialQuery!.isNotEmpty) { + controller.searchQuery = widget.initialQuery!; + } + + controller.load().then((_) { + // After data is loaded, try to pre-select by ID if requested. + if (widget.initialSelectedId != null) { + controller.selectById(widget.initialSelectedId!); + } + }); } @override diff --git a/kell_creations_apps/packages/feature_policy/test/feature_policy_test.dart b/kell_creations_apps/packages/feature_policy/test/feature_policy_test.dart index b47f6f2..4952dfa 100644 --- a/kell_creations_apps/packages/feature_policy/test/feature_policy_test.dart +++ b/kell_creations_apps/packages/feature_policy/test/feature_policy_test.dart @@ -80,6 +80,8 @@ void main() { expect(controller.isLoading, false); expect(controller.checks, isEmpty); expect(controller.selectedCheck, isNull); + expect(controller.activeCategory, isNull); + expect(controller.searchQuery, ''); expect(controller.error, isNull); }); @@ -101,5 +103,75 @@ void main() { expect(controller.selectedCheck!.id, third.id); }); + + test('setCategory filters by category', () async { + await controller.load(); + + controller.setCategory('Product Compliance'); + expect(controller.checks.length, 2); + expect(controller.checks.every((c) => c.category == 'Product Compliance'), true); + }); + + test('setCategory with null shows all checks', () async { + await controller.load(); + + controller.setCategory('Order Operations'); + expect(controller.checks.length, 2); + + controller.setCategory(null); + expect(controller.checks.length, 8); + }); + + test('setSearchQuery filters by title', () async { + await controller.load(); + + controller.setSearchQuery('pricing'); + expect(controller.checks.length, 1); + expect(controller.checks.first.title, 'Pricing Consistency'); + }); + + test('setSearchQuery filters by summary', () async { + await controller.load(); + + controller.setSearchQuery('reorder threshold'); + expect(controller.checks.length, 1); + expect(controller.checks.first.id, 'POL-006'); + }); + + test('category and search combine', () async { + await controller.load(); + + controller.setCategory('Inventory Governance'); + controller.setSearchQuery('accuracy'); + expect(controller.checks.length, 1); + expect(controller.checks.first.id, 'POL-002'); + }); + + test('selectById selects matching check', () async { + await controller.load(); + + final found = controller.selectById('POL-005'); + expect(found, true); + expect(controller.selectedCheck!.id, 'POL-005'); + }); + + test('selectById returns false for unknown ID', () async { + await controller.load(); + + final found = controller.selectById('UNKNOWN'); + expect(found, false); + }); + + test('selection is cleared when filtered out', () async { + await controller.load(); + + // Select a Product Compliance check. + controller.selectById('POL-001'); + expect(controller.selectedCheck, isNotNull); + + // Filter to Order Operations — the selected check should be cleared. + controller.setCategory('Order Operations'); + expect(controller.selectedCheck, isNull); + }); }); } diff --git a/kell_creations_apps/packages/feature_wordpress/lib/src/application/product_publishing_controller.dart b/kell_creations_apps/packages/feature_wordpress/lib/src/application/product_publishing_controller.dart index 6c3546d..69ff0a5 100644 --- a/kell_creations_apps/packages/feature_wordpress/lib/src/application/product_publishing_controller.dart +++ b/kell_creations_apps/packages/feature_wordpress/lib/src/application/product_publishing_controller.dart @@ -1,10 +1,12 @@ import 'package:flutter/foundation.dart'; import '../domain/product_draft.dart'; +import '../domain/publish_status.dart'; import 'get_product_drafts.dart'; import 'publish_product.dart'; -/// Controller that manages the product publishing workspace state. +/// Controller that manages the product publishing workspace state, including +/// filtering by publish status, free-text search, and draft selection. class ProductPublishingController extends ChangeNotifier { final GetProductDrafts _getProductDrafts; final PublishProduct _publishProduct; @@ -12,18 +14,34 @@ class ProductPublishingController extends ChangeNotifier { ProductPublishingController(this._getProductDrafts, this._publishProduct); bool isLoading = false; - List drafts = []; - ProductDraft? selectedDraft; Object? error; - /// Loads all product drafts. + /// All drafts returned by the repository (unfiltered). + List _allDrafts = []; + + /// The currently visible drafts after applying [activeFilter] and [searchQuery]. + List drafts = []; + + /// The currently selected draft for preview. + ProductDraft? selectedDraft; + + /// The active status filter label, or `null` for "all". + /// + /// Recognised values: `'draft'`, `'pendingReview'`, `'published'`, `'unpublished'`. + String? activeFilter; + + /// The current free-text search query applied to name / SKU. + String searchQuery = ''; + + /// Loads all product drafts and applies any current filter / search. Future load() async { isLoading = true; error = null; notifyListeners(); try { - drafts = await _getProductDrafts(); + _allDrafts = await _getProductDrafts(); + _applyFilters(); // Auto-select the first draft if nothing is selected. selectedDraft ??= drafts.isNotEmpty ? drafts.first : null; } catch (e) { @@ -34,12 +52,37 @@ class ProductPublishingController extends ChangeNotifier { } } + /// Sets the status filter and recomputes the visible list. + void setFilter(String? filter) { + activeFilter = filter; + _applyFilters(); + notifyListeners(); + } + + /// Sets the search query and recomputes the visible list. + void setSearchQuery(String query) { + searchQuery = query; + _applyFilters(); + notifyListeners(); + } + /// Selects a draft for preview. void selectDraft(ProductDraft draft) { selectedDraft = draft; notifyListeners(); } + /// Attempts to select a draft by SKU. Returns `true` if found. + bool selectBySku(String sku) { + final match = _allDrafts.where((d) => d.sku == sku).firstOrNull; + if (match != null) { + selectedDraft = match; + notifyListeners(); + return true; + } + return false; + } + /// Publishes the draft with the given [id] and reloads the list. Future publish(String id) async { try { @@ -50,4 +93,47 @@ class ProductPublishingController extends ChangeNotifier { notifyListeners(); } } + + // ── Private helpers ──────────────────────────────────────────────────── + + void _applyFilters() { + var result = _allDrafts; + + // Status filter + final status = _parseStatus(activeFilter); + if (status != null) { + result = result.where((d) => d.status == status).toList(); + } + + // Free-text search on name and SKU + if (searchQuery.isNotEmpty) { + final q = searchQuery.toLowerCase(); + result = result.where((d) { + return d.name.toLowerCase().contains(q) || d.sku.toLowerCase().contains(q); + }).toList(); + } + + drafts = result; + + // Keep selection valid; clear if the selected draft is no longer visible. + if (selectedDraft != null && !drafts.any((d) => d.id == selectedDraft!.id)) { + selectedDraft = null; + } + } + + static PublishStatus? _parseStatus(String? filter) { + if (filter == null) return null; + switch (filter) { + case 'draft': + return PublishStatus.draft; + case 'pendingReview': + return PublishStatus.pendingReview; + case 'published': + return PublishStatus.published; + case 'unpublished': + return PublishStatus.unpublished; + default: + return null; + } + } } diff --git a/kell_creations_apps/packages/feature_wordpress/lib/src/presentation/product_publishing_page.dart b/kell_creations_apps/packages/feature_wordpress/lib/src/presentation/product_publishing_page.dart index f11e4fb..f2694f6 100644 --- a/kell_creations_apps/packages/feature_wordpress/lib/src/presentation/product_publishing_page.dart +++ b/kell_creations_apps/packages/feature_wordpress/lib/src/presentation/product_publishing_page.dart @@ -19,7 +19,23 @@ class ProductPublishingPage extends StatefulWidget { /// Provided by the app layer to enable cross-feature handoffs. final VoidCallback? onViewPolicy; - const ProductPublishingPage({super.key, required this.repository, this.onViewPolicy}); + /// Optional initial status filter to apply on first load (e.g. `'draft'`). + final String? initialFilter; + + /// Optional initial search query to apply on first load. + final String? initialQuery; + + /// Optional SKU to pre-select on first load (from a navigation handoff). + final String? initialSelectedSku; + + const ProductPublishingPage({ + super.key, + required this.repository, + this.onViewPolicy, + this.initialFilter, + this.initialQuery, + this.initialSelectedSku, + }); @override State createState() => _ProductPublishingPageState(); @@ -33,7 +49,21 @@ class _ProductPublishingPageState extends State { super.initState(); final repo = widget.repository; controller = ProductPublishingController(GetProductDrafts(repo), PublishProduct(repo)); - controller.load(); + + // Apply any initial filter / query before loading. + if (widget.initialFilter != null) { + controller.activeFilter = widget.initialFilter; + } + if (widget.initialQuery != null && widget.initialQuery!.isNotEmpty) { + controller.searchQuery = widget.initialQuery!; + } + + controller.load().then((_) { + // After data is loaded, try to pre-select by SKU if requested. + if (widget.initialSelectedSku != null) { + controller.selectBySku(widget.initialSelectedSku!); + } + }); } @override diff --git a/kell_creations_apps/packages/feature_wordpress/test/product_publishing_controller_test.dart b/kell_creations_apps/packages/feature_wordpress/test/product_publishing_controller_test.dart index 8fa2620..f1708bf 100644 --- a/kell_creations_apps/packages/feature_wordpress/test/product_publishing_controller_test.dart +++ b/kell_creations_apps/packages/feature_wordpress/test/product_publishing_controller_test.dart @@ -26,6 +26,8 @@ void main() { expect(controller.isLoading, false); expect(controller.drafts, isEmpty); expect(controller.selectedDraft, isNull); + expect(controller.activeFilter, isNull); + expect(controller.searchQuery, ''); expect(controller.error, isNull); }); @@ -57,5 +59,75 @@ void main() { final updated = controller.drafts.firstWhere((d) => d.id == '4'); expect(updated.status, PublishStatus.published); }); + + test('setFilter filters by publish status', () async { + await controller.load(); + + controller.setFilter('draft'); + expect(controller.drafts.length, 2); + expect(controller.drafts.every((d) => d.status == PublishStatus.draft), true); + }); + + test('setFilter with null shows all drafts', () async { + await controller.load(); + + controller.setFilter('published'); + expect(controller.drafts.length, 2); + + controller.setFilter(null); + expect(controller.drafts.length, 6); + }); + + test('setSearchQuery filters by name', () async { + await controller.load(); + + controller.setSearchQuery('nightlight'); + expect(controller.drafts.length, 1); + expect(controller.drafts.first.name, 'Ocean Nightlight'); + }); + + test('setSearchQuery filters by SKU', () async { + await controller.load(); + + controller.setSearchQuery('JG-BLU'); + expect(controller.drafts.length, 1); + expect(controller.drafts.first.sku, 'JG-BLU-004'); + }); + + test('filter and search combine', () async { + await controller.load(); + + controller.setFilter('published'); + controller.setSearchQuery('floral'); + expect(controller.drafts.length, 1); + expect(controller.drafts.first.name, 'Floral Bowl Cozy'); + }); + + test('selectBySku selects matching draft', () async { + await controller.load(); + + final found = controller.selectBySku('CS-CIT-002'); + expect(found, true); + expect(controller.selectedDraft!.sku, 'CS-CIT-002'); + }); + + test('selectBySku returns false for unknown SKU', () async { + await controller.load(); + + final found = controller.selectBySku('UNKNOWN'); + expect(found, false); + }); + + test('selection is cleared when filtered out', () async { + await controller.load(); + + // Select a published draft. + controller.selectBySku('BC-FLR-001'); + expect(controller.selectedDraft, isNotNull); + + // Filter to draft status — the selected item should be cleared. + controller.setFilter('draft'); + expect(controller.selectedDraft, isNull); + }); }); }