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 }) => ({