feat(workflows): add shared filtering search and selection persistence
Validate Docs / validate-docs (push) Successful in 1m0s Details

This commit is contained in:
Mike Kell 2026-04-04 14:59:26 -04:00
parent 0f61badba6
commit 23ea1bebe1
17 changed files with 867 additions and 31 deletions

View File

@ -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.

View File

@ -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) ||

View File

@ -23,6 +23,9 @@ abstract final class AppRoutes {
static const String integrations = '/integrations';
static Route<dynamic> 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<dynamic>(settings: settings, builder: pageBuilder);
}
/// Safely extracts the `Map<String, String>` 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<String, String> _extractArgs(RouteSettings settings) {
final raw = settings.arguments;
if (raw is Map<String, String>) return raw;
return const {};
}
}

View File

@ -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');
});
});
}

View File

@ -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<InventoryItem> items = [];
Object? error;
/// All items returned by the repository (unfiltered).
List<InventoryItem> _allItems = [];
/// The currently visible items after applying [activeFilter] and [searchQuery].
List<InventoryItem> 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<void> 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;
}
}
}

View File

@ -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<InventoryPage> createState() => _InventoryPageState();
@ -26,7 +42,21 @@ class _InventoryPageState extends State<InventoryPage> {
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

View File

@ -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);
});
});
}

View File

@ -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<Order> orders = [];
Order? selectedOrder;
Object? error;
/// Loads all orders.
/// All orders returned by the repository (unfiltered).
List<Order> _allOrders = [];
/// The currently visible orders after applying [activeFilter] and [searchQuery].
List<Order> 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<void> 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;
}
}
}

View File

@ -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<OrdersPage> createState() => _OrdersPageState();
@ -33,7 +50,21 @@ class _OrdersPageState extends State<OrdersPage> {
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

View File

@ -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);
});
});
}

View File

@ -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<PolicyCheckResult> checks = [];
PolicyCheckResult? selectedCheck;
Object? error;
/// Loads all policy checks.
/// All checks returned by the repository (unfiltered).
List<PolicyCheckResult> _allChecks = [];
/// The currently visible checks after applying [activeCategory] and [searchQuery].
List<PolicyCheckResult> 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<void> 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;
}
}
}

View File

@ -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<PolicyPage> createState() => _PolicyPageState();
@ -31,7 +48,21 @@ class _PolicyPageState extends State<PolicyPage> {
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

View File

@ -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);
});
});
}

View File

@ -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<ProductDraft> drafts = [];
ProductDraft? selectedDraft;
Object? error;
/// Loads all product drafts.
/// All drafts returned by the repository (unfiltered).
List<ProductDraft> _allDrafts = [];
/// The currently visible drafts after applying [activeFilter] and [searchQuery].
List<ProductDraft> 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<void> 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<void> 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;
}
}
}

View File

@ -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<ProductPublishingPage> createState() => _ProductPublishingPageState();
@ -33,7 +49,21 @@ class _ProductPublishingPageState extends State<ProductPublishingPage> {
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

View File

@ -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);
});
});
}