Merge feat/bulk-status-actions: Stage 7A bulk move-to-draft action
Publish Docs / publish-docs (push) Successful in 1m38s
Details
Publish Docs / publish-docs (push) Successful in 1m38s
Details
This commit is contained in:
commit
2c3ed3b926
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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')),
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue