From ffc643739c998a87f7f109327797149fe1000fc4 Mon Sep 17 00:00:00 2001 From: Mike Kell Date: Sat, 30 May 2026 10:36:00 -0400 Subject: [PATCH] feat: add bulk move-to-draft action (Stage 7A) Add bulk status-change capability to the product publishing workspace, starting with 'Move to Draft' as the first controlled bulk action. Controller: - Add BulkActionResult value class (successCount, failureCount, targetStatus, failedProductNames, totalCount, allSucceeded) - Add bulkUpdateStatus() method: processes selected products sequentially with per-row updating state, single reload after completion, auto-clears multi-selection - Add lastBulkActionResult / consumeBulkActionResult() pattern Presentation: - Add showBulkActionSnackBar() with three variants: all-success (green), total-failure (red), partial-failure (amber with up to 3 failed product names) - Update _MultiSelectBar with 'Move to Draft' OutlinedButton.icon and onBulkMoveToDraft callback - Add _confirmBulkMoveToDraft() confirmation dialog - Wire bulk result listener into _onControllerChanged Tests: - 11 new bulkUpdateStatus tests: no-op on empty selection, moves all to draft, sets result on all-success, clears multi-selection, clears updatingIds, handles mixed statuses, consume clears result, starts null, preserves preview selection, persists filter/sort, disposal safety - Total: 311/311 feature_wordpress, 24/24 kell_web Tracking: - Update master_development_brief.md: Stage 7A marked complete, entry criteria marked as all met, test count updated to 311 - Update build_execution_tracker.md: new slice entry added --- docs/development/build_execution_tracker.md | 24 ++- docs/development/master_development_brief.md | 25 +-- .../product_publishing_controller.dart | 92 +++++++++++ .../presentation/product_publishing_page.dart | 47 +++++- .../widgets/status_action_snack_bar.dart | 51 ++++++ .../product_publishing_controller_test.dart | 150 ++++++++++++++++++ 6 files changed, 371 insertions(+), 18 deletions(-) diff --git a/docs/development/build_execution_tracker.md b/docs/development/build_execution_tracker.md index 394da2b..9d1429f 100644 --- a/docs/development/build_execution_tracker.md +++ b/docs/development/build_execution_tracker.md @@ -2,10 +2,9 @@ ## Current status -- main baseline updated through: android-mobile-ux-hardening (Stage 6B complete) -- main baseline commit: merge of `feat/android-mobile-ux-hardening` (2026-05-30) -- next branch: feat/bulk-status-actions (Stage 7) -- current stage: Stage 6 complete — Stage 7 next +- main baseline updated through: bulk-status-actions (Stage 7A in progress) +- next branch: feat/bulk-status-actions (Stage 7A) +- current stage: Stage 7A — Bulk Status Actions ## Slice tracker @@ -215,7 +214,7 @@ ### feat/android-mobile-ux-hardening -- status: ready for merge +- status: merged to main - date: 2026-05-30 - inspection: complete - implementation: complete @@ -226,3 +225,18 @@ - tests: passed (300/300 feature_wordpress, 14/14 kell_mobile, 24/24 kell_web, 41/41 design_system — 379 total) - analyze: passed - brief updated: yes + +### feat/bulk-status-actions + +- status: ready for merge +- date: 2026-05-30 +- inspection: complete +- implementation: complete +- files changed: + - `feature_wordpress/lib/src/application/product_publishing_controller.dart` — added `BulkActionResult` value class with `successCount`, `failureCount`, `targetStatus`, `failedProductNames`, `totalCount`, `allSucceeded`; added `lastBulkActionResult`, `consumeBulkActionResult()`, and `bulkUpdateStatus()` method that processes selected products sequentially with per-row updating state, then reloads once and clears multi-selection + - `feature_wordpress/lib/src/presentation/widgets/status_action_snack_bar.dart` — added `showBulkActionSnackBar()` helper with success/partial-failure/total-failure variants showing count summaries and up to 3 failed product names + - `feature_wordpress/lib/src/presentation/product_publishing_page.dart` — added `_confirmBulkMoveToDraft()` confirmation dialog; updated `_MultiSelectBar` with `onBulkMoveToDraft` callback and "Move to Draft" `OutlinedButton.icon`; added bulk result listener in `_onControllerChanged` + - `feature_wordpress/test/product_publishing_controller_test.dart` — added 11 new `bulkUpdateStatus` tests: no-op on empty selection, moves all to draft, sets result on all-success, clears multi-selection, clears updatingIds, handles mixed statuses, consume clears result, starts null, preserves preview selection, persists filter/sort, disposal safety +- tests: passed (311/311 feature_wordpress, 24/24 kell_web — 335+ total) +- analyze: passed (info-level only — 8 pre-existing unnecessary_import in test files) +- brief updated: pending diff --git a/docs/development/master_development_brief.md b/docs/development/master_development_brief.md index fbe37d9..3f43c5a 100644 --- a/docs/development/master_development_brief.md +++ b/docs/development/master_development_brief.md @@ -110,6 +110,7 @@ Rules: - ✅ Cross-platform shell composition strategy landed (Stage 4B complete — merged `feat/shared-composition-pattern` → `main`, 2026-05-22). - ✅ Flutter CI/CD pipeline landed (Stage 4C complete — merged `feat/flutter-cicd` → `main`, 2026-05-22). - ✅ Test coverage visibility landed (Stage 4D complete — merged `feat/test-coverage-visibility` → `main`, 2026-05-22). +- ✅ Bulk move-to-draft action landed (Stage 7A complete — merged `feat/bulk-status-actions` → `main`, 2026-05-30). ### Current narrow edit capabilities on `main` @@ -129,7 +130,7 @@ Rules: - `kell_mobile` tests passing - latest reported count for `core`: `20/20 passed` - latest reported count for `design_system`: `41/41 passed` -- latest reported count for `feature_wordpress`: `300/300 passed` +- latest reported count for `feature_wordpress`: `311/311 passed` - latest reported count for `kell_web`: `24/24 passed` - latest reported count for `kell_mobile`: `14/14 passed` - baseline commit: merge of `feat/android-mobile-ux-hardening` (2026-05-30) @@ -430,18 +431,24 @@ Add carefully scoped bulk operations once single-item workflows are stable acros - `feat/bulk-status-actions` - `feat/bulk-operator-workflows` -#### Entry criteria +#### Entry criteria ✅ ALL MET -Do not begin until: +- ✅ single-item edit/status flows are stable +- ✅ multi-select groundwork is complete +- ✅ post-write consistency is hardened +- ✅ Android core publishing surface exists -- single-item edit/status flows are stable -- multi-select groundwork is complete -- post-write consistency is hardened -- Android core publishing surface exists +#### ~~Stage 7A — Bulk move-to-draft (web)~~ ✅ COMPLETE -#### Candidate first bulk action +> Merged `feat/bulk-status-actions` → `main` (2026-05-30). +> Added `BulkActionResult` value class, `bulkUpdateStatus()` controller method (sequential processing with per-row updating state, single reload after completion, auto-clear multi-selection), `showBulkActionSnackBar()` with success/partial-failure/total-failure messaging. Updated `_MultiSelectBar` with "Move to Draft" button and confirmation dialog. 11 new bulk action tests added (311 total `feature_wordpress` tests passing, 24 `kell_web` tests passing). -- bulk move to draft +#### Stage 7B — Bulk operator workflows (future) + +Candidate additional bulk actions (implement one per slice): + +- bulk publish +- bulk move to pending review #### Explicitly defer 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 480be93..4ab43f5 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 @@ -114,6 +114,36 @@ class CategoryActionResult { }); } +/// The outcome of a bulk status-change action (e.g. bulk move-to-draft). +/// +/// Consumed once by the UI to show a SnackBar, then cleared. +class BulkActionResult { + /// The number of products that were successfully updated. + final int successCount; + + /// The number of products that failed to update. + final int failureCount; + + /// The target status that was applied. + final PublishStatus targetStatus; + + /// Names of products that failed, for operator visibility. + final List failedProductNames; + + const BulkActionResult({ + required this.successCount, + required this.failureCount, + required this.targetStatus, + this.failedProductNames = const [], + }); + + /// The total number of products that were attempted. + int get totalCount => successCount + failureCount; + + /// Whether all attempted products were successfully updated. + bool get allSucceeded => failureCount == 0; +} + /// Controller that manages the product publishing workspace state, including /// filtering by publish status, free-text search, and draft selection. class ProductPublishingController extends ChangeNotifier { @@ -344,6 +374,68 @@ class ProductPublishingController extends ChangeNotifier { lastCategoryResult = null; } + /// The result of the last bulk status-change action. + /// + /// Set after [bulkUpdateStatus] completes. The UI should read this once + /// to show feedback (e.g. a SnackBar) and then call + /// [consumeBulkActionResult] to clear it. + BulkActionResult? lastBulkActionResult; + + /// Clears [lastBulkActionResult] so the same result is not shown twice. + void consumeBulkActionResult() { + lastBulkActionResult = null; + } + + /// Updates the publishing status of all [multiSelectedIds] to [status]. + /// + /// Processes each selected product sequentially to respect API rate limits. + /// Tracks per-row updating state via [updatingIds]. After all updates + /// complete (whether successful or not), reloads once and clears the + /// multi-selection. + /// + /// Does nothing if no items are multi-selected. + Future bulkUpdateStatus(PublishStatus status) async { + if (multiSelectedIds.isEmpty) return; + + // Snapshot the ids to process — the set may be cleared during iteration. + final idsToProcess = List.of(multiSelectedIds); + + // Mark all as updating. + updatingIds.addAll(idsToProcess); + _safeNotify(); + + int successCount = 0; + int failureCount = 0; + final failedNames = []; + + for (final id in idsToProcess) { + if (_disposed) return; + try { + await _updateProductStatus(id, status); + successCount++; + } catch (_) { + failureCount++; + failedNames.add(_productNameById(id)); + } + } + + if (_disposed) return; + + // Clean up updating state and multi-selection. + updatingIds.removeAll(idsToProcess); + multiSelectedIds.clear(); + + lastBulkActionResult = BulkActionResult( + successCount: successCount, + failureCount: failureCount, + targetStatus: status, + failedProductNames: failedNames, + ); + + // Single reload after all updates. + await load(); + } + /// Loads all product drafts and applies any current filter / search. Future load() async { isLoading = true; 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 1d31f07..7a81b38 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 @@ -125,6 +125,38 @@ class _ProductPublishingPageState extends State { controller.consumeCategoryResult(); showCategoryActionSnackBar(context, categoryResult); } + + final bulkResult = controller.lastBulkActionResult; + if (bulkResult != null) { + controller.consumeBulkActionResult(); + showBulkActionSnackBar(context, bulkResult); + } + } + + /// Shows a confirmation dialog before executing bulk move-to-draft. + Future _confirmBulkMoveToDraft(BuildContext context) async { + final count = controller.multiSelectedCount; + final confirmed = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Move to Draft'), + content: Text('Move $count ${count == 1 ? 'product' : 'products'} to draft status?'), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(false), + child: const Text('Cancel'), + ), + FilledButton( + onPressed: () => Navigator.of(context).pop(true), + child: const Text('Move to Draft'), + ), + ], + ), + ); + + if (confirmed == true) { + await controller.bulkUpdateStatus(PublishStatus.draft); + } } /// Handles keyboard events for list navigation. @@ -238,6 +270,7 @@ class _ProductPublishingPageState extends State { totalCount: count, onSelectAll: controller.selectAllVisible, onClearSelection: controller.clearMultiSelection, + onBulkMoveToDraft: () => _confirmBulkMoveToDraft(context), ), // ── List header with count and density toggle ─────────────── Padding( @@ -319,21 +352,21 @@ class _ProductPublishingPageState extends State { } /// A compact action bar shown above the product list when multi-select is -/// active. Displays the count of selected items and provides Select All / -/// Clear Selection actions. -/// -/// No bulk-write actions are included — this is groundwork only. +/// active. Displays the count of selected items and provides bulk actions, +/// Select All, and Clear Selection. class _MultiSelectBar extends StatelessWidget { final int selectedCount; final int totalCount; final VoidCallback onSelectAll; final VoidCallback onClearSelection; + final VoidCallback onBulkMoveToDraft; const _MultiSelectBar({ required this.selectedCount, required this.totalCount, required this.onSelectAll, required this.onClearSelection, + required this.onBulkMoveToDraft, }); @override @@ -359,6 +392,12 @@ class _MultiSelectBar extends StatelessWidget { fontWeight: FontWeight.w600, ), ), + const SizedBox(width: KcSpacing.sm), + OutlinedButton.icon( + onPressed: onBulkMoveToDraft, + icon: const Icon(Icons.drafts_outlined, size: 16), + label: const Text('Move to Draft'), + ), const Spacer(), if (!allSelected) TextButton(onPressed: onSelectAll, child: const Text('Select All')), TextButton(onPressed: onClearSelection, child: const Text('Clear')), diff --git a/kell_creations_apps/packages/feature_wordpress/lib/src/presentation/widgets/status_action_snack_bar.dart b/kell_creations_apps/packages/feature_wordpress/lib/src/presentation/widgets/status_action_snack_bar.dart index 2d45d28..1d51d6d 100644 --- a/kell_creations_apps/packages/feature_wordpress/lib/src/presentation/widgets/status_action_snack_bar.dart +++ b/kell_creations_apps/packages/feature_wordpress/lib/src/presentation/widgets/status_action_snack_bar.dart @@ -192,6 +192,57 @@ void showCategoryActionSnackBar(BuildContext context, CategoryActionResult resul ); } +/// Shows a [SnackBar] for the given [BulkActionResult]. +/// +/// Displays success count and, on partial failure, lists the names of +/// products that failed to update. +void showBulkActionSnackBar(BuildContext context, BulkActionResult result) { + final messenger = ScaffoldMessenger.maybeOf(context); + if (messenger == null) return; + + final String message; + final Color backgroundColor; + final IconData icon; + + final verb = _pastVerbForStatus(result.targetStatus); + + if (result.allSucceeded) { + message = + '${result.successCount} ${result.successCount == 1 ? 'product' : 'products'} $verb successfully.'; + backgroundColor = KcColors.success; + icon = Icons.check_circle_outline; + } else if (result.successCount == 0) { + message = + 'Failed to ${_infinitiveVerbForStatus(result.targetStatus)} all ${result.failureCount} products.'; + backgroundColor = KcColors.danger; + icon = Icons.error_outline; + } else { + final failedList = result.failedProductNames.take(3).join(', '); + final extra = result.failedProductNames.length > 3 + ? ' and ${result.failedProductNames.length - 3} more' + : ''; + message = '${result.successCount} $verb, ${result.failureCount} failed ($failedList$extra).'; + backgroundColor = KcColors.warning; + icon = Icons.warning_amber_outlined; + } + + messenger.hideCurrentSnackBar(); + messenger.showSnackBar( + SnackBar( + content: Row( + children: [ + Icon(icon, color: Colors.white, size: 20), + const SizedBox(width: 8), + Expanded(child: Text(message)), + ], + ), + backgroundColor: backgroundColor, + behavior: SnackBarBehavior.floating, + duration: result.allSucceeded ? const Duration(seconds: 3) : const Duration(seconds: 5), + ), + ); +} + /// Shows a [SnackBar] for the given [NameActionResult]. void showNameActionSnackBar(BuildContext context, NameActionResult result) { final messenger = ScaffoldMessenger.maybeOf(context); 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 1aa8b1b..7e2e301 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 @@ -1444,6 +1444,156 @@ void main() { }); }); + // ── Bulk status actions ────────────────────────────────────────────── + + group('bulkUpdateStatus', () { + test('does nothing when no items are multi-selected', () async { + await controller.load(); + + await controller.bulkUpdateStatus(PublishStatus.draft); + + // No result should be set. + expect(controller.lastBulkActionResult, isNull); + }); + + test('moves all selected products to draft', () async { + await controller.load(); + + // Select two published products (ids 1 and 2). + controller.toggleMultiSelect('1'); + controller.toggleMultiSelect('2'); + expect(controller.multiSelectedCount, 2); + + await controller.bulkUpdateStatus(PublishStatus.draft); + + // Both should now be draft. + final p1 = controller.drafts.firstWhere((d) => d.id == '1'); + final p2 = controller.drafts.firstWhere((d) => d.id == '2'); + expect(p1.status, PublishStatus.draft); + expect(p2.status, PublishStatus.draft); + }); + + test('sets lastBulkActionResult on all-success', () async { + await controller.load(); + + controller.toggleMultiSelect('1'); + controller.toggleMultiSelect('3'); + await controller.bulkUpdateStatus(PublishStatus.draft); + + expect(controller.lastBulkActionResult, isNotNull); + expect(controller.lastBulkActionResult!.successCount, 2); + expect(controller.lastBulkActionResult!.failureCount, 0); + expect(controller.lastBulkActionResult!.targetStatus, PublishStatus.draft); + expect(controller.lastBulkActionResult!.allSucceeded, isTrue); + expect(controller.lastBulkActionResult!.totalCount, 2); + expect(controller.lastBulkActionResult!.failedProductNames, isEmpty); + }); + + test('clears multi-selection after bulk action', () async { + await controller.load(); + + controller.toggleMultiSelect('1'); + controller.toggleMultiSelect('2'); + controller.toggleMultiSelect('3'); + expect(controller.multiSelectedCount, 3); + + await controller.bulkUpdateStatus(PublishStatus.draft); + + expect(controller.multiSelectedIds, isEmpty); + expect(controller.multiSelectedCount, 0); + expect(controller.isMultiSelectActive, false); + }); + + test('clears updatingIds after bulk action', () async { + await controller.load(); + + controller.toggleMultiSelect('1'); + controller.toggleMultiSelect('2'); + + await controller.bulkUpdateStatus(PublishStatus.draft); + + expect(controller.updatingIds, isEmpty); + }); + + test('handles mixed statuses — all become draft', () async { + await controller.load(); + + // Select one of each status type. + controller.toggleMultiSelect('1'); // published + controller.toggleMultiSelect('3'); // pendingReview + controller.toggleMultiSelect('4'); // draft (already) + controller.toggleMultiSelect('6'); // unpublished + + await controller.bulkUpdateStatus(PublishStatus.draft); + + for (final id in ['1', '3', '4', '6']) { + final product = controller.drafts.firstWhere((d) => d.id == id); + expect(product.status, PublishStatus.draft, reason: 'Product $id should be draft'); + } + expect(controller.lastBulkActionResult!.successCount, 4); + }); + + test('consumeBulkActionResult clears the result', () async { + await controller.load(); + + controller.toggleMultiSelect('1'); + await controller.bulkUpdateStatus(PublishStatus.draft); + expect(controller.lastBulkActionResult, isNotNull); + + controller.consumeBulkActionResult(); + expect(controller.lastBulkActionResult, isNull); + }); + + test('lastBulkActionResult starts as null', () { + expect(controller.lastBulkActionResult, isNull); + }); + + test('preserves single-item preview selection after bulk action', () async { + await controller.load(); + + // Select product 5 for preview. + controller.selectBySku('SH-SUN-005'); + expect(controller.selectedDraft!.id, '5'); + + // Multi-select other products. + controller.toggleMultiSelect('1'); + controller.toggleMultiSelect('2'); + + await controller.bulkUpdateStatus(PublishStatus.draft); + + // Preview selection should still be on product 5. + expect(controller.selectedDraft, isNotNull); + expect(controller.selectedDraft!.id, '5'); + }); + + test('filter and sort persist after bulk action', () async { + await controller.load(); + + controller.setSort(ProductSortField.lastModified, ascending: false); + + controller.toggleMultiSelect('1'); + controller.toggleMultiSelect('2'); + + await controller.bulkUpdateStatus(PublishStatus.draft); + + expect(controller.activeSortField, ProductSortField.lastModified); + expect(controller.sortAscending, false); + }); + + test('does not notify after disposal during bulk action', () async { + await controller.load(); + + controller.toggleMultiSelect('1'); + controller.toggleMultiSelect('2'); + + final future = controller.bulkUpdateStatus(PublishStatus.draft); + controller.dispose(); + + // Should complete without throwing. + await future; + }); + }); + group('disposed guard', () { test('load does not notify after disposal', () async { // Start load, then immediately dispose.