Merge feat/bulk-status-actions: Stage 7A bulk move-to-draft action
Publish Docs / publish-docs (push) Successful in 1m38s Details

This commit is contained in:
Mike Kell 2026-05-30 10:36:13 -04:00
commit 2c3ed3b926
6 changed files with 371 additions and 18 deletions

View File

@ -2,10 +2,9 @@
## Current status ## Current status
- main baseline updated through: android-mobile-ux-hardening (Stage 6B complete) - main baseline updated through: bulk-status-actions (Stage 7A in progress)
- main baseline commit: merge of `feat/android-mobile-ux-hardening` (2026-05-30) - next branch: feat/bulk-status-actions (Stage 7A)
- next branch: feat/bulk-status-actions (Stage 7) - current stage: Stage 7A — Bulk Status Actions
- current stage: Stage 6 complete — Stage 7 next
## Slice tracker ## Slice tracker
@ -215,7 +214,7 @@
### feat/android-mobile-ux-hardening ### feat/android-mobile-ux-hardening
- status: ready for merge - status: merged to main
- date: 2026-05-30 - date: 2026-05-30
- inspection: complete - inspection: complete
- implementation: 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) - tests: passed (300/300 feature_wordpress, 14/14 kell_mobile, 24/24 kell_web, 41/41 design_system — 379 total)
- analyze: passed - analyze: passed
- brief updated: yes - 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

View File

@ -110,6 +110,7 @@ Rules:
- ✅ Cross-platform shell composition strategy landed (Stage 4B complete — merged `feat/shared-composition-pattern``main`, 2026-05-22). - ✅ 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). - ✅ 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). - ✅ 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` ### Current narrow edit capabilities on `main`
@ -129,7 +130,7 @@ Rules:
- `kell_mobile` tests passing - `kell_mobile` tests passing
- latest reported count for `core`: `20/20 passed` - latest reported count for `core`: `20/20 passed`
- latest reported count for `design_system`: `41/41 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_web`: `24/24 passed`
- latest reported count for `kell_mobile`: `14/14 passed` - latest reported count for `kell_mobile`: `14/14 passed`
- baseline commit: merge of `feat/android-mobile-ux-hardening` (2026-05-30) - 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-status-actions`
- `feat/bulk-operator-workflows` - `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 #### ~~Stage 7A — Bulk move-to-draft (web)~~ ✅ COMPLETE
- multi-select groundwork is complete
- post-write consistency is hardened
- Android core publishing surface exists
#### 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 #### Explicitly defer

View File

@ -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<String> 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 /// Controller that manages the product publishing workspace state, including
/// filtering by publish status, free-text search, and draft selection. /// filtering by publish status, free-text search, and draft selection.
class ProductPublishingController extends ChangeNotifier { class ProductPublishingController extends ChangeNotifier {
@ -344,6 +374,68 @@ class ProductPublishingController extends ChangeNotifier {
lastCategoryResult = null; 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<void> bulkUpdateStatus(PublishStatus status) async {
if (multiSelectedIds.isEmpty) return;
// Snapshot the ids to process the set may be cleared during iteration.
final idsToProcess = List<String>.of(multiSelectedIds);
// Mark all as updating.
updatingIds.addAll(idsToProcess);
_safeNotify();
int successCount = 0;
int failureCount = 0;
final failedNames = <String>[];
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. /// Loads all product drafts and applies any current filter / search.
Future<void> load() async { Future<void> load() async {
isLoading = true; isLoading = true;

View File

@ -125,6 +125,38 @@ class _ProductPublishingPageState extends State<ProductPublishingPage> {
controller.consumeCategoryResult(); controller.consumeCategoryResult();
showCategoryActionSnackBar(context, categoryResult); 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<void> _confirmBulkMoveToDraft(BuildContext context) async {
final count = controller.multiSelectedCount;
final confirmed = await showDialog<bool>(
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. /// Handles keyboard events for list navigation.
@ -238,6 +270,7 @@ class _ProductPublishingPageState extends State<ProductPublishingPage> {
totalCount: count, totalCount: count,
onSelectAll: controller.selectAllVisible, onSelectAll: controller.selectAllVisible,
onClearSelection: controller.clearMultiSelection, onClearSelection: controller.clearMultiSelection,
onBulkMoveToDraft: () => _confirmBulkMoveToDraft(context),
), ),
// List header with count and density toggle // List header with count and density toggle
Padding( Padding(
@ -319,21 +352,21 @@ class _ProductPublishingPageState extends State<ProductPublishingPage> {
} }
/// A compact action bar shown above the product list when multi-select is /// A compact action bar shown above the product list when multi-select is
/// active. Displays the count of selected items and provides Select All / /// active. Displays the count of selected items and provides bulk actions,
/// Clear Selection actions. /// Select All, and Clear Selection.
///
/// No bulk-write actions are included this is groundwork only.
class _MultiSelectBar extends StatelessWidget { class _MultiSelectBar extends StatelessWidget {
final int selectedCount; final int selectedCount;
final int totalCount; final int totalCount;
final VoidCallback onSelectAll; final VoidCallback onSelectAll;
final VoidCallback onClearSelection; final VoidCallback onClearSelection;
final VoidCallback onBulkMoveToDraft;
const _MultiSelectBar({ const _MultiSelectBar({
required this.selectedCount, required this.selectedCount,
required this.totalCount, required this.totalCount,
required this.onSelectAll, required this.onSelectAll,
required this.onClearSelection, required this.onClearSelection,
required this.onBulkMoveToDraft,
}); });
@override @override
@ -359,6 +392,12 @@ class _MultiSelectBar extends StatelessWidget {
fontWeight: FontWeight.w600, 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(), const Spacer(),
if (!allSelected) TextButton(onPressed: onSelectAll, child: const Text('Select All')), if (!allSelected) TextButton(onPressed: onSelectAll, child: const Text('Select All')),
TextButton(onPressed: onClearSelection, child: const Text('Clear')), TextButton(onPressed: onClearSelection, child: const Text('Clear')),

View File

@ -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]. /// Shows a [SnackBar] for the given [NameActionResult].
void showNameActionSnackBar(BuildContext context, NameActionResult result) { void showNameActionSnackBar(BuildContext context, NameActionResult result) {
final messenger = ScaffoldMessenger.maybeOf(context); final messenger = ScaffoldMessenger.maybeOf(context);

View File

@ -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', () { group('disposed guard', () {
test('load does not notify after disposal', () async { test('load does not notify after disposal', () async {
// Start load, then immediately dispose. // Start load, then immediately dispose.