Merge feat/multi-select-groundwork into main (Stage 3A complete)
Publish Docs / publish-docs (push) Successful in 1m26s
Details
Publish Docs / publish-docs (push) Successful in 1m26s
Details
This commit is contained in:
commit
f3fbbca06d
|
|
@ -81,6 +81,7 @@ Rules:
|
|||
- ✅ Category-only product edit landed (Stage 1B complete — merged `feat/category-only-edit` → `main` at `8e7e4cb`, 2026-04-11). Stage 1 complete.
|
||||
- ✅ Post-write consistency hardening landed (Stage 2A complete — merged `feat/post-write-consistency` → `main` at `7acff83`, 2026-04-11).
|
||||
- ✅ Publishing workflow UX hardening landed (Stage 2B complete — merged `feat/publishing-ux-hardening` → `main` at `b81016d`, 2026-04-11). Stage 2 complete.
|
||||
- ✅ Multi-select groundwork landed (Stage 3A complete — merged `feat/multi-select-groundwork` → `main`, 2026-05-22).
|
||||
|
||||
### Current narrow edit capabilities on `main`
|
||||
|
||||
|
|
@ -95,14 +96,14 @@ Rules:
|
|||
- `dart analyze` clean
|
||||
- `feature_wordpress` tests passing
|
||||
- `kell_web` dashboard tests passing
|
||||
- latest reported count for `feature_wordpress`: `247/247 passed`
|
||||
- latest reported count for `feature_wordpress`: `262/262 passed`
|
||||
- latest reported count for `kell_web` dashboard tests: `5/5 passed`
|
||||
- baseline commit: `b81016d` (2026-04-11)
|
||||
- baseline commit: merge of `feat/multi-select-groundwork` (2026-05-22)
|
||||
|
||||
### Next recommended branch
|
||||
|
||||
**`feat/multi-select-groundwork`** — Stage 3A: Multi-select groundwork (read/state only first).
|
||||
Branch from `main` at `b81016d`. Stage 2 (operational hardening) is complete.
|
||||
**`feat/list-efficiency-improvements`** — Stage 3B: List efficiency improvements.
|
||||
Branch from latest `main`. Stage 3A (multi-select groundwork) is complete.
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -184,24 +185,10 @@ Increase throughput for product triage and management using existing data.
|
|||
- `feat/multi-select-groundwork`
|
||||
- `feat/list-efficiency-improvements`
|
||||
|
||||
#### Stage 3A — Multi-select groundwork (read/state only first)
|
||||
#### ~~Stage 3A — Multi-select groundwork (read/state only first)~~ ✅ COMPLETE
|
||||
|
||||
##### Goal
|
||||
|
||||
Prepare for future bulk actions without implementing bulk writes yet.
|
||||
|
||||
##### Requirements
|
||||
|
||||
- add selection model for multiple items
|
||||
- show selected-count UI
|
||||
- allow clear selection
|
||||
- preserve current single-item preview behavior where appropriate
|
||||
- do not add bulk publish/edit/delete actions yet
|
||||
|
||||
##### Definition of done
|
||||
|
||||
- multi-select state exists and is tested
|
||||
- no bulk writes introduced
|
||||
> Merged `feat/multi-select-groundwork` → `main` (2026-05-22).
|
||||
> Added multi-selection state to `ProductPublishingController` (`toggleMultiSelect`, `clearMultiSelection`, `selectAllVisible`, `isMultiSelected`, `multiSelectedIds`, `multiSelectedCount`). Updated `ProductDraftCard` with optional leading checkbox for multi-select mode. Added `_MultiSelectBar` to `ProductPublishingPage` with selected-count display, Select All, and Clear actions. Single-item preview selection preserved independently. 15 new multi-select tests added (262 total `feature_wordpress` tests passing). No bulk writes introduced.
|
||||
|
||||
#### Stage 3B — List efficiency improvements
|
||||
|
||||
|
|
|
|||
|
|
@ -157,6 +157,46 @@ class ProductPublishingController extends ChangeNotifier {
|
|||
/// Whether the current sort is ascending (`true`) or descending (`false`).
|
||||
bool sortAscending = true;
|
||||
|
||||
/// Product IDs that the operator has selected for potential bulk actions.
|
||||
///
|
||||
/// Multi-select is independent of the single-item [selectedDraft] preview.
|
||||
/// Adding or removing items here does not change which product is shown
|
||||
/// in the detail panel.
|
||||
final Set<String> multiSelectedIds = {};
|
||||
|
||||
/// The number of products currently multi-selected.
|
||||
int get multiSelectedCount => multiSelectedIds.length;
|
||||
|
||||
/// Whether multi-select mode is active (at least one item selected).
|
||||
bool get isMultiSelectActive => multiSelectedIds.isNotEmpty;
|
||||
|
||||
/// Whether the product with [id] is part of the multi-selection.
|
||||
bool isMultiSelected(String id) => multiSelectedIds.contains(id);
|
||||
|
||||
/// Toggles the multi-select state of the product with [id].
|
||||
///
|
||||
/// If the product is already selected it is removed; otherwise it is added.
|
||||
void toggleMultiSelect(String id) {
|
||||
if (multiSelectedIds.contains(id)) {
|
||||
multiSelectedIds.remove(id);
|
||||
} else {
|
||||
multiSelectedIds.add(id);
|
||||
}
|
||||
_safeNotify();
|
||||
}
|
||||
|
||||
/// Selects all currently visible products (those in [drafts]).
|
||||
void selectAllVisible() {
|
||||
multiSelectedIds.addAll(drafts.map((d) => d.id));
|
||||
_safeNotify();
|
||||
}
|
||||
|
||||
/// Clears the entire multi-selection.
|
||||
void clearMultiSelection() {
|
||||
multiSelectedIds.clear();
|
||||
_safeNotify();
|
||||
}
|
||||
|
||||
/// Product IDs that currently have an in-flight status update.
|
||||
///
|
||||
/// Used to prevent duplicate clicks and to let the UI show per-row
|
||||
|
|
|
|||
|
|
@ -204,6 +204,14 @@ class _ProductPublishingPageState extends State<ProductPublishingPage> {
|
|||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// ── Multi-select action bar ──────────────────────────────────
|
||||
if (controller.isMultiSelectActive)
|
||||
_MultiSelectBar(
|
||||
selectedCount: controller.multiSelectedCount,
|
||||
totalCount: count,
|
||||
onSelectAll: controller.selectAllVisible,
|
||||
onClearSelection: controller.clearMultiSelection,
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: KcSpacing.xs, bottom: KcSpacing.sm),
|
||||
child: Text(
|
||||
|
|
@ -223,6 +231,8 @@ class _ProductPublishingPageState extends State<ProductPublishingPage> {
|
|||
draft: draft,
|
||||
isSelected: draft.id == controller.selectedDraft?.id,
|
||||
onTap: () => controller.selectDraft(draft),
|
||||
isMultiSelected: controller.isMultiSelected(draft.id),
|
||||
onMultiSelectToggle: () => controller.toggleMultiSelect(draft.id),
|
||||
),
|
||||
);
|
||||
},
|
||||
|
|
@ -262,3 +272,53 @@ class _ProductPublishingPageState extends State<ProductPublishingPage> {
|
|||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 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.
|
||||
class _MultiSelectBar extends StatelessWidget {
|
||||
final int selectedCount;
|
||||
final int totalCount;
|
||||
final VoidCallback onSelectAll;
|
||||
final VoidCallback onClearSelection;
|
||||
|
||||
const _MultiSelectBar({
|
||||
required this.selectedCount,
|
||||
required this.totalCount,
|
||||
required this.onSelectAll,
|
||||
required this.onClearSelection,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final allSelected = selectedCount == totalCount;
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: KcSpacing.sm, vertical: KcSpacing.xs),
|
||||
margin: const EdgeInsets.only(bottom: KcSpacing.sm),
|
||||
decoration: BoxDecoration(
|
||||
color: KcColors.denimBlue.withValues(alpha: 0.08),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(color: KcColors.denimBlue.withValues(alpha: 0.3)),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.checklist, size: 18, color: KcColors.denimBlue),
|
||||
const SizedBox(width: KcSpacing.xs),
|
||||
Text(
|
||||
'$selectedCount selected',
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: KcColors.denimBlue,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
if (!allSelected) TextButton(onPressed: onSelectAll, child: const Text('Select All')),
|
||||
TextButton(onPressed: onClearSelection, child: const Text('Clear')),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,16 +7,36 @@ import 'publish_status_chip.dart';
|
|||
/// A card displaying a summary of a [ProductDraft].
|
||||
///
|
||||
/// Shows the product name, SKU, price, category, and publish status.
|
||||
/// Highlights when [isSelected] is true.
|
||||
/// Highlights when [isSelected] is true (single-item preview selection).
|
||||
///
|
||||
/// When [isMultiSelected] is non-null, a leading checkbox is shown to
|
||||
/// support multi-select mode. Tapping the checkbox fires [onMultiSelectToggle];
|
||||
/// tapping the rest of the card still fires [onTap] for single-item preview.
|
||||
class ProductDraftCard extends StatelessWidget {
|
||||
final ProductDraft draft;
|
||||
final bool isSelected;
|
||||
final VoidCallback? onTap;
|
||||
|
||||
const ProductDraftCard({super.key, required this.draft, this.isSelected = false, this.onTap});
|
||||
/// Whether this card is part of the multi-selection.
|
||||
/// When `null`, the checkbox is hidden (multi-select not active).
|
||||
final bool? isMultiSelected;
|
||||
|
||||
/// Called when the multi-select checkbox is toggled.
|
||||
final VoidCallback? onMultiSelectToggle;
|
||||
|
||||
const ProductDraftCard({
|
||||
super.key,
|
||||
required this.draft,
|
||||
this.isSelected = false,
|
||||
this.onTap,
|
||||
this.isMultiSelected,
|
||||
this.onMultiSelectToggle,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final showCheckbox = isMultiSelected != null;
|
||||
|
||||
return GestureDetector(
|
||||
onTap: onTap,
|
||||
child: AnimatedContainer(
|
||||
|
|
@ -33,43 +53,61 @@ class ProductDraftCard extends StatelessWidget {
|
|||
BoxShadow(blurRadius: 8, offset: Offset(0, 2), color: Color(0x11000000)),
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
draft.name,
|
||||
style: Theme.of(context).textTheme.titleLarge,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
const SizedBox(height: KcSpacing.xs),
|
||||
Text('SKU: ${draft.sku}', style: Theme.of(context).textTheme.bodyMedium),
|
||||
const SizedBox(height: KcSpacing.sm),
|
||||
Row(
|
||||
children: [
|
||||
Text(
|
||||
'\$${draft.price.toStringAsFixed(2)}',
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.bodyLarge?.copyWith(fontWeight: FontWeight.w600),
|
||||
),
|
||||
const SizedBox(width: KcSpacing.sm),
|
||||
Text(
|
||||
draft.category,
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: KcColors.neutral),
|
||||
),
|
||||
],
|
||||
),
|
||||
const Spacer(),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
PublishStatusChip(status: draft.status),
|
||||
Text(
|
||||
'${draft.lastModified.year}-${draft.lastModified.month.toString().padLeft(2, '0')}-${draft.lastModified.day.toString().padLeft(2, '0')}',
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(color: KcColors.neutral),
|
||||
),
|
||||
],
|
||||
if (showCheckbox) ...[
|
||||
Checkbox(
|
||||
value: isMultiSelected ?? false,
|
||||
onChanged: (_) => onMultiSelectToggle?.call(),
|
||||
),
|
||||
const SizedBox(width: KcSpacing.xs),
|
||||
],
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
draft.name,
|
||||
style: Theme.of(context).textTheme.titleLarge,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
const SizedBox(height: KcSpacing.xs),
|
||||
Text('SKU: ${draft.sku}', style: Theme.of(context).textTheme.bodyMedium),
|
||||
const SizedBox(height: KcSpacing.sm),
|
||||
Row(
|
||||
children: [
|
||||
Text(
|
||||
'\$${draft.price.toStringAsFixed(2)}',
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.bodyLarge?.copyWith(fontWeight: FontWeight.w600),
|
||||
),
|
||||
const SizedBox(width: KcSpacing.sm),
|
||||
Text(
|
||||
draft.category,
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.bodyMedium?.copyWith(color: KcColors.neutral),
|
||||
),
|
||||
],
|
||||
),
|
||||
const Spacer(),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
PublishStatusChip(status: draft.status),
|
||||
Text(
|
||||
'${draft.lastModified.year}-${draft.lastModified.month.toString().padLeft(2, '0')}-${draft.lastModified.day.toString().padLeft(2, '0')}',
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.bodySmall?.copyWith(color: KcColors.neutral),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
|
|
|||
|
|
@ -1017,6 +1017,203 @@ void main() {
|
|||
});
|
||||
});
|
||||
|
||||
// ── Multi-select groundwork ─────────────────────────────────────────
|
||||
|
||||
group('multiSelect', () {
|
||||
test('starts with empty multi-selection', () {
|
||||
expect(controller.multiSelectedIds, isEmpty);
|
||||
expect(controller.multiSelectedCount, 0);
|
||||
expect(controller.isMultiSelectActive, false);
|
||||
});
|
||||
|
||||
test('toggleMultiSelect adds an id', () async {
|
||||
await controller.load();
|
||||
|
||||
controller.toggleMultiSelect('1');
|
||||
|
||||
expect(controller.isMultiSelected('1'), true);
|
||||
expect(controller.multiSelectedCount, 1);
|
||||
expect(controller.isMultiSelectActive, true);
|
||||
});
|
||||
|
||||
test('toggleMultiSelect removes an already-selected id', () async {
|
||||
await controller.load();
|
||||
|
||||
controller.toggleMultiSelect('1');
|
||||
expect(controller.isMultiSelected('1'), true);
|
||||
|
||||
controller.toggleMultiSelect('1');
|
||||
expect(controller.isMultiSelected('1'), false);
|
||||
expect(controller.multiSelectedCount, 0);
|
||||
expect(controller.isMultiSelectActive, false);
|
||||
});
|
||||
|
||||
test('multiple ids can be multi-selected', () async {
|
||||
await controller.load();
|
||||
|
||||
controller.toggleMultiSelect('1');
|
||||
controller.toggleMultiSelect('3');
|
||||
controller.toggleMultiSelect('5');
|
||||
|
||||
expect(controller.multiSelectedCount, 3);
|
||||
expect(controller.isMultiSelected('1'), true);
|
||||
expect(controller.isMultiSelected('3'), true);
|
||||
expect(controller.isMultiSelected('5'), true);
|
||||
expect(controller.isMultiSelected('2'), false);
|
||||
});
|
||||
|
||||
test('clearMultiSelection removes all multi-selected ids', () async {
|
||||
await controller.load();
|
||||
|
||||
controller.toggleMultiSelect('1');
|
||||
controller.toggleMultiSelect('2');
|
||||
controller.toggleMultiSelect('3');
|
||||
expect(controller.multiSelectedCount, 3);
|
||||
|
||||
controller.clearMultiSelection();
|
||||
|
||||
expect(controller.multiSelectedIds, isEmpty);
|
||||
expect(controller.multiSelectedCount, 0);
|
||||
expect(controller.isMultiSelectActive, false);
|
||||
});
|
||||
|
||||
test('selectAllVisible selects all visible draft ids', () async {
|
||||
await controller.load();
|
||||
|
||||
controller.selectAllVisible();
|
||||
|
||||
expect(controller.multiSelectedCount, 6);
|
||||
for (final draft in controller.drafts) {
|
||||
expect(controller.isMultiSelected(draft.id), true);
|
||||
}
|
||||
});
|
||||
|
||||
test('selectAllVisible respects active filter', () async {
|
||||
await controller.load();
|
||||
|
||||
controller.setFilter('draft');
|
||||
expect(controller.drafts.length, 2);
|
||||
|
||||
controller.selectAllVisible();
|
||||
|
||||
expect(controller.multiSelectedCount, 2);
|
||||
expect(controller.isMultiSelected('4'), true);
|
||||
expect(controller.isMultiSelected('5'), true);
|
||||
// Non-visible ids should not be selected.
|
||||
expect(controller.isMultiSelected('1'), false);
|
||||
});
|
||||
|
||||
test('multi-select is independent of single-item preview selection', () async {
|
||||
await controller.load();
|
||||
|
||||
// Multi-select two items.
|
||||
controller.toggleMultiSelect('1');
|
||||
controller.toggleMultiSelect('3');
|
||||
|
||||
// Single-select a different item for preview.
|
||||
controller.selectDraft(controller.drafts.firstWhere((d) => d.id == '5'));
|
||||
|
||||
// Preview selection should be independent.
|
||||
expect(controller.selectedDraft!.id, '5');
|
||||
expect(controller.isMultiSelected('5'), false);
|
||||
expect(controller.multiSelectedCount, 2);
|
||||
});
|
||||
|
||||
test('multi-selection persists across load cycles', () async {
|
||||
await controller.load();
|
||||
|
||||
controller.toggleMultiSelect('2');
|
||||
controller.toggleMultiSelect('4');
|
||||
|
||||
await controller.load();
|
||||
|
||||
expect(controller.isMultiSelected('2'), true);
|
||||
expect(controller.isMultiSelected('4'), true);
|
||||
expect(controller.multiSelectedCount, 2);
|
||||
});
|
||||
|
||||
test('multi-selection persists after write operations', () async {
|
||||
await controller.load();
|
||||
|
||||
controller.toggleMultiSelect('1');
|
||||
controller.toggleMultiSelect('4');
|
||||
|
||||
// Update price on product 4.
|
||||
await controller.updatePrice('4', 20.00);
|
||||
|
||||
// Multi-selection should still be intact.
|
||||
expect(controller.isMultiSelected('1'), true);
|
||||
expect(controller.isMultiSelected('4'), true);
|
||||
expect(controller.multiSelectedCount, 2);
|
||||
});
|
||||
|
||||
test('toggleMultiSelect notifies listeners', () async {
|
||||
await controller.load();
|
||||
|
||||
int notifyCount = 0;
|
||||
controller.addListener(() => notifyCount++);
|
||||
|
||||
controller.toggleMultiSelect('1');
|
||||
expect(notifyCount, 1);
|
||||
|
||||
controller.toggleMultiSelect('1');
|
||||
expect(notifyCount, 2);
|
||||
});
|
||||
|
||||
test('clearMultiSelection notifies listeners', () async {
|
||||
await controller.load();
|
||||
|
||||
controller.toggleMultiSelect('1');
|
||||
|
||||
int notifyCount = 0;
|
||||
controller.addListener(() => notifyCount++);
|
||||
|
||||
controller.clearMultiSelection();
|
||||
expect(notifyCount, 1);
|
||||
});
|
||||
|
||||
test('selectAllVisible notifies listeners', () async {
|
||||
await controller.load();
|
||||
|
||||
int notifyCount = 0;
|
||||
controller.addListener(() => notifyCount++);
|
||||
|
||||
controller.selectAllVisible();
|
||||
expect(notifyCount, 1);
|
||||
});
|
||||
|
||||
test('selectAllVisible with search selects only matching products', () async {
|
||||
await controller.load();
|
||||
|
||||
controller.setSearchQuery('coaster');
|
||||
expect(controller.drafts.length, 2);
|
||||
|
||||
controller.selectAllVisible();
|
||||
|
||||
expect(controller.multiSelectedCount, 2);
|
||||
expect(controller.isMultiSelected('2'), true); // Citrus Coaster Set
|
||||
expect(controller.isMultiSelected('6'), true); // Sublimated Slate Coaster
|
||||
expect(controller.isMultiSelected('1'), false);
|
||||
});
|
||||
|
||||
test('selectAllVisible is additive to existing selection', () async {
|
||||
await controller.load();
|
||||
|
||||
// Select one product manually.
|
||||
controller.toggleMultiSelect('1');
|
||||
|
||||
// Filter to drafts only and select all visible.
|
||||
controller.setFilter('draft');
|
||||
controller.selectAllVisible();
|
||||
|
||||
// Should have the manually selected + the two drafts.
|
||||
expect(controller.isMultiSelected('1'), true);
|
||||
expect(controller.isMultiSelected('4'), true);
|
||||
expect(controller.isMultiSelected('5'), true);
|
||||
expect(controller.multiSelectedCount, 3);
|
||||
});
|
||||
});
|
||||
|
||||
group('disposed guard', () {
|
||||
test('load does not notify after disposal', () async {
|
||||
// Start load, then immediately dispose.
|
||||
|
|
|
|||
Loading…
Reference in New Issue