diff --git a/catalyst_voices/apps/voices/lib/common/ext/string_ext.dart b/catalyst_voices/apps/voices/lib/common/ext/string_ext.dart index 9349c7dd7e1..b1443b93017 100644 --- a/catalyst_voices/apps/voices/lib/common/ext/string_ext.dart +++ b/catalyst_voices/apps/voices/lib/common/ext/string_ext.dart @@ -6,6 +6,21 @@ extension StringExt on String { return ''; } } + + String starred({ + bool leading = true, + bool isEnabled = true, + }) { + if (!isEnabled) { + return this; + } + + return leading ? withPrefix('*') : withSuffix('*'); + } + + String withPrefix(String value) => '$value$this'; + + String withSuffix(String value) => '$this$value'; } extension UrlParser on String { diff --git a/catalyst_voices/apps/voices/lib/widgets/document_builder/document_token_value_widget.dart b/catalyst_voices/apps/voices/lib/widgets/document_builder/document_token_value_widget.dart index 680c5aaa441..5992ab3ea1a 100644 --- a/catalyst_voices/apps/voices/lib/widgets/document_builder/document_token_value_widget.dart +++ b/catalyst_voices/apps/voices/lib/widgets/document_builder/document_token_value_widget.dart @@ -1,3 +1,4 @@ +import 'package:catalyst_voices/common/ext/string_ext.dart'; import 'package:catalyst_voices/widgets/text_field/token_field.dart'; import 'package:catalyst_voices/widgets/text_field/voices_int_field.dart'; import 'package:catalyst_voices_models/catalyst_voices_models.dart'; @@ -67,16 +68,11 @@ class _DocumentTokenValueWidgetState extends State { @override Widget build(BuildContext context) { - var label = widget.label; - if (widget.isRequired) { - label = '*$label'; - } - return TokenField( controller: _controller, focusNode: _focusNode, onFieldSubmitted: _notifyChangeListener, - labelText: label, + labelText: widget.label.starred(isEnabled: widget.isRequired), range: widget.range, currency: widget.currency, showHelper: widget.isEditMode, diff --git a/catalyst_voices/apps/voices/lib/widgets/document_builder/single_grouped_tag_selector_widget.dart b/catalyst_voices/apps/voices/lib/widgets/document_builder/single_grouped_tag_selector_widget.dart new file mode 100644 index 00000000000..b724905b561 --- /dev/null +++ b/catalyst_voices/apps/voices/lib/widgets/document_builder/single_grouped_tag_selector_widget.dart @@ -0,0 +1,313 @@ +import 'package:catalyst_voices/common/ext/string_ext.dart'; +import 'package:catalyst_voices/widgets/widgets.dart'; +import 'package:catalyst_voices_brands/catalyst_voices_brands.dart'; +import 'package:catalyst_voices_localization/catalyst_voices_localization.dart'; +import 'package:catalyst_voices_models/catalyst_voices_models.dart'; +import 'package:collection/collection.dart'; +import 'package:flutter/material.dart'; + +class SingleGroupedTagSelectorWidget extends StatefulWidget { + final DocumentNodeId id; + final GroupedTagsSelection selection; + final List groupedTags; + final bool isEditMode; + final ValueChanged onChanged; + final bool isRequired; + + const SingleGroupedTagSelectorWidget({ + super.key, + required this.id, + this.selection = const GroupedTagsSelection(), + required this.groupedTags, + required this.isEditMode, + required this.onChanged, + required this.isRequired, + }); + + @override + State createState() { + return _SingleGroupedTagSelectorWidgetState(); + } +} + +class _SingleGroupedTagSelectorWidgetState + extends State { + GroupedTagsSelection _selection = const GroupedTagsSelection(); + + @override + void initState() { + super.initState(); + + _selection = widget.selection; + } + + @override + void didUpdateWidget(covariant SingleGroupedTagSelectorWidget oldWidget) { + super.didUpdateWidget(oldWidget); + + if (widget.selection != oldWidget.selection) { + _selection = widget.selection; + } + } + + @override + Widget build(BuildContext context) { + if (widget.isEditMode) { + return _TagSelector( + groupedTags: widget.groupedTags, + selection: _selection, + onGroupChanged: _handleGroupedTagsSelection, + onSelectionChanged: _handleTagSelection, + isRequired: widget.isRequired, + ); + } else { + return _GroupedTagChip(_selection); + } + } + + void _handleGroupedTagsSelection(GroupedTags? groupedTags) { + setState(() { + final groupChanged = _selection.group != groupedTags?.group; + + _selection = _selection.copyWith( + group: Optional(groupedTags?.group), + tag: groupChanged ? const Optional.empty() : null, + ); + }); + } + + void _handleTagSelection(GroupedTagsSelection value) { + setState(() { + _selection = value; + + final change = DocumentChange(nodeId: widget.id, value: value); + widget.onChanged(change); + }); + } +} + +class _TagSelector extends StatelessWidget { + final List groupedTags; + final GroupedTagsSelection selection; + final ValueChanged onGroupChanged; + final ValueChanged onSelectionChanged; + final bool isRequired; + + GroupedTags? get _selectedGroupedTags { + final selected = groupedTags.firstWhereOrNull(selection.selects); + + return selected ?? groupedTags.firstOrNull; + } + + const _TagSelector({ + required this.groupedTags, + required this.selection, + required this.onGroupChanged, + required this.onSelectionChanged, + required this.isRequired, + }); + + @override + Widget build(BuildContext context) { + final selectedGroup = _selectedGroupedTags; + + final tags = selectedGroup?.tags ?? const []; + final selectedTag = tags.contains(selection.tag) ? selection.tag : null; + + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _TagSelectorLabel( + context.l10n.singleGroupedTagSelectorTitle, + starred: isRequired, + ), + const SizedBox(height: 4), + _TagGroupsDropdown( + groupedTags: groupedTags, + onChanged: onGroupChanged, + value: selectedGroup, + ), + const SizedBox(height: 8.5), + _TagSelectorLabel( + context.l10n.singleGroupedTagSelectorRelevantTag, + starred: isRequired, + ), + const SizedBox(height: 12), + _TagChipGroup( + tags: tags, + selectedTag: selectedTag, + onChanged: (tag) { + final group = selectedGroup!.group; + + final selection = GroupedTagsSelection(group: group, tag: tag); + + onSelectionChanged(selection); + }, + ), + ], + ); + } +} + +class _TagSelectorLabel extends StatelessWidget { + final String data; + final bool starred; + + const _TagSelectorLabel( + this.data, { + required this.starred, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final colors = theme.colors; + final textTheme = theme.textTheme; + + return Text( + data.starred(isEnabled: starred), + style: textTheme.titleSmall?.copyWith(color: colors.textOnPrimaryLevel0), + ); + } +} + +class _TagGroupsDropdown extends StatelessWidget { + final List groupedTags; + final ValueChanged onChanged; + final GroupedTags? value; + + const _TagGroupsDropdown({ + required this.groupedTags, + required this.onChanged, + required this.value, + }); + + @override + Widget build(BuildContext context) { + return VoicesDropdownFormField( + items: groupedTags + .map((e) => DropdownMenuItem(value: e, child: Text(e.group))) + .toList(), + value: value, + onChanged: onChanged, + ); + } +} + +class _TagChipGroup extends StatelessWidget { + final List tags; + final String? selectedTag; + final ValueChanged onChanged; + + const _TagChipGroup({ + required this.tags, + this.selectedTag, + required this.onChanged, + }); + + @override + Widget build(BuildContext context) { + final selectedTag = this.selectedTag; + + return Wrap( + spacing: 10, + runSpacing: 10, + children: tags.map((tag) { + final isSelected = tag == selectedTag; + return _TagChip( + key: ObjectKey(tag), + name: tag, + isSelected: isSelected, + isEnabled: true, + onTap: () => isSelected ? onChanged(null) : onChanged(tag), + ); + }).toList(), + ); + } +} + +class _GroupedTagChip extends StatelessWidget { + final GroupedTagsSelection data; + + const _GroupedTagChip( + this.data, + ); + + @override + Widget build(BuildContext context) { + final isValid = data.isValid; + + return _TagChip( + key: const ValueKey('SelectedGroupedTagChipKey'), + name: isValid ? '$data' : context.l10n.noTagSelected, + isSelected: isValid, + isEnabled: isValid, + ); + } +} + +class _TagChip extends StatelessWidget { + final String name; + final bool isSelected; + final bool isEnabled; + final VoidCallback? onTap; + + Set get states => { + if (isSelected) WidgetState.selected, + if (!isEnabled) WidgetState.disabled, + }; + + const _TagChip({ + super.key, + required this.name, + this.isSelected = false, + this.isEnabled = true, + this.onTap, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final colors = theme.colors; + + final backgroundColor = WidgetStateProperty.resolveWith((states) { + if (states.contains(WidgetState.disabled)) { + return colors.onSurfaceNeutralOpaqueLv2; + } + + if (states.contains(WidgetState.selected)) { + return colors.onPrimaryContainer; + } + + return null; + }); + + final foregroundColor = WidgetStateProperty.resolveWith((states) { + if (states.contains(WidgetState.disabled)) { + return colors.textDisabled; + } + + if (states.contains(WidgetState.selected)) { + return colors.textOnPrimaryWhite; + } + + return null; + }); + + return VoicesChip.rectangular( + content: Text( + name, + style: TextStyle(color: foregroundColor.resolve(states)), + ), + trailing: onTap != null && isSelected + ? Icon( + Icons.clear, + color: foregroundColor.resolve(states), + ) + : null, + backgroundColor: backgroundColor.resolve(states), + onTap: onTap, + ); + } +} diff --git a/catalyst_voices/apps/voices/lib/widgets/dropdown/voices_dropdown_form_field.dart b/catalyst_voices/apps/voices/lib/widgets/dropdown/voices_dropdown_form_field.dart new file mode 100644 index 00000000000..fcf9f9272a5 --- /dev/null +++ b/catalyst_voices/apps/voices/lib/widgets/dropdown/voices_dropdown_form_field.dart @@ -0,0 +1,30 @@ +import 'package:catalyst_voices_assets/catalyst_voices_assets.dart'; +import 'package:catalyst_voices_brands/catalyst_voices_brands.dart'; +import 'package:flutter/material.dart'; + +class VoicesDropdownFormField extends StatelessWidget { + final List>? items; + final T? value; + final ValueChanged? onChanged; + + const VoicesDropdownFormField({ + super.key, + required this.items, + this.value, + required this.onChanged, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + return DropdownButtonFormField( + items: items, + value: value, + onChanged: onChanged, + icon: VoicesAssets.icons.chevronDown.buildIcon(), + style: theme.textTheme.bodyLarge, + dropdownColor: theme.colors.onSurfaceNeutralOpaqueLv1, + ); + } +} diff --git a/catalyst_voices/apps/voices/lib/widgets/tiles/document_builder_section_tile.dart b/catalyst_voices/apps/voices/lib/widgets/tiles/document_builder_section_tile.dart index 5dd893a6520..62f384efdc1 100644 --- a/catalyst_voices/apps/voices/lib/widgets/tiles/document_builder_section_tile.dart +++ b/catalyst_voices/apps/voices/lib/widgets/tiles/document_builder_section_tile.dart @@ -1,5 +1,6 @@ import 'package:catalyst_voices/widgets/document_builder/agreement_confirmation_widget.dart'; import 'package:catalyst_voices/widgets/document_builder/document_token_value_widget.dart'; +import 'package:catalyst_voices/widgets/document_builder/single_grouped_tag_selector_widget.dart'; import 'package:catalyst_voices/widgets/widgets.dart'; import 'package:catalyst_voices_localization/catalyst_voices_localization.dart'; import 'package:catalyst_voices_models/catalyst_voices_models.dart'; @@ -49,8 +50,7 @@ class _DocumentBuilderSectionTileState _builder = _editedSection.toBuilder(); // TODO(damian-molinski): validation - _isValid = - _editedSection.properties.every((element) => element.value != null); + _isValid = _editedSection.properties.every(_dummyValidation); } @override @@ -63,8 +63,7 @@ class _DocumentBuilderSectionTileState _pendingChanges.clear(); // TODO(damian-molinski): validation - _isValid = - _editedSection.properties.every((element) => element.value != null); + _isValid = _editedSection.properties.every(_dummyValidation); } } @@ -130,10 +129,18 @@ class _DocumentBuilderSectionTileState _pendingChanges.add(change); // TODO(damian-molinski): validation - _isValid = - _editedSection.properties.every((element) => element.value != null); + _isValid = _editedSection.properties.every(_dummyValidation); }); } + + bool _dummyValidation(DocumentProperty property) { + final value = property.value; + if (value is GroupedTagsSelection) { + return value.isValid; + } + + return value != null; + } } class _Header extends StatelessWidget { @@ -227,6 +234,20 @@ class _PropertyBuilder extends StatelessWidget { case NestedQuestionsListDefinition(): case NestedQuestionsDefinition(): case SingleGroupedTagSelectorDefinition(): + final value = property.value; + + final selection = value is GroupedTagsSelection + ? value + : const GroupedTagsSelection(); + + return SingleGroupedTagSelectorWidget( + id: property.schema.nodeId, + selection: selection, + groupedTags: property.groupedTags(), + isEditMode: isEditMode, + onChanged: onChanged, + isRequired: property.schema.isRequired, + ); case TagGroupDefinition(): case TagSelectionDefinition(): case DurationInMonthsDefinition(): diff --git a/catalyst_voices/apps/voices/lib/widgets/widgets.dart b/catalyst_voices/apps/voices/lib/widgets/widgets.dart index 06873115bc5..8fcfa48f12d 100644 --- a/catalyst_voices/apps/voices/lib/widgets/widgets.dart +++ b/catalyst_voices/apps/voices/lib/widgets/widgets.dart @@ -29,6 +29,7 @@ export 'containers/space_side_panel.dart'; export 'containers/workspace_text_tile_container.dart'; export 'drawer/voices_drawer.dart'; export 'drawer/voices_drawer_space_chooser.dart'; +export 'dropdown/voices_dropdown_form_field.dart'; export 'footers/links_page_footer.dart'; export 'footers/standard_links_page_footer.dart'; export 'headers/brand_header.dart'; diff --git a/catalyst_voices/packages/internal/catalyst_voices_brands/lib/src/themes/catalyst.dart b/catalyst_voices/packages/internal/catalyst_voices_brands/lib/src/themes/catalyst.dart index 63ddbc54084..c2eebc5dbf7 100644 --- a/catalyst_voices/packages/internal/catalyst_voices_brands/lib/src/themes/catalyst.dart +++ b/catalyst_voices/packages/internal/catalyst_voices_brands/lib/src/themes/catalyst.dart @@ -4,6 +4,7 @@ import 'package:catalyst_voices_brands/src/theme_extensions/brand_assets.dart'; import 'package:catalyst_voices_brands/src/theme_extensions/voices_color_scheme.dart'; import 'package:catalyst_voices_brands/src/themes/widgets/buttons_theme.dart'; import 'package:catalyst_voices_brands/src/themes/widgets/toggles_theme.dart'; +import 'package:catalyst_voices_brands/src/themes/widgets/voices_input_decoration_theme.dart'; import 'package:flutter/material.dart'; import 'package:google_fonts/google_fonts.dart'; @@ -357,5 +358,10 @@ ThemeData _buildThemeData( textSelectionTheme: TextSelectionThemeData( cursorColor: voicesColorScheme.textPrimary, ), + inputDecorationTheme: VoicesInputDecorationTheme( + textTheme: textTheme, + colorsSchema: colorScheme, + colors: voicesColorScheme, + ), ).copyWithButtonsTheme().copyWithTogglesTheme(); } diff --git a/catalyst_voices/packages/internal/catalyst_voices_brands/lib/src/themes/widgets/voices_input_decoration_theme.dart b/catalyst_voices/packages/internal/catalyst_voices_brands/lib/src/themes/widgets/voices_input_decoration_theme.dart new file mode 100644 index 00000000000..c7a76100da3 --- /dev/null +++ b/catalyst_voices/packages/internal/catalyst_voices_brands/lib/src/themes/widgets/voices_input_decoration_theme.dart @@ -0,0 +1,70 @@ +import 'package:catalyst_voices_brands/catalyst_voices_brands.dart'; +import 'package:flutter/material.dart'; + +class VoicesInputDecorationTheme extends InputDecorationTheme { + VoicesInputDecorationTheme({ + required TextTheme textTheme, + required ColorScheme colorsSchema, + required VoicesColorScheme colors, + }) : super( + labelStyle: textTheme.titleSmall, + contentPadding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 12, + ), + filled: true, + fillColor: colors.onSurfaceNeutralOpaqueLv1, + border: _Border(colorsSchema, colors), + ); +} + +class _Border extends MaterialStateOutlineInputBorder { + final ColorScheme colorScheme; + final VoicesColorScheme colors; + + const _Border( + this.colorScheme, + this.colors, + ); + + @override + InputBorder resolve(Set states) { + if (states.contains(WidgetState.disabled)) { + return OutlineInputBorder( + borderSide: BorderSide( + color: colors.outlineBorder!, + width: 1, + ), + borderRadius: BorderRadius.circular(4), + ); + } + + if (states.contains(WidgetState.error)) { + return OutlineInputBorder( + borderSide: BorderSide( + color: colors.iconsError!, + width: 2, + ), + borderRadius: BorderRadius.circular(4), + ); + } + + if (states.contains(WidgetState.focused)) { + return OutlineInputBorder( + borderSide: BorderSide( + color: colorScheme.primary, + width: 2, + ), + borderRadius: BorderRadius.circular(4), + ); + } + + return OutlineInputBorder( + borderSide: BorderSide( + color: colors.outlineBorderVariant!, + width: 1, + ), + borderRadius: BorderRadius.circular(4), + ); + } +} diff --git a/catalyst_voices/packages/internal/catalyst_voices_localization/lib/l10n/intl_en.arb b/catalyst_voices/packages/internal/catalyst_voices_localization/lib/l10n/intl_en.arb index 80db8d29851..356c26e6833 100644 --- a/catalyst_voices/packages/internal/catalyst_voices_localization/lib/l10n/intl_en.arb +++ b/catalyst_voices/packages/internal/catalyst_voices_localization/lib/l10n/intl_en.arb @@ -1185,5 +1185,11 @@ "@and": { "description": "General text. May be used in context of A4000 and $5000" }, - "errorValidationTokenNotParsed": "Invalid input. Could not parse parse." + "errorValidationTokenNotParsed": "Invalid input. Could not parse parse.", + "noTagSelected": "No Tag Selected", + "@noTagSelected": { + "description": "For example in context of document builder" + }, + "singleGroupedTagSelectorTitle": "Please choose the most relevant category group and tag related to the outcomes of your proposal", + "singleGroupedTagSelectorRelevantTag": "Select the most relevant tag" } \ No newline at end of file diff --git a/catalyst_voices/packages/internal/catalyst_voices_models/lib/src/catalyst_voices_models.dart b/catalyst_voices/packages/internal/catalyst_voices_models/lib/src/catalyst_voices_models.dart index 8ec48596546..45d3def4af7 100644 --- a/catalyst_voices/packages/internal/catalyst_voices_models/lib/src/catalyst_voices_models.dart +++ b/catalyst_voices/packages/internal/catalyst_voices_models/lib/src/catalyst_voices_models.dart @@ -7,6 +7,7 @@ export 'campaign/campaign_category.dart'; export 'campaign/campaign_publish.dart'; export 'campaign/campaign_section.dart'; export 'crypto/lock_factor.dart'; +export 'document/defined_property/grouped_tags.dart'; export 'document/document.dart'; export 'document/document_builder.dart'; export 'document/document_change.dart'; diff --git a/catalyst_voices/packages/internal/catalyst_voices_models/lib/src/document/defined_property/grouped_tags.dart b/catalyst_voices/packages/internal/catalyst_voices_models/lib/src/document/defined_property/grouped_tags.dart new file mode 100644 index 00000000000..dd306615d1a --- /dev/null +++ b/catalyst_voices/packages/internal/catalyst_voices_models/lib/src/document/defined_property/grouped_tags.dart @@ -0,0 +1,111 @@ +import 'package:catalyst_voices_models/catalyst_voices_models.dart'; +import 'package:catalyst_voices_shared/catalyst_voices_shared.dart'; +import 'package:equatable/equatable.dart'; + +final class GroupedTagsSelection extends Equatable { + final String? group; + final String? tag; + + const GroupedTagsSelection({ + this.group, + this.tag, + }); + + bool get isValid => group != null && tag != null; + + GroupedTagsSelection copyWith({ + Optional? group, + Optional? tag, + }) { + return GroupedTagsSelection( + group: group.dataOr(this.group), + tag: tag.dataOr(this.tag), + ); + } + + bool selects(GroupedTags groupedTag) { + final group = this.group; + final tag = this.tag; + + final groupMatches = group == groupedTag.group; + final tagFoundOrNull = tag == null || groupedTag.tags.contains(tag); + + return groupMatches && tagFoundOrNull; + } + + @override + String toString() => '$group: $tag'; + + @override + List get props => [ + group, + tag, + ]; +} + +final class GroupedTags extends Equatable { + final String group; + final List tags; + + const GroupedTags({ + required this.group, + required this.tags, + }); + + // Note. this method may be converted to factory function for + // SingleGroupedTagSelector which extends DocumentProperty. + // SingleGroupedTagSelector could easily implement validation. + static List fromLogicalGroups( + List groups, { + Logger? logger, + }) { + return groups.where((element) { + final conditions = element.conditions; + if (conditions.length != 2) { + logger?.warning('$element has invalid conditions length (!=2)'); + return false; + } + + final group = conditions[0]; + final selection = conditions[1]; + + if (group.definition is! TagGroupDefinition) { + logger?.warning('Group[$group] definition is not group'); + return false; + } + + if (group.value is! String) { + logger?.warning('Group[$group] does not have String value'); + return false; + } + + if (selection.definition is! TagSelectionDefinition) { + logger?.warning('Group[$selection] definition is not selection'); + return false; + } + + if (selection.enumValues == null) { + logger?.warning('Group[$selection] does not have enum values'); + return false; + } + + return true; + }).map( + (e) { + final group = e.conditions[0].value! as String; + final values = e.conditions[1].enumValues!; + + return GroupedTags(group: group, tags: values); + }, + ).toList(); + } + + @override + String toString() => '$group: $tags'; + + @override + List get props => [ + group, + tags, + ]; +} diff --git a/catalyst_voices/packages/internal/catalyst_voices_models/lib/src/document/document.dart b/catalyst_voices/packages/internal/catalyst_voices_models/lib/src/document/document.dart index aa1013d4905..f788a2eb8a0 100644 --- a/catalyst_voices/packages/internal/catalyst_voices_models/lib/src/document/document.dart +++ b/catalyst_voices/packages/internal/catalyst_voices_models/lib/src/document/document.dart @@ -1,5 +1,4 @@ -import 'package:catalyst_voices_models/src/document/document_builder.dart'; -import 'package:catalyst_voices_models/src/document/document_schema.dart'; +import 'package:catalyst_voices_models/catalyst_voices_models.dart'; import 'package:equatable/equatable.dart'; // TODO(dtscalac): tests @@ -91,6 +90,16 @@ final class DocumentProperty extends Equatable { return DocumentPropertyBuilder.fromProperty(this); } + List groupedTags() { + assert( + schema.definition is SingleGroupedTagSelectorDefinition, + 'Grouped tags are available only for SingleGroupedTagSelector', + ); + + final oneOf = schema.oneOf ?? const []; + return GroupedTags.fromLogicalGroups(oneOf); + } + @override List get props => [schema, value]; } diff --git a/catalyst_voices/packages/internal/catalyst_voices_models/lib/src/document/document_definitions.dart b/catalyst_voices/packages/internal/catalyst_voices_models/lib/src/document/document_definitions.dart index 02955a30c9e..0f6cec74d31 100644 --- a/catalyst_voices/packages/internal/catalyst_voices_models/lib/src/document/document_definitions.dart +++ b/catalyst_voices/packages/internal/catalyst_voices_models/lib/src/document/document_definitions.dart @@ -462,6 +462,15 @@ final class SingleGroupedTagSelectorDefinition required this.additionalProperties, }); + @visibleForTesting + const SingleGroupedTagSelectorDefinition.dummy() + : this( + type: DocumentDefinitionsObjectType.object, + note: '', + format: DocumentDefinitionsFormat.singleGroupedTagSelector, + additionalProperties: true, + ); + @override List get props => [ format, @@ -482,6 +491,15 @@ final class TagGroupDefinition extends BaseDocumentDefinition { required this.pattern, }); + @visibleForTesting + const TagGroupDefinition.dummy() + : this( + type: DocumentDefinitionsObjectType.string, + note: '', + format: DocumentDefinitionsFormat.tagGroup, + pattern: '', + ); + @override List get props => [ format, @@ -502,6 +520,15 @@ final class TagSelectionDefinition extends BaseDocumentDefinition { required this.pattern, }); + @visibleForTesting + const TagSelectionDefinition.dummy() + : this( + type: DocumentDefinitionsObjectType.string, + note: '', + format: DocumentDefinitionsFormat.tagSelection, + pattern: '', + ); + @override List get props => [ format, diff --git a/catalyst_voices/packages/internal/catalyst_voices_models/lib/src/document/document_schema.dart b/catalyst_voices/packages/internal/catalyst_voices_models/lib/src/document/document_schema.dart index 0151cf6615a..e3070b7671c 100644 --- a/catalyst_voices/packages/internal/catalyst_voices_models/lib/src/document/document_schema.dart +++ b/catalyst_voices/packages/internal/catalyst_voices_models/lib/src/document/document_schema.dart @@ -2,6 +2,7 @@ import 'package:catalyst_voices_models/src/document/document_definitions.dart'; import 'package:catalyst_voices_models/src/document/document_node_id.dart'; import 'package:catalyst_voices_shared/catalyst_voices_shared.dart'; import 'package:equatable/equatable.dart'; +import 'package:meta/meta.dart'; /// A document schema that describes the structure of a document. /// @@ -122,6 +123,7 @@ final class DocumentSchemaProperty extends Equatable final List? enumValues; final Range? range; final Range? itemsRange; + final List? oneOf; final bool isRequired; const DocumentSchemaProperty({ @@ -135,9 +137,26 @@ final class DocumentSchemaProperty extends Equatable required this.enumValues, required this.range, required this.itemsRange, + required this.oneOf, required this.isRequired, }); + @visibleForTesting + const DocumentSchemaProperty.optional({ + required this.definition, + required this.nodeId, + required this.id, + this.title, + this.description, + this.defaultValue, + this.guidance, + this.enumValues, + this.range, + this.itemsRange, + this.oneOf, + this.isRequired = false, + }); + @override List get props => [ definition, @@ -150,6 +169,42 @@ final class DocumentSchemaProperty extends Equatable enumValues, range, itemsRange, + oneOf, isRequired, ]; } + +final class DocumentSchemaLogicalGroup extends Equatable { + final List conditions; + + const DocumentSchemaLogicalGroup({ + required this.conditions, + }); + + @override + List get props => [ + conditions, + ]; +} + +final class DocumentSchemaLogicalCondition extends Equatable { + final BaseDocumentDefinition definition; + final String id; + final Object? value; + final List? enumValues; + + const DocumentSchemaLogicalCondition({ + required this.definition, + required this.id, + required this.value, + required this.enumValues, + }); + + @override + List get props => [ + definition, + id, + value, + enumValues, + ]; +} diff --git a/catalyst_voices/packages/internal/catalyst_voices_models/pubspec.yaml b/catalyst_voices/packages/internal/catalyst_voices_models/pubspec.yaml index 4f6fc3eaa22..954c8734c1b 100644 --- a/catalyst_voices/packages/internal/catalyst_voices_models/pubspec.yaml +++ b/catalyst_voices/packages/internal/catalyst_voices_models/pubspec.yaml @@ -16,6 +16,10 @@ dependencies: collection: ^1.18.0 convert: ^3.1.1 equatable: ^2.0.7 + # Flutter is added because tests are using @visibleForTesting and loggers which are flutter + # dependencies and tests are requiring them. + flutter: + sdk: flutter json_annotation: ^4.9.0 meta: ^1.10.0 password_strength: ^0.2.0 diff --git a/catalyst_voices/packages/internal/catalyst_voices_models/test/document/defined_property/grouped_tags.dart b/catalyst_voices/packages/internal/catalyst_voices_models/test/document/defined_property/grouped_tags.dart new file mode 100644 index 00000000000..df00d2d576a --- /dev/null +++ b/catalyst_voices/packages/internal/catalyst_voices_models/test/document/defined_property/grouped_tags.dart @@ -0,0 +1,157 @@ +import 'package:catalyst_voices_models/catalyst_voices_models.dart'; +import 'package:test/test.dart'; + +void main() { + group(GroupedTags, () { + const group = DocumentSchemaLogicalCondition( + definition: TagGroupDefinition.dummy(), + id: 'group', + value: 'Governance', + enumValues: null, + ); + const groupSelector = DocumentSchemaLogicalCondition( + definition: TagSelectionDefinition.dummy(), + id: 'tag', + value: null, + enumValues: [ + 'Governance', + 'DAO', + ], + ); + + test('correct oneOf produces valid selector', () { + // Given + const oneOf = [ + DocumentSchemaLogicalGroup( + conditions: [ + group, + groupSelector, + ], + ), + ]; + + const expected = [ + GroupedTags(group: 'Governance', tags: ['Governance', 'DAO']), + ]; + + // When + final selector = GroupedTags.fromLogicalGroups(oneOf); + + // Then + expect(selector, expected); + }); + + test('when group is missing selector is not created', () { + // Given + const oneOf = [ + DocumentSchemaLogicalGroup( + conditions: [ + groupSelector, + ], + ), + ]; + + const expected = []; + + // When + final selector = GroupedTags.fromLogicalGroups(oneOf); + + // Then + expect(selector, expected); + }); + + test('when group selector is missing selector is not created', () { + // Given + const oneOf = [ + DocumentSchemaLogicalGroup( + conditions: [ + group, + ], + ), + ]; + + const expected = []; + + // When + final selector = GroupedTags.fromLogicalGroups(oneOf); + + // Then + expect(selector, expected); + }); + + test('when group value is missing selector is not created', () { + // Given + const oneOf = [ + DocumentSchemaLogicalGroup( + conditions: [ + DocumentSchemaLogicalCondition( + definition: TagGroupDefinition.dummy(), + id: 'group', + value: null, + enumValues: null, + ), + groupSelector, + ], + ), + ]; + + const expected = []; + + // When + final selector = GroupedTags.fromLogicalGroups(oneOf); + + // Then + expect(selector, expected); + }); + + test('when group value is invalid selector is not created', () { + // Given + const oneOf = [ + DocumentSchemaLogicalGroup( + conditions: [ + DocumentSchemaLogicalCondition( + definition: TagGroupDefinition.dummy(), + id: 'group', + value: 1, + enumValues: null, + ), + groupSelector, + ], + ), + ]; + + const expected = []; + + // When + final selector = GroupedTags.fromLogicalGroups(oneOf); + + // Then + expect(selector, expected); + }); + + test('when group selector enum is missing selector is not created', () { + // Given + const oneOf = [ + DocumentSchemaLogicalGroup( + conditions: [ + group, + DocumentSchemaLogicalCondition( + definition: TagSelectionDefinition.dummy(), + id: 'tag', + value: null, + enumValues: null, + ), + ], + ), + ]; + + const expected = []; + + // When + final selector = GroupedTags.fromLogicalGroups(oneOf); + + // Then + expect(selector, expected); + }); + }); +} diff --git a/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/dto/document/schema/document_schema_logical_property_dto.dart b/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/dto/document/schema/document_schema_logical_property_dto.dart new file mode 100644 index 00000000000..06eb027f72e --- /dev/null +++ b/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/dto/document/schema/document_schema_logical_property_dto.dart @@ -0,0 +1,76 @@ +import 'package:catalyst_voices_models/catalyst_voices_models.dart'; +import 'package:catalyst_voices_repositories/src/utils/document_schema_dto_converter.dart'; +import 'package:json_annotation/json_annotation.dart'; + +part 'document_schema_logical_property_dto.g.dart'; + +@JsonSerializable(includeIfNull: false) +final class DocumentSchemaLogicalGroupDto { + @DocumentSchemaLogicalPropertiesDtoConverter() + @JsonKey(name: 'properties') + final List? conditions; + + DocumentSchemaLogicalGroupDto({ + this.conditions, + }); + + factory DocumentSchemaLogicalGroupDto.fromJson( + Map json, + ) { + return _$DocumentSchemaLogicalGroupDtoFromJson(json); + } + + Map toJson() { + return _$DocumentSchemaLogicalGroupDtoToJson(this); + } + + DocumentSchemaLogicalGroup toModel( + List definitions, + ) { + final conditions = this.conditions ?? const []; + + return DocumentSchemaLogicalGroup( + conditions: conditions.map((e) => e.toModel(definitions)).toList(), + ); + } +} + +@JsonSerializable(includeIfNull: false) +final class DocumentSchemaLogicalConditionDto { + @JsonKey(name: r'$ref') + final String ref; + @JsonKey(includeToJson: false) + final String id; + @JsonKey(name: 'const') + final Object? value; + @JsonKey(name: 'enum') + final List? enumValues; + + const DocumentSchemaLogicalConditionDto({ + this.ref = '', + required this.id, + this.value, + this.enumValues, + }); + + factory DocumentSchemaLogicalConditionDto.fromJson( + Map json, + ) { + return _$DocumentSchemaLogicalConditionDtoFromJson(json); + } + + Map toJson() { + return _$DocumentSchemaLogicalConditionDtoToJson(this); + } + + DocumentSchemaLogicalCondition toModel( + List definitions, + ) { + return DocumentSchemaLogicalCondition( + definition: definitions.getDefinition(ref), + id: id, + value: value, + enumValues: enumValues, + ); + } +} diff --git a/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/dto/document/schema/document_schema_property_dto.dart b/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/dto/document/schema/document_schema_property_dto.dart index cd32518ec9c..e5aad1ab40d 100644 --- a/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/dto/document/schema/document_schema_property_dto.dart +++ b/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/dto/document/schema/document_schema_property_dto.dart @@ -1,10 +1,11 @@ import 'package:catalyst_voices_models/catalyst_voices_models.dart'; +import 'package:catalyst_voices_repositories/src/dto/document/schema/document_schema_logical_property_dto.dart'; import 'package:catalyst_voices_shared/catalyst_voices_shared.dart'; import 'package:json_annotation/json_annotation.dart'; part 'document_schema_property_dto.g.dart'; -@JsonSerializable() +@JsonSerializable(includeIfNull: false) final class DocumentSchemaPropertyDto { @JsonKey(name: r'$ref') final String ref; @@ -12,29 +13,25 @@ final class DocumentSchemaPropertyDto { final String id; final String? title; final String? description; - @JsonKey(includeIfNull: false) final int? minLength; - @JsonKey(includeIfNull: false) final int? maxLength; @JsonKey(name: 'default') final Object? defaultValue; @JsonKey(name: 'x-guidance') final String? guidance; - @JsonKey(name: 'enum', includeIfNull: false) + @JsonKey(name: 'enum') final List? enumValues; - @JsonKey(includeIfNull: false) final int? maxItems; - @JsonKey(includeIfNull: false) final int? minItems; - @JsonKey(includeIfNull: false) final int? minimum; - @JsonKey(includeIfNull: false) final int? maximum; // TODO(ryszard-schossler): return to this - @JsonKey(includeIfNull: false) final Map? items; + // Logical boolean algebra conditions + final List? oneOf; + const DocumentSchemaPropertyDto({ this.ref = '', required this.id, @@ -50,6 +47,7 @@ final class DocumentSchemaPropertyDto { this.minimum, this.maximum, this.items, + this.oneOf, }); factory DocumentSchemaPropertyDto.fromJson(Map json) => @@ -73,6 +71,7 @@ final class DocumentSchemaPropertyDto { enumValues: enumValues, range: Range.optionalRangeOf(min: minimum, max: maximum), itemsRange: Range.optionalRangeOf(min: minItems, max: maxItems), + oneOf: oneOf?.map((e) => e.toModel(definitions)).toList(), isRequired: isRequired, ); } diff --git a/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/utils/document_schema_dto_converter.dart b/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/utils/document_schema_dto_converter.dart index 76dea779f0c..631de313204 100644 --- a/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/utils/document_schema_dto_converter.dart +++ b/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/utils/document_schema_dto_converter.dart @@ -1,3 +1,4 @@ +import 'package:catalyst_voices_repositories/src/dto/document/schema/document_schema_logical_property_dto.dart'; import 'package:catalyst_voices_repositories/src/dto/document/schema/document_schema_property_dto.dart'; import 'package:catalyst_voices_repositories/src/dto/document/schema/document_schema_section_dto.dart'; import 'package:catalyst_voices_repositories/src/dto/document/schema/document_schema_segment_dto.dart'; @@ -72,3 +73,25 @@ final class DocumentSchemaPropertiesDtoConverter }; } } + +final class DocumentSchemaLogicalPropertiesDtoConverter + implements + JsonConverter, + Map> { + const DocumentSchemaLogicalPropertiesDtoConverter(); + + @override + List fromJson(Map json) { + final properties = json.convertMapToListWithIds(); + return properties.map(DocumentSchemaLogicalConditionDto.fromJson).toList(); + } + + @override + Map toJson( + List properties, + ) { + return { + for (final property in properties) property.id: property.toJson(), + }; + } +} diff --git a/catalyst_voices/packages/internal/catalyst_voices_repositories/test/src/document/schema/document_schema_property_dto_test.dart b/catalyst_voices/packages/internal/catalyst_voices_repositories/test/src/document/schema/document_schema_property_dto_test.dart new file mode 100644 index 00000000000..f82d3cb3207 --- /dev/null +++ b/catalyst_voices/packages/internal/catalyst_voices_repositories/test/src/document/schema/document_schema_property_dto_test.dart @@ -0,0 +1,62 @@ +import 'dart:convert'; + +import 'package:catalyst_voices_repositories/src/dto/document/schema/document_schema_property_dto.dart'; +import 'package:test/test.dart'; + +import '../../../helpers/read_json.dart'; + +void main() { + group(DocumentSchemaPropertyDto, () { + const schemaPath = + 'test/assets/0ce8ab38-9258-4fbc-a62e-7faa6e58318f.schema.json'; + + late Map schemaJson; + + setUpAll(() { + schemaJson = json.decode(readJson(schemaPath)) as Map; + }); + + test('includeIfNull does not add keys for values that are null', () { + // Given + const dto = DocumentSchemaPropertyDto( + ref: '#/definitions/section', + id: 'solution', + ); + const expectedJson = { + r'$ref': '#/definitions/section', + }; + + // When + final json = dto.toJson(); + + // Then + expect(json, expectedJson); + }); + + group('grouped_tag', () { + test('oneOf is parsed correctly', () { + // Given + // ignore: avoid_dynamic_calls + final json = schemaJson['properties']['horizons']['properties']['theme'] + ['properties']['grouped_tag'] as Map + ..['id'] = 'grouped_tag'; + + // When + final dto = DocumentSchemaPropertyDto.fromJson(json); + + // Then + expect(dto.ref, '#/definitions/singleGroupedTagSelector'); + expect( + dto.oneOf, + allOf(isNotNull, hasLength(13)), + ); + + for (final group in dto.oneOf!) { + expect(group.conditions, hasLength(2)); + expect(group.conditions![0].id, 'group'); + expect(group.conditions![1].id, 'tag'); + } + }); + }); + }); +}