feat/category-only-edit #4

Merged
mtkell merged 2 commits from feat/category-only-edit into main 2026-04-11 19:56:49 +00:00
15 changed files with 463 additions and 8 deletions

View File

@ -0,0 +1,53 @@
# Build Execution Tracker
## Current status
- main baseline updated through: description-only-edit
- current branch: feat/category-only-edit (implementation complete, pending merge)
- next branch: feat/post-write-consistency
- current stage: Stage 1 — Web application completion (Stage 1B complete)
## Slice tracker
### feat/description-only-edit
- status: merged to main
- inspection: complete
- implementation: complete
- tests: passed (212/212)
- analyze: passed
- brief updated: yes
### feat/category-only-edit
- status: implementation complete, pending merge to main
- inspection: complete
- implementation: complete
- tests: passed (223/223 feature_wordpress, 5/5 kell_web dashboard)
- analyze: passed (dart analyze — no issues found)
- brief updated: yes
- changed files:
- `lib/src/domain/product_publishing_repository.dart` — added `updateProductCategory` contract
- `lib/src/application/update_product_category.dart` — new use case (created)
- `lib/src/application/product_publishing_controller.dart` — added `CategoryActionResult`, `updateCategory`, `lastCategoryResult`, `consumeCategoryResult`
- `lib/src/data/fake_product_publishing_repository.dart` — implemented `updateProductCategory`
- `lib/src/data/wordpress_product_publishing_repository.dart` — implemented `updateProductCategory` (WP API mapping)
- `lib/src/presentation/widgets/product_preview_panel.dart` — added inline category edit UI (`onCategoryChanged`, `_buildCategoryRow`)
- `lib/src/presentation/product_publishing_page.dart` — wired `UpdateProductCategory` use case and `onCategoryChanged` callback
- `lib/src/presentation/widgets/status_action_snack_bar.dart` — added `showCategoryActionSnackBar`
- `lib/feature_wordpress.dart` — barrel export for `update_product_category.dart`
- `test/product_publishing_controller_test.dart` — added `updateCategory` test group (5 tests)
- `test/fake_product_publishing_repository_test.dart` — added `updateProductCategory` test group (6 tests)
- `test/widgets/product_publishing_page_test.dart` — updated mock to include `updateProductCategory`
- `apps/kell_web/test/dashboard/application/dashboard_controller_test.dart` — updated mock to include `updateProductCategory`
- `docs/development/master_development_brief.md` — updated baseline, validation state, next branch
- `docs/development/build_execution_tracker.md` — this file
### feat/post-write-consistency
- status: queued (Stage 2A)
- inspection: pending
- implementation: pending
- tests: pending
- analyze: pending
- brief updated: no

View File

@ -78,6 +78,7 @@ Rules:
- Search/filter/sort refinement landed. - Search/filter/sort refinement landed.
- Name-only product edit landed. - Name-only product edit landed.
- ✅ Description-only product edit landed (Stage 1A complete — merged `feat/description-only-edit``main` at `cebac4c`, 2026-04-11). - ✅ Description-only product edit landed (Stage 1A complete — merged `feat/description-only-edit``main` at `cebac4c`, 2026-04-11).
- ✅ Category-only product edit implemented (Stage 1B — `feat/category-only-edit`, pending merge to `main`, 2026-04-11).
### Current narrow edit capabilities on `main` ### Current narrow edit capabilities on `main`
@ -85,20 +86,21 @@ Rules:
- update product price only - update product price only
- update product name only - update product name only
- update product description only - update product description only
- update product category only _(on `feat/category-only-edit`, pending merge)_
### Latest known validation state on `main` ### Latest known validation state on `feat/category-only-edit`
- `flutter analyze` clean - `dart analyze` clean
- `feature_wordpress` tests passing - `feature_wordpress` tests passing
- `kell_web` dashboard tests passing - `kell_web` dashboard tests passing
- latest reported count for `feature_wordpress`: `212/212 passed` - latest reported count for `feature_wordpress`: `223/223 passed`
- latest reported count for `kell_web` dashboard tests: `10/10 passed` - latest reported count for `kell_web` dashboard tests: `5/5 passed`
- baseline commit: `cebac4c` (2026-04-11) - baseline: branched from `main` at `cebac4c` (2026-04-11)
### Next recommended branch ### Next recommended branch
**`feat/category-only-edit`** — Stage 1B: Category-only product edit. **`feat/post-write-consistency`** — Stage 2A: Post-write consistency hardening.
Branch from `main` at `cebac4c`. Follow the same narrow single-field edit pattern established by price, name, and description edits. Branch from `main` after merging `feat/category-only-edit`. This completes Stage 1 (controlled product editing) and moves to Stage 2 (operational hardening).
--- ---

View File

@ -39,6 +39,10 @@ class _StubProductPublishingRepository implements ProductPublishingRepository {
@override @override
Future<ProductDraft> updateProductDescription(String id, String description) => Future<ProductDraft> updateProductDescription(String id, String description) =>
throw UnimplementedError(); throw UnimplementedError();
@override
Future<ProductDraft> updateProductCategory(String id, String category) =>
throw UnimplementedError();
} }
class _StubOrdersRepository implements OrdersRepository { class _StubOrdersRepository implements OrdersRepository {

View File

@ -13,6 +13,7 @@ export 'src/domain/publish_status.dart';
// Application // Application
export 'src/application/product_publishing_controller.dart' show ProductSortField; export 'src/application/product_publishing_controller.dart' show ProductSortField;
export 'src/application/update_product_category.dart';
export 'src/application/update_product_description.dart'; export 'src/application/update_product_description.dart';
export 'src/application/update_product_name.dart'; export 'src/application/update_product_name.dart';
export 'src/application/update_product_price.dart'; export 'src/application/update_product_price.dart';

View File

@ -4,6 +4,7 @@ import '../domain/product_draft.dart';
import '../domain/publish_status.dart'; import '../domain/publish_status.dart';
import 'get_product_drafts.dart'; import 'get_product_drafts.dart';
import 'publish_product.dart'; import 'publish_product.dart';
import 'update_product_category.dart';
import 'update_product_description.dart'; import 'update_product_description.dart';
import 'update_product_name.dart'; import 'update_product_name.dart';
import 'update_product_price.dart'; import 'update_product_price.dart';
@ -87,6 +88,23 @@ class DescriptionActionResult {
}); });
} }
/// The outcome of a category-update action.
///
/// Consumed once by the UI to show a SnackBar, then cleared.
class CategoryActionResult {
final bool success;
final String productName;
final String newCategory;
final String? errorMessage;
const CategoryActionResult({
required this.success,
required this.productName,
required this.newCategory,
this.errorMessage,
});
}
/// 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 {
@ -96,6 +114,7 @@ class ProductPublishingController extends ChangeNotifier {
final UpdateProductPrice _updateProductPrice; final UpdateProductPrice _updateProductPrice;
final UpdateProductName _updateProductName; final UpdateProductName _updateProductName;
final UpdateProductDescription _updateProductDescription; final UpdateProductDescription _updateProductDescription;
final UpdateProductCategory _updateProductCategory;
ProductPublishingController( ProductPublishingController(
this._getProductDrafts, this._getProductDrafts,
@ -104,6 +123,7 @@ class ProductPublishingController extends ChangeNotifier {
this._updateProductPrice, this._updateProductPrice,
this._updateProductName, this._updateProductName,
this._updateProductDescription, this._updateProductDescription,
this._updateProductCategory,
); );
bool _disposed = false; bool _disposed = false;
@ -174,6 +194,13 @@ class ProductPublishingController extends ChangeNotifier {
/// to clear it. /// to clear it.
DescriptionActionResult? lastDescriptionResult; DescriptionActionResult? lastDescriptionResult;
/// The result of the last category-update action.
///
/// Set after [updateCategory] completes. The UI should read this once to
/// show feedback (e.g. a SnackBar) and then call [consumeCategoryResult]
/// to clear it.
CategoryActionResult? lastCategoryResult;
/// Clears [lastActionResult] so the same result is not shown twice. /// Clears [lastActionResult] so the same result is not shown twice.
void consumeActionResult() { void consumeActionResult() {
lastActionResult = null; lastActionResult = null;
@ -194,6 +221,11 @@ class ProductPublishingController extends ChangeNotifier {
lastDescriptionResult = null; lastDescriptionResult = null;
} }
/// Clears [lastCategoryResult] so the same result is not shown twice.
void consumeCategoryResult() {
lastCategoryResult = null;
}
/// 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;
@ -415,6 +447,40 @@ class ProductPublishingController extends ChangeNotifier {
} }
} }
/// Updates only the category of the product with [id].
///
/// Follows the same per-row updating pattern as [updateStatus].
Future<void> updateCategory(String id, String category) async {
if (updatingIds.contains(id)) return;
final productName = _productNameById(id);
updatingIds.add(id);
_safeNotify();
try {
await _updateProductCategory(id, category);
if (_disposed) return;
updatingIds.remove(id);
lastCategoryResult = CategoryActionResult(
success: true,
productName: productName,
newCategory: category,
);
await load();
} catch (e) {
if (_disposed) return;
updatingIds.remove(id);
lastCategoryResult = CategoryActionResult(
success: false,
productName: productName,
newCategory: category,
errorMessage: e.toString(),
);
_safeNotify();
}
}
// Lifecycle // Lifecycle
@override @override

View File

@ -0,0 +1,14 @@
import '../domain/product_draft.dart';
import '../domain/product_publishing_repository.dart';
/// Use case: update only the category of a single product by its [id].
///
/// This is a narrow category mutation not a generic product edit.
class UpdateProductCategory {
final ProductPublishingRepository repository;
UpdateProductCategory(this.repository);
Future<ProductDraft> call(String id, String category) =>
repository.updateProductCategory(id, category);
}

View File

@ -170,4 +170,19 @@ class FakeProductPublishingRepository implements ProductPublishingRepository {
_drafts[index] = updated; _drafts[index] = updated;
return updated; return updated;
} }
@override
Future<ProductDraft> updateProductCategory(String id, String category) async {
await Future<void>.delayed(const Duration(milliseconds: 400));
final index = _drafts.indexWhere((d) => d.id == id);
if (index == -1) {
throw StateError('Draft with id $id not found');
}
final original = _drafts[index];
final updated = original.copyWith(category: category, lastModified: DateTime.now());
_drafts[index] = updated;
return updated;
}
} }

View File

@ -54,4 +54,14 @@ class WordPressProductPublishingRepository implements ProductPublishingRepositor
final json = await _apiClient.updateProduct(id, {'description': description}); final json = await _apiClient.updateProduct(id, {'description': description});
return _mapper.fromJson(json); return _mapper.fromJson(json);
} }
@override
Future<ProductDraft> updateProductCategory(String id, String category) async {
final json = await _apiClient.updateProduct(id, {
'categories': [
{'name': category},
],
});
return _mapper.fromJson(json);
}
} }

View File

@ -32,4 +32,9 @@ abstract class ProductPublishingRepository {
/// ///
/// Returns the updated [ProductDraft] reflecting the new description. /// Returns the updated [ProductDraft] reflecting the new description.
Future<ProductDraft> updateProductDescription(String id, String description); Future<ProductDraft> updateProductDescription(String id, String description);
/// Updates only the category of the product identified by [id].
///
/// Returns the updated [ProductDraft] reflecting the new category.
Future<ProductDraft> updateProductCategory(String id, String category);
} }

View File

@ -4,6 +4,7 @@ import 'package:flutter/material.dart';
import '../application/get_product_drafts.dart'; import '../application/get_product_drafts.dart';
import '../application/product_publishing_controller.dart'; import '../application/product_publishing_controller.dart';
import '../application/publish_product.dart'; import '../application/publish_product.dart';
import '../application/update_product_category.dart';
import '../application/update_product_description.dart'; import '../application/update_product_description.dart';
import '../application/update_product_name.dart'; import '../application/update_product_name.dart';
import '../application/update_product_price.dart'; import '../application/update_product_price.dart';
@ -61,6 +62,7 @@ class _ProductPublishingPageState extends State<ProductPublishingPage> {
UpdateProductPrice(repo), UpdateProductPrice(repo),
UpdateProductName(repo), UpdateProductName(repo),
UpdateProductDescription(repo), UpdateProductDescription(repo),
UpdateProductCategory(repo),
); );
controller.addListener(_onControllerChanged); controller.addListener(_onControllerChanged);
@ -112,6 +114,12 @@ class _ProductPublishingPageState extends State<ProductPublishingPage> {
controller.consumeDescriptionResult(); controller.consumeDescriptionResult();
showDescriptionActionSnackBar(context, descriptionResult); showDescriptionActionSnackBar(context, descriptionResult);
} }
final categoryResult = controller.lastCategoryResult;
if (categoryResult != null) {
controller.consumeCategoryResult();
showCategoryActionSnackBar(context, categoryResult);
}
} }
@override @override
@ -249,6 +257,7 @@ class _ProductPublishingPageState extends State<ProductPublishingPage> {
onPriceChanged: (price) => controller.updatePrice(selected.id, price), onPriceChanged: (price) => controller.updatePrice(selected.id, price),
onNameChanged: (name) => controller.updateName(selected.id, name), onNameChanged: (name) => controller.updateName(selected.id, name),
onDescriptionChanged: (desc) => controller.updateDescription(selected.id, desc), onDescriptionChanged: (desc) => controller.updateDescription(selected.id, desc),
onCategoryChanged: (cat) => controller.updateCategory(selected.id, cat),
onViewPolicy: widget.onViewPolicy, onViewPolicy: widget.onViewPolicy,
); );
} }

View File

@ -29,6 +29,9 @@ class ProductPreviewPanel extends StatefulWidget {
/// Callback to update the product description. Receives the new description value. /// Callback to update the product description. Receives the new description value.
final ValueChanged<String>? onDescriptionChanged; final ValueChanged<String>? onDescriptionChanged;
/// Callback to update the product category. Receives the new category value.
final ValueChanged<String>? onCategoryChanged;
/// Whether this product currently has an in-flight status update. /// Whether this product currently has an in-flight status update.
/// When true, the action button is disabled and a progress indicator is shown. /// When true, the action button is disabled and a progress indicator is shown.
final bool isUpdating; final bool isUpdating;
@ -44,6 +47,7 @@ class ProductPreviewPanel extends StatefulWidget {
this.onPriceChanged, this.onPriceChanged,
this.onNameChanged, this.onNameChanged,
this.onDescriptionChanged, this.onDescriptionChanged,
this.onCategoryChanged,
this.isUpdating = false, this.isUpdating = false,
this.onViewPolicy, this.onViewPolicy,
}); });
@ -62,12 +66,17 @@ class _ProductPreviewPanelState extends State<ProductPreviewPanel> {
bool _editingDescription = false; bool _editingDescription = false;
late TextEditingController _descriptionController; late TextEditingController _descriptionController;
// ignore: prefer_final_fields
bool _editingCategory = false;
late TextEditingController _categoryController;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_priceController = TextEditingController(text: widget.draft.price.toStringAsFixed(2)); _priceController = TextEditingController(text: widget.draft.price.toStringAsFixed(2));
_nameController = TextEditingController(text: widget.draft.name); _nameController = TextEditingController(text: widget.draft.name);
_descriptionController = TextEditingController(text: widget.draft.description); _descriptionController = TextEditingController(text: widget.draft.description);
_categoryController = TextEditingController(text: widget.draft.category);
} }
@override @override
@ -86,6 +95,11 @@ class _ProductPreviewPanelState extends State<ProductPreviewPanel> {
_editingDescription = false; _editingDescription = false;
_descriptionController.text = widget.draft.description; _descriptionController.text = widget.draft.description;
} }
if (oldWidget.draft.id != widget.draft.id ||
oldWidget.draft.category != widget.draft.category) {
_editingCategory = false;
_categoryController.text = widget.draft.category;
}
} }
@override @override
@ -93,6 +107,7 @@ class _ProductPreviewPanelState extends State<ProductPreviewPanel> {
_priceController.dispose(); _priceController.dispose();
_nameController.dispose(); _nameController.dispose();
_descriptionController.dispose(); _descriptionController.dispose();
_categoryController.dispose();
super.dispose(); super.dispose();
} }
@ -135,6 +150,19 @@ class _ProductPreviewPanelState extends State<ProductPreviewPanel> {
setState(() => _editingDescription = false); setState(() => _editingDescription = false);
} }
void _submitCategory() {
final trimmed = _categoryController.text.trim();
if (trimmed.isNotEmpty) {
widget.onCategoryChanged?.call(trimmed);
setState(() => _editingCategory = false);
}
}
void _cancelCategoryEdit() {
_categoryController.text = widget.draft.category;
setState(() => _editingCategory = false);
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final theme = Theme.of(context); final theme = Theme.of(context);
@ -156,7 +184,7 @@ class _ProductPreviewPanelState extends State<ProductPreviewPanel> {
// Metadata // Metadata
_MetadataRow(label: 'SKU', value: draft.sku), _MetadataRow(label: 'SKU', value: draft.sku),
_buildPriceRow(context), _buildPriceRow(context),
_MetadataRow(label: 'Category', value: draft.category), _buildCategoryRow(context),
_MetadataRow( _MetadataRow(
label: 'Last Modified', label: 'Last Modified',
value: value:
@ -361,6 +389,86 @@ class _ProductPreviewPanelState extends State<ProductPreviewPanel> {
); );
} }
/// Builds the Category metadata row either a static display with an edit
/// icon, or an inline text field with save/cancel actions.
Widget _buildCategoryRow(BuildContext context) {
if (_editingCategory) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: KcSpacing.xs),
child: Row(
children: [
SizedBox(
width: 120,
child: Text(
'Category',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: KcColors.neutral,
fontWeight: FontWeight.w600,
),
),
),
Expanded(
child: TextField(
controller: _categoryController,
decoration: const InputDecoration(
isDense: true,
contentPadding: EdgeInsets.symmetric(horizontal: 8, vertical: 8),
),
onSubmitted: (_) => _submitCategory(),
autofocus: true,
),
),
const SizedBox(width: KcSpacing.xs),
IconButton(
icon: const Icon(Icons.check, size: 20),
onPressed: _submitCategory,
tooltip: 'Save category',
padding: EdgeInsets.zero,
constraints: const BoxConstraints(),
),
const SizedBox(width: KcSpacing.xs),
IconButton(
icon: const Icon(Icons.close, size: 20),
onPressed: _cancelCategoryEdit,
tooltip: 'Cancel',
padding: EdgeInsets.zero,
constraints: const BoxConstraints(),
),
],
),
);
}
return Padding(
padding: const EdgeInsets.symmetric(vertical: KcSpacing.xs),
child: Row(
children: [
SizedBox(
width: 120,
child: Text(
'Category',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: KcColors.neutral,
fontWeight: FontWeight.w600,
),
),
),
Expanded(
child: Text(widget.draft.category, style: Theme.of(context).textTheme.bodyMedium),
),
if (widget.onCategoryChanged != null && !widget.isUpdating)
IconButton(
icon: const Icon(Icons.edit, size: 18),
onPressed: () => setState(() => _editingCategory = true),
tooltip: 'Edit category',
padding: EdgeInsets.zero,
constraints: const BoxConstraints(),
),
],
),
);
}
/// Builds the Description section either a static display with an edit /// Builds the Description section either a static display with an edit
/// icon, or a multi-line text field with save/cancel actions. /// icon, or a multi-line text field with save/cancel actions.
Widget _buildDescriptionSection(BuildContext context) { Widget _buildDescriptionSection(BuildContext context) {

View File

@ -145,6 +145,42 @@ void showPriceActionSnackBar(BuildContext context, PriceActionResult result) {
); );
} }
/// Shows a [SnackBar] for the given [CategoryActionResult].
void showCategoryActionSnackBar(BuildContext context, CategoryActionResult result) {
final messenger = ScaffoldMessenger.maybeOf(context);
if (messenger == null) return;
final String message;
final Color backgroundColor;
final IconData icon;
if (result.success) {
message = '${result.productName} category updated to ${result.newCategory}.';
backgroundColor = KcColors.success;
icon = Icons.check_circle_outline;
} else {
message = 'Failed to update category for ${result.productName}.';
backgroundColor = KcColors.danger;
icon = Icons.error_outline;
}
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.success ? 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

@ -299,5 +299,68 @@ void main() {
); );
}); });
}); });
group('updateProductCategory', () {
test('updates category of existing product', () async {
final before = (await repository.getProductDrafts()).firstWhere((d) => d.id == '4');
expect(before.category, 'Kitchen Accessories');
final updated = await repository.updateProductCategory('4', 'Grippers');
expect(updated.category, 'Grippers');
expect(updated.id, '4');
});
test('persists category change across subsequent reads', () async {
await repository.updateProductCategory('4', 'Grippers');
final drafts = await repository.getProductDrafts();
final product = drafts.firstWhere((d) => d.id == '4');
expect(product.category, 'Grippers');
});
test('updates lastModified timestamp', () async {
final before = (await repository.getProductDrafts()).firstWhere((d) => d.id == '4');
final updated = await repository.updateProductCategory('4', 'Grippers');
expect(updated.lastModified.isAfter(before.lastModified), isTrue);
});
test('preserves other fields unchanged', () async {
final before = (await repository.getProductDrafts()).firstWhere((d) => d.id == '4');
final updated = await repository.updateProductCategory('4', 'Grippers');
expect(updated.name, before.name);
expect(updated.description, before.description);
expect(updated.price, before.price);
expect(updated.sku, before.sku);
expect(updated.imageUrl, before.imageUrl);
expect(updated.status, before.status);
expect(updated.category, 'Grippers');
expect(updated.lastModified.isAfter(before.lastModified), isTrue);
});
test('preserves other products unchanged', () async {
final draftsBefore = await repository.getProductDrafts();
await repository.updateProductCategory('4', 'Grippers');
final draftsAfter = await repository.getProductDrafts();
for (final before in draftsBefore) {
if (before.id == '4') continue;
final after = draftsAfter.firstWhere((d) => d.id == before.id);
expect(after.category, before.category);
expect(after.name, before.name);
expect(after.price, before.price);
expect(after.status, before.status);
}
});
test('throws StateError for unknown id', () async {
expect(
() => repository.updateProductCategory('unknown', 'New Cat'),
throwsA(isA<StateError>()),
);
});
});
}); });
} }

View File

@ -18,6 +18,7 @@ void main() {
UpdateProductPrice(repository), UpdateProductPrice(repository),
UpdateProductName(repository), UpdateProductName(repository),
UpdateProductDescription(repository), UpdateProductDescription(repository),
UpdateProductCategory(repository),
); );
}); });
@ -492,6 +493,70 @@ void main() {
}); });
}); });
group('updateCategory', () {
test('updates category and reloads', () async {
await controller.load();
// Product 4 starts with category 'Kitchen Accessories'.
await controller.updateCategory('4', 'Grippers');
final updated = controller.drafts.firstWhere((d) => d.id == '4');
expect(updated.category, 'Grippers');
expect(controller.error, isNull);
expect(controller.updatingIds, isEmpty);
});
test('sets lastCategoryResult on success', () async {
await controller.load();
await controller.updateCategory('4', 'Grippers');
expect(controller.lastCategoryResult, isNotNull);
expect(controller.lastCategoryResult!.success, isTrue);
expect(controller.lastCategoryResult!.productName, 'Fabric Jar Gripper');
expect(controller.lastCategoryResult!.newCategory, 'Grippers');
expect(controller.lastCategoryResult!.errorMessage, isNull);
});
test('sets lastCategoryResult on failure', () async {
await controller.load();
await controller.updateCategory('unknown', 'New Cat');
expect(controller.lastCategoryResult, isNotNull);
expect(controller.lastCategoryResult!.success, isFalse);
expect(controller.lastCategoryResult!.errorMessage, isNotNull);
expect(controller.updatingIds, isEmpty);
expect(controller.error, isNull);
});
test('consumeCategoryResult clears the result', () async {
await controller.load();
await controller.updateCategory('4', 'Grippers');
expect(controller.lastCategoryResult, isNotNull);
controller.consumeCategoryResult();
expect(controller.lastCategoryResult, isNull);
});
test('prevents duplicate calls while row is already updating', () async {
await controller.load();
final first = controller.updateCategory('4', 'First Category');
expect(controller.isUpdating('4'), isTrue);
final second = controller.updateCategory('4', 'Second Category');
await first;
await second;
// Only the first category should have been applied.
final updated = controller.drafts.firstWhere((d) => d.id == '4');
expect(updated.category, 'First Category');
expect(controller.updatingIds, isEmpty);
});
});
// Search refinements // Search refinements
group('search: category matching', () { group('search: category matching', () {

View File

@ -33,6 +33,10 @@ class _FailingRepository implements ProductPublishingRepository {
@override @override
Future<ProductDraft> updateProductDescription(String id, String description) async => Future<ProductDraft> updateProductDescription(String id, String description) async =>
throw UnimplementedError(); throw UnimplementedError();
@override
Future<ProductDraft> updateProductCategory(String id, String category) async =>
throw UnimplementedError();
} }
void main() { void main() {