feat/publishing-ux-hardening #6
|
|
@ -44,9 +44,14 @@
|
|||
|
||||
### feat/publishing-ux-hardening
|
||||
|
||||
- status: queued (Stage 2B — next)
|
||||
- inspection: pending
|
||||
- implementation: pending
|
||||
- tests: pending
|
||||
- analyze: pending
|
||||
- brief updated: no
|
||||
- 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
|
||||
|
|
|
|||
|
|
@ -98,10 +98,18 @@ Rules:
|
|||
- latest reported count for `kell_web` dashboard tests: `5/5 passed`
|
||||
- baseline commit: `7acff83` (2026-04-11)
|
||||
|
||||
### Next recommended branch
|
||||
### In-progress branch
|
||||
|
||||
**`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).
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -59,16 +59,20 @@ 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() {
|
||||
|
|
@ -84,20 +88,24 @@ 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -113,54 +121,86 @@ class _ProductPreviewPanelState extends State<ProductPreviewPanel> {
|
|||
|
||||
void _submitPrice() {
|
||||
final parsed = double.tryParse(_priceController.text);
|
||||
if (parsed != null && parsed >= 0) {
|
||||
widget.onPriceChanged?.call(parsed);
|
||||
setState(() => _editingPrice = false);
|
||||
if (parsed == null || parsed < 0) {
|
||||
setState(() => _priceError = 'Enter a valid price.');
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
_priceError = null;
|
||||
_editingPrice = false;
|
||||
});
|
||||
widget.onPriceChanged?.call(parsed);
|
||||
}
|
||||
|
||||
void _cancelEdit() {
|
||||
_priceController.text = widget.draft.price.toStringAsFixed(2);
|
||||
setState(() => _editingPrice = false);
|
||||
setState(() {
|
||||
_priceError = null;
|
||||
_editingPrice = false;
|
||||
});
|
||||
}
|
||||
|
||||
void _submitName() {
|
||||
final trimmed = _nameController.text.trim();
|
||||
if (trimmed.isNotEmpty) {
|
||||
widget.onNameChanged?.call(trimmed);
|
||||
setState(() => _editingName = false);
|
||||
if (trimmed.isEmpty) {
|
||||
setState(() => _nameError = 'Name cannot be empty.');
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
_nameError = null;
|
||||
_editingName = false;
|
||||
});
|
||||
widget.onNameChanged?.call(trimmed);
|
||||
}
|
||||
|
||||
void _cancelNameEdit() {
|
||||
_nameController.text = widget.draft.name;
|
||||
setState(() => _editingName = false);
|
||||
setState(() {
|
||||
_nameError = null;
|
||||
_editingName = false;
|
||||
});
|
||||
}
|
||||
|
||||
void _submitDescription() {
|
||||
final trimmed = _descriptionController.text.trim();
|
||||
if (trimmed.isNotEmpty) {
|
||||
widget.onDescriptionChanged?.call(trimmed);
|
||||
setState(() => _editingDescription = false);
|
||||
if (trimmed.isEmpty) {
|
||||
setState(() => _descriptionError = 'Description cannot be empty.');
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
_descriptionError = null;
|
||||
_editingDescription = false;
|
||||
});
|
||||
widget.onDescriptionChanged?.call(trimmed);
|
||||
}
|
||||
|
||||
void _cancelDescriptionEdit() {
|
||||
_descriptionController.text = widget.draft.description;
|
||||
setState(() => _editingDescription = false);
|
||||
setState(() {
|
||||
_descriptionError = null;
|
||||
_editingDescription = false;
|
||||
});
|
||||
}
|
||||
|
||||
void _submitCategory() {
|
||||
final trimmed = _categoryController.text.trim();
|
||||
if (trimmed.isNotEmpty) {
|
||||
widget.onCategoryChanged?.call(trimmed);
|
||||
setState(() => _editingCategory = false);
|
||||
if (trimmed.isEmpty) {
|
||||
setState(() => _categoryError = 'Category cannot be empty.');
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
_categoryError = null;
|
||||
_editingCategory = false;
|
||||
});
|
||||
widget.onCategoryChanged?.call(trimmed);
|
||||
}
|
||||
|
||||
void _cancelCategoryEdit() {
|
||||
_categoryController.text = widget.draft.category;
|
||||
setState(() => _editingCategory = false);
|
||||
setState(() {
|
||||
_categoryError = null;
|
||||
_editingCategory = false;
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
|
|
@ -246,35 +286,42 @@ class _ProductPreviewPanelState extends State<ProductPreviewPanel> {
|
|||
final draft = widget.draft;
|
||||
|
||||
if (_editingName) {
|
||||
return Row(
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(
|
||||
child: TextField(
|
||||
controller: _nameController,
|
||||
style: theme.textTheme.headlineMedium,
|
||||
decoration: const InputDecoration(
|
||||
isDense: true,
|
||||
contentPadding: EdgeInsets.symmetric(horizontal: 8, vertical: 8),
|
||||
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,
|
||||
),
|
||||
),
|
||||
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(),
|
||||
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(),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
|
|
@ -308,48 +355,55 @@ class _ProductPreviewPanelState extends State<ProductPreviewPanel> {
|
|||
if (_editingPrice) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: KcSpacing.xs),
|
||||
child: Row(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 120,
|
||||
child: Text(
|
||||
'Price',
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: KcColors.neutral,
|
||||
fontWeight: FontWeight.w600,
|
||||
Row(
|
||||
children: [
|
||||
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,
|
||||
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),
|
||||
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,
|
||||
),
|
||||
),
|
||||
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(),
|
||||
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(),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
|
|
@ -395,44 +449,51 @@ class _ProductPreviewPanelState extends State<ProductPreviewPanel> {
|
|||
if (_editingCategory) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: KcSpacing.xs),
|
||||
child: Row(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 120,
|
||||
child: Text(
|
||||
'Category',
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: KcColors.neutral,
|
||||
fontWeight: FontWeight.w600,
|
||||
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),
|
||||
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,
|
||||
),
|
||||
),
|
||||
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(),
|
||||
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(),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
|
|
@ -484,7 +545,7 @@ class _ProductPreviewPanelState extends State<ProductPreviewPanel> {
|
|||
const Spacer(),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.check, size: 20),
|
||||
onPressed: _submitDescription,
|
||||
onPressed: widget.isUpdating ? null : _submitDescription,
|
||||
tooltip: 'Save description',
|
||||
padding: EdgeInsets.zero,
|
||||
constraints: const BoxConstraints(),
|
||||
|
|
@ -492,7 +553,7 @@ class _ProductPreviewPanelState extends State<ProductPreviewPanel> {
|
|||
const SizedBox(width: KcSpacing.xs),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.close, size: 20),
|
||||
onPressed: _cancelDescriptionEdit,
|
||||
onPressed: widget.isUpdating ? null : _cancelDescriptionEdit,
|
||||
tooltip: 'Cancel description edit',
|
||||
padding: EdgeInsets.zero,
|
||||
constraints: const BoxConstraints(),
|
||||
|
|
@ -502,13 +563,15 @@ 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: const InputDecoration(
|
||||
decoration: InputDecoration(
|
||||
isDense: true,
|
||||
contentPadding: EdgeInsets.all(8),
|
||||
border: OutlineInputBorder(),
|
||||
contentPadding: const EdgeInsets.all(8),
|
||||
border: const OutlineInputBorder(),
|
||||
errorText: _descriptionError,
|
||||
),
|
||||
autofocus: true,
|
||||
),
|
||||
|
|
|
|||
|
|
@ -4,6 +4,12 @@ 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.
|
||||
|
|
@ -23,7 +29,8 @@ void showStatusActionSnackBar(BuildContext context, StatusActionResult result) {
|
|||
icon = Icons.check_circle_outline;
|
||||
} else {
|
||||
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;
|
||||
icon = Icons.error_outline;
|
||||
}
|
||||
|
|
@ -55,11 +62,12 @@ void showDescriptionActionSnackBar(BuildContext context, DescriptionActionResult
|
|||
final IconData icon;
|
||||
|
||||
if (result.success) {
|
||||
message = '${result.productName} description updated.';
|
||||
message = '${result.productName} description updated successfully.';
|
||||
backgroundColor = KcColors.success;
|
||||
icon = Icons.check_circle_outline;
|
||||
} 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;
|
||||
icon = Icons.error_outline;
|
||||
}
|
||||
|
|
@ -119,11 +127,13 @@ void showPriceActionSnackBar(BuildContext context, PriceActionResult result) {
|
|||
final IconData icon;
|
||||
|
||||
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;
|
||||
icon = Icons.check_circle_outline;
|
||||
} 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;
|
||||
icon = Icons.error_outline;
|
||||
}
|
||||
|
|
@ -155,11 +165,12 @@ void showCategoryActionSnackBar(BuildContext context, CategoryActionResult resul
|
|||
final IconData icon;
|
||||
|
||||
if (result.success) {
|
||||
message = '${result.productName} category updated to ${result.newCategory}.';
|
||||
message = '${result.productName} category updated to ${result.newCategory} successfully.';
|
||||
backgroundColor = KcColors.success;
|
||||
icon = Icons.check_circle_outline;
|
||||
} 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;
|
||||
icon = Icons.error_outline;
|
||||
}
|
||||
|
|
@ -191,11 +202,12 @@ void showNameActionSnackBar(BuildContext context, NameActionResult result) {
|
|||
final IconData icon;
|
||||
|
||||
if (result.success) {
|
||||
message = 'Product renamed to ${result.newName}.';
|
||||
message = '${result.productName} renamed to ${result.newName} successfully.';
|
||||
backgroundColor = KcColors.success;
|
||||
icon = Icons.check_circle_outline;
|
||||
} 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;
|
||||
icon = Icons.error_outline;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -73,6 +73,7 @@ void main() {
|
|||
ValueChanged<double>? onPriceChanged,
|
||||
ValueChanged<String>? onNameChanged,
|
||||
ValueChanged<String>? onDescriptionChanged,
|
||||
ValueChanged<String>? onCategoryChanged,
|
||||
bool isUpdating = false,
|
||||
}) {
|
||||
return MaterialApp(
|
||||
|
|
@ -86,6 +87,7 @@ void main() {
|
|||
onPriceChanged: onPriceChanged,
|
||||
onNameChanged: onNameChanged,
|
||||
onDescriptionChanged: onDescriptionChanged,
|
||||
onCategoryChanged: onCategoryChanged,
|
||||
isUpdating: isUpdating,
|
||||
),
|
||||
),
|
||||
|
|
@ -560,4 +562,221 @@ 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);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -98,7 +98,7 @@ void main() {
|
|||
);
|
||||
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);
|
||||
});
|
||||
|
||||
|
|
@ -130,7 +130,7 @@ void main() {
|
|||
);
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in New Issue