diff --git a/ui/app/src/components/ContentTypeManagement/components/AdditionalFieldChip.tsx b/ui/app/src/components/ContentTypeManagement/components/AdditionalFieldChip.tsx new file mode 100644 index 0000000000..e13e9cf1f1 --- /dev/null +++ b/ui/app/src/components/ContentTypeManagement/components/AdditionalFieldChip.tsx @@ -0,0 +1,72 @@ +/* + * Copyright (C) 2007-2026 Crafter Software Corporation. All Rights Reserved. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as published by + * the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +import SubdirectoryArrowRightRoundedIcon from '@mui/icons-material/SubdirectoryArrowRightRounded'; +import Box from '@mui/material/Box'; +import Typography from '@mui/material/Typography'; +import React from 'react'; +import useIsDarkModeTheme from '../../../hooks/useIsDarkModeTheme'; +import { FormattedMessage } from 'react-intl'; +import InfoOutlinedIcon from '@mui/icons-material/InfoOutlined'; +import Tooltip from '@mui/material/Tooltip'; +import { processAdditionalFieldMacro } from '../../FormsEngine/lib/formUtils'; + +export function AdditionalFieldChip(props: { parentFieldId: string; fieldId: string }) { + const { fieldId, parentFieldId } = props; + const isDark = useIsDarkModeTheme(); + + const additionalFieldId = processAdditionalFieldMacro(parentFieldId, fieldId); + + return ( + + + + + {additionalFieldId} + + + + + + + } + placement="top" + > + + + + + + ); +} + +export default AdditionalFieldChip; diff --git a/ui/app/src/components/ContentTypeManagement/components/FieldChip.tsx b/ui/app/src/components/ContentTypeManagement/components/FieldChip.tsx index c7b5d73e97..d9b609b816 100644 --- a/ui/app/src/components/ContentTypeManagement/components/FieldChip.tsx +++ b/ui/app/src/components/ContentTypeManagement/components/FieldChip.tsx @@ -32,6 +32,7 @@ import controlDescriptors from '../descriptors/controls'; import dataSourceDescriptors from '../descriptors/dataSources'; import { applyTranslations } from '../utils'; import Button from '@mui/material/Button'; +import AdditionalFieldChip from './AdditionalFieldChip'; function composeFieldPath(fieldPath: string, fieldId: string): string { return fieldPath ? `${fieldPath}.${fieldId}` : fieldId; @@ -77,90 +78,97 @@ export function FieldChip(props: FieldChipProps) { '&:hover': { bgcolor: 'action.selected' } }; const { formatMessage } = useIntl(); + const descriptor = descriptors[field.type]; + const additionalFields = descriptor?.metadata?.additionalFields; return ( - - + - - {(field as NewContentTypeField).NEW ? ( - - - - ) : ( - <> + + + {(field as NewContentTypeField).NEW ? ( - {field.name} + - - ({field.id}) - - - )} - {error && } - - - {descriptors[field.type] - ? applyTranslations(descriptors[field.type], formatMessage).name - : capitalize(field.type).replaceAll('-', ' ')} - - - {isRepeat && ( - - {Object.entries(field.fields).map(([fieldId, subField]) => ( - - ))} - + ) : ( + <> + + {field.name} + + + ({field.id}) + + + )} + {error && } + + + {descriptors[field.type] + ? applyTranslations(descriptors[field.type], formatMessage).name + : capitalize(field.type).replaceAll('-', ' ')} + - )} - + {isRepeat && ( + + {Object.entries(field.fields).map(([fieldId, subField]) => ( + + ))} + + + )} + + {additionalFields?.map((additionalFieldId) => ( + + ))} + ); } diff --git a/ui/app/src/components/ContentTypeManagement/descriptors/controls/dateTime.ts b/ui/app/src/components/ContentTypeManagement/descriptors/controls/dateTime.ts index 9418f3844e..3b9cd87842 100644 --- a/ui/app/src/components/ContentTypeManagement/descriptors/controls/dateTime.ts +++ b/ui/app/src/components/ContentTypeManagement/descriptors/controls/dateTime.ts @@ -116,7 +116,8 @@ export const dateTimeDescriptor: DescriptorContentType = { required: commonFieldPropertiesDescriptors['required'] }, metadata: { - suffixes: ['_dt'] + suffixes: ['_dt'], + additionalFields: ['{id}_tz'] } }; diff --git a/ui/app/src/components/DateTimeTimezonePicker/DateTimeTimezonePicker.tsx b/ui/app/src/components/DateTimeTimezonePicker/DateTimeTimezonePicker.tsx index 9bcca763a5..e87279f51c 100644 --- a/ui/app/src/components/DateTimeTimezonePicker/DateTimeTimezonePicker.tsx +++ b/ui/app/src/components/DateTimeTimezonePicker/DateTimeTimezonePicker.tsx @@ -39,6 +39,7 @@ import type { PartialSxRecord } from '../../models'; export interface DateTimeTimezonePickerProps { id?: string; value: string | Date | number | null; + timezoneValue?: string; disabled?: boolean; disablePast?: boolean; autoUpdatePastDate?: boolean; @@ -51,6 +52,7 @@ export interface DateTimeTimezonePickerProps { size?: TextFieldProps['size']; onError?: DateTimePickerProps['onError']; onChange?(date: Date): void; + onTimezoneChange?(timezone: string): void; } export function DateTimeTimezonePicker(props: DateTimeTimezonePickerProps) { @@ -59,6 +61,7 @@ export function DateTimeTimezonePicker(props: DateTimeTimezonePickerProps) { const { id, value: dateProp, + timezoneValue, disabled = false, disablePast = false, autoUpdatePastDate = false, @@ -70,12 +73,15 @@ export function DateTimeTimezonePicker(props: DateTimeTimezonePickerProps) { sxs = {}, size = 'small', onChange, + onTimezoneChange, onError } = props; const hour12 = dateTimeFormatOptions?.hour12; const timeZones = useMemo(() => moment.tz.names(), []); const [selectedDate, setSelectedDate] = useState(null); - const [selectedTimezone, setSelectedTimezone] = useState(resolvedLocaleData.timeZone ?? null); + const [selectedTimezone, setSelectedTimezone] = useState( + timezoneValue ? timezoneValue : (resolvedLocaleData.timeZone ?? null) + ); // The control timezone lags behind selectedTimezone. It is only updated when there's a different // selectedTimezone to the navigator's locale, and the value (date) prop changes. const [controlTimezone, setControlTimezone] = useState(resolvedLocaleData.timeZone ?? null); @@ -100,6 +106,7 @@ export function DateTimeTimezonePicker(props: DateTimeTimezonePickerProps) { event.preventDefault(); event.stopPropagation(); setSelectedTimezone(value); + onTimezoneChange?.(value); }) as AutocompleteProps['onChange']; useEffect(() => { if (!dateProp) { diff --git a/ui/app/src/components/FormsEngine/FormsEngine.tsx b/ui/app/src/components/FormsEngine/FormsEngine.tsx index fb0fe6c0c7..9b3321b216 100644 --- a/ui/app/src/components/FormsEngine/FormsEngine.tsx +++ b/ui/app/src/components/FormsEngine/FormsEngine.tsx @@ -96,6 +96,7 @@ import { displayFormBeingSavedSnack, fetchUpdateRequirements, generateDefaultChangesComment, + getAdditionalFieldsIdsFromDescriptor, getCurrentChildFormStateSummary, getScrollContainer, getTargetHeight, @@ -142,6 +143,8 @@ import useMount from '../../hooks/useMount'; import { nnou, nou } from '../../utils/object'; import { buildContentXml } from './lib/valueSerializers'; import { processPathMacros } from '../../utils/path'; +import { useCustomControlsDescriptorsById } from '../../hooks/useCustomControlsDescriptorsById'; +import controlDescriptors from '../ContentTypeManagement/descriptors/controls'; export interface FormSavePromiseResult { close: boolean; @@ -283,6 +286,7 @@ function FormBootstrap(props: FormsEngineProps) { const [reloadNonce, setReloadNonce] = useState(0); const triggerReload = useCallback(() => setReloadNonce((nonce) => nonce + 1), []); const effectiveUpdatePath = renamedPath ?? update?.path; + const customControls = useCustomControlsDescriptorsById(); const contextApi = useMemo(() => { const getInitialValues = () => stableFormContextRef.current.originalValues; @@ -374,7 +378,7 @@ function FormBootstrap(props: FormsEngineProps) { expandedStateBySectionId: buildSectionExpandedStateAtoms(contentType.sections), fileName: atom('') }); - const atomValueCreator: Parameters[3] = (fieldId, value) => { + const atomValueCreator: Parameters[3] = (fieldId, value, isAdditional) => { setFieldAtoms( stableFormContextRef, contentType, @@ -382,15 +386,42 @@ function FormBootstrap(props: FormsEngineProps) { fieldId, atoms, value, - { siteId, contentTypesById: effectRefs.current.contentTypesById } + { siteId, contentTypesById: effectRefs.current.contentTypesById }, + isAdditional ); }; const values = repeat.values ?? - createParsedValuesObject(fieldsToRender, {}, effectRefs.current.contentTypesById, atomValueCreator); + createParsedValuesObject( + fieldsToRender, + {}, + effectRefs.current.contentTypesById, + atomValueCreator, + customControls + ); + const descriptors = { ...customControls, ...controlDescriptors }; + const additionalFieldsIds: string[] = []; // If repeat.values was provided, `createCleanValuesObject` didn't run; hence, atomValueCreator needs to be run manually. - repeat.values && Object.keys(values).forEach((fieldId) => atomValueCreator(fieldId, values[fieldId])); + if (repeat.values) { + // First gather all additional fields ids from the provided values + (fieldsToRender ?? []).forEach((field) => { + const descriptor = descriptors[field.type]; + if (!descriptor) return; + additionalFieldsIds.push(...getAdditionalFieldsIdsFromDescriptor(field.id, descriptor)); + }); + // Ensure descriptor additional fields exist in values so atoms are created + additionalFieldsIds.forEach((additionalFieldId) => { + if (!(additionalFieldId in values)) { + values[additionalFieldId] = undefined; + } + }); + // Run atomValueCreator for each field considering the additional fields + Object.keys(values).forEach((fieldId) => { + const isAdditional = additionalFieldsIds.includes(fieldId); + atomValueCreator(fieldId, values[fieldId], isAdditional); + }); + } const xmlDoc = fromString(parentStackData.itemMeta.contentXml ?? ''); const fieldId = repeat.fieldId; @@ -431,7 +462,8 @@ function FormBootstrap(props: FormsEngineProps) { stableFormContextRef, parentPathInSite, siteId, - contentTypesById: effectRefs.current.contentTypesById + contentTypesById: effectRefs.current.contentTypesById, + customControls }); initializeState(requirements.atoms, requirements.values, requirements.itemMeta); }; @@ -463,12 +495,24 @@ function FormBootstrap(props: FormsEngineProps) { fileName: atom('') }); const contentObject = createObjectWithSystemProps(contentType); - const values = createParsedValuesObject(contentType.fields, contentObject, contentTypesById, (fieldId, value) => { - setFieldAtoms(stableFormContextRef, contentType, contentType.fields, fieldId, atoms, value, { - siteId, - contentTypesById - }); - }); + const values = createParsedValuesObject( + contentType.fields, + contentObject, + contentTypesById, + (fieldId, value, isAdditional) => { + setFieldAtoms( + stableFormContextRef, + contentType, + contentType.fields, + fieldId, + atoms, + value, + { siteId, contentTypesById }, + isAdditional + ); + }, + customControls + ); const { [XmlKeys.fileName]: _, ...valuesWithoutFileName } = values; const objectId = contentObject[XmlKeys.modelId] as string; @@ -531,7 +575,7 @@ function FormBootstrap(props: FormsEngineProps) { requirements.contentType.fields, requirements.contentObject, effectRefs.current.contentTypesById, - (fieldId, value) => { + (fieldId, value, isAdditional) => { setFieldAtoms( stableFormContextRef, requirements.contentType, @@ -539,9 +583,11 @@ function FormBootstrap(props: FormsEngineProps) { fieldId, atoms, value, - { siteId, contentTypesById } + { siteId, contentTypesById }, + isAdditional ); - } + }, + customControls ); initializeState(atoms, values, { diff --git a/ui/app/src/components/FormsEngine/controls/DateTime.tsx b/ui/app/src/components/FormsEngine/controls/DateTime.tsx index 4cb6b7e719..470e330ced 100644 --- a/ui/app/src/components/FormsEngine/controls/DateTime.tsx +++ b/ui/app/src/components/FormsEngine/controls/DateTime.tsx @@ -21,9 +21,10 @@ import { DateTimeTimezonePicker, type DateTimeTimezonePickerProps } from '../../ import SecondaryButton from '../../SecondaryButton'; import { FormattedMessage } from 'react-intl'; import Box from '@mui/material/Box'; -import { StableFormContext } from '../lib/formsEngineContext'; +import { StableFormContext, useStableFormContext } from '../lib/formsEngineContext'; import { processPopulateExpression, validateDatePopulateExpression } from '../lib/controlHelpers'; import { getPropertyValue, isFieldReadOnly } from '../lib/formUtils'; +import { atom, PrimitiveAtom, useAtom } from 'jotai'; export interface DateTimeProps extends ControlProps { value: string; @@ -36,6 +37,12 @@ export function DateTime(props: DateTimeProps) { const stableFormContext = useContext(StableFormContext); const isCreateMode = Boolean(stableFormContext?.props?.create); + const formContext = useStableFormContext(); + const atoms = formContext.atoms; + const [timezoneValue, setTimezoneValue] = useAtom( + (atoms.valueByFieldId[`${field.id}_tz`] as PrimitiveAtom) ?? (atom('') as PrimitiveAtom) + ); + // region field properties/validations const allowPastDate: boolean = getPropertyValue(field.properties, 'allowPastDate') as boolean; @@ -102,10 +109,12 @@ export function DateTime(props: DateTimeProps) { setTimezoneValue(tz)} disableTimezoneSelection={!useCustomTimezone} pickers={pickers} size="medium" diff --git a/ui/app/src/components/FormsEngine/lib/formUtils.tsx b/ui/app/src/components/FormsEngine/lib/formUtils.tsx index 71f613c543..b9a4de4c22 100644 --- a/ui/app/src/components/FormsEngine/lib/formUtils.tsx +++ b/ui/app/src/components/FormsEngine/lib/formUtils.tsx @@ -79,6 +79,9 @@ import { showErrorDialog } from '../../../state/actions/dialogs'; import { ensureSingleSlash } from '../../../utils/string'; import { isPagePath } from '../../../utils/path'; import { nou } from '../../../utils/object'; +import type { DescriptorContentType } from '../../ContentTypeManagement/utils'; +import type { DescriptorControlType } from '../../ContentTypeManagement/controlMap'; +import controlDescriptors from '../../ContentTypeManagement/descriptors/controls'; /** * Returns the scroll container for the form's container. @@ -489,7 +492,8 @@ export function setFieldAtoms( fieldId: string, atomsTarget: FormsEngineAtoms, value: unknown, - validatorsData?: ValidatorsData + validatorsData?: ValidatorsData, + isAdditional?: boolean ): void { let field = fieldLookup[fieldId]; if (!field) { @@ -508,6 +512,20 @@ export function setFieldAtoms( id: 'folder-name', name: 'Folder Name' }; + } else if (isAdditional) { + field = { + defaultValue: undefined, + description: '', + fields: undefined, + helpText: '', + properties: undefined, + sortable: false, + type: '', + validations: undefined, + values: undefined, + id: fieldId, + name: fieldId + }; } else { !systemFieldsNotInType.includes(fieldId) && console.warn(`Field ${fieldId} not found in content type "${contentType.name}" (${contentType.id})`); @@ -833,6 +851,7 @@ export function prepareEmbeddedItemForm(props: { parentPathInSite: string; siteId: string; contentTypesById?: LookupTable; + customControls?: LookupTable; }): { atoms: FormsEngineAtoms; values: LookupTable; itemMeta: FormsEngineItemMetaContextProps } { const { username, @@ -845,7 +864,8 @@ export function prepareEmbeddedItemForm(props: { lockError, affectedPackages, siteId, - contentTypesById + contentTypesById, + customControls } = props; const lockResultAtom = atom({ locked, @@ -860,9 +880,22 @@ export function prepareEmbeddedItemForm(props: { }); const values = update.values; const validatorsData = { siteId, contentTypesById }; + + const descriptors = { ...customControls, ...controlDescriptors }; + let additionalFieldsIds = []; + // Retrieve all additional fields ids from the contentType fields. + Object.values(contentType.fields).forEach((field) => { + const type = field.type; + additionalFieldsIds = [ + ...additionalFieldsIds, + ...getAdditionalFieldsIdsFromDescriptor(field.id, descriptors[type]) + ]; + }); + Object.entries(values).forEach(([fieldId, value]) => { + const isAdditionalField = additionalFieldsIds.includes(fieldId); // System fields (e.g. content-type, display-template, etc.) are not part of the content type, but are part of the content object. We don't need atoms or validity checks for these. - if (!contentType.fields[fieldId]) return; + if (!contentType.fields[fieldId] && !isAdditionalField) return; const [valueAtom, validityAtom] = createFieldAtoms( contentType.fields[fieldId], value, @@ -870,7 +903,7 @@ export function prepareEmbeddedItemForm(props: { validatorsData ); atoms.valueByFieldId[fieldId] = valueAtom; - atoms.validationByFieldId[fieldId] = validityAtom; + if (!isAdditionalField) atoms.validationByFieldId[fieldId] = validityAtom; }); const xmlDoc = fromString(parentStackData.itemMeta.contentXml); const element = xmlDoc.querySelector(`[id="${update.modelId}"]`); @@ -927,3 +960,13 @@ export function composePathForType(basePath: string, fileName: string, contentTy return ensureSingleSlash(`${basePath}/${fileName}.xml`); } } + +export function getAdditionalFieldsIdsFromDescriptor(fieldId: string, descriptor: DescriptorContentType): string[] { + const additionalFields = descriptor.metadata?.additionalFields ?? []; + return additionalFields.map((additionalField) => processAdditionalFieldMacro(fieldId, additionalField)); +} + +// TODO: are there going to be other placeholders besides {id}? +export function processAdditionalFieldMacro(parentFieldId: string, fieldId: string): string { + return fieldId.replaceAll('{id}', parentFieldId); +} diff --git a/ui/app/src/components/FormsEngine/lib/valueRetrievers.ts b/ui/app/src/components/FormsEngine/lib/valueRetrievers.ts index 34daf2dd96..48928581ca 100644 --- a/ui/app/src/components/FormsEngine/lib/valueRetrievers.ts +++ b/ui/app/src/components/FormsEngine/lib/valueRetrievers.ts @@ -25,6 +25,8 @@ import type { DescriptorControlType } from '../../ContentTypeManagement/controlM import { nnou } from '../../../utils/object'; import { v4 as uuid } from 'uuid'; import { Matcher } from 'path-expression-matcher'; +import controlDescriptors from '../../ContentTypeManagement/descriptors/controls'; +import { getAdditionalFieldsIdsFromDescriptor } from './formUtils'; export type ValueRetriever = (value: unknown, field: ContentTypeField) => T; @@ -99,12 +101,14 @@ export const valueRetrieverLookup: Record | ContentTypeField[], xmlDeserializedValues: LookupTable, contentTypesLookup: LookupTable, - fieldCallback?: (fieldId: string, value: unknown) => void + fieldCallback?: (fieldId: string, value: unknown, isAdditionalField?: boolean) => void, + customControls?: LookupTable ): LookupTable { const values = {}; systemFieldsNotInType.forEach((systemFieldId) => { @@ -113,7 +117,20 @@ export function createParsedValuesObject( fieldCallback?.(systemFieldId, values[systemFieldId]); } }); + // TODO: should controlDescriptors have priority over customControls to avoid overriding OOB controls? + const descriptors = { ...customControls, ...controlDescriptors }; (Array.isArray(contentTypeFields) ? contentTypeFields : Object.values(contentTypeFields)).forEach((field) => { + const descriptor = descriptors[field.type]; + const additionalFieldIds = descriptor ? getAdditionalFieldsIdsFromDescriptor(field.id, descriptor) : []; + + additionalFieldIds.forEach((additionalFieldId) => { + values[additionalFieldId] = createParsedValueForField( + xmlDeserializedValues[additionalFieldId], + field, + contentTypesLookup + ); + fieldCallback?.(additionalFieldId, values[additionalFieldId], true); + }); values[field.id] = createParsedValueForField(xmlDeserializedValues[field.id], field, contentTypesLookup); fieldCallback?.(field.id, values[field.id]); }); diff --git a/ui/app/src/hooks/useCustomControls.ts b/ui/app/src/hooks/useCustomControls.ts new file mode 100644 index 0000000000..5f3ff952af --- /dev/null +++ b/ui/app/src/hooks/useCustomControls.ts @@ -0,0 +1,23 @@ +/* + * Copyright (C) 2007-2026 Crafter Software Corporation. All Rights Reserved. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as published by + * the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +import useSelection from './useSelection'; + +export function useCustomControls() { + return useSelection((state) => state.uiConfig.controls); +} + +export default useCustomControls; diff --git a/ui/app/src/hooks/useCustomControlsDescriptorsById.ts b/ui/app/src/hooks/useCustomControlsDescriptorsById.ts new file mode 100644 index 0000000000..adaa774008 --- /dev/null +++ b/ui/app/src/hooks/useCustomControlsDescriptorsById.ts @@ -0,0 +1,28 @@ +/* + * Copyright (C) 2007-2026 Crafter Software Corporation. All Rights Reserved. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as published by + * the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +import useCustomControls from './useCustomControls'; + +export function useCustomControlsDescriptorsById() { + const controls = useCustomControls(); + const customControlsDescriptorsById = {}; + if (controls) { + Object.values(controls).forEach((control) => { + customControlsDescriptorsById[control.id] = control.descriptor; + }); + } + return customControlsDescriptorsById; +} diff --git a/ui/app/src/models/GlobalState.ts b/ui/app/src/models/GlobalState.ts index 57c68147e6..f82b370c6f 100644 --- a/ui/app/src/models/GlobalState.ts +++ b/ui/app/src/models/GlobalState.ts @@ -74,6 +74,7 @@ import { ViewPackagesDialogStateProps } from '../components/ViewPackagesDialog'; import type { FolderMoveAlertDialogStateProps } from '../components/FolderMoveAlertDialog/FolderMoveAlertDialog'; import type { PublishingStatus } from './Publishing'; import type { Archetype } from '../components/ContentTypeManagement/descriptors/archetypes'; +import { DescriptorContentType } from '../components/ContentTypeManagement/utils'; export type HighlightMode = 'all' | 'move'; @@ -277,6 +278,11 @@ export interface GlobalState { references: LookupTable; xml: string; remoteGitBranch: string; + controls: LookupTable<{ + id: string; + icon: SystemIconDescriptor; + descriptor: DescriptorContentType; + }>; }; pathNavigator: LookupTable; pathNavigatorTree: LookupTable; diff --git a/ui/app/src/state/reducers/uiConfig.ts b/ui/app/src/state/reducers/uiConfig.ts index a5e2deaa08..ba71d704be 100644 --- a/ui/app/src/state/reducers/uiConfig.ts +++ b/ui/app/src/state/reducers/uiConfig.ts @@ -27,6 +27,7 @@ import { fetchSiteLocales, fetchSiteLocalesComplete, fetchSiteLocalesFailed } fr import { deserialize, fromString, serialize } from '../../utils/xml'; import { applyDeserializedXMLTransforms } from '../../utils/object'; import { getUserLocaleCode, getUserTimeZone } from '../../utils/datetime'; +import { asArray } from '../../utils/array'; const initialState: GlobalState['uiConfig'] = { error: null, @@ -55,6 +56,7 @@ const initialState: GlobalState['uiConfig'] = { } }, references: null, + controls: null, xml: null, publishing: { deleteCommentRequired: false, @@ -80,6 +82,7 @@ const reducer = createReducer(initialState, (builder) = .addCase(fetchSiteUiConfigComplete, (state, { payload }) => { let config = payload.config; const references = {}; + const controls = {}; if (config) { const configDOM = fromString(config); const site = payload.site; @@ -104,6 +107,38 @@ const reducer = createReducer(initialState, (builder) = configDOM.querySelectorAll('widget').forEach((e, index) => e.setAttribute('uiKey', String(index))); + configDOM + .querySelectorAll('[id="craftercms.components.ContentTypeManagement"] > configuration > controls') + .forEach((tag) => { + const deserializedControl = deserialize(tag.innerHTML); + controls[tag.id] = { + id: tag.id, + icon: deserializedControl.icon + }; + const descriptor = deserializedControl.descriptor; + if (descriptor) { + // Check for arrays.. + controls[tag.id].descriptor = { + ...descriptor, + id: tag.id, + fields: descriptor.fields ? descriptor.fields : {}, // if no fields (or fields is an empty array, which happens when there an empty fields tag), set to empty object + sections: descriptor.sections + ? asArray(descriptor.sections).map((section) => ({ + ...section, + fields: section.fields ? asArray(section.fields) : [] + })) + : [], + metadata: { + ...descriptor.metadata, + suffixes: descriptor.metadata?.suffixes ? asArray(descriptor.metadata.suffixes) : null, + additionalFields: descriptor.metadata?.additionalFields + ? asArray(descriptor.metadata.additionalFields) + : null + } + }; + } + }); + config = serialize(configDOM); } @@ -111,7 +146,8 @@ const reducer = createReducer(initialState, (builder) = ...state, isFetching: false, xml: config, - references + references, + controls }; }) .addCase(fetchSiteUiConfigFailed, (state, { payload }) => ({