tage 1A — Description-only product edit implemented
Validate Docs / validate-docs (push) Successful in 56s Details

This commit is contained in:
Mike Kell 2026-04-11 11:07:36 -04:00
parent de44b02d76
commit 8d1a01581a
14 changed files with 471 additions and 3 deletions

View File

@ -35,6 +35,10 @@ class _StubProductPublishingRepository implements ProductPublishingRepository {
@override
Future<ProductDraft> updateProductName(String id, String name) => throw UnimplementedError();
@override
Future<ProductDraft> updateProductDescription(String id, String description) =>
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_description.dart';
export 'src/application/update_product_name.dart';
export 'src/application/update_product_price.dart';
export 'src/application/update_product_status.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_description.dart';
import 'update_product_name.dart';
import 'update_product_price.dart';
import 'update_product_status.dart';
@ -71,6 +72,21 @@ class NameActionResult {
});
}
/// The outcome of a description-update action.
///
/// Consumed once by the UI to show a SnackBar, then cleared.
class DescriptionActionResult {
final bool success;
final String productName;
final String? errorMessage;
const DescriptionActionResult({
required this.success,
required this.productName,
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 {
@ -79,6 +95,7 @@ class ProductPublishingController extends ChangeNotifier {
final UpdateProductStatus _updateProductStatus;
final UpdateProductPrice _updateProductPrice;
final UpdateProductName _updateProductName;
final UpdateProductDescription _updateProductDescription;
ProductPublishingController(
this._getProductDrafts,
@ -86,6 +103,7 @@ class ProductPublishingController extends ChangeNotifier {
this._updateProductStatus,
this._updateProductPrice,
this._updateProductName,
this._updateProductDescription,
);
bool _disposed = false;
@ -149,6 +167,13 @@ class ProductPublishingController extends ChangeNotifier {
/// to clear it.
NameActionResult? lastNameResult;
/// The result of the last description-update action.
///
/// Set after [updateDescription] completes. The UI should read this once to
/// show feedback (e.g. a SnackBar) and then call [consumeDescriptionResult]
/// to clear it.
DescriptionActionResult? lastDescriptionResult;
/// Clears [lastActionResult] so the same result is not shown twice.
void consumeActionResult() {
lastActionResult = null;
@ -164,6 +189,11 @@ class ProductPublishingController extends ChangeNotifier {
lastNameResult = null;
}
/// Clears [lastDescriptionResult] so the same result is not shown twice.
void consumeDescriptionResult() {
lastDescriptionResult = null;
}
/// Loads all product drafts and applies any current filter / search.
Future<void> load() async {
isLoading = true;
@ -356,6 +386,35 @@ class ProductPublishingController extends ChangeNotifier {
}
}
/// Updates only the description of the product with [id].
///
/// Follows the same per-row updating pattern as [updateStatus].
Future<void> updateDescription(String id, String description) async {
if (updatingIds.contains(id)) return;
final productName = _productNameById(id);
updatingIds.add(id);
_safeNotify();
try {
await _updateProductDescription(id, description);
if (_disposed) return;
updatingIds.remove(id);
lastDescriptionResult = DescriptionActionResult(success: true, productName: productName);
await load();
} catch (e) {
if (_disposed) return;
updatingIds.remove(id);
lastDescriptionResult = DescriptionActionResult(
success: false,
productName: productName,
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 description of a single product by its [id].
///
/// This is a narrow description mutation not a generic product edit.
class UpdateProductDescription {
final ProductPublishingRepository repository;
UpdateProductDescription(this.repository);
Future<ProductDraft> call(String id, String description) =>
repository.updateProductDescription(id, description);
}

View File

@ -155,4 +155,19 @@ class FakeProductPublishingRepository implements ProductPublishingRepository {
_drafts[index] = updated;
return updated;
}
@override
Future<ProductDraft> updateProductDescription(String id, String description) 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(description: description, lastModified: DateTime.now());
_drafts[index] = updated;
return updated;
}
}

View File

@ -48,4 +48,10 @@ class WordPressProductPublishingRepository implements ProductPublishingRepositor
final json = await _apiClient.updateProduct(id, {'name': name});
return _mapper.fromJson(json);
}
@override
Future<ProductDraft> updateProductDescription(String id, String description) async {
final json = await _apiClient.updateProduct(id, {'description': description});
return _mapper.fromJson(json);
}
}

View File

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

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_description.dart';
import '../application/update_product_name.dart';
import '../application/update_product_price.dart';
import '../application/update_product_status.dart';
@ -59,6 +60,7 @@ class _ProductPublishingPageState extends State<ProductPublishingPage> {
UpdateProductStatus(repo),
UpdateProductPrice(repo),
UpdateProductName(repo),
UpdateProductDescription(repo),
);
controller.addListener(_onControllerChanged);
@ -104,6 +106,12 @@ class _ProductPublishingPageState extends State<ProductPublishingPage> {
controller.consumeNameResult();
showNameActionSnackBar(context, nameResult);
}
final descriptionResult = controller.lastDescriptionResult;
if (descriptionResult != null) {
controller.consumeDescriptionResult();
showDescriptionActionSnackBar(context, descriptionResult);
}
}
@override
@ -240,6 +248,7 @@ class _ProductPublishingPageState extends State<ProductPublishingPage> {
onMoveToDraft: () => controller.updateStatus(selected.id, PublishStatus.draft),
onPriceChanged: (price) => controller.updatePrice(selected.id, price),
onNameChanged: (name) => controller.updateName(selected.id, name),
onDescriptionChanged: (desc) => controller.updateDescription(selected.id, desc),
onViewPolicy: widget.onViewPolicy,
);
}

View File

@ -26,6 +26,9 @@ class ProductPreviewPanel extends StatefulWidget {
/// Callback to update the product name. Receives the new name value.
final ValueChanged<String>? onNameChanged;
/// Callback to update the product description. Receives the new description value.
final ValueChanged<String>? onDescriptionChanged;
/// 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;
@ -40,6 +43,7 @@ class ProductPreviewPanel extends StatefulWidget {
this.onMoveToDraft,
this.onPriceChanged,
this.onNameChanged,
this.onDescriptionChanged,
this.isUpdating = false,
this.onViewPolicy,
});
@ -55,11 +59,15 @@ class _ProductPreviewPanelState extends State<ProductPreviewPanel> {
bool _editingName = false;
late TextEditingController _nameController;
bool _editingDescription = false;
late TextEditingController _descriptionController;
@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);
}
@override
@ -73,12 +81,18 @@ class _ProductPreviewPanelState extends State<ProductPreviewPanel> {
_editingName = false;
_nameController.text = widget.draft.name;
}
if (oldWidget.draft.id != widget.draft.id ||
oldWidget.draft.description != widget.draft.description) {
_editingDescription = false;
_descriptionController.text = widget.draft.description;
}
}
@override
void dispose() {
_priceController.dispose();
_nameController.dispose();
_descriptionController.dispose();
super.dispose();
}
@ -108,6 +122,19 @@ class _ProductPreviewPanelState extends State<ProductPreviewPanel> {
setState(() => _editingName = false);
}
void _submitDescription() {
final trimmed = _descriptionController.text.trim();
if (trimmed.isNotEmpty) {
widget.onDescriptionChanged?.call(trimmed);
setState(() => _editingDescription = false);
}
}
void _cancelDescriptionEdit() {
_descriptionController.text = widget.draft.description;
setState(() => _editingDescription = false);
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
@ -139,9 +166,7 @@ class _ProductPreviewPanelState extends State<ProductPreviewPanel> {
const SizedBox(height: KcSpacing.md),
// Description
Text('Description', style: theme.textTheme.titleLarge),
const SizedBox(height: KcSpacing.sm),
Text(draft.description, style: theme.textTheme.bodyLarge),
_buildDescriptionSection(context),
const SizedBox(height: KcSpacing.xl),
// Policy link
@ -335,6 +360,77 @@ class _ProductPreviewPanelState extends State<ProductPreviewPanel> {
),
);
}
/// 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) {
final theme = Theme.of(context);
if (_editingDescription) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Text('Description', style: theme.textTheme.titleLarge),
const Spacer(),
IconButton(
icon: const Icon(Icons.check, size: 20),
onPressed: _submitDescription,
tooltip: 'Save description',
padding: EdgeInsets.zero,
constraints: const BoxConstraints(),
),
const SizedBox(width: KcSpacing.xs),
IconButton(
icon: const Icon(Icons.close, size: 20),
onPressed: _cancelDescriptionEdit,
tooltip: 'Cancel description edit',
padding: EdgeInsets.zero,
constraints: const BoxConstraints(),
),
],
),
const SizedBox(height: KcSpacing.sm),
TextField(
controller: _descriptionController,
maxLines: 5,
minLines: 3,
style: theme.textTheme.bodyLarge,
decoration: const InputDecoration(
isDense: true,
contentPadding: EdgeInsets.all(8),
border: OutlineInputBorder(),
),
autofocus: true,
),
],
);
}
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Text('Description', style: theme.textTheme.titleLarge),
if (widget.onDescriptionChanged != null && !widget.isUpdating) ...[
const SizedBox(width: KcSpacing.xs),
IconButton(
icon: const Icon(Icons.edit, size: 18),
onPressed: () => setState(() => _editingDescription = true),
tooltip: 'Edit description',
padding: EdgeInsets.zero,
constraints: const BoxConstraints(),
),
],
],
),
const SizedBox(height: KcSpacing.sm),
Text(widget.draft.description, style: theme.textTheme.bodyLarge),
],
);
}
}
class _MetadataRow extends StatelessWidget {

View File

@ -45,6 +45,42 @@ void showStatusActionSnackBar(BuildContext context, StatusActionResult result) {
);
}
/// Shows a [SnackBar] for the given [DescriptionActionResult].
void showDescriptionActionSnackBar(BuildContext context, DescriptionActionResult 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} description updated.';
backgroundColor = KcColors.success;
icon = Icons.check_circle_outline;
} else {
message = 'Failed to update description 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),
),
);
}
/// Past-tense verb for success messages (e.g. "X published successfully").
String _pastVerbForStatus(PublishStatus status) {
switch (status) {

View File

@ -243,5 +243,61 @@ void main() {
);
});
});
group('updateProductDescription', () {
test('updates description of the target product', () async {
final updated = await repository.updateProductDescription('4', 'A brand new description.');
expect(updated.id, '4');
expect(updated.description, 'A brand new description.');
});
test('persists the description change in the list', () async {
await repository.updateProductDescription('4', 'A brand new description.');
final drafts = await repository.getProductDrafts();
final product4 = drafts.firstWhere((d) => d.id == '4');
expect(product4.description, 'A brand new description.');
});
test('preserves all fields except description and lastModified', () async {
final draftsBefore = await repository.getProductDrafts();
final before = draftsBefore.firstWhere((d) => d.id == '4');
final updated = await repository.updateProductDescription('4', 'A brand new description.');
expect(updated.name, before.name);
expect(updated.price, before.price);
expect(updated.sku, before.sku);
expect(updated.category, before.category);
expect(updated.imageUrl, before.imageUrl);
expect(updated.status, before.status);
expect(updated.description, 'A brand new description.');
expect(updated.lastModified.isAfter(before.lastModified), isTrue);
});
test('preserves other products unchanged', () async {
final draftsBefore = await repository.getProductDrafts();
await repository.updateProductDescription('4', 'A brand new description.');
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.description, before.description);
expect(after.name, before.name);
expect(after.price, before.price);
expect(after.status, before.status);
}
});
test('throws StateError for unknown id', () async {
expect(
() => repository.updateProductDescription('unknown', 'New desc'),
throwsA(isA<StateError>()),
);
});
});
});
}

View File

@ -17,6 +17,7 @@ void main() {
UpdateProductStatus(repository),
UpdateProductPrice(repository),
UpdateProductName(repository),
UpdateProductDescription(repository),
);
});
@ -428,6 +429,69 @@ void main() {
});
});
group('updateDescription', () {
test('updates description and reloads', () async {
await controller.load();
// Product 4 starts with a known description.
await controller.updateDescription('4', 'A brand new description.');
final updated = controller.drafts.firstWhere((d) => d.id == '4');
expect(updated.description, 'A brand new description.');
expect(controller.error, isNull);
expect(controller.updatingIds, isEmpty);
});
test('sets lastDescriptionResult on success', () async {
await controller.load();
await controller.updateDescription('4', 'Updated desc.');
expect(controller.lastDescriptionResult, isNotNull);
expect(controller.lastDescriptionResult!.success, isTrue);
expect(controller.lastDescriptionResult!.productName, 'Fabric Jar Gripper');
expect(controller.lastDescriptionResult!.errorMessage, isNull);
});
test('sets lastDescriptionResult on failure', () async {
await controller.load();
await controller.updateDescription('unknown', 'New desc.');
expect(controller.lastDescriptionResult, isNotNull);
expect(controller.lastDescriptionResult!.success, isFalse);
expect(controller.lastDescriptionResult!.errorMessage, isNotNull);
expect(controller.updatingIds, isEmpty);
expect(controller.error, isNull);
});
test('consumeDescriptionResult clears the result', () async {
await controller.load();
await controller.updateDescription('4', 'Updated desc.');
expect(controller.lastDescriptionResult, isNotNull);
controller.consumeDescriptionResult();
expect(controller.lastDescriptionResult, isNull);
});
test('prevents duplicate calls while row is already updating', () async {
await controller.load();
final first = controller.updateDescription('4', 'First description');
expect(controller.isUpdating('4'), isTrue);
final second = controller.updateDescription('4', 'Second description');
await first;
await second;
// Only the first description should have been applied.
final updated = controller.drafts.firstWhere((d) => d.id == '4');
expect(updated.description, 'First description');
expect(controller.updatingIds, isEmpty);
});
});
// Search refinements
group('search: category matching', () {

View File

@ -72,6 +72,7 @@ void main() {
VoidCallback? onMoveToDraft,
ValueChanged<double>? onPriceChanged,
ValueChanged<String>? onNameChanged,
ValueChanged<String>? onDescriptionChanged,
bool isUpdating = false,
}) {
return MaterialApp(
@ -84,6 +85,7 @@ void main() {
onMoveToDraft: onMoveToDraft,
onPriceChanged: onPriceChanged,
onNameChanged: onNameChanged,
onDescriptionChanged: onDescriptionChanged,
isUpdating: isUpdating,
),
),
@ -461,4 +463,101 @@ void main() {
expect(find.byType(TextField), findsOneWidget);
});
});
group('description editing', () {
testWidgets('shows edit description icon when onDescriptionChanged is provided', (
tester,
) async {
await tester.pumpWidget(buildTestWidget(sampleDraft, onDescriptionChanged: (_) {}));
expect(find.byTooltip('Edit description'), findsOneWidget);
});
testWidgets('hides edit description icon when onDescriptionChanged is null', (tester) async {
await tester.pumpWidget(buildTestWidget(sampleDraft));
expect(find.byTooltip('Edit description'), findsNothing);
});
testWidgets('hides edit description icon while updating', (tester) async {
await tester.pumpWidget(
buildTestWidget(sampleDraft, onDescriptionChanged: (_) {}, isUpdating: true),
);
expect(find.byTooltip('Edit description'), findsNothing);
});
testWidgets('tapping edit description icon shows text field with current description', (
tester,
) async {
await tester.pumpWidget(buildTestWidget(sampleDraft, onDescriptionChanged: (_) {}));
await tester.tap(find.byTooltip('Edit description'));
await tester.pump();
// Should show a multi-line TextField.
final textFields = find.byType(TextField);
expect(textFields, findsOneWidget);
final textField = tester.widget<TextField>(textFields);
expect(textField.controller!.text, 'A beautifully crafted test product.');
});
testWidgets('tapping check icon submits the new description', (tester) async {
String? receivedDescription;
await tester.pumpWidget(
buildTestWidget(sampleDraft, onDescriptionChanged: (d) => receivedDescription = d),
);
// Enter edit mode.
await tester.tap(find.byTooltip('Edit description'));
await tester.pump();
// Clear and type a new description.
await tester.enterText(find.byType(TextField), 'Updated description text.');
await tester.tap(find.byTooltip('Save description'));
await tester.pump();
expect(receivedDescription, 'Updated description text.');
// Should exit edit mode no more text field.
expect(find.byType(TextField), findsNothing);
});
testWidgets('tapping close icon cancels description editing', (tester) async {
String? receivedDescription;
await tester.pumpWidget(
buildTestWidget(sampleDraft, onDescriptionChanged: (d) => receivedDescription = d),
);
// Enter edit mode.
await tester.tap(find.byTooltip('Edit description'));
await tester.pump();
// Type a new description but cancel.
await tester.enterText(find.byType(TextField), 'Cancelled description');
await tester.tap(find.byTooltip('Cancel description edit'));
await tester.pump();
expect(receivedDescription, isNull);
// Should exit edit mode and show original description.
expect(find.byType(TextField), findsNothing);
expect(find.text('A beautifully crafted test product.'), findsOneWidget);
});
testWidgets('does not submit empty description', (tester) async {
String? receivedDescription;
await tester.pumpWidget(
buildTestWidget(sampleDraft, onDescriptionChanged: (d) => receivedDescription = d),
);
await tester.tap(find.byTooltip('Edit description'));
await tester.pump();
// Clear the field and try to submit.
await tester.enterText(find.byType(TextField), '');
await tester.tap(find.byTooltip('Save description'));
await tester.pump();
// Should not have called the callback.
expect(receivedDescription, isNull);
// Should still be in edit mode.
expect(find.byType(TextField), findsOneWidget);
});
});
}

View File

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