feat: add canonical name endpoint and update product renaming functionality in admin panel
Test Suite / test (24.15.0) (push) Has been cancelled

This commit is contained in:
Nils-Johan Gynther
2026-05-09 22:58:23 +02:00
parent 3ff27701fc
commit 9f3f5c1cef
4 changed files with 115 additions and 40 deletions
@@ -191,6 +191,12 @@ class AdminRepository {
Future<void> restoreProduct(int productId) =>
_postVoid(ProductApiPaths.restore(productId));
Future<void> updateCanonicalName(int productId, String canonicalName) =>
_patchVoid(
ProductApiPaths.canonicalName(productId),
{'canonicalName': canonicalName.trim()},
);
/// Skapar en ny aktiv produkt (kräver admin). Returnerar `{id, name, categoryId?}`.
Future<Map<String, dynamic>> createProduct(String name, {int? categoryId}) =>
_post<Map<String, dynamic>>(
@@ -42,6 +42,7 @@ class _AdminProductsPanelState extends ConsumerState<AdminProductsPanel> {
final Set<int> _selectedIds = <int>{};
final Map<int, String> _rowCategoryValue = <int, String>{};
final Set<int> _rowCategorySaving = <int>{};
List<({String value, String label})> _cachedCategoryOptions = [];
@override
void initState() {
@@ -65,6 +66,7 @@ class _AdminProductsPanelState extends ConsumerState<AdminProductsPanel> {
setState(() {
_products = results[0] as List<AdminProduct>;
_categories = results[1] as List<AdminCategoryNode>;
_cachedCategoryOptions = _flattenCategories(_categories);
_rowCategoryValue.clear();
_rowCategorySaving.clear();
});
@@ -76,22 +78,14 @@ class _AdminProductsPanelState extends ConsumerState<AdminProductsPanel> {
}
}
String _sortLabel(_ProductSort sort) {
switch (sort) {
case _ProductSort.newest:
return context.l10n.adminSortNewest;
case _ProductSort.oldest:
return context.l10n.adminSortOldest;
case _ProductSort.nameAsc:
return context.l10n.adminSortNameAsc;
case _ProductSort.nameDesc:
return context.l10n.adminSortNameDesc;
case _ProductSort.categoryAsc:
return context.l10n.adminSortCategoryAsc;
case _ProductSort.categoryDesc:
return context.l10n.adminSortCategoryDesc;
}
}
String _sortLabel(_ProductSort sort) => switch (sort) {
_ProductSort.newest => context.l10n.adminSortNewest,
_ProductSort.oldest => context.l10n.adminSortOldest,
_ProductSort.nameAsc => context.l10n.adminSortNameAsc,
_ProductSort.nameDesc => context.l10n.adminSortNameDesc,
_ProductSort.categoryAsc => context.l10n.adminSortCategoryAsc,
_ProductSort.categoryDesc => context.l10n.adminSortCategoryDesc,
};
List<({String value, String label})> _flattenCategories(
List<AdminCategoryNode> nodes, [
@@ -132,7 +126,7 @@ class _AdminProductsPanelState extends ConsumerState<AdminProductsPanel> {
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
buildCopyableErrorSnackBar(context, mapErrorToUserMessage(e, context)),
_showError(e);
);
} finally {
if (mounted) setState(() => _isApplying = false);
@@ -181,7 +175,7 @@ class _AdminProductsPanelState extends ConsumerState<AdminProductsPanel> {
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
buildCopyableErrorSnackBar(context, mapErrorToUserMessage(e, context)),
_showError(e);
);
} finally {
if (mounted) setState(() => _isAiRunning = false);
@@ -321,7 +315,70 @@ class _AdminProductsPanelState extends ConsumerState<AdminProductsPanel> {
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
buildCopyableErrorSnackBar(context, mapErrorToUserMessage(e, context)),
_showError(e);
);
}
}
Future<void> _renameProduct(AdminProduct product) async {
final controller = TextEditingController(
text: product.canonicalName?.isNotEmpty == true
? product.canonicalName
: product.name,
);
final newName = await showDialog<String>(
context: context,
builder: (dialogContext) => AlertDialog(
title: const Text('Byt namn på produkt'),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Produkt-ID: ${product.id}'),
const SizedBox(height: 12),
TextField(
controller: controller,
autofocus: true,
decoration: const InputDecoration(
labelText: 'Kanoniskt namn',
border: OutlineInputBorder(),
),
onSubmitted: (value) {
if (value.trim().isNotEmpty) Navigator.pop(dialogContext, value.trim());
},
),
],
),
actions: [
TextButton(
onPressed: () => Navigator.pop(dialogContext),
child: Text(context.l10n.cancelAction),
),
FilledButton(
onPressed: () {
final value = controller.text.trim();
if (value.isNotEmpty) Navigator.pop(dialogContext, value);
},
child: const Text('Spara'),
),
],
),
);
controller.dispose();
if (newName == null || !mounted) return;
try {
await ref.read(adminRepositoryProvider).updateCanonicalName(product.id, newName);
if (!mounted) return;
await _load();
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Namn uppdaterat till "$newName"')),
);
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
_showError(e);
);
}
}
@@ -358,7 +415,7 @@ class _AdminProductsPanelState extends ConsumerState<AdminProductsPanel> {
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
buildCopyableErrorSnackBar(context, mapErrorToUserMessage(e, context)),
_showError(e);
);
}
}
@@ -367,9 +424,8 @@ class _AdminProductsPanelState extends ConsumerState<AdminProductsPanel> {
if (_selectedIds.isEmpty || !_showDeletedOnly || _isApplying) return;
setState(() => _isApplying = true);
try {
for (final id in _selectedIds.toList()) {
await ref.read(adminRepositoryProvider).restoreProduct(id);
}
final repo = ref.read(adminRepositoryProvider);
await Future.wait(_selectedIds.map(repo.restoreProduct));
if (!mounted) return;
setState(() => _selectedIds.clear());
await _load();
@@ -380,7 +436,7 @@ class _AdminProductsPanelState extends ConsumerState<AdminProductsPanel> {
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
buildCopyableErrorSnackBar(context, mapErrorToUserMessage(e, context)),
_showError(e);
);
} finally {
if (mounted) setState(() => _isApplying = false);
@@ -399,7 +455,7 @@ class _AdminProductsPanelState extends ConsumerState<AdminProductsPanel> {
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
buildCopyableErrorSnackBar(context, mapErrorToUserMessage(e, context)),
_showError(e);
);
}
}
@@ -435,22 +491,30 @@ class _AdminProductsPanelState extends ConsumerState<AdminProductsPanel> {
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
buildCopyableErrorSnackBar(context, mapErrorToUserMessage(e, context)),
_showError(e);
);
setState(() => _rowCategorySaving.remove(product.id));
}
}
String _nameForId(int id) {
final match = _products.where((p) => p.id == id).toList();
if (match.isEmpty) return '#$id';
return match.first.displayName;
for (final p in _products) {
if (p.id == id) return p.displayName;
}
return '#$id';
}
void _showError(Object e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
_showError(e);
);
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final categoryOptions = _flattenCategories(_categories);
final categoryOptions = _cachedCategoryOptions;
final filtered = _products.where((product) {
if (!_showDeletedOnly && _showUncategorizedOnly && product.categoryId != null) {
return false;
@@ -709,6 +773,13 @@ class _AdminProductsPanelState extends ConsumerState<AdminProductsPanel> {
else
const SizedBox.shrink(),
const SizedBox(width: 8),
if (!_showDeletedOnly)
TextButton.icon(
onPressed: () => _renameProduct(product),
icon: const Icon(Icons.drive_file_rename_outline),
label: const Text('Byt namn'),
),
const SizedBox(width: 4),
if (_showDeletedOnly)
TextButton.icon(
onPressed: () => _restoreProduct(product),