Compare commits

...

3 Commits

Author SHA1 Message Date
Mike Kell c67f92f844 Merge feat/bulk-operator-workflows into main (Stage 7B complete)
Publish Docs / publish-docs (push) Successful in 41s Details
2026-07-11 08:08:14 -04:00
Mike Kell 6898d9b877 chore: mark feat/bulk-operator-workflows as merged to main in tracker 2026-07-11 08:04:15 -04:00
Mike Kell c4f76a90e5 feat: add bulk publish and bulk submit-for-review actions (Stage 7B)
Validate Docs / validate-docs (push) Successful in 59s Details
Flutter Analyze / Dart Analyze (push) Has been cancelled Details
Flutter Test / Flutter Tests (push) Has been cancelled Details
- Enable pendingReview in WordPressProductMapper.toWooCommerceStatus()
  (maps to WooCommerce 'pending'; only unpublished throws ArgumentError)
- Add Publish and Submit for Review buttons to _MultiSelectBar with
  confirmation dialogs and proper plural/singular messaging
- Refactor multi-select bar layout from Row to Wrap for responsive overflow
- Add 3 controller tests: bulk publish, bulk pending review, mixed statuses
- Add 5 page widget tests: button visibility, confirmation dialogs, cancel
  preserves selection, plural count
- Update repository test: remove pendingReview from unsupported-throws
- Update mapper test: pendingReview now returns 'pending'
- 319/319 feature_wordpress tests passing, dart analyze clean
- Update master_development_brief.md and build_execution_tracker.md
2026-05-30 11:17:13 -04:00
8 changed files with 322 additions and 32 deletions

View File

@ -2,9 +2,9 @@
## Current status
- main baseline updated through: feat/inventory-mobile-surface (Stage 6C complete)
- next branch: feat/bulk-operator-workflows (Stage 7B)
- current stage: Stage 6C complete — mobile inventory surface with MobileInventoryPage (search, filter chips, item count, pull-to-refresh, compact 120px cards), MobileInventoryDetailPage (haptic feedback, adjust quantity dialog, local SnackBar listener, SafeArea), shell updated from shared InventoryPage to MobileInventoryPage, and 6 new kell_mobile tests (26/26 total). Stage 6 complete.
- 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
@ -377,3 +377,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: merged to main
- 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

View File

@ -110,9 +110,18 @@ 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).
- ✅ Android app shell and bootstrap landed (Stage 5A complete — merged `feat/android-app-shell``main`, 2026-05-28).
- ✅ Android publishing surface landed (Stage 5B complete — merged `feat/android-publishing-surface``main`, 2026-05-29). Stage 5 complete.
- ✅ Android feedback and action polish landed (Stage 6A complete — merged `feat/android-feedback-polish``main`, 2026-05-29).
- ✅ Android mobile workflow hardening landed (Stage 6B complete — merged `feat/android-mobile-ux-hardening``main`, 2026-05-30).
- ✅ Bulk move-to-draft action landed (Stage 7A complete — merged `feat/bulk-status-actions``main`, 2026-05-30).
- ✅ Inventory domain expansion landed (Stage 5A-inv complete — merged `feat/inventory-domain-expansion``main`, 2026-07-10).
- ✅ Inventory page UI landed (Stage 5B-inv complete — merged `feat/inventory-page-ui``main`, 2026-07-10).
- ✅ Orders domain expansion landed (Stage 5C complete — merged `feat/orders-domain``main`, 2026-07-10).
- ✅ Orders page UI hardening landed (Stage 5D complete — merged `feat/orders-page-ui``main`, 2026-07-10).
- ✅ Orders page UI landed (Stage 5D complete — merged `feat/orders-page-ui``main`, 2026-07-10).
- ✅ Orders mobile surface landed (Stage 6B-orders complete — merged `feat/orders-mobile-surface``main`, 2026-07-10).
- ✅ Inventory mobile surface landed (Stage 6C complete — merged `feat/inventory-mobile-surface``main`, 2026-07-10). Stage 6 complete.
- ✅ Bulk publish and bulk submit-for-review actions landed (Stage 7B complete — merged `feat/bulk-operator-workflows``main`, 2026-07-11). Stage 7 complete.
### Current narrow edit capabilities on `main`
@ -127,7 +136,6 @@ Rules:
- `dart analyze` clean
- `core` tests passing
- `design_system` tests passing
- `feature_inventory` tests passing
- `feature_wordpress` tests passing
- `kell_web` tests passing
- `kell_mobile` tests passing
@ -135,11 +143,11 @@ Rules:
- latest reported count for `design_system`: `41/41 passed`
- latest reported count for `feature_inventory`: `75/75 passed`
- latest reported count for `feature_orders`: `115/115 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`: `26/26 passed`
- total: `612/612 passed`
- baseline commit: merge of `feat/inventory-mobile-surface` (Stage 6C complete, 2026-07-10)
- total: `620/620 passed`
- baseline commit: merge of `feat/bulk-operator-workflows` into `main` (Stage 7B complete, 2026-07-11)
#### Baseline test coverage (established 2026-05-22)
@ -155,8 +163,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).
Branch from latest `main`. Stage 6C (inventory mobile surface) is complete. All three primary features (Products, Orders, Inventory) now have mobile-optimized surfaces on Android. Stage 6 is complete. Next logical step is adding more bulk publishing actions.
**`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 916).
---
@ -449,12 +457,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
@ -1254,7 +1260,7 @@ Working rules:
| ------------------- | ------------ | ----------------- | ------------ | -------------- | -------------------------------------------------- | ------- | -------------------- |
| `feature_wordpress` | ✅ Complete | ✅ Complete | ✅ Complete | ✅ WooCommerce | ✅ Complete | 294 | **Production-ready** |
| `feature_inventory` | ⚠️ Read-only | ⚠️ Read-only | ⚠️ Read-only | ❌ None | ⚠️ Read-only | Minimal | **Fake-only MVP** |
| `feature_orders` | ✅ Write ops | ✅ Write ops | ✅ Write ops | ❌ None | ✅ Write-capable UI | 115 | **Fake write-ready** |
| `feature_orders` | ⚠️ Read-only | ⚠️ Read-only | ⚠️ Read-only | ❌ None | ⚠️ Read-only | Some | **Fake-only MVP** |
| `feature_policy` | ✅ Complete | ✅ Complete | ✅ Complete | ❌ None | ✅ Complete | Minimal | **Fake-only MVP** |
| `feature_finance` | ❌ Stub | ❌ Stub | ❌ Stub | ❌ None | ❌ Stub | None | **Scaffolded only** |
| `feature_mrp` | ❌ Stub | ❌ Stub | ❌ Stub | ❌ None | ❌ Stub | None | **Scaffolded only** |

View File

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

View File

@ -159,6 +159,58 @@ class _ProductPublishingPageState extends State<ProductPublishingPage> {
}
}
/// Shows a confirmation dialog before executing bulk publish.
Future<void> _confirmBulkPublish(BuildContext context) async {
final count = controller.multiSelectedCount;
final confirmed = await showDialog<bool>(
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<void> _confirmBulkSubmitForReview(BuildContext context) async {
final count = controller.multiSelectedCount;
final confirmed = await showDialog<bool>(
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<ProductPublishingPage> {
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')),
],

View File

@ -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', () {

View File

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

View File

@ -175,11 +175,8 @@ void main() {
expect(WordPressProductMapper.toWooCommerceStatus(PublishStatus.published), 'publish');
});
test('pendingReview throws ArgumentError', () {
expect(
() => WordPressProductMapper.toWooCommerceStatus(PublishStatus.pendingReview),
throwsA(isA<ArgumentError>()),
);
test('pendingReview -> "pending"', () {
expect(WordPressProductMapper.toWooCommerceStatus(PublishStatus.pendingReview), 'pending');
});
test('unpublished throws ArgumentError', () {

View File

@ -374,10 +374,7 @@ void main() {
final repository = WordPressProductPublishingRepository(apiClient: apiClient);
expect(
() => repository.updateProductStatus('1', PublishStatus.pendingReview),
throwsA(isA<ArgumentError>()),
);
// Only unpublished has no WooCommerce equivalent.
expect(
() => repository.updateProductStatus('1', PublishStatus.unpublished),
throwsA(isA<ArgumentError>()),