feat/publishing-ux-hardening #6

Merged
mtkell merged 2 commits from feat/publishing-ux-hardening into main 2026-04-11 20:42:24 +00:00
6 changed files with 448 additions and 141 deletions
Showing only changes of commit 02cc75c655 - Show all commits

View File

@ -44,9 +44,14 @@
### feat/publishing-ux-hardening ### feat/publishing-ux-hardening
- status: queued (Stage 2B — next) - status: in progress (Stage 2B)
- inspection: pending - inspection: complete
- implementation: pending - implementation: complete
- tests: pending - files changed:
- analyze: pending - `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
- brief updated: no - `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

@ -98,10 +98,18 @@ Rules:
- latest reported count for `kell_web` dashboard tests: `5/5 passed` - latest reported count for `kell_web` dashboard tests: `5/5 passed`
- baseline commit: `7acff83` (2026-04-11) - baseline commit: `7acff83` (2026-04-11)
### Next recommended branch ### In-progress branch
**`feat/publishing-ux-hardening`** — Stage 2B: Publishing workflow UX hardening. **`feat/publishing-ux-hardening`** — Stage 2B: Publishing workflow UX hardening.
Branch from `main` at `7acff83`. Stage 2A (post-write consistency) is complete. Branch from `main` at `7acff83`. Implementation complete, pending merge.
- `dart analyze` clean
- `feature_wordpress` tests: `247/247 passed` (13 new tests added)
- Ready for PR into `main`.
### Next recommended branch (after 2B merges)
**`feat/multi-select-groundwork`** — Stage 3A: Multi-select groundwork (read/state only first).
--- ---

View File

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

View File

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

View File

@ -73,6 +73,7 @@ void main() {
ValueChanged<double>? onPriceChanged, ValueChanged<double>? onPriceChanged,
ValueChanged<String>? onNameChanged, ValueChanged<String>? onNameChanged,
ValueChanged<String>? onDescriptionChanged, ValueChanged<String>? onDescriptionChanged,
ValueChanged<String>? onCategoryChanged,
bool isUpdating = false, bool isUpdating = false,
}) { }) {
return MaterialApp( return MaterialApp(
@ -86,6 +87,7 @@ void main() {
onPriceChanged: onPriceChanged, onPriceChanged: onPriceChanged,
onNameChanged: onNameChanged, onNameChanged: onNameChanged,
onDescriptionChanged: onDescriptionChanged, onDescriptionChanged: onDescriptionChanged,
onCategoryChanged: onCategoryChanged,
isUpdating: isUpdating, isUpdating: isUpdating,
), ),
), ),
@ -560,4 +562,221 @@ void main() {
expect(find.byType(TextField), findsOneWidget); 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(); await tester.pumpAndSettle();
expect(find.text('Failed to publish Floral Bowl Cozy.'), findsOneWidget); expect(find.text('Failed to publish Floral Bowl Cozy. Network error'), findsOneWidget);
expect(find.byIcon(Icons.error_outline), findsOneWidget); expect(find.byIcon(Icons.error_outline), findsOneWidget);
}); });
@ -130,7 +130,7 @@ void main() {
); );
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(find.text('Failed to move to draft Ocean Nightlight.'), findsOneWidget); expect(find.text('Failed to move to draft Ocean Nightlight. Server error'), findsOneWidget);
expect(find.byIcon(Icons.error_outline), findsOneWidget); expect(find.byIcon(Icons.error_outline), findsOneWidget);
}); });
}); });