Compare commits
No commits in common. "8e7e4cbc69be6569aada73900c22f86e3431e6c8" and "3e233b0df6237d0535823f4b279209bde48df293" have entirely different histories.
8e7e4cbc69
...
3e233b0df6
|
|
@ -1,53 +0,0 @@
|
||||||
# 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
|
|
||||||
|
|
@ -78,7 +78,6 @@ 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`
|
||||||
|
|
||||||
|
|
@ -86,21 +85,20 @@ 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 `feat/category-only-edit`
|
### Latest known validation state on `main`
|
||||||
|
|
||||||
- `dart analyze` clean
|
- `flutter 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`: `223/223 passed`
|
- latest reported count for `feature_wordpress`: `212/212 passed`
|
||||||
- latest reported count for `kell_web` dashboard tests: `5/5 passed`
|
- latest reported count for `kell_web` dashboard tests: `10/10 passed`
|
||||||
- baseline: branched from `main` at `cebac4c` (2026-04-11)
|
- baseline commit: `cebac4c` (2026-04-11)
|
||||||
|
|
||||||
### Next recommended branch
|
### Next recommended branch
|
||||||
|
|
||||||
**`feat/post-write-consistency`** — Stage 2A: Post-write consistency hardening.
|
**`feat/category-only-edit`** — Stage 1B: Category-only product edit.
|
||||||
Branch from `main` after merging `feat/category-only-edit`. This completes Stage 1 (controlled product editing) and moves to Stage 2 (operational hardening).
|
Branch from `main` at `cebac4c`. Follow the same narrow single-field edit pattern established by price, name, and description edits.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -39,10 +39,6 @@ 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 {
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,6 @@ 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';
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,6 @@ 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';
|
||||||
|
|
@ -88,23 +87,6 @@ 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 {
|
||||||
|
|
@ -114,7 +96,6 @@ 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,
|
||||||
|
|
@ -123,7 +104,6 @@ class ProductPublishingController extends ChangeNotifier {
|
||||||
this._updateProductPrice,
|
this._updateProductPrice,
|
||||||
this._updateProductName,
|
this._updateProductName,
|
||||||
this._updateProductDescription,
|
this._updateProductDescription,
|
||||||
this._updateProductCategory,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
bool _disposed = false;
|
bool _disposed = false;
|
||||||
|
|
@ -194,13 +174,6 @@ 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;
|
||||||
|
|
@ -221,11 +194,6 @@ 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;
|
||||||
|
|
@ -447,40 +415,6 @@ 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
|
||||||
|
|
|
||||||
|
|
@ -1,14 +0,0 @@
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
@ -170,19 +170,4 @@ 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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -54,14 +54,4 @@ 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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -32,9 +32,4 @@ 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);
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,6 @@ 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';
|
||||||
|
|
@ -62,7 +61,6 @@ 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);
|
||||||
|
|
@ -114,12 +112,6 @@ 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
|
||||||
|
|
@ -257,7 +249,6 @@ 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,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -29,9 +29,6 @@ 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;
|
||||||
|
|
@ -47,7 +44,6 @@ 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,
|
||||||
});
|
});
|
||||||
|
|
@ -66,17 +62,12 @@ 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
|
||||||
|
|
@ -95,11 +86,6 @@ 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
|
||||||
|
|
@ -107,7 +93,6 @@ class _ProductPreviewPanelState extends State<ProductPreviewPanel> {
|
||||||
_priceController.dispose();
|
_priceController.dispose();
|
||||||
_nameController.dispose();
|
_nameController.dispose();
|
||||||
_descriptionController.dispose();
|
_descriptionController.dispose();
|
||||||
_categoryController.dispose();
|
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -150,19 +135,6 @@ 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);
|
||||||
|
|
@ -184,7 +156,7 @@ class _ProductPreviewPanelState extends State<ProductPreviewPanel> {
|
||||||
// ── Metadata ───────────────────────────────────────────────
|
// ── Metadata ───────────────────────────────────────────────
|
||||||
_MetadataRow(label: 'SKU', value: draft.sku),
|
_MetadataRow(label: 'SKU', value: draft.sku),
|
||||||
_buildPriceRow(context),
|
_buildPriceRow(context),
|
||||||
_buildCategoryRow(context),
|
_MetadataRow(label: 'Category', value: draft.category),
|
||||||
_MetadataRow(
|
_MetadataRow(
|
||||||
label: 'Last Modified',
|
label: 'Last Modified',
|
||||||
value:
|
value:
|
||||||
|
|
@ -389,86 +361,6 @@ 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) {
|
||||||
|
|
|
||||||
|
|
@ -145,42 +145,6 @@ 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);
|
||||||
|
|
|
||||||
|
|
@ -299,68 +299,5 @@ 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>()),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,6 @@ void main() {
|
||||||
UpdateProductPrice(repository),
|
UpdateProductPrice(repository),
|
||||||
UpdateProductName(repository),
|
UpdateProductName(repository),
|
||||||
UpdateProductDescription(repository),
|
UpdateProductDescription(repository),
|
||||||
UpdateProductCategory(repository),
|
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -493,70 +492,6 @@ 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', () {
|
||||||
|
|
|
||||||
|
|
@ -33,10 +33,6 @@ 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() {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue