feat: add updateCategoryMine endpoint to manage product category updates
Test Suite / test (24.15.0) (push) Has been cancelled

- Implemented a new PATCH endpoint in ProductsController to update the category of a user's product.
- Added corresponding service method in ProductsService to handle business logic and validation.
- Created UpdateCategoryMineDto for request validation.
- Enhanced error handling for forbidden actions and not found resources.
- Updated API error mapping in Flutter to handle specific forbidden messages.
- Modified ProductPickerField to allow product creation directly from the picker.
- Added tests for the new endpoint and service method to ensure proper functionality and error handling.
This commit is contained in:
Nils-Johan Gynther
2026-05-11 21:41:42 +02:00
parent 8e0166c68a
commit f19c157e8f
15 changed files with 756 additions and 31 deletions
+8 -1
View File
@@ -4,6 +4,11 @@ import 'package:flutter/services.dart';
import '../l10n/l10n.dart';
import 'api_exception.dart';
const _safeForbiddenMessages = {
'Du kan inte omkategorisera, ta bort eller ändra produkter som är globala.',
'Du kan bara omkategorisera dina egna produkter',
};
String mapErrorToUserMessage(Object error, BuildContext context) {
final l10n = context.l10n;
if (error is ApiException) {
@@ -11,7 +16,9 @@ String mapErrorToUserMessage(Object error, BuildContext context) {
case ApiErrorType.unauthorized:
return l10n.sessionExpiredError;
case ApiErrorType.forbidden:
return l10n.forbiddenError;
return _safeForbiddenMessages.contains(error.message)
? error.message
: l10n.forbiddenError;
case ApiErrorType.server:
return l10n.serverError;
case ApiErrorType.network:
+1
View File
@@ -13,6 +13,7 @@ class ProductApiPaths {
static const deleted = '/products/deleted';
static const merge = '/products/merge';
static const mergePrivate = '/products/private/merge';
static String updateMineCategory(int id) => '/products/mine/$id/category';
static String mergePreview(int sourceProductId, int targetProductId) =>
'/products/merge-preview?sourceProductId=$sourceProductId&targetProductId=$targetProductId';
static String setStatus(int id) => '/products/$id/status';
@@ -36,6 +36,9 @@ class ProductPickerField extends StatelessWidget {
/// If set, the picker bottom sheet opens with this text pre-filled in the search field.
final String? initialQuery;
/// Optional callback to create a new product directly from the picker.
final Future<ProductOption?> Function(String name)? onCreate;
const ProductPickerField({
super.key,
required this.products,
@@ -46,6 +49,7 @@ class ProductPickerField extends StatelessWidget {
this.label = 'Produkt',
this.errorText,
this.initialQuery,
this.onCreate,
});
@override
@@ -57,7 +61,7 @@ class ProductPickerField extends StatelessWidget {
orElse: () => null,
);
final interactive = enabled && !isLoading && products.isNotEmpty;
final interactive = enabled && !isLoading && (products.isNotEmpty || onCreate != null);
return MouseRegion(
cursor: interactive ? SystemMouseCursors.click : MouseCursor.defer,
@@ -81,7 +85,9 @@ class ProductPickerField extends StatelessWidget {
: selected == null
? Text(
products.isEmpty
? 'Inga produkter tillgängliga'
? (onCreate != null
? 'Inga produkter tillgängliga. Tryck för att välja eller skapa.'
: 'Inga produkter tillgängliga')
: 'Tryck för att välja produkt',
)
: Text(selected.name),
@@ -98,6 +104,7 @@ class ProductPickerField extends StatelessWidget {
value: value,
label: label,
initialQuery: initialQuery,
onCreate: onCreate,
);
if (!context.mounted) return;
if (result == null) return;
@@ -18,6 +18,7 @@ import '../data/inventory_providers.dart';
import '../../import/data/receipt_import_session.dart' show ImportDestination;
import 'inventory_category_helpers.dart';
import 'inventory_category_product_section.dart';
import 'inventory_product_mutations.dart';
class CreateInventoryScreen extends ConsumerStatefulWidget {
final String? initialDestination;
@@ -185,18 +186,24 @@ class _CreateInventoryScreenState
if (selected == null || !mounted) return;
setState(() {
_selectedCategoryId = selected.id;
final selectedCategoryIds = _selectedCategoryBranchIds();
final current = _selectedProduct();
final currentCategoryId = tryParseDynamicInt(current?['categoryId']);
if (!productInSelectedBranch(
productCategoryId: currentCategoryId,
selectedCategoryIds: selectedCategoryIds,
)) {
_selectedProductId = null;
}
});
}
Future<void> _syncSelectedProductCategory(String? token) async {
await syncSelectedProductCategory(
ref: ref,
token: token,
selectedProductId: _selectedProductId,
selectedCategoryId: _selectedCategoryId,
products: _products,
tryParseDynamicInt: tryParseDynamicInt,
setProducts: (updated) {
if (!mounted) return;
setState(() => _products = updated);
},
);
}
Future<void> _pickDate(bool isBestBefore) async {
final picked = await showDatePicker(
context: context,
@@ -215,6 +222,21 @@ class _CreateInventoryScreenState
}
}
Future<ProductOption?> _createProductInSelectedCategory(String rawName) async {
return createProductInSelectedCategory(
ref: ref,
context: context,
rawName: rawName,
selectedCategoryId: _selectedCategoryId,
products: _products,
tryParseDynamicInt: tryParseDynamicInt,
setProducts: (updated) {
if (!mounted) return;
setState(() => _products = updated);
},
);
}
Future<void> _save() async {
if (_selectedProductId == null) {
ScaffoldMessenger.of(context).showSnackBar(
@@ -253,6 +275,7 @@ class _CreateInventoryScreenState
setState(() => _saving = true);
try {
final token = await ref.read(authStateProvider.future);
await _syncSelectedProductCategory(token);
final body = <String, dynamic>{
'productId': _selectedProductId,
'quantity':
@@ -351,6 +374,7 @@ class _CreateInventoryScreenState
productOptions: productOptions,
selectedProductId: _selectedProductId,
onProductChanged: (value) => setState(() => _selectedProductId = value),
onCreateProduct: _selectedCategoryId == null ? null : _createProductInSelectedCategory,
isLoadingProducts: _loadingProducts,
productEnabled: !_saving,
productLabel: context.l10n.inventoryProductLabel,
@@ -17,6 +17,7 @@ class InventoryCategoryProductSection extends StatelessWidget {
final List<product_picker.ProductOption> productOptions;
final int? selectedProductId;
final ValueChanged<int?> onProductChanged;
final Future<product_picker.ProductOption?> Function(String name)? onCreateProduct;
final bool isLoadingProducts;
final bool productEnabled;
final String productLabel;
@@ -36,6 +37,7 @@ class InventoryCategoryProductSection extends StatelessWidget {
required this.productOptions,
required this.selectedProductId,
required this.onProductChanged,
this.onCreateProduct,
required this.isLoadingProducts,
required this.productEnabled,
required this.productLabel,
@@ -78,6 +80,7 @@ class InventoryCategoryProductSection extends StatelessWidget {
enabled: productEnabled,
label: productLabel,
onChanged: onProductChanged,
onCreate: onCreateProduct,
),
],
);
@@ -18,6 +18,7 @@ import '../data/inventory_providers.dart';
import '../domain/inventory_item.dart';
import 'inventory_category_helpers.dart';
import 'inventory_category_product_section.dart';
import 'inventory_product_mutations.dart';
class InventoryEditScreen extends ConsumerStatefulWidget {
final int itemId;
@@ -206,18 +207,24 @@ class _InventoryEditScreenState extends ConsumerState<InventoryEditScreen> {
if (selected == null || !mounted) return;
setState(() {
_selectedCategoryId = selected.id;
final selectedCategoryIds = _selectedCategoryBranchIds();
final current = _selectedProduct();
final currentCategoryId = tryParseDynamicInt(current?['categoryId']);
if (!productInSelectedBranch(
productCategoryId: currentCategoryId,
selectedCategoryIds: selectedCategoryIds,
)) {
_selectedProductId = null;
}
});
}
Future<void> _syncSelectedProductCategory(String? token) async {
await syncSelectedProductCategory(
ref: ref,
token: token,
selectedProductId: _selectedProductId,
selectedCategoryId: _selectedCategoryId,
products: _products,
tryParseDynamicInt: tryParseDynamicInt,
setProducts: (updated) {
if (!mounted) return;
setState(() => _products = updated);
},
);
}
Future<void> _pickDate(bool isBestBefore) async {
final picked = await showDatePicker(
context: context,
@@ -236,6 +243,21 @@ class _InventoryEditScreenState extends ConsumerState<InventoryEditScreen> {
}
}
Future<ProductOption?> _createProductInSelectedCategory(String rawName) async {
return createProductInSelectedCategory(
ref: ref,
context: context,
rawName: rawName,
selectedCategoryId: _selectedCategoryId,
products: _products,
tryParseDynamicInt: tryParseDynamicInt,
setProducts: (updated) {
if (!mounted) return;
setState(() => _products = updated);
},
);
}
Future<void> _save() async {
if (_selectedProductId == null) {
ScaffoldMessenger.of(context).showSnackBar(
@@ -248,6 +270,7 @@ class _InventoryEditScreenState extends ConsumerState<InventoryEditScreen> {
setState(() => _saving = true);
try {
final token = await ref.read(authStateProvider.future);
await _syncSelectedProductCategory(token);
final body = <String, dynamic>{
'productId': _selectedProductId,
'quantity':
@@ -343,6 +366,7 @@ class _InventoryEditScreenState extends ConsumerState<InventoryEditScreen> {
productOptions: _productOptions(),
selectedProductId: _selectedProductId,
onProductChanged: (value) => setState(() => _selectedProductId = value),
onCreateProduct: _selectedCategoryId == null ? null : _createProductInSelectedCategory,
isLoadingProducts: _loadingProducts,
productEnabled: !_saving,
productLabel: context.l10n.inventoryProductLabel,
@@ -0,0 +1,89 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../core/api/api_error_mapper.dart';
import '../../../core/api/api_paths.dart';
import '../../../core/api/api_providers.dart';
import '../../../core/ui/product_picker_field.dart' show ProductOption;
import '../../auth/data/auth_providers.dart';
Future<void> syncSelectedProductCategory({
required WidgetRef ref,
required String? token,
required int? selectedProductId,
required int? selectedCategoryId,
required List<Map<String, dynamic>> products,
required int? Function(dynamic) tryParseDynamicInt,
required void Function(List<Map<String, dynamic>>) setProducts,
}) async {
final productId = selectedProductId;
final categoryId = selectedCategoryId;
if (productId == null || categoryId == null || token == null || token.isEmpty) return;
final current = products.cast<Map<String, dynamic>?>().firstWhere(
(p) => tryParseDynamicInt(p?['id']) == productId,
orElse: () => null,
);
final currentCategoryId = tryParseDynamicInt(current?['categoryId']);
if (currentCategoryId == categoryId) return;
final api = ref.read(apiClientProvider);
await api.patchJson(
ProductApiPaths.updateMineCategory(productId),
body: {'categoryId': categoryId},
token: token,
);
final updatedProducts = products
.map(
(p) => tryParseDynamicInt(p['id']) == productId
? {...p, 'categoryId': categoryId}
: p,
)
.toList();
setProducts(updatedProducts);
}
Future<ProductOption?> createProductInSelectedCategory({
required WidgetRef ref,
required BuildContext context,
required String rawName,
required int? selectedCategoryId,
required List<Map<String, dynamic>> products,
required int? Function(dynamic) tryParseDynamicInt,
required void Function(List<Map<String, dynamic>>) setProducts,
}) async {
final name = rawName.trim();
final categoryId = selectedCategoryId;
if (name.isEmpty || categoryId == null) return null;
try {
final token = await ref.read(authStateProvider.future);
final api = ref.read(apiClientProvider);
final data = await api.postJson(
ProductApiPaths.createPrivate,
body: {
'name': name,
'categoryId': categoryId,
},
token: token,
);
final created = Map<String, dynamic>.from(data as Map);
final createdId = tryParseDynamicInt(created['id']);
if (createdId == null) return null;
setProducts([...products, created]);
return (
id: createdId,
name: (created['canonicalName'] ?? created['name'] ?? '').toString(),
categoryId: tryParseDynamicInt(created['categoryId']),
);
} catch (e) {
ScaffoldMessenger.of(context).showSnackBar(
buildCopyableErrorSnackBar(context, mapErrorToUserMessage(e, context)),
);
return null;
}
}