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
This commit is contained in:
Faris Armoush
2025-07-27 11:20:39 +03:00
committed by GitHub
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,10 +22,27 @@ 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) {
@ -36,10 +53,21 @@ class SpaceDetailsForm extends StatelessWidget {
fontWeight: FontWeight.w400,
color: ColorsManager.blackColor,
),
child: title,
child: widget.title,
),
backgroundColor: ColorsManager.white,
content: SizedBox(
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),
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,10 +48,7 @@ class _SpaceNameTextFieldState extends State<SpaceNameTextField> {
@override
Widget build(BuildContext context) {
return Form(
key: _formKey,
autovalidateMode: AutovalidateMode.onUserInteraction,
child: TextFormField(
return TextFormField(
controller: _controller,
onChanged: (value) => context.read<SpaceDetailsModelBloc>().add(
UpdateSpaceDetailsName(value),
@ -72,7 +70,6 @@ class _SpaceNameTextFieldState extends State<SpaceNameTextField> {
color: context.theme.colorScheme.error,
),
),
),
);
}

View File

@ -89,7 +89,12 @@ class _SpaceSubSpacesDialogState extends State<SpaceSubSpacesDialog> {
),
child: const SelectableText('Create Sub-Space'),
),
content: Column(
content: ConstrainedBox(
constraints: BoxConstraints(
maxHeight: context.screenHeight * 0.4,
),
child: SingleChildScrollView(
child: Column(
spacing: 12,
mainAxisSize: MainAxisSize.min,
children: [
@ -112,6 +117,8 @@ class _SpaceSubSpacesDialogState extends State<SpaceSubSpacesDialog> {
),
],
),
),
),
actions: [
SpaceDetailsActionButtons(
onSave: _hasDuplicateNames ? null : _handleSave,

View File

@ -182,8 +182,12 @@ class _AssignTagsDialogState extends State<AssignTagsDialog> {
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
width: double.infinity,
ConstrainedBox(
constraints: BoxConstraints(
minWidth: double.infinity,
maxHeight: context.screenHeight * 0.6,
),
child: SingleChildScrollView(
child: AssignTagsTable(
productAllocations: allProductAllocations,
subspaces: _space.subspaces,
@ -193,6 +197,7 @@ class _AssignTagsDialogState extends State<AssignTagsDialog> {
onProductDeleted: _handleProductDelete,
),
),
),
if (hasErrors)
AssignTagsErrorMessages(
errorMessages: _validationErrors.values.toSet().toList(),