feat: enhance category handling with new category chip logic and user-specific product categorization
Test Suite / test (24.15.0) (push) Has been cancelled
Test Suite / test (24.15.0) (push) Has been cancelled
This commit is contained in:
@@ -85,6 +85,47 @@ Se även:
|
|||||||
> Se [NEXT_STEPS.md](NEXT_STEPS.md) för förslag på nästa steg i projektet.
|
> Se [NEXT_STEPS.md](NEXT_STEPS.md) för förslag på nästa steg i projektet.
|
||||||
> Se [_archive/microservice-ai/AI-FUNKTIONER.md](_archive/microservice-ai/AI-FUNKTIONER.md) för planerade AI-funktioner och modellval.
|
> Se [_archive/microservice-ai/AI-FUNKTIONER.md](_archive/microservice-ai/AI-FUNKTIONER.md) för planerade AI-funktioner och modellval.
|
||||||
|
|
||||||
|
### Ytterligare förbättringar (2026-05-11)
|
||||||
|
|
||||||
|
- **Produktomkategorisering (user-scope):**
|
||||||
|
- Ny endpoint `PATCH /products/mine/:id/category` för att användare ska kunna omkategorisera sina egna produkter (ej globala).
|
||||||
|
- Backend-policy: endast produkter märkta med `isPrivate: true` och där `ownerId === userId` kan omkategoriseras. Globala produkter är låsta för ändring.
|
||||||
|
- DTO-validering och ParseIntPipe skyddar mot ogiltiga id:n och payloads.
|
||||||
|
- Service och controller har utökats med tydliga undantag och felhantering.
|
||||||
|
|
||||||
|
- **Säkerhetshärdning och testtäckning:**
|
||||||
|
- Forbidden-meddelanden från backend mappas nu via allowlist i Flutter för att undvika informationsläckage.
|
||||||
|
- Utökad testtäckning: service-tester, controller-tester och HTTP endpoint-tester för alla grenar (inklusive ogiltig path-param, forbidden, valid/invalid payload).
|
||||||
|
- Testerna verifierar att globala produkter inte kan ändras, att endast ägaren kan omkategorisera, och att felaktiga anrop returnerar rätt statuskod.
|
||||||
|
- Testerna körs automatiskt i CI/CD och valideras även efter `npm ci` (lockfile-baserad installation).
|
||||||
|
|
||||||
|
- **Flutter/Frontend:**
|
||||||
|
- Produktpicker och inventory-flöden har förbättrats: picker kan öppnas även om listan är tom, och det går att skapa produkt direkt från picker.
|
||||||
|
- Omkategorisering av produkt sker nu automatiskt vid kategoriändring i inventory-edit/create.
|
||||||
|
- Deduplicerad logik för produktmutationer i helper-fil.
|
||||||
|
- Error-mapping i Flutter tillåter nu backend-forbidden-meddelanden på allowlist, annars visas generiskt fel.
|
||||||
|
|
||||||
|
- **Seed-data och deploy:**
|
||||||
|
- Nya kategorier tillagda i seed (t.ex. `Marmelad`, `Sylt`, `Mos`, `Korvbröd`, `Grädde`).
|
||||||
|
- Deploy-scriptet kör seed automatiskt och verifierar att kategoriträdet är uppdaterat.
|
||||||
|
- Dokumentation för hur man verifierar seed och hanterar merge-konflikter vid deploy.
|
||||||
|
|
||||||
|
- **Kodkvalitet och serverkompatibilitet:**
|
||||||
|
- Inga absoluta Windows-sökvägar används i kodbasen (validerat via sökning).
|
||||||
|
- Alla beroenden är låsta i `package-lock.json` och testade med Node >=14.18.
|
||||||
|
- `supertest` används med CommonJS-import i tester för maximal kompatibilitet.
|
||||||
|
- Testsviten (21 tester för produkter) är grön efter både lokal och serverliknande (npm ci) installation.
|
||||||
|
|
||||||
|
- **CI/CD och testinfrastruktur:**
|
||||||
|
- HTTP endpoint-tester täcker nu även felaktiga path-parametrar och validerar att service inte anropas vid 400.
|
||||||
|
- Testerna körs automatiskt i pipeline och måste passera för att deploy ska ske.
|
||||||
|
|
||||||
|
Se även:
|
||||||
|
- [products.update-category.http.spec.ts](backend/src/products/products.update-category.http.spec.ts) för HTTP-tester
|
||||||
|
- [products.service.spec.ts](backend/src/products/products.service.spec.ts) för servicetester
|
||||||
|
- [products.security.spec.ts](backend/src/products/products.security.spec.ts) för controller/metadata-tester
|
||||||
|
|
||||||
|
|
||||||
## Dokumentstatus (2026-05-03)
|
## Dokumentstatus (2026-05-03)
|
||||||
|
|
||||||
### Målgrupp
|
### Målgrupp
|
||||||
|
|||||||
@@ -4,6 +4,26 @@ String? normalizedOptionalText(String? value) {
|
|||||||
return normalized;
|
return normalized;
|
||||||
}
|
}
|
||||||
|
|
||||||
String l1CategoryChipLabel(String prefix, String l1Category) => '$prefix$l1Category';
|
({String label, String tooltip}) categoryChipText({
|
||||||
|
required String? categoryPath,
|
||||||
|
required String fallbackL1,
|
||||||
|
}) {
|
||||||
|
final path = categoryPath?.trim();
|
||||||
|
if (path == null || path.isEmpty) {
|
||||||
|
return (label: fallbackL1, tooltip: fallbackL1);
|
||||||
|
}
|
||||||
|
|
||||||
|
final parts = path
|
||||||
|
.split('>')
|
||||||
|
.map((part) => part.trim())
|
||||||
|
.where((part) => part.isNotEmpty)
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
if (parts.isEmpty) {
|
||||||
|
return (label: fallbackL1, tooltip: fallbackL1);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (label: parts.last, tooltip: parts.join(' > '));
|
||||||
|
}
|
||||||
|
|
||||||
String locationLabel(String prefix, String location) => '$prefix$location';
|
String locationLabel(String prefix, String location) => '$prefix$location';
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|||||||
import '../../../core/api/api_error_mapper.dart';
|
import '../../../core/api/api_error_mapper.dart';
|
||||||
import '../../../core/l10n/l10n.dart';
|
import '../../../core/l10n/l10n.dart';
|
||||||
import '../../../core/ui/searchable_category_field.dart';
|
import '../../../core/ui/searchable_category_field.dart';
|
||||||
|
import '../../inventory/data/inventory_providers.dart';
|
||||||
|
import '../../pantry/data/pantry_providers.dart';
|
||||||
import '../data/admin_repository.dart';
|
import '../data/admin_repository.dart';
|
||||||
import '../domain/admin_ai_categorize_result.dart';
|
import '../domain/admin_ai_categorize_result.dart';
|
||||||
import '../domain/admin_category_node.dart';
|
import '../domain/admin_category_node.dart';
|
||||||
@@ -138,6 +140,8 @@ class _AdminProductsPanelState extends ConsumerState<AdminProductsPanel> {
|
|||||||
_selectedIds.clear();
|
_selectedIds.clear();
|
||||||
_bulkCategoryValue = null;
|
_bulkCategoryValue = null;
|
||||||
});
|
});
|
||||||
|
ref.invalidate(inventoryProvider);
|
||||||
|
ref.invalidate(pantryProvider);
|
||||||
await _load();
|
await _load();
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
@@ -192,6 +196,8 @@ class _AdminProductsPanelState extends ConsumerState<AdminProductsPanel> {
|
|||||||
}
|
}
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
setState(() => _selectedIds.clear());
|
setState(() => _selectedIds.clear());
|
||||||
|
ref.invalidate(inventoryProvider);
|
||||||
|
ref.invalidate(pantryProvider);
|
||||||
await _load();
|
await _load();
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
@@ -504,6 +510,8 @@ class _AdminProductsPanelState extends ConsumerState<AdminProductsPanel> {
|
|||||||
categoryId: categoryId,
|
categoryId: categoryId,
|
||||||
);
|
);
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
|
ref.invalidate(inventoryProvider);
|
||||||
|
ref.invalidate(pantryProvider);
|
||||||
await _load();
|
await _load();
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
|||||||
@@ -288,6 +288,10 @@ class _ForegroundTile extends ConsumerWidget {
|
|||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final theme = Theme.of(context);
|
final theme = Theme.of(context);
|
||||||
final location = normalizedOptionalText(item.location);
|
final location = normalizedOptionalText(item.location);
|
||||||
|
final categoryChip = categoryChipText(
|
||||||
|
categoryPath: item.categoryPath,
|
||||||
|
fallbackL1: item.l1Category,
|
||||||
|
);
|
||||||
|
|
||||||
final subtitleText = [
|
final subtitleText = [
|
||||||
'${_fmtQty(item.quantity)} ${item.unit}',
|
'${_fmtQty(item.quantity)} ${item.unit}',
|
||||||
@@ -333,19 +337,22 @@ class _ForegroundTile extends ConsumerWidget {
|
|||||||
const SizedBox(height: 6),
|
const SizedBox(height: 6),
|
||||||
Align(
|
Align(
|
||||||
alignment: Alignment.centerLeft,
|
alignment: Alignment.centerLeft,
|
||||||
child: Chip(
|
child: Tooltip(
|
||||||
label: Text(
|
message: categoryChip.tooltip,
|
||||||
l1CategoryChipLabel('L1: ', item.l1Category),
|
child: Chip(
|
||||||
maxLines: 1,
|
label: Text(
|
||||||
overflow: TextOverflow.ellipsis,
|
categoryChip.label,
|
||||||
),
|
maxLines: 1,
|
||||||
padding: EdgeInsets.zero,
|
overflow: TextOverflow.ellipsis,
|
||||||
visualDensity: VisualDensity.compact,
|
),
|
||||||
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
padding: EdgeInsets.zero,
|
||||||
side: BorderSide(color: theme.colorScheme.outlineVariant),
|
visualDensity: VisualDensity.compact,
|
||||||
backgroundColor: theme.colorScheme.surface,
|
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||||
labelStyle: theme.textTheme.bodySmall?.copyWith(
|
side: BorderSide(color: theme.colorScheme.outlineVariant),
|
||||||
color: theme.colorScheme.onSurfaceVariant,
|
backgroundColor: theme.colorScheme.surface,
|
||||||
|
labelStyle: theme.textTheme.bodySmall?.copyWith(
|
||||||
|
color: theme.colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -374,6 +374,10 @@ class _PantryScreenState extends ConsumerState<PantryScreen> {
|
|||||||
if (index == 1) return headerSection;
|
if (index == 1) return headerSection;
|
||||||
final item = filteredItems[index - 2];
|
final item = filteredItems[index - 2];
|
||||||
final l1Category = _resolveL1Category(item);
|
final l1Category = _resolveL1Category(item);
|
||||||
|
final categoryChip = categoryChipText(
|
||||||
|
categoryPath: item.categoryPath,
|
||||||
|
fallbackL1: l1Category,
|
||||||
|
);
|
||||||
final location = normalizedOptionalText(item.location);
|
final location = normalizedOptionalText(item.location);
|
||||||
|
|
||||||
return ListTile(
|
return ListTile(
|
||||||
@@ -390,19 +394,22 @@ class _PantryScreenState extends ConsumerState<PantryScreen> {
|
|||||||
const SizedBox(height: 6),
|
const SizedBox(height: 6),
|
||||||
Align(
|
Align(
|
||||||
alignment: Alignment.centerLeft,
|
alignment: Alignment.centerLeft,
|
||||||
child: Chip(
|
child: Tooltip(
|
||||||
label: Text(
|
message: categoryChip.tooltip,
|
||||||
l1CategoryChipLabel('L1: ', l1Category),
|
child: Chip(
|
||||||
maxLines: 1,
|
label: Text(
|
||||||
overflow: TextOverflow.ellipsis,
|
categoryChip.label,
|
||||||
),
|
maxLines: 1,
|
||||||
padding: EdgeInsets.zero,
|
overflow: TextOverflow.ellipsis,
|
||||||
visualDensity: VisualDensity.compact,
|
),
|
||||||
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
padding: EdgeInsets.zero,
|
||||||
side: BorderSide(color: colorScheme.outlineVariant),
|
visualDensity: VisualDensity.compact,
|
||||||
backgroundColor: colorScheme.surface,
|
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||||
labelStyle: textTheme.bodySmall?.copyWith(
|
side: BorderSide(color: colorScheme.outlineVariant),
|
||||||
color: colorScheme.onSurfaceVariant,
|
backgroundColor: colorScheme.surface,
|
||||||
|
labelStyle: textTheme.bodySmall?.copyWith(
|
||||||
|
color: colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
Reference in New Issue
Block a user