diff --git a/docs/development/build_execution_tracker.md b/docs/development/build_execution_tracker.md index 0af6c38..aab1fb6 100644 --- a/docs/development/build_execution_tracker.md +++ b/docs/development/build_execution_tracker.md @@ -2,9 +2,9 @@ ## Current status -- main baseline updated through: bulk-status-actions (Stage 7A complete) -- next branch: feat/bulk-operator-workflows (Stage 7B) or feat/integrations-contracts (Stage 8A) -- current stage: Stage 7A complete — deciding next slice +- main baseline updated through: bulk-operator-workflows (Stage 7B complete) +- next branch: feat/integrations-contracts (Stage 8A) +- current stage: Stage 7 complete — proceeding to infrastructure activation ## Slice tracker @@ -244,7 +244,7 @@ ### chore/analyze-cleanup-and-tracker-sync -- status: in progress +- status: merged to main - date: 2026-05-30 - inspection: complete - implementation: complete @@ -260,3 +260,20 @@ - tests: passed (311/311 feature_wordpress, 14/14 kell_mobile, 24/24 kell_web, 41/41 design_system, 20/20 core — 410 total) - analyze: passed (dart analyze --fatal-infos — no issues found across all packages) - brief updated: yes + +### feat/bulk-operator-workflows + +- status: in progress +- date: 2026-05-30 +- inspection: complete +- implementation: complete +- files changed: + - `feature_wordpress/lib/src/data/wordpress_product_mapper.dart` — `toWooCommerceStatus()` now maps `PublishStatus.pendingReview` → `'pending'` instead of throwing `ArgumentError`; only `unpublished` throws + - `feature_wordpress/lib/src/presentation/product_publishing_page.dart` — added `_confirmBulkPublish()` and `_confirmBulkSubmitForReview()` confirmation dialogs; updated `_MultiSelectBar` with `onBulkPublish` and `onBulkSubmitForReview` callbacks; added "Publish" and "Submit for Review" `OutlinedButton.icon` buttons; refactored bar layout from `Row` to `Wrap` for responsive overflow + - `feature_wordpress/test/wordpress_product_mapper_test.dart` — updated `pendingReview` test: now expects `'pending'` return value instead of `ArgumentError` + - `feature_wordpress/test/product_publishing_controller_test.dart` — added 3 new bulk tests: publishes all selected products, submits all for review, handles mixed statuses to published + - `feature_wordpress/test/widgets/product_publishing_page_test.dart` — added 5 new widget tests: multi-select bar shows Publish button, Publish confirmation dialog, Submit for Review confirmation dialog, cancel preserves selection, plural count in dialog + - `feature_wordpress/test/wordpress_product_publishing_repository_test.dart` — removed `pendingReview` from unsupported-throws test; only `unpublished` asserted +- tests: passed (319/319 feature_wordpress) +- analyze: passed (dart analyze --fatal-infos — no issues found) +- brief updated: yes diff --git a/docs/development/master_development_brief.md b/docs/development/master_development_brief.md index 287dcc3..6058063 100644 --- a/docs/development/master_development_brief.md +++ b/docs/development/master_development_brief.md @@ -111,6 +111,7 @@ Rules: - ✅ 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). +- ✅ Bulk publish and bulk submit-for-review actions landed (Stage 7B complete — merged `feat/bulk-operator-workflows` → `main`, 2026-05-30). ### Current narrow edit capabilities on `main` @@ -130,11 +131,11 @@ 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`: `311/311 passed` +- latest reported count for `feature_wordpress`: `319/319 passed` - latest reported count for `kell_web`: `24/24 passed` - latest reported count for `kell_mobile`: `14/14 passed` -- total: `410/410 passed` -- baseline commit: merge of `feat/bulk-status-actions` + analyze cleanup (2026-05-30) +- total: `418/418 passed` +- baseline commit: merge of `feat/bulk-operator-workflows` (2026-05-30) #### Baseline test coverage (established 2026-05-22) @@ -150,8 +151,8 @@ No minimum thresholds are enforced — this is visibility-only tracking. Coverag ### Next recommended branch -**`feat/bulk-operator-workflows`** — Stage 7B: Additional bulk actions (bulk publish, bulk move to pending review), or **`feat/integrations-contracts`** — Stage 8A: Integration abstractions. -Branch from latest `main`. Stage 7A (bulk move-to-draft) is complete. Stage 7B adds incremental bulk actions. Stage 8A begins infrastructure activation required for all subsequent feature work (Stages 9–16). +**`feat/integrations-contracts`** — Stage 8A: Integration abstractions. +Branch from latest `main`. Stage 7 (bulk actions) is complete. Stage 8A begins infrastructure activation required for all subsequent feature work (Stages 9–16). --- @@ -444,12 +445,10 @@ Add carefully scoped bulk operations once single-item workflows are stable acros > 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). -#### Stage 7B — Bulk operator workflows (future) +#### ~~Stage 7B — Bulk operator workflows~~ ✅ COMPLETE -Candidate additional bulk actions (implement one per slice): - -- bulk publish -- bulk move to pending review +> Merged `feat/bulk-operator-workflows` → `main` (2026-05-30). +> Enabled `pendingReview` status in `WordPressProductMapper.toWooCommerceStatus()` (maps to WooCommerce `'pending'`). Added "Publish" and "Submit for Review" bulk action buttons to `_MultiSelectBar` with confirmation dialogs. Refactored bar layout from `Row` to `Wrap` for responsive overflow. 8 new tests added across mapper, controller, page, and repository test suites (319 total `feature_wordpress` tests passing). Stage 7 complete. #### Explicitly defer diff --git a/kell_creations_apps/packages/feature_wordpress/lib/src/data/wordpress_product_mapper.dart b/kell_creations_apps/packages/feature_wordpress/lib/src/data/wordpress_product_mapper.dart index 602c019..48180b2 100644 --- a/kell_creations_apps/packages/feature_wordpress/lib/src/data/wordpress_product_mapper.dart +++ b/kell_creations_apps/packages/feature_wordpress/lib/src/data/wordpress_product_mapper.dart @@ -32,8 +32,9 @@ class WordPressProductMapper { /// Converts a [PublishStatus] domain value to the corresponding /// WooCommerce REST API status string. /// - /// Only [PublishStatus.draft] and [PublishStatus.published] are supported - /// in the first pass. Throws [ArgumentError] for unsupported values. + /// Supports [PublishStatus.draft], [PublishStatus.published], and + /// [PublishStatus.pendingReview]. Throws [ArgumentError] for + /// [PublishStatus.unpublished] which has no direct WooCommerce equivalent. /// /// WooCommerce accepts: `publish`, `draft`, `pending`, `private`, `trash`. static String toWooCommerceStatus(PublishStatus status) { @@ -43,11 +44,12 @@ class WordPressProductMapper { case PublishStatus.published: return 'publish'; case PublishStatus.pendingReview: + return 'pending'; case PublishStatus.unpublished: throw ArgumentError.value( status, 'status', - 'Unsupported status for WooCommerce update in the first pass', + 'Unsupported status for WooCommerce update — unpublished has no direct WooCommerce equivalent', ); } } 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 7a81b38..22d224e 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 @@ -159,6 +159,58 @@ class _ProductPublishingPageState extends State { } } + /// Shows a confirmation dialog before executing bulk publish. + Future _confirmBulkPublish(BuildContext context) async { + final count = controller.multiSelectedCount; + final confirmed = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Publish Products'), + content: Text('Publish $count ${count == 1 ? 'product' : 'products'} to the store?'), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(false), + child: const Text('Cancel'), + ), + FilledButton( + onPressed: () => Navigator.of(context).pop(true), + child: const Text('Publish'), + ), + ], + ), + ); + + if (confirmed == true) { + await controller.bulkUpdateStatus(PublishStatus.published); + } + } + + /// Shows a confirmation dialog before executing bulk submit for review. + Future _confirmBulkSubmitForReview(BuildContext context) async { + final count = controller.multiSelectedCount; + final confirmed = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Submit for Review'), + content: Text('Submit $count ${count == 1 ? 'product' : 'products'} for review?'), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(false), + child: const Text('Cancel'), + ), + FilledButton( + onPressed: () => Navigator.of(context).pop(true), + child: const Text('Submit for Review'), + ), + ], + ), + ); + + if (confirmed == true) { + await controller.bulkUpdateStatus(PublishStatus.pendingReview); + } + } + /// Handles keyboard events for list navigation. KeyEventResult _handleKeyEvent(FocusNode node, KeyEvent event) { if (event is! KeyDownEvent && event is! KeyRepeatEvent) { @@ -271,6 +323,8 @@ class _ProductPublishingPageState extends State { onSelectAll: controller.selectAllVisible, onClearSelection: controller.clearMultiSelection, onBulkMoveToDraft: () => _confirmBulkMoveToDraft(context), + onBulkPublish: () => _confirmBulkPublish(context), + onBulkSubmitForReview: () => _confirmBulkSubmitForReview(context), ), // ── List header with count and density toggle ─────────────── Padding( @@ -360,6 +414,8 @@ class _MultiSelectBar extends StatelessWidget { final VoidCallback onSelectAll; final VoidCallback onClearSelection; final VoidCallback onBulkMoveToDraft; + final VoidCallback onBulkPublish; + final VoidCallback onBulkSubmitForReview; const _MultiSelectBar({ required this.selectedCount, @@ -367,6 +423,8 @@ class _MultiSelectBar extends StatelessWidget { required this.onSelectAll, required this.onClearSelection, required this.onBulkMoveToDraft, + required this.onBulkPublish, + required this.onBulkSubmitForReview, }); @override @@ -381,10 +439,12 @@ class _MultiSelectBar extends StatelessWidget { borderRadius: BorderRadius.circular(8), border: Border.all(color: KcColors.denimBlue.withValues(alpha: 0.3)), ), - child: Row( + child: Wrap( + spacing: KcSpacing.xs, + runSpacing: KcSpacing.xs, + crossAxisAlignment: WrapCrossAlignment.center, children: [ Icon(Icons.checklist, size: 18, color: KcColors.denimBlue), - const SizedBox(width: KcSpacing.xs), Text( '$selectedCount selected', style: Theme.of(context).textTheme.bodyMedium?.copyWith( @@ -392,13 +452,21 @@ 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(), + OutlinedButton.icon( + onPressed: onBulkPublish, + icon: const Icon(Icons.publish_outlined, size: 16), + label: const Text('Publish'), + ), + OutlinedButton.icon( + onPressed: onBulkSubmitForReview, + icon: const Icon(Icons.rate_review_outlined, size: 16), + label: const Text('Submit for Review'), + ), 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/test/product_publishing_controller_test.dart b/kell_creations_apps/packages/feature_wordpress/test/product_publishing_controller_test.dart index 45b266a..73f299f 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 @@ -1589,6 +1589,66 @@ void main() { // Should complete without throwing. await future; }); + + test('publishes all selected products via bulk action', () async { + await controller.load(); + + // Select two draft products (ids 4 and 5). + controller.toggleMultiSelect('4'); + controller.toggleMultiSelect('5'); + expect(controller.multiSelectedCount, 2); + + await controller.bulkUpdateStatus(PublishStatus.published); + + final p4 = controller.drafts.firstWhere((d) => d.id == '4'); + final p5 = controller.drafts.firstWhere((d) => d.id == '5'); + expect(p4.status, PublishStatus.published); + expect(p5.status, PublishStatus.published); + expect(controller.lastBulkActionResult, isNotNull); + expect(controller.lastBulkActionResult!.successCount, 2); + expect(controller.lastBulkActionResult!.targetStatus, PublishStatus.published); + expect(controller.lastBulkActionResult!.allSucceeded, isTrue); + }); + + test('submits all selected products for review via bulk action', () async { + await controller.load(); + + // Select two draft products (ids 4 and 5). + controller.toggleMultiSelect('4'); + controller.toggleMultiSelect('5'); + + await controller.bulkUpdateStatus(PublishStatus.pendingReview); + + final p4 = controller.drafts.firstWhere((d) => d.id == '4'); + final p5 = controller.drafts.firstWhere((d) => d.id == '5'); + expect(p4.status, PublishStatus.pendingReview); + expect(p5.status, PublishStatus.pendingReview); + expect(controller.lastBulkActionResult, isNotNull); + expect(controller.lastBulkActionResult!.successCount, 2); + expect(controller.lastBulkActionResult!.targetStatus, PublishStatus.pendingReview); + expect(controller.lastBulkActionResult!.allSucceeded, isTrue); + }); + + test('handles mixed statuses — all become published', () async { + await controller.load(); + + // Select one of each status type. + controller.toggleMultiSelect('3'); // pendingReview + controller.toggleMultiSelect('4'); // draft + controller.toggleMultiSelect('6'); // unpublished + + await controller.bulkUpdateStatus(PublishStatus.published); + + for (final id in ['3', '4', '6']) { + final product = controller.drafts.firstWhere((d) => d.id == id); + expect( + product.status, + PublishStatus.published, + reason: 'Product $id should be published', + ); + } + expect(controller.lastBulkActionResult!.successCount, 3); + }); }); group('disposed guard', () { diff --git a/kell_creations_apps/packages/feature_wordpress/test/widgets/product_publishing_page_test.dart b/kell_creations_apps/packages/feature_wordpress/test/widgets/product_publishing_page_test.dart index 029ceb2..5770734 100644 --- a/kell_creations_apps/packages/feature_wordpress/test/widgets/product_publishing_page_test.dart +++ b/kell_creations_apps/packages/feature_wordpress/test/widgets/product_publishing_page_test.dart @@ -215,4 +215,147 @@ void main() { expect(find.byIcon(Icons.touch_app_outlined), findsOneWidget); }); }); + + group('ProductPublishingPage multi-select bulk actions', () { + Widget buildPage({FakeProductPublishingRepository? repo}) { + return MaterialApp( + theme: buildKcTheme(), + home: Scaffold( + body: ProductPublishingPage(repository: repo ?? FakeProductPublishingRepository()), + ), + ); + } + + testWidgets('multi-select bar shows Publish button', (tester) async { + tester.view.physicalSize = const Size(600, 800); + tester.view.devicePixelRatio = 1.0; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + await tester.pumpWidget(buildPage()); + await tester.pumpAndSettle(); + + // Activate multi-select by long-pressing the first product card's checkbox area. + // The checkbox appears on the card — we toggle multi-select via the card's onMultiSelectToggle. + // ProductDraftCard has a Checkbox when in multi-select mode, but multi-select is + // triggered by tapping the checkbox. Since no items are selected initially, + // the checkbox is shown as leading widget. Let's find checkboxes. + final checkboxes = find.byType(Checkbox); + expect(checkboxes, findsWidgets); + + // Tap first checkbox to enter multi-select mode. + await tester.tap(checkboxes.first); + await tester.pumpAndSettle(); + + // Multi-select bar should appear with all three bulk action buttons. + expect(find.text('Move to Draft'), findsOneWidget); + expect(find.text('Publish'), findsOneWidget); + expect(find.text('Submit for Review'), findsOneWidget); + expect(find.text('1 selected'), findsOneWidget); + }); + + testWidgets('Publish button shows confirmation dialog', (tester) async { + tester.view.physicalSize = const Size(600, 800); + tester.view.devicePixelRatio = 1.0; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + await tester.pumpWidget(buildPage()); + await tester.pumpAndSettle(); + + // Activate multi-select. + final checkboxes = find.byType(Checkbox); + await tester.tap(checkboxes.first); + await tester.pumpAndSettle(); + + // Tap the Publish button. + await tester.tap(find.text('Publish')); + await tester.pumpAndSettle(); + + // Confirmation dialog should appear. + expect(find.text('Publish Products'), findsOneWidget); + expect(find.text('Publish 1 product to the store?'), findsOneWidget); + expect(find.text('Cancel'), findsOneWidget); + }); + + testWidgets('Submit for Review button shows confirmation dialog', (tester) async { + tester.view.physicalSize = const Size(600, 800); + tester.view.devicePixelRatio = 1.0; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + await tester.pumpWidget(buildPage()); + await tester.pumpAndSettle(); + + // Activate multi-select. + final checkboxes = find.byType(Checkbox); + await tester.tap(checkboxes.first); + await tester.pumpAndSettle(); + + // Tap the Submit for Review button. + await tester.tap(find.text('Submit for Review')); + await tester.pumpAndSettle(); + + // Confirmation dialog should appear. + expect(find.text('Submit for Review'), findsWidgets); // button + dialog title + expect(find.text('Submit 1 product for review?'), findsOneWidget); + expect(find.text('Cancel'), findsOneWidget); + }); + + testWidgets('cancelling publish dialog does not change statuses', (tester) async { + tester.view.physicalSize = const Size(600, 800); + tester.view.devicePixelRatio = 1.0; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + await tester.pumpWidget(buildPage()); + await tester.pumpAndSettle(); + + // Activate multi-select. + final checkboxes = find.byType(Checkbox); + await tester.tap(checkboxes.first); + await tester.pumpAndSettle(); + + // Tap the Publish button. + await tester.tap(find.text('Publish')); + await tester.pumpAndSettle(); + + // Cancel the dialog. + await tester.tap(find.text('Cancel')); + await tester.pumpAndSettle(); + + // Multi-select should still be active (nothing changed). + expect(find.text('1 selected'), findsOneWidget); + }); + + testWidgets('plural product count in confirmation dialog', (tester) async { + tester.view.physicalSize = const Size(600, 800); + tester.view.devicePixelRatio = 1.0; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + await tester.pumpWidget(buildPage()); + await tester.pumpAndSettle(); + + // Select two products. + final checkboxes = find.byType(Checkbox); + await tester.tap(checkboxes.at(0)); + await tester.pumpAndSettle(); + await tester.tap(checkboxes.at(1)); + await tester.pumpAndSettle(); + + expect(find.text('2 selected'), findsOneWidget); + + // Tap the Publish button. + await tester.tap(find.text('Publish')); + await tester.pumpAndSettle(); + + // Dialog should use plural form. + expect(find.text('Publish 2 products to the store?'), findsOneWidget); + + // Dismiss. + await tester.tap(find.text('Cancel')); + await tester.pumpAndSettle(); + }); + }); } diff --git a/kell_creations_apps/packages/feature_wordpress/test/wordpress_product_mapper_test.dart b/kell_creations_apps/packages/feature_wordpress/test/wordpress_product_mapper_test.dart index faf40dc..6877e56 100644 --- a/kell_creations_apps/packages/feature_wordpress/test/wordpress_product_mapper_test.dart +++ b/kell_creations_apps/packages/feature_wordpress/test/wordpress_product_mapper_test.dart @@ -175,11 +175,8 @@ void main() { expect(WordPressProductMapper.toWooCommerceStatus(PublishStatus.published), 'publish'); }); - test('pendingReview throws ArgumentError', () { - expect( - () => WordPressProductMapper.toWooCommerceStatus(PublishStatus.pendingReview), - throwsA(isA()), - ); + test('pendingReview -> "pending"', () { + expect(WordPressProductMapper.toWooCommerceStatus(PublishStatus.pendingReview), 'pending'); }); test('unpublished throws ArgumentError', () { diff --git a/kell_creations_apps/packages/feature_wordpress/test/wordpress_product_publishing_repository_test.dart b/kell_creations_apps/packages/feature_wordpress/test/wordpress_product_publishing_repository_test.dart index 2a4a10a..d805d99 100644 --- a/kell_creations_apps/packages/feature_wordpress/test/wordpress_product_publishing_repository_test.dart +++ b/kell_creations_apps/packages/feature_wordpress/test/wordpress_product_publishing_repository_test.dart @@ -374,10 +374,7 @@ void main() { final repository = WordPressProductPublishingRepository(apiClient: apiClient); - expect( - () => repository.updateProductStatus('1', PublishStatus.pendingReview), - throwsA(isA()), - ); + // Only unpublished has no WooCommerce equivalent. expect( () => repository.updateProductStatus('1', PublishStatus.unpublished), throwsA(isA()),