feat(wordpress): add category-only product edit (Stage 1B)
Validate Docs / validate-docs (push) Successful in 1m3s Details

This commit is contained in:
Mike Kell 2026-04-11 15:54:51 -04:00
parent 24671f5f59
commit b69edd3e4a
14 changed files with 410 additions and 8 deletions

View File

@ -78,6 +78,7 @@ Rules:
- Search/filter/sort refinement 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).
- ✅ Category-only product edit implemented (Stage 1B — `feat/category-only-edit`, pending merge to `main`, 2026-04-11).
### Current narrow edit capabilities on `main`
@ -85,20 +86,21 @@ Rules:
- update product price only
- update product name 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
- `kell_web` dashboard tests passing
- latest reported count for `feature_wordpress`: `212/212 passed`
- latest reported count for `kell_web` dashboard tests: `10/10 passed`
- baseline commit: `cebac4c` (2026-04-11)
- latest reported count for `feature_wordpress`: `223/223 passed`
- latest reported count for `kell_web` dashboard tests: `5/5 passed`
- baseline: branched from `main` at `cebac4c` (2026-04-11)
### Next recommended branch
**`feat/category-only-edit`** — Stage 1B: Category-only product edit.
Branch from `main` at `cebac4c`. Follow the same narrow single-field edit pattern established by price, name, and description edits.
**`feat/post-write-consistency`** — Stage 2A: Post-write consistency hardening.
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
Future<ProductDraft> updateProductDescription(String id, String description) =>
throw UnimplementedError();
@override
Future<ProductDraft> updateProductCategory(String id, String category) =>
throw UnimplementedError();
}
class _StubOrdersRepository implements OrdersRepository {

View File

@ -13,6 +13,7 @@ export 'src/domain/publish_status.dart';
// Application
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_name.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 'get_product_drafts.dart';
import 'publish_product.dart';
import 'update_product_category.dart';
import 'update_product_description.dart';
import 'update_product_name.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
/// filtering by publish status, free-text search, and draft selection.
class ProductPublishingController extends ChangeNotifier {
@ -96,6 +114,7 @@ class ProductPublishingController extends ChangeNotifier {
final UpdateProductPrice _updateProductPrice;
final UpdateProductName _updateProductName;
final UpdateProductDescription _updateProductDescription;
final UpdateProductCategory _updateProductCategory;
ProductPublishingController(
this._getProductDrafts,
@ -104,6 +123,7 @@ class ProductPublishingController extends ChangeNotifier {
this._updateProductPrice,
this._updateProductName,
this._updateProductDescription,
this._updateProductCategory,
);
bool _disposed = false;
@ -174,6 +194,13 @@ class ProductPublishingController extends ChangeNotifier {
/// to clear it.
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.
void consumeActionResult() {
lastActionResult = null;
@ -194,6 +221,11 @@ class ProductPublishingController extends ChangeNotifier {
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.
Future<void> load() async {
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
@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;
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});
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.
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/product_publishing_controller.dart';
import '../application/publish_product.dart';
import '../application/update_product_category.dart';
import '../application/update_product_description.dart';
import '../application/update_product_name.dart';
import '../application/update_product_price.dart';
@ -61,6 +62,7 @@ class _ProductPublishingPageState extends State<ProductPublishingPage> {
UpdateProductPrice(repo),
UpdateProductName(repo),
UpdateProductDescription(repo),
UpdateProductCategory(repo),
);
controller.addListener(_onControllerChanged);
@ -112,6 +114,12 @@ class _ProductPublishingPageState extends State<ProductPublishingPage> {
controller.consumeDescriptionResult();
showDescriptionActionSnackBar(context, descriptionResult);
}
final categoryResult = controller.lastCategoryResult;
if (categoryResult != null) {
controller.consumeCategoryResult();
showCategoryActionSnackBar(context, categoryResult);
}
}
@override
@ -249,6 +257,7 @@ class _ProductPublishingPageState extends State<ProductPublishingPage> {
onPriceChanged: (price) => controller.updatePrice(selected.id, price),
onNameChanged: (name) => controller.updateName(selected.id, name),
onDescriptionChanged: (desc) => controller.updateDescription(selected.id, desc),
onCategoryChanged: (cat) => controller.updateCategory(selected.id, cat),
onViewPolicy: widget.onViewPolicy,
);
}

View File

@ -29,6 +29,9 @@ class ProductPreviewPanel extends StatefulWidget {
/// Callback to update the product description. Receives the new description value.
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.
/// When true, the action button is disabled and a progress indicator is shown.
final bool isUpdating;
@ -44,6 +47,7 @@ class ProductPreviewPanel extends StatefulWidget {
this.onPriceChanged,
this.onNameChanged,
this.onDescriptionChanged,
this.onCategoryChanged,
this.isUpdating = false,
this.onViewPolicy,
});
@ -62,12 +66,17 @@ class _ProductPreviewPanelState extends State<ProductPreviewPanel> {
bool _editingDescription = false;
late TextEditingController _descriptionController;
// ignore: prefer_final_fields
bool _editingCategory = false;
late TextEditingController _categoryController;
@override
void initState() {
super.initState();
_priceController = TextEditingController(text: widget.draft.price.toStringAsFixed(2));
_nameController = TextEditingController(text: widget.draft.name);
_descriptionController = TextEditingController(text: widget.draft.description);
_categoryController = TextEditingController(text: widget.draft.category);
}
@override
@ -86,6 +95,11 @@ class _ProductPreviewPanelState extends State<ProductPreviewPanel> {
_editingDescription = false;
_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
@ -93,6 +107,7 @@ class _ProductPreviewPanelState extends State<ProductPreviewPanel> {
_priceController.dispose();
_nameController.dispose();
_descriptionController.dispose();
_categoryController.dispose();
super.dispose();
}
@ -135,6 +150,19 @@ class _ProductPreviewPanelState extends State<ProductPreviewPanel> {
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
Widget build(BuildContext context) {
final theme = Theme.of(context);
@ -156,7 +184,7 @@ class _ProductPreviewPanelState extends State<ProductPreviewPanel> {
// Metadata
_MetadataRow(label: 'SKU', value: draft.sku),
_buildPriceRow(context),
_MetadataRow(label: 'Category', value: draft.category),
_buildCategoryRow(context),
_MetadataRow(
label: 'Last Modified',
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
/// icon, or a multi-line text field with save/cancel actions.
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].
void showNameActionSnackBar(BuildContext context, NameActionResult result) {
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),
UpdateProductName(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
group('search: category matching', () {

View File

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