Compare commits

..

No commits in common. "02cc75c6558c33ac1275e36d32b3669fa1a641cb" and "7acff83bf4eff5a13f89c762357d6b771be32dec" have entirely different histories.

6 changed files with 163 additions and 460 deletions

View File

@ -2,9 +2,9 @@
## Current status
- main baseline updated through: post-write-consistency
- main baseline commit: `7acff83` (2026-04-11)
- next branch: feat/publishing-ux-hardening
- main baseline updated through: category-only-edit
- main baseline commit: `8e7e4cb` (2026-04-11)
- next branch: feat/post-write-consistency
- current stage: Stage 2 — Web application operational hardening
## Slice tracker
@ -31,8 +31,7 @@
### feat/post-write-consistency
- status: merged to main
- commit: `7acff83`
- status: implemented — ready for review / merge
- inspection: complete
- implementation: complete
- files changed:
@ -41,17 +40,3 @@
- tests: passed (234/234 feature_wordpress)
- analyze: passed (dart analyze — no issues found)
- brief updated: yes
### feat/publishing-ux-hardening
- status: in progress (Stage 2B)
- inspection: complete
- implementation: complete
- files changed:
- `feature_wordpress/lib/src/presentation/widgets/product_preview_panel.dart` — added inline validation error state (`_nameError`, `_priceError`, `_descriptionError`, `_categoryError`); error messages shown below text fields on empty/invalid submit; errors cleared on cancel; added `onCategoryChanged` callback; text fields and save buttons disabled during `isUpdating`; added tooltips (`Save price`, `Save category`, `Cancel`) for consistency
- `feature_wordpress/lib/src/presentation/widgets/status_action_snack_bar.dart` — failure SnackBar now appends `errorMessage` detail to the failure text for operator visibility
- `feature_wordpress/test/widgets/product_preview_panel_test.dart` — added `onCategoryChanged` to test helper; added 13 new tests: inline validation errors (name, price, description, category), error-clears-on-cancel, valid-input-clears-error, and text-field-disabled-during-isUpdating for all four edit fields
- `feature_wordpress/test/widgets/status_action_snack_bar_test.dart` — updated 2 failure SnackBar assertions to match new wording that includes error detail
- tests: passed (247/247 feature_wordpress)
- analyze: passed (dart analyze — no issues found)
- brief updated: yes

View File

@ -79,7 +79,6 @@ Rules:
- 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 landed (Stage 1B complete — merged `feat/category-only-edit``main` at `8e7e4cb`, 2026-04-11). Stage 1 complete.
- ✅ Post-write consistency hardening landed (Stage 2A complete — merged `feat/post-write-consistency``main` at `7acff83`, 2026-04-11).
### Current narrow edit capabilities on `main`
@ -94,22 +93,21 @@ Rules:
- `dart analyze` clean
- `feature_wordpress` tests passing
- `kell_web` dashboard tests passing
- latest reported count for `feature_wordpress`: `234/234 passed`
- latest reported count for `feature_wordpress`: `223/223 passed`
- latest reported count for `kell_web` dashboard tests: `5/5 passed`
- baseline commit: `7acff83` (2026-04-11)
- baseline commit: `8e7e4cb` (2026-04-11)
### In-progress branch
**`feat/publishing-ux-hardening`** — Stage 2B: Publishing workflow UX hardening.
Branch from `main` at `7acff83`. Implementation complete, pending merge.
### Post-write consistency (Stage 2A) — on branch `feat/post-write-consistency`
- `dart analyze` clean
- `feature_wordpress` tests: `247/247 passed` (13 new tests added)
- Ready for PR into `main`.
- `feature_wordpress` tests: `234/234 passed`
- controller `_refreshSelection()` preserves/refreshes selection after all writes
- 11 new post-write consistency tests added
### Next recommended branch (after 2B merges)
### Next recommended branch
**`feat/multi-select-groundwork`** — Stage 3A: Multi-select groundwork (read/state only first).
**`feat/publishing-ux-hardening`** — Stage 2B: Publishing workflow UX hardening.
Branch from `main` after merging `feat/post-write-consistency`. Stage 2A is complete on branch.
---
@ -168,10 +166,24 @@ Improve operator consistency, predictability, and usability after writes.
- `feat/post-write-consistency`
- `feat/publishing-ux-hardening`
#### ~~Stage 2A — Post-write consistency hardening~~ ✅ COMPLETE
#### Stage 2A — Post-write consistency hardening
> Merged `feat/post-write-consistency``main` at `7acff83` (2026-04-11).
> Added `_refreshSelection()` to `ProductPublishingController` to preserve/refresh selection after all write-triggered reloads. 11 new post-write consistency tests added (234 total `feature_wordpress` tests passing).
##### Goal
Make list/detail behavior predictable after edits and status changes.
##### Requirements
- preserve selection after update where sensible
- maintain search/filter/sort persistence after writes
- handle item repositioning under active sort cleanly
- ensure latest values refresh correctly
- verify last-modified updates behave consistently
##### Definition of done
- post-action behavior feels stable and predictable
- focused tests cover persistence and repositioning
#### Stage 2B — Publishing workflow UX hardening

View File

@ -59,20 +59,16 @@ class ProductPreviewPanel extends StatefulWidget {
class _ProductPreviewPanelState extends State<ProductPreviewPanel> {
bool _editingPrice = false;
late TextEditingController _priceController;
String? _priceError;
bool _editingName = false;
late TextEditingController _nameController;
String? _nameError;
bool _editingDescription = false;
late TextEditingController _descriptionController;
String? _descriptionError;
// ignore: prefer_final_fields
bool _editingCategory = false;
late TextEditingController _categoryController;
String? _categoryError;
@override
void initState() {
@ -88,24 +84,20 @@ class _ProductPreviewPanelState extends State<ProductPreviewPanel> {
super.didUpdateWidget(oldWidget);
if (oldWidget.draft.id != widget.draft.id || oldWidget.draft.price != widget.draft.price) {
_editingPrice = false;
_priceError = null;
_priceController.text = widget.draft.price.toStringAsFixed(2);
}
if (oldWidget.draft.id != widget.draft.id || oldWidget.draft.name != widget.draft.name) {
_editingName = false;
_nameError = null;
_nameController.text = widget.draft.name;
}
if (oldWidget.draft.id != widget.draft.id ||
oldWidget.draft.description != widget.draft.description) {
_editingDescription = false;
_descriptionError = null;
_descriptionController.text = widget.draft.description;
}
if (oldWidget.draft.id != widget.draft.id ||
oldWidget.draft.category != widget.draft.category) {
_editingCategory = false;
_categoryError = null;
_categoryController.text = widget.draft.category;
}
}
@ -121,86 +113,54 @@ class _ProductPreviewPanelState extends State<ProductPreviewPanel> {
void _submitPrice() {
final parsed = double.tryParse(_priceController.text);
if (parsed == null || parsed < 0) {
setState(() => _priceError = 'Enter a valid price.');
return;
if (parsed != null && parsed >= 0) {
widget.onPriceChanged?.call(parsed);
setState(() => _editingPrice = false);
}
setState(() {
_priceError = null;
_editingPrice = false;
});
widget.onPriceChanged?.call(parsed);
}
void _cancelEdit() {
_priceController.text = widget.draft.price.toStringAsFixed(2);
setState(() {
_priceError = null;
_editingPrice = false;
});
setState(() => _editingPrice = false);
}
void _submitName() {
final trimmed = _nameController.text.trim();
if (trimmed.isEmpty) {
setState(() => _nameError = 'Name cannot be empty.');
return;
if (trimmed.isNotEmpty) {
widget.onNameChanged?.call(trimmed);
setState(() => _editingName = false);
}
setState(() {
_nameError = null;
_editingName = false;
});
widget.onNameChanged?.call(trimmed);
}
void _cancelNameEdit() {
_nameController.text = widget.draft.name;
setState(() {
_nameError = null;
_editingName = false;
});
setState(() => _editingName = false);
}
void _submitDescription() {
final trimmed = _descriptionController.text.trim();
if (trimmed.isEmpty) {
setState(() => _descriptionError = 'Description cannot be empty.');
return;
if (trimmed.isNotEmpty) {
widget.onDescriptionChanged?.call(trimmed);
setState(() => _editingDescription = false);
}
setState(() {
_descriptionError = null;
_editingDescription = false;
});
widget.onDescriptionChanged?.call(trimmed);
}
void _cancelDescriptionEdit() {
_descriptionController.text = widget.draft.description;
setState(() {
_descriptionError = null;
_editingDescription = false;
});
setState(() => _editingDescription = false);
}
void _submitCategory() {
final trimmed = _categoryController.text.trim();
if (trimmed.isEmpty) {
setState(() => _categoryError = 'Category cannot be empty.');
return;
if (trimmed.isNotEmpty) {
widget.onCategoryChanged?.call(trimmed);
setState(() => _editingCategory = false);
}
setState(() {
_categoryError = null;
_editingCategory = false;
});
widget.onCategoryChanged?.call(trimmed);
}
void _cancelCategoryEdit() {
_categoryController.text = widget.draft.category;
setState(() {
_categoryError = null;
_editingCategory = false;
});
setState(() => _editingCategory = false);
}
@override
@ -286,42 +246,35 @@ class _ProductPreviewPanelState extends State<ProductPreviewPanel> {
final draft = widget.draft;
if (_editingName) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
return Row(
children: [
Row(
children: [
Expanded(
child: TextField(
controller: _nameController,
enabled: !widget.isUpdating,
style: theme.textTheme.headlineMedium,
decoration: InputDecoration(
isDense: true,
contentPadding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8),
errorText: _nameError,
),
onSubmitted: (_) => _submitName(),
autofocus: true,
),
Expanded(
child: TextField(
controller: _nameController,
style: theme.textTheme.headlineMedium,
decoration: const InputDecoration(
isDense: true,
contentPadding: EdgeInsets.symmetric(horizontal: 8, vertical: 8),
),
const SizedBox(width: KcSpacing.xs),
IconButton(
icon: const Icon(Icons.check, size: 20),
onPressed: widget.isUpdating ? null : _submitName,
tooltip: 'Save name',
padding: EdgeInsets.zero,
constraints: const BoxConstraints(),
),
const SizedBox(width: KcSpacing.xs),
IconButton(
icon: const Icon(Icons.close, size: 20),
onPressed: widget.isUpdating ? null : _cancelNameEdit,
tooltip: 'Cancel',
padding: EdgeInsets.zero,
constraints: const BoxConstraints(),
),
],
onSubmitted: (_) => _submitName(),
autofocus: true,
),
),
const SizedBox(width: KcSpacing.xs),
IconButton(
icon: const Icon(Icons.check, size: 20),
onPressed: _submitName,
tooltip: 'Save name',
padding: EdgeInsets.zero,
constraints: const BoxConstraints(),
),
const SizedBox(width: KcSpacing.xs),
IconButton(
icon: const Icon(Icons.close, size: 20),
onPressed: _cancelNameEdit,
tooltip: 'Cancel',
padding: EdgeInsets.zero,
constraints: const BoxConstraints(),
),
],
);
@ -355,55 +308,48 @@ class _ProductPreviewPanelState extends State<ProductPreviewPanel> {
if (_editingPrice) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: KcSpacing.xs),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
child: Row(
children: [
Row(
children: [
SizedBox(
width: 120,
child: Text(
'Price',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: KcColors.neutral,
fontWeight: FontWeight.w600,
),
),
SizedBox(
width: 120,
child: Text(
'Price',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: KcColors.neutral,
fontWeight: FontWeight.w600,
),
SizedBox(
width: 100,
child: TextField(
controller: _priceController,
enabled: !widget.isUpdating,
keyboardType: const TextInputType.numberWithOptions(decimal: true),
inputFormatters: [FilteringTextInputFormatter.allow(RegExp(r'[\d.]'))],
decoration: InputDecoration(
prefixText: '\$ ',
isDense: true,
contentPadding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8),
errorText: _priceError,
),
onSubmitted: (_) => _submitPrice(),
autofocus: true,
),
),
),
SizedBox(
width: 100,
child: TextField(
controller: _priceController,
keyboardType: const TextInputType.numberWithOptions(decimal: true),
inputFormatters: [FilteringTextInputFormatter.allow(RegExp(r'[\d.]'))],
decoration: const InputDecoration(
prefixText: '\$ ',
isDense: true,
contentPadding: EdgeInsets.symmetric(horizontal: 8, vertical: 8),
),
const SizedBox(width: KcSpacing.xs),
IconButton(
icon: const Icon(Icons.check, size: 20),
onPressed: widget.isUpdating ? null : _submitPrice,
tooltip: 'Save price',
padding: EdgeInsets.zero,
constraints: const BoxConstraints(),
),
const SizedBox(width: KcSpacing.xs),
IconButton(
icon: const Icon(Icons.close, size: 20),
onPressed: widget.isUpdating ? null : _cancelEdit,
tooltip: 'Cancel',
padding: EdgeInsets.zero,
constraints: const BoxConstraints(),
),
],
onSubmitted: (_) => _submitPrice(),
autofocus: true,
),
),
const SizedBox(width: KcSpacing.xs),
IconButton(
icon: const Icon(Icons.check, size: 20),
onPressed: _submitPrice,
tooltip: 'Save price',
padding: EdgeInsets.zero,
constraints: const BoxConstraints(),
),
const SizedBox(width: KcSpacing.xs),
IconButton(
icon: const Icon(Icons.close, size: 20),
onPressed: _cancelEdit,
tooltip: 'Cancel',
padding: EdgeInsets.zero,
constraints: const BoxConstraints(),
),
],
),
@ -449,51 +395,44 @@ class _ProductPreviewPanelState extends State<ProductPreviewPanel> {
if (_editingCategory) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: KcSpacing.xs),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
child: Row(
children: [
Row(
children: [
SizedBox(
width: 120,
child: Text(
'Category',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: KcColors.neutral,
fontWeight: FontWeight.w600,
),
),
SizedBox(
width: 120,
child: Text(
'Category',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: KcColors.neutral,
fontWeight: FontWeight.w600,
),
Expanded(
child: TextField(
controller: _categoryController,
enabled: !widget.isUpdating,
decoration: InputDecoration(
isDense: true,
contentPadding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8),
errorText: _categoryError,
),
onSubmitted: (_) => _submitCategory(),
autofocus: true,
),
),
),
Expanded(
child: TextField(
controller: _categoryController,
decoration: const InputDecoration(
isDense: true,
contentPadding: EdgeInsets.symmetric(horizontal: 8, vertical: 8),
),
const SizedBox(width: KcSpacing.xs),
IconButton(
icon: const Icon(Icons.check, size: 20),
onPressed: widget.isUpdating ? null : _submitCategory,
tooltip: 'Save category',
padding: EdgeInsets.zero,
constraints: const BoxConstraints(),
),
const SizedBox(width: KcSpacing.xs),
IconButton(
icon: const Icon(Icons.close, size: 20),
onPressed: widget.isUpdating ? null : _cancelCategoryEdit,
tooltip: 'Cancel',
padding: EdgeInsets.zero,
constraints: const BoxConstraints(),
),
],
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(),
),
],
),
@ -545,7 +484,7 @@ class _ProductPreviewPanelState extends State<ProductPreviewPanel> {
const Spacer(),
IconButton(
icon: const Icon(Icons.check, size: 20),
onPressed: widget.isUpdating ? null : _submitDescription,
onPressed: _submitDescription,
tooltip: 'Save description',
padding: EdgeInsets.zero,
constraints: const BoxConstraints(),
@ -553,7 +492,7 @@ class _ProductPreviewPanelState extends State<ProductPreviewPanel> {
const SizedBox(width: KcSpacing.xs),
IconButton(
icon: const Icon(Icons.close, size: 20),
onPressed: widget.isUpdating ? null : _cancelDescriptionEdit,
onPressed: _cancelDescriptionEdit,
tooltip: 'Cancel description edit',
padding: EdgeInsets.zero,
constraints: const BoxConstraints(),
@ -563,15 +502,13 @@ class _ProductPreviewPanelState extends State<ProductPreviewPanel> {
const SizedBox(height: KcSpacing.sm),
TextField(
controller: _descriptionController,
enabled: !widget.isUpdating,
maxLines: 5,
minLines: 3,
style: theme.textTheme.bodyLarge,
decoration: InputDecoration(
decoration: const InputDecoration(
isDense: true,
contentPadding: const EdgeInsets.all(8),
border: const OutlineInputBorder(),
errorText: _descriptionError,
contentPadding: EdgeInsets.all(8),
border: OutlineInputBorder(),
),
autofocus: true,
),

View File

@ -4,12 +4,6 @@ import 'package:flutter/material.dart';
import '../../application/product_publishing_controller.dart';
import '../../domain/publish_status.dart';
/// Truncates [text] to [maxLength] characters, appending '' if truncated.
String _truncate(String text, {int maxLength = 60}) {
if (text.length <= maxLength) return text;
return '${text.substring(0, maxLength)}';
}
/// Shows a [SnackBar] for the given [StatusActionResult].
///
/// Uses [KcColors.success] / [KcColors.danger] to match the design system.
@ -29,8 +23,7 @@ void showStatusActionSnackBar(BuildContext context, StatusActionResult result) {
icon = Icons.check_circle_outline;
} else {
final verb = _infinitiveVerbForStatus(result.targetStatus);
final detail = result.errorMessage != null ? ' ${_truncate(result.errorMessage!)}' : '';
message = 'Failed to $verb ${result.productName}.$detail';
message = 'Failed to $verb ${result.productName}.';
backgroundColor = KcColors.danger;
icon = Icons.error_outline;
}
@ -62,12 +55,11 @@ void showDescriptionActionSnackBar(BuildContext context, DescriptionActionResult
final IconData icon;
if (result.success) {
message = '${result.productName} description updated successfully.';
message = '${result.productName} description updated.';
backgroundColor = KcColors.success;
icon = Icons.check_circle_outline;
} else {
final detail = result.errorMessage != null ? ' ${_truncate(result.errorMessage!)}' : '';
message = 'Failed to update description for ${result.productName}.$detail';
message = 'Failed to update description for ${result.productName}.';
backgroundColor = KcColors.danger;
icon = Icons.error_outline;
}
@ -127,13 +119,11 @@ void showPriceActionSnackBar(BuildContext context, PriceActionResult result) {
final IconData icon;
if (result.success) {
message =
'${result.productName} price updated to \$${result.newPrice.toStringAsFixed(2)} successfully.';
message = '${result.productName} price updated to \$${result.newPrice.toStringAsFixed(2)}.';
backgroundColor = KcColors.success;
icon = Icons.check_circle_outline;
} else {
final detail = result.errorMessage != null ? ' ${_truncate(result.errorMessage!)}' : '';
message = 'Failed to update price for ${result.productName}.$detail';
message = 'Failed to update price for ${result.productName}.';
backgroundColor = KcColors.danger;
icon = Icons.error_outline;
}
@ -165,12 +155,11 @@ void showCategoryActionSnackBar(BuildContext context, CategoryActionResult resul
final IconData icon;
if (result.success) {
message = '${result.productName} category updated to ${result.newCategory} successfully.';
message = '${result.productName} category updated to ${result.newCategory}.';
backgroundColor = KcColors.success;
icon = Icons.check_circle_outline;
} else {
final detail = result.errorMessage != null ? ' ${_truncate(result.errorMessage!)}' : '';
message = 'Failed to update category for ${result.productName}.$detail';
message = 'Failed to update category for ${result.productName}.';
backgroundColor = KcColors.danger;
icon = Icons.error_outline;
}
@ -202,12 +191,11 @@ void showNameActionSnackBar(BuildContext context, NameActionResult result) {
final IconData icon;
if (result.success) {
message = '${result.productName} renamed to ${result.newName} successfully.';
message = 'Product renamed to ${result.newName}.';
backgroundColor = KcColors.success;
icon = Icons.check_circle_outline;
} else {
final detail = result.errorMessage != null ? ' ${_truncate(result.errorMessage!)}' : '';
message = 'Failed to rename ${result.productName}.$detail';
message = 'Failed to rename ${result.productName}.';
backgroundColor = KcColors.danger;
icon = Icons.error_outline;
}

View File

@ -73,7 +73,6 @@ void main() {
ValueChanged<double>? onPriceChanged,
ValueChanged<String>? onNameChanged,
ValueChanged<String>? onDescriptionChanged,
ValueChanged<String>? onCategoryChanged,
bool isUpdating = false,
}) {
return MaterialApp(
@ -87,7 +86,6 @@ void main() {
onPriceChanged: onPriceChanged,
onNameChanged: onNameChanged,
onDescriptionChanged: onDescriptionChanged,
onCategoryChanged: onCategoryChanged,
isUpdating: isUpdating,
),
),
@ -562,221 +560,4 @@ void main() {
expect(find.byType(TextField), findsOneWidget);
});
});
// Inline validation error messages (Stage 2B)
group('inline validation errors', () {
testWidgets('shows error when submitting empty name', (tester) async {
await tester.pumpWidget(buildTestWidget(sampleDraft, onNameChanged: (_) {}));
await tester.tap(find.byTooltip('Edit name'));
await tester.pump();
await tester.enterText(find.byType(TextField), '');
await tester.tap(find.byTooltip('Save name'));
await tester.pump();
expect(find.text('Name cannot be empty.'), findsOneWidget);
});
testWidgets('clears name error on cancel', (tester) async {
await tester.pumpWidget(buildTestWidget(sampleDraft, onNameChanged: (_) {}));
await tester.tap(find.byTooltip('Edit name'));
await tester.pump();
// Trigger the error.
await tester.enterText(find.byType(TextField), '');
await tester.tap(find.byTooltip('Save name'));
await tester.pump();
expect(find.text('Name cannot be empty.'), findsOneWidget);
// Cancel should clear the error and exit edit mode.
await tester.tap(find.byTooltip('Cancel'));
await tester.pump();
expect(find.text('Name cannot be empty.'), findsNothing);
expect(find.byType(TextField), findsNothing);
});
testWidgets('shows error when submitting invalid price', (tester) async {
await tester.pumpWidget(buildTestWidget(sampleDraft, onPriceChanged: (_) {}));
await tester.tap(find.byIcon(Icons.edit));
await tester.pump();
// Clear the field (empty string is not a valid price).
await tester.enterText(find.byType(TextField), '');
await tester.tap(find.byIcon(Icons.check));
await tester.pump();
expect(find.text('Enter a valid price.'), findsOneWidget);
});
testWidgets('clears price error on cancel', (tester) async {
await tester.pumpWidget(buildTestWidget(sampleDraft, onPriceChanged: (_) {}));
await tester.tap(find.byIcon(Icons.edit));
await tester.pump();
await tester.enterText(find.byType(TextField), '');
await tester.tap(find.byIcon(Icons.check));
await tester.pump();
expect(find.text('Enter a valid price.'), findsOneWidget);
await tester.tap(find.byIcon(Icons.close));
await tester.pump();
expect(find.text('Enter a valid price.'), findsNothing);
});
testWidgets('shows error when submitting empty description', (tester) async {
await tester.pumpWidget(buildTestWidget(sampleDraft, onDescriptionChanged: (_) {}));
await tester.tap(find.byTooltip('Edit description'));
await tester.pump();
await tester.enterText(find.byType(TextField), '');
await tester.tap(find.byTooltip('Save description'));
await tester.pump();
expect(find.text('Description cannot be empty.'), findsOneWidget);
});
testWidgets('clears description error on cancel', (tester) async {
await tester.pumpWidget(buildTestWidget(sampleDraft, onDescriptionChanged: (_) {}));
await tester.tap(find.byTooltip('Edit description'));
await tester.pump();
await tester.enterText(find.byType(TextField), '');
await tester.tap(find.byTooltip('Save description'));
await tester.pump();
expect(find.text('Description cannot be empty.'), findsOneWidget);
await tester.tap(find.byTooltip('Cancel description edit'));
await tester.pump();
expect(find.text('Description cannot be empty.'), findsNothing);
});
testWidgets('shows error when submitting empty category', (tester) async {
await tester.pumpWidget(buildTestWidget(sampleDraft, onCategoryChanged: (_) {}));
await tester.tap(find.byTooltip('Edit category'));
await tester.pump();
await tester.enterText(find.byType(TextField), '');
await tester.tap(find.byTooltip('Save category'));
await tester.pump();
expect(find.text('Category cannot be empty.'), findsOneWidget);
});
testWidgets('clears category error on cancel', (tester) async {
await tester.pumpWidget(buildTestWidget(sampleDraft, onCategoryChanged: (_) {}));
await tester.tap(find.byTooltip('Edit category'));
await tester.pump();
await tester.enterText(find.byType(TextField), '');
await tester.tap(find.byTooltip('Save category'));
await tester.pump();
expect(find.text('Category cannot be empty.'), findsOneWidget);
await tester.tap(find.byTooltip('Cancel'));
await tester.pump();
expect(find.text('Category cannot be empty.'), findsNothing);
});
testWidgets('valid input clears previous error', (tester) async {
String? receivedName;
await tester.pumpWidget(buildTestWidget(sampleDraft, onNameChanged: (n) => receivedName = n));
await tester.tap(find.byTooltip('Edit name'));
await tester.pump();
// Trigger error first.
await tester.enterText(find.byType(TextField), '');
await tester.tap(find.byTooltip('Save name'));
await tester.pump();
expect(find.text('Name cannot be empty.'), findsOneWidget);
// Now enter valid input and submit.
await tester.enterText(find.byType(TextField), 'Valid Name');
await tester.tap(find.byTooltip('Save name'));
await tester.pump();
expect(find.text('Name cannot be empty.'), findsNothing);
expect(receivedName, 'Valid Name');
});
});
// Disabled state during isUpdating (Stage 2B)
group('edit fields disabled during isUpdating', () {
testWidgets('name text field disabled while updating', (tester) async {
// Start with isUpdating = false to enter edit mode.
await tester.pumpWidget(buildTestWidget(sampleDraft, onNameChanged: (_) {}));
await tester.tap(find.byTooltip('Edit name'));
await tester.pump();
expect(find.byType(TextField), findsOneWidget);
// Rebuild with isUpdating = true.
await tester.pumpWidget(
buildTestWidget(sampleDraft, onNameChanged: (_) {}, isUpdating: true),
);
await tester.pump();
// The text field should be disabled.
final textField = tester.widget<TextField>(find.byType(TextField));
expect(textField.enabled, false);
});
testWidgets('price text field disabled while updating', (tester) async {
await tester.pumpWidget(buildTestWidget(sampleDraft, onPriceChanged: (_) {}));
await tester.tap(find.byIcon(Icons.edit));
await tester.pump();
// Rebuild with isUpdating = true.
await tester.pumpWidget(
buildTestWidget(sampleDraft, onPriceChanged: (_) {}, isUpdating: true),
);
await tester.pump();
final textField = tester.widget<TextField>(find.byType(TextField));
expect(textField.enabled, false);
});
testWidgets('description text field disabled while updating', (tester) async {
await tester.pumpWidget(buildTestWidget(sampleDraft, onDescriptionChanged: (_) {}));
await tester.tap(find.byTooltip('Edit description'));
await tester.pump();
// Rebuild with isUpdating = true.
await tester.pumpWidget(
buildTestWidget(sampleDraft, onDescriptionChanged: (_) {}, isUpdating: true),
);
await tester.pump();
final textField = tester.widget<TextField>(find.byType(TextField));
expect(textField.enabled, false);
});
testWidgets('category text field disabled while updating', (tester) async {
await tester.pumpWidget(buildTestWidget(sampleDraft, onCategoryChanged: (_) {}));
await tester.tap(find.byTooltip('Edit category'));
await tester.pump();
// Rebuild with isUpdating = true.
await tester.pumpWidget(
buildTestWidget(sampleDraft, onCategoryChanged: (_) {}, isUpdating: true),
);
await tester.pump();
final textField = tester.widget<TextField>(find.byType(TextField));
expect(textField.enabled, false);
});
});
}

View File

@ -98,7 +98,7 @@ void main() {
);
await tester.pumpAndSettle();
expect(find.text('Failed to publish Floral Bowl Cozy. Network error'), findsOneWidget);
expect(find.text('Failed to publish Floral Bowl Cozy.'), findsOneWidget);
expect(find.byIcon(Icons.error_outline), findsOneWidget);
});
@ -130,7 +130,7 @@ void main() {
);
await tester.pumpAndSettle();
expect(find.text('Failed to move to draft Ocean Nightlight. Server error'), findsOneWidget);
expect(find.text('Failed to move to draft Ocean Nightlight.'), findsOneWidget);
expect(find.byIcon(Icons.error_outline), findsOneWidget);
});
});