From c4275bafaccd1c2e114e79aeb0835ae8f41a5bb9 Mon Sep 17 00:00:00 2001 From: jvega190 Date: Tue, 13 Jan 2026 10:50:30 -0600 Subject: [PATCH 1/9] [8500] Support for Controls Writing Sibling Tags --- .../components/AdditionalFieldChip.tsx | 67 ++++++++ .../components/FieldChip.tsx | 154 +++++++++--------- .../components/FormsEngine/FormsEngine.tsx | 35 +++- .../components/FormsEngine/lib/formUtils.tsx | 30 +++- .../FormsEngine/lib/valueRetrievers.ts | 18 +- ui/app/src/hooks/useCustomControls.ts | 23 +++ .../hooks/useCustomControlsDescriptorsById.ts | 28 ++++ ui/app/src/models/GlobalState.ts | 6 + ui/app/src/state/reducers/uiConfig.ts | 38 ++++- 9 files changed, 315 insertions(+), 84 deletions(-) create mode 100644 ui/app/src/components/ContentTypeManagement/components/AdditionalFieldChip.tsx create mode 100644 ui/app/src/hooks/useCustomControls.ts create mode 100644 ui/app/src/hooks/useCustomControlsDescriptorsById.ts 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..d1b195a95b --- /dev/null +++ b/ui/app/src/components/ContentTypeManagement/components/AdditionalFieldChip.tsx @@ -0,0 +1,67 @@ +/* + * 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'; + +export function AdditionalFieldChip(props) { + const fieldId = props.fieldId ?? 'test'; + const isDark = useIsDarkModeTheme(); + + return ( + + + + + {fieldId} + + + + + + + + + + + + ); +} + +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..bd94413828 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/FormsEngine/FormsEngine.tsx b/ui/app/src/components/FormsEngine/FormsEngine.tsx index 404a1f6937..99cf247a85 100644 --- a/ui/app/src/components/FormsEngine/FormsEngine.tsx +++ b/ui/app/src/components/FormsEngine/FormsEngine.tsx @@ -119,6 +119,7 @@ import { displayWithPendingChangesConfirm } from '../../utils/ui'; import useActiveUser from '../../hooks/useActiveUser'; import FormBackToTop from './components/FormBackToTop'; import { createComponentId } from '../../utils/system'; +import { useCustomControlsDescriptorsById } from '../../hooks/useCustomControlsDescriptorsById'; export interface FormSavePromiseResult { close: boolean; @@ -247,6 +248,7 @@ function FormBootstrap(props: FormsEngineProps) { const username = useActiveUser()?.username; const effectRefs = useUpdateRefs({ contentTypesById, username }); const stableFormContextRef = useRef(formsStackData[stackIndex]); + const customControls = useCustomControlsDescriptorsById(); const contextApi = useMemo(() => { const getInitialValues = () => stableFormContextRef.current.originalValues; @@ -337,7 +339,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, @@ -345,7 +347,8 @@ function FormBootstrap(props: FormsEngineProps) { fieldId, atoms, value, - siteId + siteId, + isAdditional ); }; const values = @@ -424,9 +427,23 @@ 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); - }); + const values = createParsedValuesObject( + contentType.fields, + contentObject, + contentTypesById, + (fieldId, value, isAdditional) => { + setFieldAtoms( + stableFormContextRef, + contentType, + contentType.fields, + fieldId, + atoms, + value, + siteId, + isAdditional + ); + } + ); initializeState(atoms, values, { id: contentObject[XmlKeys.modelId] as string, @@ -481,7 +498,7 @@ function FormBootstrap(props: FormsEngineProps) { requirements.contentType.fields, requirements.contentObject, effectRefs.current.contentTypesById, - (fieldId, value) => { + (fieldId, value, isAdditional) => { setFieldAtoms( stableFormContextRef, requirements.contentType, @@ -489,9 +506,11 @@ function FormBootstrap(props: FormsEngineProps) { fieldId, atoms, value, - siteId + siteId, + isAdditional ); - } + }, + customControls ); initializeState(atoms, values, { diff --git a/ui/app/src/components/FormsEngine/lib/formUtils.tsx b/ui/app/src/components/FormsEngine/lib/formUtils.tsx index 2d54c0940b..fda63ad9f3 100644 --- a/ui/app/src/components/FormsEngine/lib/formUtils.tsx +++ b/ui/app/src/components/FormsEngine/lib/formUtils.tsx @@ -73,6 +73,7 @@ import { createComponentId } from '../../../utils/system'; import { showErrorDialog } from '../../../state/actions/dialogs'; import { ensureSingleSlash } from '../../../utils/string'; import { nou } from '../../../utils/object'; +import type { DescriptorContentType } from '../../ContentTypeManagement/utils'; /** * Returns the scroll container for the form's container. @@ -487,7 +488,8 @@ export function setFieldAtoms( fieldId: string, atomsTarget: FormsEngineAtoms, value: unknown, - siteId?: string + siteId?: string, + isAdditional?: boolean ): void { let field = fieldLookup[fieldId]; if (!field) { @@ -506,6 +508,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})`); @@ -915,3 +931,15 @@ export function composePathForType(basePath: string, fileName: string, contentTy return ensureSingleSlash(`${basePath}/${fileName}.xml`); } } + +export function getAdditionalFieldsIdsFromDescriptor( + field: ContentTypeField, + descriptor: DescriptorContentType +): string[] { + // console.log('field', field); + // console.log(`descriptor for ${field.id}/${field.type}`, descriptor); + const additionalFields = descriptor.metadata?.additionalFields ?? []; + + // TODO: are there going to be other placeholders besides {id}? + return additionalFields.map((additionalField) => additionalField.replaceAll('{id}', field.id)); +} diff --git a/ui/app/src/components/FormsEngine/lib/valueRetrievers.ts b/ui/app/src/components/FormsEngine/lib/valueRetrievers.ts index 2bad82b932..0d69d428a3 100644 --- a/ui/app/src/components/FormsEngine/lib/valueRetrievers.ts +++ b/ui/app/src/components/FormsEngine/lib/valueRetrievers.ts @@ -23,6 +23,8 @@ import { systemFieldsNotInType, XmlKeys } from './formConsts'; import { deserialize } from '../../../utils/xml'; import type { DescriptorControlType } from '../../ContentTypeManagement/controlMap'; import { nnou } from '../../../utils/object'; +import controlDescriptors from '../../ContentTypeManagement/descriptors/controls'; +import { getAdditionalFieldsIdsFromDescriptor } from './formUtils'; export type ValueRetriever = (value: unknown, field: ContentTypeField) => T; @@ -90,12 +92,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) => { @@ -104,7 +108,19 @@ 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 additionalFieldIds = getAdditionalFieldsIdsFromDescriptor(field, descriptors[field.type]); + + 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 5c1cf9f0ac..366c1f4c0b 100644 --- a/ui/app/src/models/GlobalState.ts +++ b/ui/app/src/models/GlobalState.ts @@ -74,6 +74,7 @@ import { PackageDetailsDialogStateProps } from '../components'; import { ViewPackagesDialogStateProps } from '../components/ViewPackagesDialog'; import type { FolderMoveAlertDialogStateProps } from '../components/FolderMoveAlertDialog/FolderMoveAlertDialog'; import type { PublishingStatus } from './Publishing'; +import { DescriptorContentType } from '../components/ContentTypeManagement/utils'; export type HighlightMode = 'all' | 'move'; @@ -275,6 +276,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 af61da1b32..d946d59d4f 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 + references: references, + controls }; }) .addCase(fetchSiteUiConfigFailed, (state, { payload }) => ({ From a954c6bcd63982487b892d8238c801b2bee275fc Mon Sep 17 00:00:00 2001 From: jvega190 Date: Wed, 14 Jan 2026 08:48:55 -0600 Subject: [PATCH 2/9] [8500] Add key to AdditionalFielChip under a map --- .../components/ContentTypeManagement/components/FieldChip.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ui/app/src/components/ContentTypeManagement/components/FieldChip.tsx b/ui/app/src/components/ContentTypeManagement/components/FieldChip.tsx index bd94413828..f032f83469 100644 --- a/ui/app/src/components/ContentTypeManagement/components/FieldChip.tsx +++ b/ui/app/src/components/ContentTypeManagement/components/FieldChip.tsx @@ -166,7 +166,7 @@ export function FieldChip(props: FieldChipProps) { )} {additionalFields?.map((additionalFieldId) => ( - + ))} ); From 811e10517ef5e91c4bd6fabb91100bbda43df2af Mon Sep 17 00:00:00 2001 From: jvega190 Date: Wed, 14 Jan 2026 08:49:55 -0600 Subject: [PATCH 3/9] [8500] Fix typo, add i18n to message --- .../ContentTypeManagement/components/AdditionalFieldChip.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/ui/app/src/components/ContentTypeManagement/components/AdditionalFieldChip.tsx b/ui/app/src/components/ContentTypeManagement/components/AdditionalFieldChip.tsx index d1b195a95b..de4b407341 100644 --- a/ui/app/src/components/ContentTypeManagement/components/AdditionalFieldChip.tsx +++ b/ui/app/src/components/ContentTypeManagement/components/AdditionalFieldChip.tsx @@ -53,7 +53,9 @@ export function AdditionalFieldChip(props) { + } placement="top" > From 97d67ee887610234c0f32b943aad0f82899b86c9 Mon Sep 17 00:00:00 2001 From: jvega190 Date: Wed, 14 Jan 2026 10:30:36 -0600 Subject: [PATCH 4/9] [8500] Fix repeat group atom creation not considering additionalFields --- .../components/FormsEngine/FormsEngine.tsx | 31 +++++++++++++++++-- .../components/FormsEngine/lib/formUtils.tsx | 9 ++---- .../FormsEngine/lib/valueRetrievers.ts | 2 +- 3 files changed, 31 insertions(+), 11 deletions(-) diff --git a/ui/app/src/components/FormsEngine/FormsEngine.tsx b/ui/app/src/components/FormsEngine/FormsEngine.tsx index 99cf247a85..50bf01459d 100644 --- a/ui/app/src/components/FormsEngine/FormsEngine.tsx +++ b/ui/app/src/components/FormsEngine/FormsEngine.tsx @@ -86,6 +86,7 @@ import { displayFormBeingSavedSnack, fetchUpdateRequirements, generateDefaultChangesComment, + getAdditionalFieldsIdsFromDescriptor, getCurrentChildFormStateSummary, getScrollContainer, getTargetHeight, @@ -120,6 +121,7 @@ import useActiveUser from '../../hooks/useActiveUser'; import FormBackToTop from './components/FormBackToTop'; import { createComponentId } from '../../utils/system'; import { useCustomControlsDescriptorsById } from '../../hooks/useCustomControlsDescriptorsById'; +import controlDescriptors from '../ContentTypeManagement/descriptors/controls'; export interface FormSavePromiseResult { close: boolean; @@ -353,10 +355,32 @@ function FormBootstrap(props: FormsEngineProps) { }; const values = repeat.values ?? - createParsedValuesObject(fieldsToRender, {}, effectRefs.current.contentTypesById, atomValueCreator); + createParsedValuesObject( + fieldsToRender, + {}, + effectRefs.current.contentTypesById, + atomValueCreator, + customControls + ); + const descriptors = { ...customControls, ...controlDescriptors }; + let additionalFieldsIds = []; // 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 type = field.type; + additionalFieldsIds = [ + ...additionalFieldsIds, + ...getAdditionalFieldsIdsFromDescriptor(field.id, descriptors[type]) + ]; + }); + // 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; @@ -442,7 +466,8 @@ function FormBootstrap(props: FormsEngineProps) { siteId, isAdditional ); - } + }, + customControls ); initializeState(atoms, values, { diff --git a/ui/app/src/components/FormsEngine/lib/formUtils.tsx b/ui/app/src/components/FormsEngine/lib/formUtils.tsx index fda63ad9f3..374565cc60 100644 --- a/ui/app/src/components/FormsEngine/lib/formUtils.tsx +++ b/ui/app/src/components/FormsEngine/lib/formUtils.tsx @@ -932,14 +932,9 @@ export function composePathForType(basePath: string, fileName: string, contentTy } } -export function getAdditionalFieldsIdsFromDescriptor( - field: ContentTypeField, - descriptor: DescriptorContentType -): string[] { - // console.log('field', field); - // console.log(`descriptor for ${field.id}/${field.type}`, descriptor); +export function getAdditionalFieldsIdsFromDescriptor(fieldId: string, descriptor: DescriptorContentType): string[] { const additionalFields = descriptor.metadata?.additionalFields ?? []; // TODO: are there going to be other placeholders besides {id}? - return additionalFields.map((additionalField) => additionalField.replaceAll('{id}', field.id)); + return additionalFields.map((additionalField) => additionalField.replaceAll('{id}', fieldId)); } diff --git a/ui/app/src/components/FormsEngine/lib/valueRetrievers.ts b/ui/app/src/components/FormsEngine/lib/valueRetrievers.ts index 0d69d428a3..8f00c27414 100644 --- a/ui/app/src/components/FormsEngine/lib/valueRetrievers.ts +++ b/ui/app/src/components/FormsEngine/lib/valueRetrievers.ts @@ -111,7 +111,7 @@ export function createParsedValuesObject( // 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 additionalFieldIds = getAdditionalFieldsIdsFromDescriptor(field, descriptors[field.type]); + const additionalFieldIds = getAdditionalFieldsIdsFromDescriptor(field.id, descriptors[field.type]); additionalFieldIds.forEach((additionalFieldId) => { values[additionalFieldId] = createParsedValueForField( From 1badbe8c66e34ffef0578131ad2863377895fa5e Mon Sep 17 00:00:00 2001 From: jvega190 Date: Wed, 14 Jan 2026 12:17:33 -0600 Subject: [PATCH 5/9] [8500] Fix embedded components additionalFields atoms creation --- .../components/FormsEngine/FormsEngine.tsx | 3 ++- .../components/FormsEngine/lib/formUtils.tsx | 23 ++++++++++++++++--- 2 files changed, 22 insertions(+), 4 deletions(-) diff --git a/ui/app/src/components/FormsEngine/FormsEngine.tsx b/ui/app/src/components/FormsEngine/FormsEngine.tsx index 50bf01459d..599131f40e 100644 --- a/ui/app/src/components/FormsEngine/FormsEngine.tsx +++ b/ui/app/src/components/FormsEngine/FormsEngine.tsx @@ -419,7 +419,8 @@ function FormBootstrap(props: FormsEngineProps) { update, parentStackData, stableFormContextRef, - parentPathInSite + parentPathInSite, + customControls }); initializeState(requirements.atoms, requirements.values, requirements.itemMeta); }; diff --git a/ui/app/src/components/FormsEngine/lib/formUtils.tsx b/ui/app/src/components/FormsEngine/lib/formUtils.tsx index 374565cc60..01f5176c2c 100644 --- a/ui/app/src/components/FormsEngine/lib/formUtils.tsx +++ b/ui/app/src/components/FormsEngine/lib/formUtils.tsx @@ -74,6 +74,8 @@ import { showErrorDialog } from '../../../state/actions/dialogs'; import { ensureSingleSlash } from '../../../utils/string'; 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. @@ -815,6 +817,7 @@ export function prepareEmbeddedItemForm(props: { parentStackData: StableFormContextProps; stableFormContextRef: RefObject; parentPathInSite: string; + customControls?: LookupTable; }): { atoms: FormsEngineAtoms; values: LookupTable; itemMeta: FormsEngineItemMetaContextProps } { const { username, @@ -825,7 +828,8 @@ export function prepareEmbeddedItemForm(props: { parentPathInSite, locked, lockError, - affectedPackages + affectedPackages, + customControls } = props; const lockResultAtom = atom({ locked, @@ -839,12 +843,25 @@ export function prepareEmbeddedItemForm(props: { fileName: atom(update.modelId) }); const values = update.values; + + 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, stableFormContextRef); 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}"]`); From 4bb8378e06b4e91a46182fea050a75ef497c95bc Mon Sep 17 00:00:00 2001 From: jvega190 Date: Wed, 14 Jan 2026 14:10:42 -0600 Subject: [PATCH 6/9] [8500] Show processed additionalFieldId in AdditionalFieldChip --- .../components/AdditionalFieldChip.tsx | 9 ++++++--- .../ContentTypeManagement/components/FieldChip.tsx | 2 +- ui/app/src/components/FormsEngine/lib/formUtils.tsx | 7 +++++-- 3 files changed, 12 insertions(+), 6 deletions(-) diff --git a/ui/app/src/components/ContentTypeManagement/components/AdditionalFieldChip.tsx b/ui/app/src/components/ContentTypeManagement/components/AdditionalFieldChip.tsx index de4b407341..e13e9cf1f1 100644 --- a/ui/app/src/components/ContentTypeManagement/components/AdditionalFieldChip.tsx +++ b/ui/app/src/components/ContentTypeManagement/components/AdditionalFieldChip.tsx @@ -22,11 +22,14 @@ 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) { - const fieldId = props.fieldId ?? 'test'; +export function AdditionalFieldChip(props: { parentFieldId: string; fieldId: string }) { + const { fieldId, parentFieldId } = props; const isDark = useIsDarkModeTheme(); + const additionalFieldId = processAdditionalFieldMacro(parentFieldId, fieldId); + return ( @@ -46,7 +49,7 @@ export function AdditionalFieldChip(props) { ]} > - {fieldId} + {additionalFieldId} diff --git a/ui/app/src/components/ContentTypeManagement/components/FieldChip.tsx b/ui/app/src/components/ContentTypeManagement/components/FieldChip.tsx index f032f83469..d9b609b816 100644 --- a/ui/app/src/components/ContentTypeManagement/components/FieldChip.tsx +++ b/ui/app/src/components/ContentTypeManagement/components/FieldChip.tsx @@ -166,7 +166,7 @@ export function FieldChip(props: FieldChipProps) { )} {additionalFields?.map((additionalFieldId) => ( - + ))} ); diff --git a/ui/app/src/components/FormsEngine/lib/formUtils.tsx b/ui/app/src/components/FormsEngine/lib/formUtils.tsx index 01f5176c2c..c4174835f3 100644 --- a/ui/app/src/components/FormsEngine/lib/formUtils.tsx +++ b/ui/app/src/components/FormsEngine/lib/formUtils.tsx @@ -951,7 +951,10 @@ export function composePathForType(basePath: string, fileName: string, contentTy 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}? - return additionalFields.map((additionalField) => additionalField.replaceAll('{id}', fieldId)); +// TODO: are there going to be other placeholders besides {id}? +export function processAdditionalFieldMacro(parentFieldId: string, fieldId: string): string { + return fieldId.replaceAll('{id}', parentFieldId); } From ea027b54317ea0003bc815460e5fa16cda30fdc2 Mon Sep 17 00:00:00 2001 From: jvega190 Date: Fri, 16 Jan 2026 15:42:16 -0600 Subject: [PATCH 7/9] [8500] Add additionalField to dateTime descriptor, add props for timezoneValue set/update in DateTimeTimezonePicker --- .../descriptors/controls/dateTime.ts | 3 ++- .../DateTimeTimezonePicker/DateTimeTimezonePicker.tsx | 9 ++++++++- .../src/components/FormsEngine/controls/DateTime.tsx | 11 ++++++++++- 3 files changed, 20 insertions(+), 3 deletions(-) diff --git a/ui/app/src/components/ContentTypeManagement/descriptors/controls/dateTime.ts b/ui/app/src/components/ContentTypeManagement/descriptors/controls/dateTime.ts index 6267082dd3..43fefe30f5 100644 --- a/ui/app/src/components/ContentTypeManagement/descriptors/controls/dateTime.ts +++ b/ui/app/src/components/ContentTypeManagement/descriptors/controls/dateTime.ts @@ -127,7 +127,8 @@ export const dateTimeDescriptor: DescriptorContentType = { } }, 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 4d114fdc88..b96f14767a 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); @@ -99,6 +105,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/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" From 6dcd921aa7b6df4f85eea3c88c36ae04704ed510 Mon Sep 17 00:00:00 2001 From: jvega190 Date: Tue, 28 Jul 2026 12:40:12 -0600 Subject: [PATCH 8/9] Refactor FormsEngine to improve handling of additional fields in repeat values. Ensure additional fields are correctly initialized in values and streamline the logic for gathering field IDs from descriptors. --- .../src/components/FormsEngine/FormsEngine.tsx | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/ui/app/src/components/FormsEngine/FormsEngine.tsx b/ui/app/src/components/FormsEngine/FormsEngine.tsx index cfa62a622f..9b3321b216 100644 --- a/ui/app/src/components/FormsEngine/FormsEngine.tsx +++ b/ui/app/src/components/FormsEngine/FormsEngine.tsx @@ -401,16 +401,20 @@ function FormBootstrap(props: FormsEngineProps) { ); const descriptors = { ...customControls, ...controlDescriptors }; - let additionalFieldsIds = []; + const additionalFieldsIds: string[] = []; // If repeat.values was provided, `createCleanValuesObject` didn't run; hence, atomValueCreator needs to be run manually. if (repeat.values) { // First gather all additional fields ids from the provided values - fieldsToRender.forEach((field) => { - const type = field.type; - additionalFieldsIds = [ - ...additionalFieldsIds, - ...getAdditionalFieldsIdsFromDescriptor(field.id, descriptors[type]) - ]; + (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) => { From 423d1c66cc047090f550e67c81bb6460a33756e2 Mon Sep 17 00:00:00 2001 From: jvega190 Date: Tue, 28 Jul 2026 12:40:54 -0600 Subject: [PATCH 9/9] Guard against missing descriptors when resolving additional fields. --- ui/app/src/components/FormsEngine/lib/valueRetrievers.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/ui/app/src/components/FormsEngine/lib/valueRetrievers.ts b/ui/app/src/components/FormsEngine/lib/valueRetrievers.ts index 0df8947099..48928581ca 100644 --- a/ui/app/src/components/FormsEngine/lib/valueRetrievers.ts +++ b/ui/app/src/components/FormsEngine/lib/valueRetrievers.ts @@ -120,7 +120,8 @@ export function createParsedValuesObject( // 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 additionalFieldIds = getAdditionalFieldsIdsFromDescriptor(field.id, descriptors[field.type]); + const descriptor = descriptors[field.type]; + const additionalFieldIds = descriptor ? getAdditionalFieldsIdsFromDescriptor(field.id, descriptor) : []; additionalFieldIds.forEach((additionalFieldId) => { values[additionalFieldId] = createParsedValueForField(