Compare commits

...

7 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
f1cf8d88d3 Matched design of duplicate space dialog, with the design language of the system. 2025-07-24 16:40:03 +03:00
9 changed files with 254 additions and 111 deletions

View File

@ -68,4 +68,20 @@ abstract final class SpacesRecursiveHelper {
return space;
}).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,
builder: (_) => DuplicateSpaceDialog(
initialName: space.spaceName,
selectedSpaceUuid: space.uuid,
selectedCommunityUuid: selectedCommunity.uuid,
selectedSpace: space,
selectedCommunity: selectedCommunity,
onSuccess: (spaces) {
final updatedCommunity = selectedCommunity.copyWith(
spaces: spaces,

View File

@ -1,6 +1,7 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.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/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';
@ -12,15 +13,15 @@ class DuplicateSpaceDialog extends StatelessWidget {
const DuplicateSpaceDialog({
required this.initialName,
required this.onSuccess,
required this.selectedSpaceUuid,
required this.selectedCommunityUuid,
required this.selectedSpace,
required this.selectedCommunity,
super.key,
});
final String initialName;
final void Function(List<SpaceModel> spaces) onSuccess;
final String selectedSpaceUuid;
final String selectedCommunityUuid;
final SpaceModel selectedSpace;
final CommunityModel selectedCommunity;
@override
Widget build(BuildContext context) {
@ -31,9 +32,10 @@ class DuplicateSpaceDialog extends StatelessWidget {
child: BlocListener<DuplicateSpaceBloc, DuplicateSpaceState>(
listener: _listener,
child: DuplicateSpaceDialogForm(
initialName: initialName,
selectedSpaceUuid: selectedSpaceUuid,
selectedCommunityUuid: selectedCommunityUuid,
initialName: _getInitialName(),
selectedSpaceUuid: selectedSpace.uuid,
selectedCommunityUuid: selectedCommunity.uuid,
siblingNames: _getSiblingNames(),
),
),
);
@ -66,4 +68,69 @@ class DuplicateSpaceDialog extends StatelessWidget {
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

@ -3,18 +3,22 @@ import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:syncrow_web/pages/space_management_v2/modules/duplicate_space/domain/params/duplicate_space_param.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/widgets/duplicate_space_text_field.dart';
import 'package:syncrow_web/pages/space_management_v2/modules/space_details/presentation/widgets/space_details_action_buttons.dart';
import 'package:syncrow_web/utils/color_manager.dart';
class DuplicateSpaceDialogForm extends StatefulWidget {
const DuplicateSpaceDialogForm({
required this.initialName,
required this.selectedSpaceUuid,
required this.selectedCommunityUuid,
required this.siblingNames,
super.key,
});
final String initialName;
final String selectedSpaceUuid;
final String selectedCommunityUuid;
final List<String> siblingNames;
@override
State<DuplicateSpaceDialogForm> createState() => _DuplicateSpaceDialogFormState();
@ -22,18 +26,33 @@ class DuplicateSpaceDialogForm extends StatefulWidget {
class _DuplicateSpaceDialogFormState extends State<DuplicateSpaceDialogForm> {
late final TextEditingController _nameController;
bool _isNameValid = true;
String? _errorText;
@override
void initState() {
super.initState();
_nameController = TextEditingController(text: '${widget.initialName}(1)');
_nameController = TextEditingController(
text: widget.initialName,
);
_nameController.addListener(_validateName);
}
void _validateName() => setState(
() => _isNameValid = _nameController.text.trim() != widget.initialName,
);
void _validateName() {
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
void dispose() {
@ -44,27 +63,32 @@ class _DuplicateSpaceDialogFormState extends State<DuplicateSpaceDialogForm> {
@override
Widget build(BuildContext context) {
return AlertDialog(
title: const SelectableText('Duplicate Space'),
title: const SelectableText(
'Duplicate Space',
style: TextStyle(
fontSize: 30,
fontWeight: FontWeight.w400,
color: ColorsManager.blackColor,
),
),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
spacing: 16,
children: [
const SelectableText('Enter a new name for the duplicated space:'),
DuplicateSpaceTextField(
nameController: _nameController,
isNameValid: _isNameValid,
initialName: widget.initialName,
errorText: _errorText,
),
],
),
actions: [
TextButton(
onPressed: Navigator.of(context).pop,
child: const Text('Cancel'),
),
TextButton(
onPressed: _isNameValid ? () => _submit(context) : null,
child: const Text('Duplicate'),
SpaceDetailsActionButtons(
spacerFlex: 2,
onSave: _errorText == null ? () => _submit(context) : null,
onCancel: Navigator.of(context).pop,
saveButtonLabel: 'Duplicate',
),
],
);

View File

@ -5,16 +5,12 @@ import 'package:syncrow_web/utils/extension/build_context_x.dart';
class DuplicateSpaceTextField extends StatelessWidget {
const DuplicateSpaceTextField({
required this.nameController,
required this.isNameValid,
required this.initialName,
required this.errorText,
super.key,
});
final TextEditingController nameController;
final bool isNameValid;
final String initialName;
String get _errorText => 'Name must be different from "$initialName"';
final String? errorText;
@override
Widget build(BuildContext context) {
@ -24,9 +20,10 @@ class DuplicateSpaceTextField extends StatelessWidget {
color: ColorsManager.blackColor,
),
decoration: InputDecoration(
label: const Text('Space Name'),
border: _border(),
enabledBorder: _border(),
filled: true,
fillColor: ColorsManager.boxColor,
border: _border(ColorsManager.transparentColor),
enabledBorder: _border(ColorsManager.transparentColor),
focusedBorder: _border(ColorsManager.primaryColor),
errorBorder: _border(context.theme.colorScheme.error),
focusedErrorBorder: _border(context.theme.colorScheme.error),
@ -34,14 +31,14 @@ class DuplicateSpaceTextField extends StatelessWidget {
color: context.theme.colorScheme.error,
fontSize: 8,
),
errorText: isNameValid ? null : _errorText,
errorText: errorText,
),
);
}
OutlineInputBorder _border([Color? color]) {
return OutlineInputBorder(
borderRadius: BorderRadius.circular(16),
borderRadius: BorderRadius.circular(8),
borderSide: BorderSide(
color: color ?? ColorsManager.blackColor,
width: 0.5,

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/extension/build_context_x.dart';
class SpaceDetailsForm extends StatelessWidget {
class SpaceDetailsForm extends StatefulWidget {
const SpaceDetailsForm({
required this.title,
required this.space,
@ -22,24 +22,52 @@ class SpaceDetailsForm extends StatelessWidget {
final SpaceDetailsModel space;
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
Widget build(BuildContext context) {
return BlocProvider(
create: (context) => SpaceDetailsModelBloc(initialState: space),
create: (context) => SpaceDetailsModelBloc(initialState: widget.space),
child: BlocBuilder<SpaceDetailsModelBloc, SpaceDetailsModel>(
buildWhen: (previous, current) => previous != current,
builder: (context, space) {
return AlertDialog(
title: DefaultTextStyle(
style: context.textTheme.titleLarge!.copyWith(
fontSize: 30,
fontWeight: FontWeight.w400,
color: ColorsManager.blackColor,
),
child: title,
buildWhen: (previous, current) => previous != current,
builder: (context, space) {
return AlertDialog(
title: DefaultTextStyle(
style: context.textTheme.titleLarge!.copyWith(
fontSize: 30,
fontWeight: FontWeight.w400,
color: ColorsManager.blackColor,
),
backgroundColor: ColorsManager.white,
content: SizedBox(
child: widget.title,
),
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,
width: context.screenWidth * 0.4,
child: Row(
@ -70,14 +98,16 @@ class SpaceDetailsForm extends StatelessWidget {
],
),
),
actions: [
SpaceDetailsActionButtons(
onSave: () => onSave(space),
onCancel: Navigator.of(context).pop,
),
],
);
}),
),
actions: [
SpaceDetailsActionButtons(
onSave: _isFormValid ? () => widget.onSave(space) : null,
onCancel: Navigator.of(context).pop,
),
],
);
},
),
);
}
}

View File

@ -33,12 +33,13 @@ class _SpaceNameTextFieldState extends State<SpaceNameTextField> {
super.dispose();
}
final _formKey = GlobalKey<FormState>();
String? _validateName(String? value) {
if (value == null || value.isEmpty) {
return '*Space name should not be empty.';
}
if (value.length > 50) {
return '*Space name cannot be longer than 50 characters.';
}
if (widget.isNameFieldExist(value)) {
return '*Name already exists';
}
@ -47,30 +48,26 @@ class _SpaceNameTextFieldState extends State<SpaceNameTextField> {
@override
Widget build(BuildContext context) {
return Form(
key: _formKey,
autovalidateMode: AutovalidateMode.onUserInteraction,
child: TextFormField(
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,
return TextFormField(
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,
),
),
);

View File

@ -89,28 +89,35 @@ class _SpaceSubSpacesDialogState extends State<SpaceSubSpacesDialog> {
),
child: const SelectableText('Create Sub-Space'),
),
content: Column(
spacing: 12,
mainAxisSize: MainAxisSize.min,
children: [
SubSpacesInput(
subSpaces: _subspaces,
onSubspaceAdded: _handleSubspaceAdded,
onSubspaceDeleted: _handleSubspaceDeleted,
controller: _subspaceNameController,
),
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),
content: ConstrainedBox(
constraints: BoxConstraints(
maxHeight: context.screenHeight * 0.4,
),
child: SingleChildScrollView(
child: Column(
spacing: 12,
mainAxisSize: MainAxisSize.min,
children: [
SubSpacesInput(
subSpaces: _subspaces,
onSubspaceAdded: _handleSubspaceAdded,
onSubspaceDeleted: _handleSubspaceDeleted,
controller: _subspaceNameController,
),
),
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: [
SpaceDetailsActionButtons(

View File

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