Compare commits

...

6 Commits

Author SHA1 Message Date
9a4fdb2f88 Sp 1722 duplicate space dialog enhancement (#370)
<!--
  Thanks for contributing!

Provide a description of your changes below and a general summary in the
title

Please look at the following checklist to ensure that your PR can be
accepted quickly:
-->

## Jira Ticket
[SP-1722](https://syncrow.atlassian.net/browse/SP-1722)

## Description

Implemented naming validation, that disallows for any name duplication.
Adds proper name suffix to the duplicated space's name, if there is any
match with the siblings.
Enhanced the dialog's design to match the design language of the
application.

## Type of Change

<!--- Put an `x` in all the boxes that apply: -->

- [ x ]  New feature (non-breaking change which adds functionality)
- [ ] 🛠️ Bug fix (non-breaking change which fixes an issue)
- [ x ]  Breaking change (fix or feature that would cause existing
functionality to change)
- [ ] 🧹 Code refactor
- [ ]  Build configuration change
- [ ] 📝 Documentation
- [ ] 🗑️ Chore 


[SP-1722]:
https://syncrow.atlassian.net/browse/SP-1722?atlOrigin=eyJpIjoiNWRkNTljNzYxNjVmNDY3MDlhMDU5Y2ZhYzA5YTRkZjUiLCJwIjoiZ2l0aHViLWNvbS1KU1cifQ
2025-07-27 11:20:39 +03:00
1558806cc3 Refactor SpaceDetailsForm to use StatefulWidget for form validation and improve user experience with dynamic save button state, and added space name validation to not be longer than 50 characters. 2025-07-27 11:18:08 +03:00
a4391aa73e Made AssignTagsDialog scrollable to account for a long list of product allocations, for a better UX. 2025-07-27 11:05:30 +03:00
2d69e3c72f Made SpaceSubSpacesDialog scrollable, for a better UX, and to account for a very long list of subspaces. 2025-07-27 11:01:22 +03:00
cd8ffc99ea removed unnecessary flag, that can be replaced with checking if the value equals null. 2025-07-27 10:56:54 +03:00
83895d3dda Diallows naming duplication when duplicating a space, and adds a proper suffix to the name in case of another spaces having a name match. 2025-07-27 10:51:53 +03:00
9 changed files with 232 additions and 103 deletions

View File

@ -68,4 +68,20 @@ abstract final class SpacesRecursiveHelper {
return space; return space;
}).toList(); }).toList();
} }
static SpaceModel? findParent(
List<SpaceModel> spaces,
String targetUuid,
) {
for (final space in spaces) {
if (space.children.any((child) => child.uuid == targetUuid)) {
return space;
}
final parent = findParent(space.children, targetUuid);
if (parent != null) {
return parent;
}
}
return null;
}
} }

View File

@ -49,8 +49,8 @@ class CommunityStructureHeaderActionButtonsComposer extends StatelessWidget {
context: context, context: context,
builder: (_) => DuplicateSpaceDialog( builder: (_) => DuplicateSpaceDialog(
initialName: space.spaceName, initialName: space.spaceName,
selectedSpaceUuid: space.uuid, selectedSpace: space,
selectedCommunityUuid: selectedCommunity.uuid, selectedCommunity: selectedCommunity,
onSuccess: (spaces) { onSuccess: (spaces) {
final updatedCommunity = selectedCommunity.copyWith( final updatedCommunity = selectedCommunity.copyWith(
spaces: spaces, spaces: spaces,

View File

@ -1,6 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:syncrow_web/common/widgets/app_loading_indicator.dart'; import 'package:syncrow_web/common/widgets/app_loading_indicator.dart';
import 'package:syncrow_web/pages/space_management_v2/modules/communities/domain/models/community_model.dart';
import 'package:syncrow_web/pages/space_management_v2/modules/communities/domain/models/space_model.dart'; import 'package:syncrow_web/pages/space_management_v2/modules/communities/domain/models/space_model.dart';
import 'package:syncrow_web/pages/space_management_v2/modules/duplicate_space/data/services/remote_duplicate_space_service.dart'; import 'package:syncrow_web/pages/space_management_v2/modules/duplicate_space/data/services/remote_duplicate_space_service.dart';
import 'package:syncrow_web/pages/space_management_v2/modules/duplicate_space/presentation/bloc/duplicate_space_bloc.dart'; import 'package:syncrow_web/pages/space_management_v2/modules/duplicate_space/presentation/bloc/duplicate_space_bloc.dart';
@ -12,15 +13,15 @@ class DuplicateSpaceDialog extends StatelessWidget {
const DuplicateSpaceDialog({ const DuplicateSpaceDialog({
required this.initialName, required this.initialName,
required this.onSuccess, required this.onSuccess,
required this.selectedSpaceUuid, required this.selectedSpace,
required this.selectedCommunityUuid, required this.selectedCommunity,
super.key, super.key,
}); });
final String initialName; final String initialName;
final void Function(List<SpaceModel> spaces) onSuccess; final void Function(List<SpaceModel> spaces) onSuccess;
final String selectedSpaceUuid; final SpaceModel selectedSpace;
final String selectedCommunityUuid; final CommunityModel selectedCommunity;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@ -31,10 +32,10 @@ class DuplicateSpaceDialog extends StatelessWidget {
child: BlocListener<DuplicateSpaceBloc, DuplicateSpaceState>( child: BlocListener<DuplicateSpaceBloc, DuplicateSpaceState>(
listener: _listener, listener: _listener,
child: DuplicateSpaceDialogForm( child: DuplicateSpaceDialogForm(
initialNameSuffix: '(1)', initialName: _getInitialName(),
initialName: initialName, selectedSpaceUuid: selectedSpace.uuid,
selectedSpaceUuid: selectedSpaceUuid, selectedCommunityUuid: selectedCommunity.uuid,
selectedCommunityUuid: selectedCommunityUuid, siblingNames: _getSiblingNames(),
), ),
), ),
); );
@ -67,4 +68,69 @@ class DuplicateSpaceDialog extends StatelessWidget {
break; break;
} }
} }
List<SpaceModel> _findSiblings(
List<SpaceModel> spaces,
String targetUuid,
) {
final parent = _findParent(spaces, targetUuid);
if (parent != null) {
return parent.children.where((s) => s.uuid != targetUuid).toList();
} else {
if (spaces.any((s) => s.uuid == targetUuid)) {
return spaces.where((s) => s.uuid != targetUuid).toList();
}
}
return [];
}
SpaceModel? _findParent(List<SpaceModel> allSpaces, String childUuid) {
for (final space in allSpaces) {
if (space.children.any((child) => child.uuid == childUuid)) {
return space;
}
final parent = _findParent(space.children, childUuid);
if (parent != null) {
return parent;
}
}
return null;
}
List<String> _getSiblingNames() {
final siblings = _findSiblings(selectedCommunity.spaces, selectedSpace.uuid);
final names = siblings.map((s) => s.spaceName).toList();
names.add(initialName);
return names;
}
String _getInitialName() {
final allRelevantNames = _getSiblingNames();
final nameRegex = RegExp(r'^(.*?) ?\((\d+)\)$');
final baseNameMatch = nameRegex.firstMatch(initialName);
final baseName = baseNameMatch?.group(1)?.trim() ?? initialName;
var maxSuffix = 0;
for (final name in allRelevantNames) {
if (name == baseName) {
continue;
}
final match = nameRegex.firstMatch(name);
if (match != null) {
final currentBaseName = match.group(1)!.trim();
if (currentBaseName == baseName) {
final suffix = int.parse(match.group(2)!);
if (suffix > maxSuffix) {
maxSuffix = suffix;
}
}
}
}
return '$baseName(${maxSuffix + 1})';
}
} }

View File

@ -11,14 +11,14 @@ class DuplicateSpaceDialogForm extends StatefulWidget {
required this.initialName, required this.initialName,
required this.selectedSpaceUuid, required this.selectedSpaceUuid,
required this.selectedCommunityUuid, required this.selectedCommunityUuid,
required this.initialNameSuffix, required this.siblingNames,
super.key, super.key,
}); });
final String initialName; final String initialName;
final String selectedSpaceUuid; final String selectedSpaceUuid;
final String selectedCommunityUuid; final String selectedCommunityUuid;
final String initialNameSuffix; final List<String> siblingNames;
@override @override
State<DuplicateSpaceDialogForm> createState() => _DuplicateSpaceDialogFormState(); State<DuplicateSpaceDialogForm> createState() => _DuplicateSpaceDialogFormState();
@ -26,20 +26,33 @@ class DuplicateSpaceDialogForm extends StatefulWidget {
class _DuplicateSpaceDialogFormState extends State<DuplicateSpaceDialogForm> { class _DuplicateSpaceDialogFormState extends State<DuplicateSpaceDialogForm> {
late final TextEditingController _nameController; late final TextEditingController _nameController;
bool _isNameValid = true; String? _errorText;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_nameController = TextEditingController( _nameController = TextEditingController(
text: '${widget.initialName}${widget.initialNameSuffix}', text: widget.initialName,
); );
_nameController.addListener(_validateName); _nameController.addListener(_validateName);
} }
void _validateName() => setState( void _validateName() {
() => _isNameValid = _nameController.text.trim() != widget.initialName, final name = _nameController.text.trim();
); if (name.isEmpty) {
setState(() {
_errorText = 'Name cannot be empty';
});
} else if (widget.siblingNames.contains(name)) {
setState(() {
_errorText = 'Name must be unique';
});
} else {
setState(() {
_errorText = null;
});
}
}
@override @override
void dispose() { void dispose() {
@ -66,15 +79,14 @@ class _DuplicateSpaceDialogFormState extends State<DuplicateSpaceDialogForm> {
const SelectableText('Enter a new name for the duplicated space:'), const SelectableText('Enter a new name for the duplicated space:'),
DuplicateSpaceTextField( DuplicateSpaceTextField(
nameController: _nameController, nameController: _nameController,
isNameValid: _isNameValid, errorText: _errorText,
initialName: widget.initialName,
), ),
], ],
), ),
actions: [ actions: [
SpaceDetailsActionButtons( SpaceDetailsActionButtons(
spacerFlex: 2, spacerFlex: 2,
onSave: _isNameValid ? () => _submit(context) : null, onSave: _errorText == null ? () => _submit(context) : null,
onCancel: Navigator.of(context).pop, onCancel: Navigator.of(context).pop,
saveButtonLabel: 'Duplicate', saveButtonLabel: 'Duplicate',
), ),

View File

@ -5,16 +5,12 @@ import 'package:syncrow_web/utils/extension/build_context_x.dart';
class DuplicateSpaceTextField extends StatelessWidget { class DuplicateSpaceTextField extends StatelessWidget {
const DuplicateSpaceTextField({ const DuplicateSpaceTextField({
required this.nameController, required this.nameController,
required this.isNameValid, required this.errorText,
required this.initialName,
super.key, super.key,
}); });
final TextEditingController nameController; final TextEditingController nameController;
final bool isNameValid; final String? errorText;
final String initialName;
String get _errorText => 'Name must be different from "$initialName"';
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@ -35,7 +31,7 @@ class DuplicateSpaceTextField extends StatelessWidget {
color: context.theme.colorScheme.error, color: context.theme.colorScheme.error,
fontSize: 8, fontSize: 8,
), ),
errorText: isNameValid ? null : _errorText, errorText: errorText,
), ),
); );
} }

View File

@ -10,7 +10,7 @@ import 'package:syncrow_web/pages/space_management_v2/modules/update_space/prese
import 'package:syncrow_web/utils/color_manager.dart'; import 'package:syncrow_web/utils/color_manager.dart';
import 'package:syncrow_web/utils/extension/build_context_x.dart'; import 'package:syncrow_web/utils/extension/build_context_x.dart';
class SpaceDetailsForm extends StatelessWidget { class SpaceDetailsForm extends StatefulWidget {
const SpaceDetailsForm({ const SpaceDetailsForm({
required this.title, required this.title,
required this.space, required this.space,
@ -22,24 +22,52 @@ class SpaceDetailsForm extends StatelessWidget {
final SpaceDetailsModel space; final SpaceDetailsModel space;
final void Function(SpaceDetailsModel space) onSave; final void Function(SpaceDetailsModel space) onSave;
@override
State<SpaceDetailsForm> createState() => _SpaceDetailsFormState();
}
class _SpaceDetailsFormState extends State<SpaceDetailsForm> {
final _formKey = GlobalKey<FormState>();
bool _isFormValid = false;
@override
void initState() {
super.initState();
final initialName = widget.space.spaceName;
_isFormValid = initialName.isNotEmpty &&
initialName.length <= 50 &&
!widget.space.subspaces.any((subspace) => subspace.name == initialName);
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return BlocProvider( return BlocProvider(
create: (context) => SpaceDetailsModelBloc(initialState: space), create: (context) => SpaceDetailsModelBloc(initialState: widget.space),
child: BlocBuilder<SpaceDetailsModelBloc, SpaceDetailsModel>( child: BlocBuilder<SpaceDetailsModelBloc, SpaceDetailsModel>(
buildWhen: (previous, current) => previous != current, buildWhen: (previous, current) => previous != current,
builder: (context, space) { builder: (context, space) {
return AlertDialog( return AlertDialog(
title: DefaultTextStyle( title: DefaultTextStyle(
style: context.textTheme.titleLarge!.copyWith( style: context.textTheme.titleLarge!.copyWith(
fontSize: 30, fontSize: 30,
fontWeight: FontWeight.w400, fontWeight: FontWeight.w400,
color: ColorsManager.blackColor, color: ColorsManager.blackColor,
),
child: title,
), ),
backgroundColor: ColorsManager.white, child: widget.title,
content: SizedBox( ),
backgroundColor: ColorsManager.white,
content: Form(
key: _formKey,
autovalidateMode: AutovalidateMode.onUserInteraction,
onChanged: () {
final isValid = _formKey.currentState?.validate() ?? false;
if (_isFormValid != isValid) {
setState(() {
_isFormValid = isValid;
});
}
},
child: SizedBox(
height: context.screenHeight * 0.3, height: context.screenHeight * 0.3,
width: context.screenWidth * 0.4, width: context.screenWidth * 0.4,
child: Row( child: Row(
@ -70,14 +98,16 @@ class SpaceDetailsForm extends StatelessWidget {
], ],
), ),
), ),
actions: [ ),
SpaceDetailsActionButtons( actions: [
onSave: () => onSave(space), SpaceDetailsActionButtons(
onCancel: Navigator.of(context).pop, onSave: _isFormValid ? () => widget.onSave(space) : null,
), onCancel: Navigator.of(context).pop,
], ),
); ],
}), );
},
),
); );
} }
} }

View File

@ -33,12 +33,13 @@ class _SpaceNameTextFieldState extends State<SpaceNameTextField> {
super.dispose(); super.dispose();
} }
final _formKey = GlobalKey<FormState>();
String? _validateName(String? value) { String? _validateName(String? value) {
if (value == null || value.isEmpty) { if (value == null || value.isEmpty) {
return '*Space name should not be empty.'; return '*Space name should not be empty.';
} }
if (value.length > 50) {
return '*Space name cannot be longer than 50 characters.';
}
if (widget.isNameFieldExist(value)) { if (widget.isNameFieldExist(value)) {
return '*Name already exists'; return '*Name already exists';
} }
@ -47,30 +48,26 @@ class _SpaceNameTextFieldState extends State<SpaceNameTextField> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Form( return TextFormField(
key: _formKey, controller: _controller,
autovalidateMode: AutovalidateMode.onUserInteraction, onChanged: (value) => context.read<SpaceDetailsModelBloc>().add(
child: TextFormField( UpdateSpaceDetailsName(value),
controller: _controller,
onChanged: (value) => context.read<SpaceDetailsModelBloc>().add(
UpdateSpaceDetailsName(value),
),
validator: _validateName,
style: context.textTheme.bodyMedium,
decoration: InputDecoration(
hintText: 'Please enter the name',
hintStyle: context.textTheme.bodyMedium!.copyWith(
color: ColorsManager.lightGrayColor,
),
filled: true,
fillColor: ColorsManager.boxColor,
enabledBorder: _buildBorder(context, ColorsManager.vividBlue),
focusedBorder: _buildBorder(context, ColorsManager.primaryColor),
errorBorder: _buildBorder(context, context.theme.colorScheme.error),
focusedErrorBorder: _buildBorder(context, context.theme.colorScheme.error),
errorStyle: context.textTheme.bodySmall?.copyWith(
color: context.theme.colorScheme.error,
), ),
validator: _validateName,
style: context.textTheme.bodyMedium,
decoration: InputDecoration(
hintText: 'Please enter the name',
hintStyle: context.textTheme.bodyMedium!.copyWith(
color: ColorsManager.lightGrayColor,
),
filled: true,
fillColor: ColorsManager.boxColor,
enabledBorder: _buildBorder(context, ColorsManager.vividBlue),
focusedBorder: _buildBorder(context, ColorsManager.primaryColor),
errorBorder: _buildBorder(context, context.theme.colorScheme.error),
focusedErrorBorder: _buildBorder(context, context.theme.colorScheme.error),
errorStyle: context.textTheme.bodySmall?.copyWith(
color: context.theme.colorScheme.error,
), ),
), ),
); );

View File

@ -89,28 +89,35 @@ class _SpaceSubSpacesDialogState extends State<SpaceSubSpacesDialog> {
), ),
child: const SelectableText('Create Sub-Space'), child: const SelectableText('Create Sub-Space'),
), ),
content: Column( content: ConstrainedBox(
spacing: 12, constraints: BoxConstraints(
mainAxisSize: MainAxisSize.min, maxHeight: context.screenHeight * 0.4,
children: [ ),
SubSpacesInput( child: SingleChildScrollView(
subSpaces: _subspaces, child: Column(
onSubspaceAdded: _handleSubspaceAdded, spacing: 12,
onSubspaceDeleted: _handleSubspaceDeleted, mainAxisSize: MainAxisSize.min,
controller: _subspaceNameController, children: [
), SubSpacesInput(
AnimatedSwitcher( subSpaces: _subspaces,
duration: const Duration(milliseconds: 100), onSubspaceAdded: _handleSubspaceAdded,
child: Visibility( onSubspaceDeleted: _handleSubspaceDeleted,
key: ValueKey(_hasDuplicateNames), controller: _subspaceNameController,
visible: _hasDuplicateNames,
child: const SelectableText(
'Error: Duplicate subspace names are not allowed.',
style: TextStyle(color: Colors.red),
), ),
), AnimatedSwitcher(
duration: const Duration(milliseconds: 100),
child: Visibility(
key: ValueKey(_hasDuplicateNames),
visible: _hasDuplicateNames,
child: const SelectableText(
'Error: Duplicate subspace names are not allowed.',
style: TextStyle(color: Colors.red),
),
),
),
],
), ),
], ),
), ),
actions: [ actions: [
SpaceDetailsActionButtons( SpaceDetailsActionButtons(

View File

@ -182,15 +182,20 @@ class _AssignTagsDialogState extends State<AssignTagsDialog> {
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
SizedBox( ConstrainedBox(
width: double.infinity, constraints: BoxConstraints(
child: AssignTagsTable( minWidth: double.infinity,
productAllocations: allProductAllocations, maxHeight: context.screenHeight * 0.6,
subspaces: _space.subspaces, ),
productLocations: productLocations, child: SingleChildScrollView(
onTagSelected: _handleTagChange, child: AssignTagsTable(
onLocationSelected: _handleLocationChange, productAllocations: allProductAllocations,
onProductDeleted: _handleProductDelete, subspaces: _space.subspaces,
productLocations: productLocations,
onTagSelected: _handleTagChange,
onLocationSelected: _handleLocationChange,
onProductDeleted: _handleProductDelete,
),
), ),
), ),
if (hasErrors) if (hasErrors)