From 7804602070e26725784ecc06f5bf359b8a80f0ef Mon Sep 17 00:00:00 2001 From: Viktor Nagy Date: Tue, 28 May 2024 08:56:50 +0200 Subject: [PATCH] feat: advanced field groups --- .../src/viewcontrols/browse-view.tsx | 101 ++++++++++++---- .../src/viewcontrols/edit-view.tsx | 112 ++++++++++++++---- .../test/__mocks__/schema.ts | 32 +++++ .../test/browse-view.test.tsx | 73 ++++++++---- .../sn-controls-react/test/edit-view.test.tsx | 73 ++++++++---- .../sn-controls-react/test/new-view.test.tsx | 92 ++++++++++---- .../src/FieldSettings.ts | 5 + 7 files changed, 371 insertions(+), 117 deletions(-) diff --git a/packages/sn-controls-react/src/viewcontrols/browse-view.tsx b/packages/sn-controls-react/src/viewcontrols/browse-view.tsx index 789114b5a..aa1519438 100644 --- a/packages/sn-controls-react/src/viewcontrols/browse-view.tsx +++ b/packages/sn-controls-react/src/viewcontrols/browse-view.tsx @@ -12,6 +12,7 @@ import React, { createElement, ReactElement, useEffect, useState } from 'react' import { FieldLocalization } from '../fieldcontrols/localization' import { isFullWidthField } from '../helpers' import { reactControlMapper } from '../react-control-mapper' +import { AdvancedFieldGroup, DEFAULT_GROUP_KEY } from './edit-view' /** * Interface for BrowseView properties @@ -72,8 +73,8 @@ export const BrowseView: React.FC = (props) => { const controlMapper = props.controlMapper || reactControlMapper(props.repository) const [schema, setSchema] = useState(controlMapper.getFullSchemaForContentType(props.content.Type, 'browse')) const classes = useStyles(props) - const [advancedFields, setAdvancedFields] = useState([]) - const [showAdvancedFields, setShowAdvancedFields] = useState(false) + const [advancedFields, setAdvancedFields] = useState([]) + const [advancedFieldStateGroup, setAdvancedFieldStateGroup] = useState>([]) const repository = useRepository() useEffect(() => { @@ -85,11 +86,38 @@ export const BrowseView: React.FC = (props) => { useEffect(() => { if (schema) { - const filteredFields = schema.fieldMappings - .filter((s) => s.fieldSettings.VisibleBrowse === FieldVisibility.Advanced) - .map((s) => s.fieldSettings.Name) + const groups: AdvancedFieldGroup[] = [ + { + key: DEFAULT_GROUP_KEY, + fields: [], + }, + ] - setAdvancedFields(filteredFields) + schema.fieldMappings.forEach((e) => { + if (e.fieldSettings.VisibleBrowse === FieldVisibility.Advanced) { + const category = e.fieldSettings.Customization?.Categories?.split(' ')[0] + if (category) { + const group = groups.find((g) => g.key === category) + if (group) { + group.fields.push(e) + } else { + groups.push({ + key: category, + fields: [e], + }) + } + } else { + groups.find((g) => g.key === DEFAULT_GROUP_KEY)?.fields.push(e) + } + } + }) + + setAdvancedFieldStateGroup( + groups.map((g) => { + return { key: g.key, isOpened: false } + }), + ) + setAdvancedFields(groups) } }, [schema]) @@ -125,6 +153,12 @@ export const BrowseView: React.FC = (props) => { ) } + const toggleAdvancedFieldGroup = (key: string) => { + setAdvancedFieldStateGroup((prevItems) => + prevItems.map((item) => (item.key === key ? { ...item, isOpened: !item.isOpened } : item)), + ) + } + return ( <> {props.renderTitle ? ( @@ -136,28 +170,47 @@ export const BrowseView: React.FC = (props) => { )} {schema.fieldMappings - .filter((i) => !advancedFields.includes(i.fieldSettings.Name)) + .filter( + (i) => + !advancedFields + .flatMap((g) => g.fields) + .map((g) => g.fieldSettings.Name) + .includes(i.fieldSettings.Name), + ) .sort((item1, item2) => (item2.fieldSettings.FieldIndex || 0) - (item1.fieldSettings.FieldIndex || 0)) .map((field) => renderField(field))} - {advancedFields.length > 0 && ( - - - - - {props.localization?.advancedFields ?? 'Advanced fields'} - setShowAdvancedFields(!showAdvancedFields)}> - {showAdvancedFields ? : } - + + {advancedFields.map((group, index) => + group.fields.length > 0 ? ( + + + + + {`${ + props.localization?.advancedFields ?? 'Advanced fields' + }${group.key === DEFAULT_GROUP_KEY ? '' : ` - ${group.key}`}`} + toggleAdvancedFieldGroup(group.key)}> + {advancedFieldStateGroup.find((g) => g.key === group.key)?.isOpened ? ( + + ) : ( + + )} + + + + {advancedFieldStateGroup.find((g) => g.key === group.key)?.isOpened && + group.fields + .sort( + (item1, item2) => (item2.fieldSettings.FieldIndex || 0) - (item1.fieldSettings.FieldIndex || 0), + ) + .map((field) => renderField(field))} - - {showAdvancedFields && - schema.fieldMappings - .filter((i) => advancedFields.includes(i.fieldSettings.Name)) - .sort((item1, item2) => (item2.fieldSettings.FieldIndex || 0) - (item1.fieldSettings.FieldIndex || 0)) - .map((field) => renderField(field))} - - )} + ) : ( + <> + ), + )} +
diff --git a/packages/sn-controls-react/src/viewcontrols/edit-view.tsx b/packages/sn-controls-react/src/viewcontrols/edit-view.tsx index 63af4f7f9..c4a675192 100644 --- a/packages/sn-controls-react/src/viewcontrols/edit-view.tsx +++ b/packages/sn-controls-react/src/viewcontrols/edit-view.tsx @@ -78,6 +78,13 @@ const useStyles = makeStyles((theme: Theme) => { }) }) +export interface AdvancedFieldGroup { + key: string + fields: Array<{ fieldSettings: FieldSetting; actionName: ActionName; controlType: any }> +} + +export const DEFAULT_GROUP_KEY = 'DEFAULT_GROUP_KEY' + type EditViewClassKey = Partial> /** @@ -92,8 +99,8 @@ export const EditView: React.FC = (props) => { const actionName = props.actionName || 'edit' const controlMapper = props.controlMapper || reactControlMapper(props.repository) const [schema, setSchema] = useState(controlMapper.getFullSchemaForContentType(props.contentTypeName, actionName)) - const [advancedFields, setAdvancedFields] = useState([]) - const [showAdvancedFields, setShowAdvancedFields] = useState(false) + const [advancedFields, setAdvancedFields] = useState([]) + const [advancedFieldStateGroup, setAdvancedFieldStateGroup] = useState>([]) const contentRef = useRef({}) const [content, setContent] = useState(contentRef.current) contentRef.current = content @@ -122,13 +129,41 @@ export const EditView: React.FC = (props) => { useEffect(() => { if (actionName && schema) { - const filteredFields = schema.fieldMappings - .filter((s) => - actionName === 'edit' ? s.fieldSettings.VisibleEdit : s.fieldSettings.VisibleNew === FieldVisibility.Advanced, - ) - .map((s) => s.fieldSettings.Name) + const groups: AdvancedFieldGroup[] = [ + { + key: DEFAULT_GROUP_KEY, + fields: [], + }, + ] + + schema.fieldMappings.forEach((e) => { + if ( + (actionName === 'edit' && e.fieldSettings.VisibleEdit === FieldVisibility.Advanced) || + (actionName === 'new' && e.fieldSettings.VisibleNew === FieldVisibility.Advanced) + ) { + const category = e.fieldSettings.Customization?.Categories?.split(' ')[0] + if (category) { + const group = groups.find((g) => g.key === category) + if (group) { + group.fields.push(e) + } else { + groups.push({ + key: category, + fields: [e], + }) + } + } else { + groups.find((g) => g.key === DEFAULT_GROUP_KEY)?.fields.push(e) + } + } + }) - setAdvancedFields(filteredFields) + setAdvancedFieldStateGroup( + groups.map((g) => { + return { key: g.key, isOpened: false } + }), + ) + setAdvancedFields(groups) } }, [actionName, schema]) @@ -178,6 +213,12 @@ export const EditView: React.FC = (props) => { ) } + const toggleAdvancedFieldGroup = (key: string) => { + setAdvancedFieldStateGroup((prevItems) => + prevItems.map((item) => (item.key === key ? { ...item, isOpened: !item.isOpened } : item)), + ) + } + return ( <> {props.showTitle && @@ -196,28 +237,47 @@ export const EditView: React.FC = (props) => { spacing={2} className={classes.grid}> {schema.fieldMappings - .filter((i) => !advancedFields.includes(i.fieldSettings.Name)) + .filter( + (i) => + !advancedFields + .flatMap((g) => g.fields) + .map((g) => g.fieldSettings.Name) + .includes(i.fieldSettings.Name), + ) .sort((item1, item2) => (item2.fieldSettings.FieldIndex || 0) - (item1.fieldSettings.FieldIndex || 0)) .map((field) => renderField(field))} - {advancedFields.length > 0 && ( - - - - - {props.localization?.advancedFields ?? 'Advanced fields'} - setShowAdvancedFields(!showAdvancedFields)}> - {showAdvancedFields ? : } - + + {advancedFields.map((group, index) => + group.fields.length > 0 ? ( + + + + + {`${ + props.localization?.advancedFields ?? 'Advanced fields' + }${group.key === DEFAULT_GROUP_KEY ? '' : ` - ${group.key}`}`} + toggleAdvancedFieldGroup(group.key)}> + {advancedFieldStateGroup.find((g) => g.key === group.key)?.isOpened ? ( + + ) : ( + + )} + + + + {advancedFieldStateGroup.find((g) => g.key === group.key)?.isOpened && + group.fields + .sort( + (item1, item2) => (item2.fieldSettings.FieldIndex || 0) - (item1.fieldSettings.FieldIndex || 0), + ) + .map((field) => renderField(field))} - - {showAdvancedFields && - schema.fieldMappings - .filter((i) => advancedFields.includes(i.fieldSettings.Name)) - .sort((item1, item2) => (item2.fieldSettings.FieldIndex || 0) - (item1.fieldSettings.FieldIndex || 0)) - .map((field) => renderField(field))} - - )} + ) : ( + <> + ), + )} +
diff --git a/packages/sn-controls-react/test/__mocks__/schema.ts b/packages/sn-controls-react/test/__mocks__/schema.ts index cdf43a097..4708580b9 100644 --- a/packages/sn-controls-react/test/__mocks__/schema.ts +++ b/packages/sn-controls-react/test/__mocks__/schema.ts @@ -530,6 +530,38 @@ export const schema = [ VisibleEdit: FieldSettings.FieldVisibility.Advanced, VisibleNew: FieldSettings.FieldVisibility.Advanced, } as FieldSettings.DateTimeFieldSetting, + { + Type: 'DateTimeFieldSetting', + DateTimeMode: FieldSettings.DateTimeMode.DateAndTime, + Name: 'Advanced3', + FieldClassName: 'SenseNet.ContentRepository.Fields.DateTimeField', + DisplayName: 'Advanced field #3', + Description: 'Content was last modified on this date.', + ReadOnly: false, + Compulsory: false, + VisibleBrowse: FieldSettings.FieldVisibility.Advanced, + VisibleEdit: FieldSettings.FieldVisibility.Advanced, + VisibleNew: FieldSettings.FieldVisibility.Advanced, + Customization: { + Categories: 'Group1', + }, + } as FieldSettings.DateTimeFieldSetting, + { + Type: 'DateTimeFieldSetting', + DateTimeMode: FieldSettings.DateTimeMode.DateAndTime, + Name: 'Advanced4', + FieldClassName: 'SenseNet.ContentRepository.Fields.DateTimeField', + DisplayName: 'Advanced field #4', + Description: 'Content was last modified on this date.', + ReadOnly: false, + Compulsory: false, + VisibleBrowse: FieldSettings.FieldVisibility.Advanced, + VisibleEdit: FieldSettings.FieldVisibility.Advanced, + VisibleNew: FieldSettings.FieldVisibility.Advanced, + Customization: { + Categories: 'Group2', + }, + } as FieldSettings.DateTimeFieldSetting, ], }, ] diff --git a/packages/sn-controls-react/test/browse-view.test.tsx b/packages/sn-controls-react/test/browse-view.test.tsx index f6ad8cff2..b40b8655d 100644 --- a/packages/sn-controls-react/test/browse-view.test.tsx +++ b/packages/sn-controls-react/test/browse-view.test.tsx @@ -1,4 +1,4 @@ -import { IconButton } from '@material-ui/core' +import { Box, IconButton } from '@material-ui/core' import { Repository } from '@sensenet/client-core' import { GenericContent, VersioningMode } from '@sensenet/default-content-types' import { mount, shallow } from 'enzyme' @@ -99,39 +99,68 @@ describe('Browse view component', () => { }) //Advanced field tests - it('Advanced field header should be visible', () => { - const wrapper = mount() + it('Advanced field inputs in a group should be invisible by default', () => { + const wrapper = mount() wrapper.update() - const element = wrapper.find('[data-test="advanced-field-container"]') - expect(element.exists()).toBe(true) + const element = wrapper.find('[data-test="group-container-Group1"]') + expect(element.find(DatePicker).exists()).toBe(false) wrapper.unmount() }) - it('Advanced fields should be invisible by default', () => { - const wrapper = shallow() - const element = wrapper.find('[data-test="advanced-field-container"]') - expect(element.find(DatePicker).exists()).toBe(false) - expect(element.find(ShortText).exists()).toBe(false) - }) - it('Advanced fields should be visible after clicking on show icon', () => { - const wrapper = shallow() + + it('Should render the correct amount of groups', () => { + const wrapper = mount() wrapper.update() - wrapper.find('[data-test="advanced-field-container"]').find(IconButton).simulate('click') + const elements = wrapper + .find(Box) + .filterWhere((node) => node.prop('data-test') && node.prop('data-test').startsWith('group-header')) + + expect(elements).toHaveLength(3) + + wrapper.unmount() + }) + + it('Should render the corrent title', () => { + const wrapper = mount() wrapper.update() - const element = wrapper.find('[data-test="advanced-field-container"]') - expect(element.find(DatePicker).exists()).toBe(true) - expect(element.find(ShortText).exists()).toBe(true) + const element = wrapper + .find(Box) + .filterWhere((node) => node.prop('data-test') && node.prop('data-test').startsWith('group-header')) + .at(1) + + expect(element.find('[data-test="advanced-field-group-title"]').text()).toBe('Advanced fields - Group1') + + wrapper.unmount() }) - it('Should render advanced fields in the right section', async () => { - const wrapper = mount() + + it('Should render input fields after clicking on show more button', async () => { + let wrapper + await act(async () => { + wrapper = mount() + }) wrapper.update() - const parent = wrapper.find('[data-test="advanced-field-container"]') - expect(parent.find(DatePicker)).toHaveLength(1) - expect(parent.find(ShortText)).toHaveLength(1) + const element = wrapper + .find(Box) + .filterWhere((node) => node.prop('data-test') && node.prop('data-test').startsWith('group-container')) + .at(2) + + await act(async () => { + element.find(IconButton).simulate('click') + }) + + wrapper.update() + + const updatedElement = wrapper + .find(Box) + .filterWhere((node) => node.prop('data-test') && node.prop('data-test').startsWith('group-container')) + .at(2) + + expect(updatedElement.find(DatePicker)).toHaveLength(1) + expect(updatedElement.find(ShortText).exists()).toBe(false) wrapper.unmount() }) diff --git a/packages/sn-controls-react/test/edit-view.test.tsx b/packages/sn-controls-react/test/edit-view.test.tsx index 611ab35e9..62afec3bb 100644 --- a/packages/sn-controls-react/test/edit-view.test.tsx +++ b/packages/sn-controls-react/test/edit-view.test.tsx @@ -1,4 +1,4 @@ -import { IconButton } from '@material-ui/core' +import { Box, IconButton } from '@material-ui/core' import { Repository } from '@sensenet/client-core' import { GenericContent, VersioningMode } from '@sensenet/default-content-types' import { mount, ReactWrapper, shallow } from 'enzyme' @@ -91,47 +91,74 @@ describe('Edit view component', () => { expect(onSubmit).toBeCalledWith({ VersioningMode: '1' }, 'GenericContent') }) //Advanced field tests - it('Advanced field header should be visible', () => { + it('Advanced field inputs in a group should be invisible by default', () => { const wrapper = mount( , ) wrapper.update() - const element = wrapper.find('[data-test="advanced-field-container"]') - expect(element.exists()).toBe(true) + const element = wrapper.find('[data-test="group-container-Group1"]') + expect(element.find(DatePicker).exists()).toBe(false) wrapper.unmount() }) - it('Advanced fields should be invisible by default', () => { - const wrapper = shallow( - , - ) - const element = wrapper.find('[data-test="advanced-field-container"]') - expect(element.find(DatePicker).exists()).toBe(false) - expect(element.find(ShortText).exists()).toBe(false) - }) - it('Advanced fields should be visible after clicking on show icon', () => { - const wrapper = shallow( + + it('Should render the correct amount of groups', () => { + const wrapper = mount( , ) wrapper.update() - wrapper.find('[data-test="advanced-field-container"]').find(IconButton).simulate('click') - wrapper.update() + const elements = wrapper + .find(Box) + .filterWhere((node) => node.prop('data-test') && node.prop('data-test').startsWith('group-header')) - const element = wrapper.find('[data-test="advanced-field-container"]') - expect(element.find(DatePicker).exists()).toBe(true) - expect(element.find(ShortText).exists()).toBe(true) + expect(elements).toHaveLength(3) + + wrapper.unmount() }) - it('Should render advanced fields in the right section', async () => { + + it('Should render the corrent title', () => { const wrapper = mount( , ) wrapper.update() - const parent = wrapper.find('[data-test="advanced-field-container"]') - expect(parent.find(DatePicker)).toHaveLength(1) - expect(parent.find(ShortText)).toHaveLength(1) + const element = wrapper + .find(Box) + .filterWhere((node) => node.prop('data-test') && node.prop('data-test').startsWith('group-header')) + .at(1) + + expect(element.find('[data-test="advanced-field-group-title"]').text()).toBe('Advanced fields - Group1') + + wrapper.unmount() + }) + + it('Should render input fields after clicking on show more button', async () => { + let wrapper + await act(async () => { + wrapper = mount() + }) + wrapper.update() + + const element = wrapper + .find(Box) + .filterWhere((node) => node.prop('data-test') && node.prop('data-test').startsWith('group-container')) + .at(2) + + await act(async () => { + element.find(IconButton).simulate('click') + }) + + wrapper.update() + + const updatedElement = wrapper + .find(Box) + .filterWhere((node) => node.prop('data-test') && node.prop('data-test').startsWith('group-container')) + .at(2) + + expect(updatedElement.find(DatePicker)).toHaveLength(1) + expect(updatedElement.find(ShortText).exists()).toBe(false) wrapper.unmount() }) diff --git a/packages/sn-controls-react/test/new-view.test.tsx b/packages/sn-controls-react/test/new-view.test.tsx index 4f9328145..98732c940 100644 --- a/packages/sn-controls-react/test/new-view.test.tsx +++ b/packages/sn-controls-react/test/new-view.test.tsx @@ -1,9 +1,10 @@ -import { IconButton } from '@material-ui/core' +import { Box, IconButton } from '@material-ui/core' import Typography from '@material-ui/core/Typography' import { Repository } from '@sensenet/client-core' import { GenericContent, VersioningMode } from '@sensenet/default-content-types' import { mount, shallow } from 'enzyme' import React from 'react' +import { act } from 'react-dom/test-utils' import { AllowedChildTypes, AutoComplete, @@ -46,6 +47,18 @@ export const testFile: GenericContent = { 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Duis nec iaculis lectus, sed blandit urna. Nullam in auctor odio, eu eleifend diam. Curabitur rutrum ullamcorper nunc, sit amet consectetur turpis elementum ac. Aenean lorem lorem, feugiat sit amet sem at, accumsan cursus leo.', } +export const testContent: GenericContent = { + Id: 1, + Name: 'TestContent', + DisplayName: 'Test content', + Path: '/Root/Content/TestContent', + Type: 'TestContentType', + Index: 42, + VersioningMode: [VersioningMode.Option0], + AllowedChildTypes: [1, 2], + Description: 'Lorem ipsum short description.', +} + describe('New view component', () => { it('should render all components', () => { const wrapper = shallow( @@ -82,39 +95,74 @@ describe('New view component', () => { expect(onSubmit).toBeCalledWith({ VersioningMode: '1' }, 'GenericContent') }) //Advanced field tests - it('Advanced field header should be visible', () => { - const wrapper = mount() + it('Advanced field inputs in a group should be invisible by default', () => { + const wrapper = mount( + , + ) wrapper.update() - const element = wrapper.find('[data-test="advanced-field-container"]') - expect(element.exists()).toBe(true) + const element = wrapper.find('[data-test="group-container-Group1"]') + expect(element.find(DatePicker).exists()).toBe(false) wrapper.unmount() }) - it('Advanced fields should be invisible by default', () => { - const wrapper = shallow() - const element = wrapper.find('[data-test="advanced-field-container"]') - expect(element.find(DatePicker).exists()).toBe(false) - expect(element.find(ShortText).exists()).toBe(false) - }) - it('Advanced fields should be visible after clicking on show icon', () => { - const wrapper = shallow() + + it('Should render the correct amount of groups', () => { + const wrapper = mount( + , + ) wrapper.update() - wrapper.find('[data-test="advanced-field-container"]').find(IconButton).simulate('click') + const elements = wrapper + .find(Box) + .filterWhere((node) => node.prop('data-test') && node.prop('data-test').startsWith('group-header')) + + expect(elements).toHaveLength(3) + + wrapper.unmount() + }) + + it('Should render the corrent title', () => { + const wrapper = mount( + , + ) wrapper.update() - const element = wrapper.find('[data-test="advanced-field-container"]') - expect(element.find(DatePicker).exists()).toBe(true) - expect(element.find(ShortText).exists()).toBe(true) + const element = wrapper + .find(Box) + .filterWhere((node) => node.prop('data-test') && node.prop('data-test').startsWith('group-header')) + .at(1) + + expect(element.find('[data-test="advanced-field-group-title"]').text()).toBe('Advanced fields - Group1') + + wrapper.unmount() }) - it('Should render advanced fields in the right section', async () => { - const wrapper = mount() + + it('Should render input fields after clicking on show more button', async () => { + let wrapper + await act(async () => { + wrapper = mount() + }) + wrapper.update() + + const element = wrapper + .find(Box) + .filterWhere((node) => node.prop('data-test') && node.prop('data-test').startsWith('group-container')) + .at(2) + + await act(async () => { + element.find(IconButton).simulate('click') + }) + wrapper.update() - const parent = wrapper.find('[data-test="advanced-field-container"]') - expect(parent.find(DatePicker)).toHaveLength(1) - expect(parent.find(ShortText)).toHaveLength(1) + const updatedElement = wrapper + .find(Box) + .filterWhere((node) => node.prop('data-test') && node.prop('data-test').startsWith('group-container')) + .at(2) + + expect(updatedElement.find(DatePicker)).toHaveLength(1) + expect(updatedElement.find(ShortText).exists()).toBe(false) wrapper.unmount() }) diff --git a/packages/sn-default-content-types/src/FieldSettings.ts b/packages/sn-default-content-types/src/FieldSettings.ts index db9775854..93c4a4b60 100644 --- a/packages/sn-default-content-types/src/FieldSettings.ts +++ b/packages/sn-default-content-types/src/FieldSettings.ts @@ -72,6 +72,10 @@ export function isFieldSettingOfType(setting: FieldSetti return setting.Type === type.name } +export type Customization = { + Categories: string +} + export type FieldSetting = { Name: string Type: string @@ -87,6 +91,7 @@ export type FieldSetting = { VisibleNew?: FieldVisibility VisibleEdit?: FieldVisibility FieldIndex?: number + Customization?: Customization ControlHint?: string }