diff --git a/frontend/packages/shared/src/types/ComponentSpecificConfig.ts b/frontend/packages/shared/src/types/ComponentSpecificConfig.ts new file mode 100644 index 00000000000..b3918924334 --- /dev/null +++ b/frontend/packages/shared/src/types/ComponentSpecificConfig.ts @@ -0,0 +1,85 @@ +import { ComponentType } from './ComponentType'; +import { KeyValuePairs } from 'app-shared/types/KeyValuePairs'; +import { MapLayer } from 'app-shared/types/MapLayer'; +import { FormPanelVariant } from 'app-shared/types/FormPanelVariant'; + +type Option = { + label: string; + value: T; +}; + +type OptionsComponentBase = { + options?: Option[]; + preselectedOptionIndex?: number; + optionsId?: string; +}; + +type FileUploadComponentBase = { + description: string; + hasCustomFileEndings: boolean; + maxFileSizeInMB: number; + displayMode: string; + maxNumberOfAttachments: number; + minNumberOfAttachments: number; + validFileEndings?: string; +}; + +export type ComponentSpecificConfig = { + [ComponentType.Alert]: { severity: 'success' | 'info' | 'warning' | 'danger' }; + [ComponentType.Accordion]: {}; + [ComponentType.AccordionGroup]: {}; + [ComponentType.ActionButton]: {}; + [ComponentType.AddressComponent]: { simplified: boolean }; + [ComponentType.AttachmentList]: {}; + [ComponentType.Button]: { onClickAction: () => void }; + [ComponentType.ButtonGroup]: {}; + [ComponentType.Checkboxes]: OptionsComponentBase; + [ComponentType.Custom]: { tagName: string; framework: string; [id: string]: any }; + [ComponentType.Datepicker]: { timeStamp: boolean }; + [ComponentType.Dropdown]: { optionsId: string }; + [ComponentType.FileUpload]: FileUploadComponentBase; + [ComponentType.FileUploadWithTag]: FileUploadComponentBase & { optionsId: string }; + [ComponentType.Grid]: {}; + [ComponentType.Group]: { + maxCount?: number; + edit?: { + multiPage?: boolean; + mode?: string; + }; + }; + [ComponentType.Header]: { size: string }; + [ComponentType.IFrame]: {}; + [ComponentType.Image]: { + image?: { + src?: KeyValuePairs; + align?: string | null; + width?: string; + }; + }; + [ComponentType.Input]: { disabled?: boolean }; + [ComponentType.InstanceInformation]: {}; + [ComponentType.InstantiationButton]: {}; + [ComponentType.Likert]: {}; + [ComponentType.Link]: {}; + [ComponentType.List]: {}; + [ComponentType.Map]: { + centerLocation: { + latitude: number; + longitude: number; + }; + zoom: number; + layers?: MapLayer[]; + }; + [ComponentType.MultipleSelect]: {}; + [ComponentType.NavigationBar]: {}; + [ComponentType.NavigationButtons]: { showSaveButton?: boolean; showPrev?: boolean }; + [ComponentType.Panel]: { + variant: FormPanelVariant; + showIcon: boolean; + }; + [ComponentType.Paragraph]: {}; + [ComponentType.PrintButton]: {}; + [ComponentType.RadioButtons]: OptionsComponentBase; + [ComponentType.Summary]: {}; + [ComponentType.TextArea]: {}; +}[T]; diff --git a/frontend/packages/shared/src/types/FormPanelVariant.ts b/frontend/packages/shared/src/types/FormPanelVariant.ts new file mode 100644 index 00000000000..9918c629a95 --- /dev/null +++ b/frontend/packages/shared/src/types/FormPanelVariant.ts @@ -0,0 +1,5 @@ +export enum FormPanelVariant { + Info = 'info', + Warning = 'warning', + Success = 'success', +} diff --git a/frontend/packages/shared/src/types/MapLayer.ts b/frontend/packages/shared/src/types/MapLayer.ts new file mode 100644 index 00000000000..148567c71e5 --- /dev/null +++ b/frontend/packages/shared/src/types/MapLayer.ts @@ -0,0 +1,5 @@ +export interface MapLayer { + url: string; + attribution?: string; + subdomains?: string[]; +} diff --git a/frontend/packages/shared/src/types/api/FormLayoutsResponse.ts b/frontend/packages/shared/src/types/api/FormLayoutsResponse.ts index 44964e82b7a..9345fcffc9d 100644 --- a/frontend/packages/shared/src/types/api/FormLayoutsResponse.ts +++ b/frontend/packages/shared/src/types/api/FormLayoutsResponse.ts @@ -1,5 +1,6 @@ import { KeyValuePairs } from 'app-shared/types/KeyValuePairs'; import { ComponentType } from 'app-shared/types/ComponentType'; +import { ComponentSpecificConfig } from 'app-shared/types/ComponentSpecificConfig'; export type FormLayoutsResponse = KeyValuePairs; @@ -15,8 +16,15 @@ export interface ExternalData { [key: string]: any; } -export interface ExternalComponent { +type ExternalComponentBase = { id: string; - type: ComponentType; - [key: string]: any; // Todo: Set type here -} + type: T; + dataModelBindings?: KeyValuePairs; + textResourceBindings?: KeyValuePairs; + [key: string]: any; +}; + +export type ExternalComponent = { + [componentType in ComponentType]: ExternalComponentBase & + ComponentSpecificConfig; +}[T]; diff --git a/frontend/packages/shared/src/utils/objectUtils.test.ts b/frontend/packages/shared/src/utils/objectUtils.test.ts index e89f3848cea..06b58f50f35 100644 --- a/frontend/packages/shared/src/utils/objectUtils.test.ts +++ b/frontend/packages/shared/src/utils/objectUtils.test.ts @@ -1,4 +1,4 @@ -import { areObjectsEqual } from 'app-shared/utils/objectUtils'; +import { areObjectsEqual, mapByProperty } from 'app-shared/utils/objectUtils'; describe('objectUtils', () => { describe('areObjectsEqual', () => { @@ -15,4 +15,31 @@ describe('objectUtils', () => { expect(areObjectsEqual({ a: 1, b: 2, c: 3 }, { a: 1, b: 2, c: 4 })).toBe(false); }); }); + + describe('mapByProperty', () => { + const property = 'id'; + const value1 = 'value1'; + const value2 = 'value2'; + const value3 = 'value3'; + const object1 = { [property]: value1 }; + const object2 = { [property]: value2, otherProperty: 'Some irrelevant value' }; + const object3 = { [property]: value3, otherProperty: 'Another irrelevant value' }; + + it('Maps an array of objects to a key-value pair object, where the key is the value of the property', () => { + const objectList = [object1, object2, object3]; + expect(mapByProperty(objectList, property)).toEqual({ + [value1]: object1, + [value2]: object2, + [value3]: object3, + }); + }); + + it('Throws an error if the values of the given property are not unique', () => { + const object4 = { [property]: value1 }; + const objectList = [object1, object2, object3, object4]; + const expectedError = + 'The values of the given property in the mapByProperty function should be unique.'; + expect(() => mapByProperty(objectList, property)).toThrowError(expectedError); + }); + }); }); diff --git a/frontend/packages/shared/src/utils/objectUtils.ts b/frontend/packages/shared/src/utils/objectUtils.ts index 6ef1fd30107..7a83f88a3f5 100644 --- a/frontend/packages/shared/src/utils/objectUtils.ts +++ b/frontend/packages/shared/src/utils/objectUtils.ts @@ -1,3 +1,6 @@ +import { KeyValuePairs } from 'app-shared/types/KeyValuePairs'; +import { areItemsUnique } from 'app-shared/utils/arrayUtils'; + /** * Checks if two objects are equal (shallow comparison). * @param obj1 The first object. @@ -12,4 +15,23 @@ export const areObjectsEqual = (obj1: T, obj2: T): boolean => } } return true; -} +}; + +/** + * Maps an array of objects to a key-value pair object, where the key is the value of the given property. + * Requires that the values of the given property are unique. + * @param objectList + * @param property + */ +export const mapByProperty = ( + objectList: T[], + property: keyof T, +): KeyValuePairs => { + const keys = objectList.map((object) => object[property]); + if (!areItemsUnique(keys)) { + throw new Error( + 'The values of the given property in the mapByProperty function should be unique.', + ); + } + return Object.fromEntries(objectList.map((object) => [object[property], object])); +}; diff --git a/frontend/packages/ux-editor/src/components/config/componentSpecificContent/Map/MapComponent.tsx b/frontend/packages/ux-editor/src/components/config/componentSpecificContent/Map/MapComponent.tsx index 597dce8e5f3..9597873025e 100644 --- a/frontend/packages/ux-editor/src/components/config/componentSpecificContent/Map/MapComponent.tsx +++ b/frontend/packages/ux-editor/src/components/config/componentSpecificContent/Map/MapComponent.tsx @@ -6,7 +6,7 @@ import { FormField } from '../../../FormField'; import { useText } from '../../../../hooks'; import { stringToArray, arrayToString } from '../../../../utils/stringUtils'; import classes from './MapComponent.module.css'; -import type { FormMapLayer } from '../../../../types/FormComponent'; +import type { MapLayer } from 'app-shared/types/MapLayer'; export const MapComponent = ({ component, @@ -117,7 +117,7 @@ const AddMapLayer = ({ component, handleComponentChange }: AddMapLayerProps): JS }); }; - const updateLayer = (index: number, subdomains: string[]): FormMapLayer[] => { + const updateLayer = (index: number, subdomains: string[]): MapLayer[] => { return [ ...component.layers.slice(0, index), { diff --git a/frontend/packages/ux-editor/src/components/config/componentSpecificContent/Panel/PanelComponent.test.tsx b/frontend/packages/ux-editor/src/components/config/componentSpecificContent/Panel/PanelComponent.test.tsx index 6c556fcf3c9..3f1765c611f 100644 --- a/frontend/packages/ux-editor/src/components/config/componentSpecificContent/Panel/PanelComponent.test.tsx +++ b/frontend/packages/ux-editor/src/components/config/componentSpecificContent/Panel/PanelComponent.test.tsx @@ -2,20 +2,21 @@ import React from 'react'; import { act, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { PanelComponent } from './PanelComponent'; -import { FormPanelComponent, FormPanelVariant } from '../../../../types/FormComponent'; +import { FormComponent } from '../../../../types/FormComponent'; import { renderHookWithMockStore, renderWithMockStore } from '../../../../testing/mocks'; import { useLayoutSchemaQuery } from '../../../../hooks/queries/useLayoutSchemaQuery'; import { ComponentType } from 'app-shared/types/ComponentType'; import { useFormLayoutsQuery } from '../../../../hooks/queries/useFormLayoutsQuery'; import { useFormLayoutSettingsQuery } from '../../../../hooks/queries/useFormLayoutSettingsQuery'; import { textMock } from '../../../../../../../testing/mocks/i18nMock'; +import { FormPanelVariant } from 'app-shared/types/FormPanelVariant'; // Test data: const org = 'org'; const app = 'app'; const selectedLayoutSet = 'test-layout-set'; -const component: FormPanelComponent = { +const component: FormComponent = { id: '', itemType: 'COMPONENT', type: ComponentType.Panel, diff --git a/frontend/packages/ux-editor/src/components/config/componentSpecificContent/Panel/PanelComponent.tsx b/frontend/packages/ux-editor/src/components/config/componentSpecificContent/Panel/PanelComponent.tsx index e37133396b7..baf69395105 100644 --- a/frontend/packages/ux-editor/src/components/config/componentSpecificContent/Panel/PanelComponent.tsx +++ b/frontend/packages/ux-editor/src/components/config/componentSpecificContent/Panel/PanelComponent.tsx @@ -3,7 +3,7 @@ import { Switch, Select } from '@digdir/design-system-react'; import type { IGenericEditComponent } from '../../componentConfig'; import { useText } from '../../../../hooks'; import { EditTextResourceBinding } from '../../editModal/EditTextResourceBinding'; -import { FormPanelVariant } from '../../../../types/FormComponent'; +import { FormPanelVariant } from 'app-shared/types/FormPanelVariant'; import { FormField } from '../../../FormField'; export const PanelComponent = ({ component, handleComponentChange }: IGenericEditComponent) => { diff --git a/frontend/packages/ux-editor/src/converters/formLayoutConverters/externalLayoutToInternal.test.ts b/frontend/packages/ux-editor/src/converters/formLayoutConverters/externalLayoutToInternal.test.ts new file mode 100644 index 00000000000..c4c6ecdd11c --- /dev/null +++ b/frontend/packages/ux-editor/src/converters/formLayoutConverters/externalLayoutToInternal.test.ts @@ -0,0 +1,71 @@ +import { externalLayoutToInternal } from './externalLayoutToInternal'; +import { + externalLayoutWithMultiPageGroup, + internalLayoutWithMultiPageGroup, +} from '../../testing/layoutWithMultiPageGroupMocks'; +import { createEmptyLayout } from '../../utils/formLayoutUtils'; +import { ExternalFormLayout } from 'app-shared/types/api'; +import { IInternalLayout } from '../../types/global'; +import { layoutSchemaUrl } from 'app-shared/cdn-paths'; + +describe('externalLayoutToInternal', () => { + it('Converts an external layout to an internal layout', () => { + const result = externalLayoutToInternal(externalLayoutWithMultiPageGroup); + expect(result).toEqual(internalLayoutWithMultiPageGroup); + }); + + it('Returns an empty layout if the external layout is null', () => { + const result = externalLayoutToInternal(null); + expect(result).toEqual(createEmptyLayout()); + }); + + it('Returns an empty layout with custom properties when the "data" property is null', () => { + const customProperty1 = 'test1'; + const customProperty2 = 'test2'; + const externalLayout: ExternalFormLayout = { + $schema: layoutSchemaUrl(), + data: null, + customProperty1, + customProperty2, + }; + const expectedResult: IInternalLayout = { + ...createEmptyLayout(), + customRootProperties: { + customProperty1, + customProperty2, + }, + }; + const result = externalLayoutToInternal(externalLayout); + expect(result).toEqual(expectedResult); + }); + + it('Returns an empty layout with custom properties when the "layout" property within the "data" property is null', () => { + const rootCustomProperty1 = 'test1'; + const rootCustomProperty2 = 'test2'; + const dataCustomProperty1 = 'test3'; + const dataCustomProperty2 = 'test4'; + const externalLayout: ExternalFormLayout = { + $schema: layoutSchemaUrl(), + data: { + layout: null, + dataCustomProperty1, + dataCustomProperty2, + }, + rootCustomProperty1, + rootCustomProperty2, + }; + const expectedResult: IInternalLayout = { + ...createEmptyLayout(), + customRootProperties: { + rootCustomProperty1, + rootCustomProperty2, + }, + customDataProperties: { + dataCustomProperty1, + dataCustomProperty2, + }, + }; + const result = externalLayoutToInternal(externalLayout); + expect(result).toEqual(expectedResult); + }); +}); diff --git a/frontend/packages/ux-editor/src/converters/formLayoutConverters/externalLayoutToInternal.ts b/frontend/packages/ux-editor/src/converters/formLayoutConverters/externalLayoutToInternal.ts new file mode 100644 index 00000000000..2d67ef8045d --- /dev/null +++ b/frontend/packages/ux-editor/src/converters/formLayoutConverters/externalLayoutToInternal.ts @@ -0,0 +1,161 @@ +import { ExternalComponent, ExternalData, ExternalFormLayout } from 'app-shared/types/api'; +import { + IFormDesignerComponents, + IFormDesignerContainers, + IFormLayoutOrder, + IInternalLayout, + InternalLayoutComponents, + InternalLayoutData, +} from '../../types/global'; +import { ComponentType } from 'app-shared/types/ComponentType'; +import { externalSimpleComponentToInternal } from '../simpleComponentConverters'; +import { FormComponent } from '../../types/FormComponent'; +import { FormContainer } from '../../types/FormContainer'; +import { BASE_CONTAINER_ID } from 'app-shared/constants'; +import { mapByProperty } from 'app-shared/utils/objectUtils'; +import { ExternalGroupComponent } from '../../types/ExternalGroupComponent'; +import { ExternalSimpleComponent } from '../../types/ExternalSimpleComponent'; +import { externalGroupComponentToInternal } from '../groupComponentConverters'; +import { findPageIndexInChildList, removePageIndexPrefix } from './pageIndexUtils'; +import { + createEmptyComponentStructure, + createEmptyLayout, + createEmptyLayoutData, +} from '../../utils/formLayoutUtils'; + +export const externalLayoutToInternal = ( + externalLayout: ExternalFormLayout | null, +): IInternalLayout => + externalLayout ? convertExternalLayout(externalLayout) : createEmptyLayout(); + +const convertExternalLayout = (externalLayout: ExternalFormLayout): IInternalLayout => { + const customRootProperties = getCustomRootProperties(externalLayout); + const { data } = externalLayout; + const convertedData: InternalLayoutData = data + ? convertExternalData(data) + : createEmptyLayoutData(); + return { ...convertedData, customRootProperties }; +}; + +const getCustomRootProperties = (externalLayout: ExternalFormLayout) => { + const customProperties = { ...externalLayout }; + delete customProperties.data; + delete customProperties.$schema; + return customProperties; +}; + +const convertExternalData = (externalData: ExternalData): InternalLayoutData => { + const customDataProperties = getCustomDataProperties(externalData); + const { layout } = externalData; + const convertedComponents: InternalLayoutComponents = layout + ? convertExternalComponentList(layout) + : createEmptyComponentStructure(); + return { ...convertedComponents, customDataProperties }; +}; + +const getCustomDataProperties = (externalData: ExternalData) => { + const customProperties = { ...externalData }; + delete customProperties.layout; + return customProperties; +}; + +const convertExternalComponentList = ( + externalComponents: ExternalComponent[], +): InternalLayoutComponents => ({ + components: getInternalComponents(externalComponents), + containers: getInternalContainers(externalComponents), + order: getOrderOfComponents(externalComponents), +}); + +const getInternalComponents = ( + externalComponents: ExternalComponent[], +): IFormDesignerComponents => { + const convert = (component) => convertSimpleComponent(externalComponents, component); + const components: FormComponent[] = findSimpleComponents(externalComponents).map(convert); + return mapByProperty(components, 'id'); +}; + +const getInternalContainers = ( + externalComponents: ExternalComponent[], +): IFormDesignerContainers => { + const baseContainer: FormContainer = { + id: BASE_CONTAINER_ID, + index: 0, + itemType: 'CONTAINER', + pageIndex: null, + }; + const convertedContainers = getConvertedContainers(externalComponents); + const containers: FormContainer[] = [baseContainer, ...convertedContainers]; + return mapByProperty(containers, 'id'); +}; + +const getConvertedContainers = (externalComponents: ExternalComponent[]): FormContainer[] => { + const convert = (component) => convertGroupComponent(externalComponents, component); + return findGroupComponents(externalComponents).map(convert); +}; + +const getOrderOfComponents = (externalComponents: ExternalComponent[]): IFormLayoutOrder => ({ + [BASE_CONTAINER_ID]: findTopLevelComponentIds(externalComponents), + ...getChildrenIdsOfAllContainers(externalComponents), +}); + +const findSimpleComponents = (externalComponents: ExternalComponent[]): ExternalSimpleComponent[] => + externalComponents.filter( + (component) => component.type !== ComponentType.Group, + ) as ExternalSimpleComponent[]; + +const findGroupComponents = (externalComponents: ExternalComponent[]): ExternalGroupComponent[] => + externalComponents.filter( + (component) => component.type === ComponentType.Group, + ) as ExternalGroupComponent[]; + +const findTopLevelComponentIds = (externalComponents: ExternalComponent[]) => + externalComponents + .filter((component) => findParent(externalComponents, component.id) === null) + .map(({ id }) => id); + +const getChildrenIdsOfAllContainers = ( + externalComponents: ExternalComponent[], +): IFormLayoutOrder => { + const entries: [string, string[]][] = findGroupComponents(externalComponents).map((container) => [ + container.id, + getChildIds(container), + ]); + return Object.fromEntries(entries); +}; + +const convertSimpleComponent = ( + externalComponentList: ExternalComponent[], + externalComponent: ExternalSimpleComponent, +): FormComponent => { + const pageIndex = findPageIndexOfComponent(externalComponentList, externalComponent.id); + return externalSimpleComponentToInternal(externalComponent, pageIndex); +}; + +const convertGroupComponent = ( + externalComponentList: ExternalComponent[], + externalComponent: ExternalGroupComponent, +): FormContainer => { + const pageIndex = findPageIndexOfComponent(externalComponentList, externalComponent.id); + return externalGroupComponentToInternal(externalComponent, pageIndex); +}; + +const findParent = ( + externalComponents: ExternalComponent[], + id: string, +): ExternalGroupComponent | null => + findGroupComponents(externalComponents).find((container) => + getChildIds(container).includes(id), + ) ?? null; + +const findPageIndexOfComponent = ( + externalComponents: ExternalComponent[], + id: string, +): number | null => { + const parentContainer = findParent(externalComponents, id); + if (!parentContainer?.edit?.multiPage) return null; + return findPageIndexInChildList(id, parentContainer.children); +}; + +const getChildIds = ({ edit, children = [] }: ExternalGroupComponent) => + edit?.multiPage ? children.map(removePageIndexPrefix) : children; diff --git a/frontend/packages/ux-editor/src/converters/formLayoutConverters/formLayoutConverters.test.ts b/frontend/packages/ux-editor/src/converters/formLayoutConverters/formLayoutConverters.test.ts new file mode 100644 index 00000000000..e9c8dde62b3 --- /dev/null +++ b/frontend/packages/ux-editor/src/converters/formLayoutConverters/formLayoutConverters.test.ts @@ -0,0 +1,23 @@ +import { externalLayoutToInternal } from './externalLayoutToInternal'; +import { + externalLayoutWithMultiPageGroup, + internalLayoutWithMultiPageGroup, +} from '../../testing/layoutWithMultiPageGroupMocks'; +import { internalLayoutToExternal } from './internalLayoutToExternal'; +import { ExternalFormLayout } from 'app-shared/types/api'; + +describe('formLayoutConverters', () => { + test('Internal layout remains the same when converted to en external layout and back', () => { + const convertedToExternal = internalLayoutToExternal(internalLayoutWithMultiPageGroup); + const convertedBack = externalLayoutToInternal(convertedToExternal); + expect(convertedBack).toEqual(internalLayoutWithMultiPageGroup); + }); + + test('External layout that is already converted once remains the same when converted to an internal layout and back', () => { + const convertToInternalAndBack = (layout: ExternalFormLayout) => + internalLayoutToExternal(externalLayoutToInternal(layout)); + const convertedOnce = convertToInternalAndBack(externalLayoutWithMultiPageGroup); + const convertedTwice = convertToInternalAndBack(convertedOnce); + expect(convertedTwice).toEqual(convertedOnce); + }); +}); diff --git a/frontend/packages/ux-editor/src/converters/formLayoutConverters/index.ts b/frontend/packages/ux-editor/src/converters/formLayoutConverters/index.ts new file mode 100644 index 00000000000..cb377748638 --- /dev/null +++ b/frontend/packages/ux-editor/src/converters/formLayoutConverters/index.ts @@ -0,0 +1,2 @@ +export { externalLayoutToInternal } from './externalLayoutToInternal'; +export { internalLayoutToExternal } from './internalLayoutToExternal'; diff --git a/frontend/packages/ux-editor/src/converters/formLayoutConverters/internalLayoutToExternal.test.ts b/frontend/packages/ux-editor/src/converters/formLayoutConverters/internalLayoutToExternal.test.ts new file mode 100644 index 00000000000..b08559b0318 --- /dev/null +++ b/frontend/packages/ux-editor/src/converters/formLayoutConverters/internalLayoutToExternal.test.ts @@ -0,0 +1,78 @@ +import { + internalLayoutWithMultiPageGroup, + component1Id, + component2Id, + component3Id, +} from '../../testing/layoutWithMultiPageGroupMocks'; +import { internalLayoutToExternal } from './internalLayoutToExternal'; +import { BASE_CONTAINER_ID } from 'app-shared/constants'; +import { ExternalComponent } from 'app-shared/types/api'; + +describe('internalLayoutToExternal', () => { + const result = internalLayoutToExternal(internalLayoutWithMultiPageGroup); + const { layout } = result.data; + + const simpleComponentIds = Object.keys(internalLayoutWithMultiPageGroup.components); + const containerIds = Object.keys(internalLayoutWithMultiPageGroup.containers); + const relevantContainerIds = containerIds.filter((key) => key != BASE_CONTAINER_ID); + + const findInternalComponent = (id) => + internalLayoutWithMultiPageGroup.components[id] || + internalLayoutWithMultiPageGroup.containers[id]; + const findExternalComponent = (id): ExternalComponent => + layout.find((component) => component.id === id); + + it('Creates a list containing all components and containers', () => { + const numberOfSimpleComponents = simpleComponentIds.length; + const numberOfContainers = relevantContainerIds.length; + expect(layout.length).toBe(numberOfSimpleComponents + numberOfContainers); + + simpleComponentIds.forEach((id) => { + expect(layout).toContainEqual(expect.objectContaining({ id })); + }); + + relevantContainerIds.forEach((id) => { + expect(layout).toContainEqual(expect.objectContaining({ id })); + }); + }); + + it('Orders the top level components correctly', () => { + const indexOfTopLevelComponent1 = layout.findIndex( + (component) => component.id === component1Id, + ); + const indexOfTopLevelComponent2 = layout.findIndex( + (component) => component.id === component2Id, + ); + const indexOfTopLevelComponent3 = layout.findIndex( + (component) => component.id === component3Id, + ); + expect(indexOfTopLevelComponent1).toBeLessThan(indexOfTopLevelComponent2); + expect(indexOfTopLevelComponent2).toBeLessThan(indexOfTopLevelComponent3); + }); + + it("Injects children's ids and page indices to their container's `children` array", () => { + const expectedChildIdInList = (componentId: string) => { + const component = findInternalComponent(componentId); + const { pageIndex } = component; + return pageIndex === null ? componentId : `${pageIndex}:${componentId}`; + }; + relevantContainerIds.forEach((id) => { + const childrenIds = internalLayoutWithMultiPageGroup.order[id]; + const container = findExternalComponent(id); + const expectedChildrenIds = childrenIds.map(expectedChildIdInList); + expect(container.children).toEqual(expectedChildrenIds); + }); + }); + + it('Includes custom root properties', () => { + expect(result).toEqual( + expect.objectContaining(internalLayoutWithMultiPageGroup.customRootProperties), + ); + }); + + it('Includes custom data properties', () => { + expect(result.data).toEqual( + expect.objectContaining(internalLayoutWithMultiPageGroup.customDataProperties), + ); + }); +}); diff --git a/frontend/packages/ux-editor/src/converters/formLayoutConverters/internalLayoutToExternal.ts b/frontend/packages/ux-editor/src/converters/formLayoutConverters/internalLayoutToExternal.ts new file mode 100644 index 00000000000..37a55498949 --- /dev/null +++ b/frontend/packages/ux-editor/src/converters/formLayoutConverters/internalLayoutToExternal.ts @@ -0,0 +1,90 @@ +import { IInternalLayout } from '../../types/global'; +import { ExternalComponent, ExternalFormLayout } from 'app-shared/types/api'; +import { layoutSchemaUrl } from 'app-shared/cdn-paths'; +import { ExternalGroupComponent } from '../../types/ExternalGroupComponent'; +import { internalGroupComponentToExternal } from '../groupComponentConverters'; +import { FormContainer } from '../../types/FormContainer'; +import { addPageIndexPrefix } from './pageIndexUtils'; +import { internalSimpleComponentToExternal } from '../simpleComponentConverters'; +import { BASE_CONTAINER_ID } from 'app-shared/constants'; +import { CompareFunction } from 'app-shared/utils/compareFunctions'; +import { FormComponent } from '../../types/FormComponent'; + +export const internalLayoutToExternal = (internalLayout: IInternalLayout): ExternalFormLayout => ({ + $schema: layoutSchemaUrl(), + data: { + layout: generateExternalComponents(internalLayout), + ...internalLayout.customDataProperties, + }, + ...internalLayout.customRootProperties, +}); + +export const generateExternalComponents = ( + internalLayout: IInternalLayout, +): ExternalComponent[] => { + const groupComponents = getGroupComponents(internalLayout); + const simpleComponents = getSimpleComponents(internalLayout); + const allComponents = [...groupComponents, ...simpleComponents]; + const allComponentIdsInOrder = getAllComponentIdsInOrder(internalLayout); + return allComponents.sort(compareComponentsByPosition(allComponentIdsInOrder)); +}; + +const getGroupComponents = (internalLayout: IInternalLayout): ExternalGroupComponent[] => { + const convert = (container) => convertContainer(internalLayout, container); + return findRelevantContainers(internalLayout).map(convert); +}; + +const findRelevantContainers = (internalLayout: IInternalLayout): FormContainer[] => { + const predicate = (container) => container.id !== BASE_CONTAINER_ID; + return Object.values(internalLayout.containers).filter(predicate); +}; + +const convertContainer = ( + internalLayout: IInternalLayout, + container: FormContainer, +): ExternalGroupComponent => { + const children = getGroupChildrenWithPageIndex(internalLayout, container); + return internalGroupComponentToExternal(container, children); +}; + +const getGroupChildrenWithPageIndex = ( + internalLayout: IInternalLayout, + container: FormContainer, +): string[] => { + const childrenIds = internalLayout.order[container.id]; + return childrenIds.map((childId) => getComponentIdWithPageIndex(internalLayout, childId)); +}; + +const getComponentIdWithPageIndex = ( + internalLayout: IInternalLayout, + componentId: string, +): string => { + const { pageIndex } = getComponentById(internalLayout, componentId); + return pageIndex === null ? componentId : addPageIndexPrefix(componentId, pageIndex); +}; + +const getComponentById = ( + internalLayout: IInternalLayout, + componentId: string, +): FormComponent | FormContainer => + internalLayout.components[componentId] || internalLayout.containers[componentId]; + +const getSimpleComponents = (internalLayout: IInternalLayout): ExternalComponent[] => + Object.values(internalLayout.components).map(internalSimpleComponentToExternal); + +/** + * Returns a list of all component ids in the order in which they appear in the `order` property. + * This is used to ensure that components within a group are ordered the same way with respect to each other in the final array. + */ +const getAllComponentIdsInOrder = (internalLayout: IInternalLayout): string[] => { + const { order } = internalLayout; + return Object.values(order).flat(); +}; + +const compareComponentsByPosition = + (idsInOrder: string[]): CompareFunction => + (componentA: ExternalComponent, componentB: ExternalComponent) => { + const indexA = idsInOrder.indexOf(componentA.id); + const indexB = idsInOrder.indexOf(componentB.id); + return indexA - indexB; + }; diff --git a/frontend/packages/ux-editor/src/converters/formLayoutConverters/pageIndexUtils.test.ts b/frontend/packages/ux-editor/src/converters/formLayoutConverters/pageIndexUtils.test.ts new file mode 100644 index 00000000000..2c5a4561425 --- /dev/null +++ b/frontend/packages/ux-editor/src/converters/formLayoutConverters/pageIndexUtils.test.ts @@ -0,0 +1,33 @@ +import { + extractPageIndexPrefix, + findPageIndexInChildList, + removePageIndexPrefix, +} from './pageIndexUtils'; + +describe('pageIndexUtils', () => { + describe('findPageIndexInChildList', () => { + it('Finds the page index of a component in a list of child ids', () => { + const children = ['0:test', '1:otherTest']; + expect(findPageIndexInChildList('test', children)).toBe(0); + expect(findPageIndexInChildList('otherTest', children)).toBe(1); + }); + }); + + describe('removePageIndexPrefix', () => { + it('Removes the page index prefix from a prefixed component id', () => { + expect(removePageIndexPrefix('0:test')).toBe('test'); + expect(removePageIndexPrefix('1:2:3')).toBe('2:3'); + }); + + it('Does not remove anything from an unprefixed component id', () => { + expect(removePageIndexPrefix('test')).toBe('test'); + }); + }); + + describe('extractPageIndexPrefix', () => { + it('Extracts the page index prefix from a prefixed component id', () => { + expect(extractPageIndexPrefix('0:test')).toBe(0); + expect(extractPageIndexPrefix('1:2:3')).toBe(1); + }); + }); +}); diff --git a/frontend/packages/ux-editor/src/converters/formLayoutConverters/pageIndexUtils.ts b/frontend/packages/ux-editor/src/converters/formLayoutConverters/pageIndexUtils.ts new file mode 100644 index 00000000000..d7bea9553a9 --- /dev/null +++ b/frontend/packages/ux-editor/src/converters/formLayoutConverters/pageIndexUtils.ts @@ -0,0 +1,32 @@ +/** + * Finds the page index of a component in a list of children ids. + * @param id The id of the component to find the page index of. + * @param children The list of children to search in. + * @returns The page index of the component. + */ +export const findPageIndexInChildList = (id: string, children: string[]): number | null => { + const idWithPageIndex = children.find((it) => removePageIndexPrefix(it) === id); + return extractPageIndexPrefix(idWithPageIndex); +}; + +/** + * Removes the page index prefix from a component id. + * @param id The id to remove the prefix from. + * @returns The id without the prefix. + */ +export const removePageIndexPrefix = (id: string): string => id.replace(/^\d+:/, ''); + +/** + * Extracts the page index prefix from a component id. + * @param id The id to extract the prefix from. + * @returns The page index prefix. + */ +export const extractPageIndexPrefix = (id: string): number => parseInt(id.match(/^\d+:/)[0]); + +/** + * Adds a page index prefix to a component id. + * @param id The id to add the prefix to. + * @param pageIndex The page index to add. + * @returns The id with the prefix. + */ +export const addPageIndexPrefix = (id: string, pageIndex: number): string => `${pageIndex}:${id}`; diff --git a/frontend/packages/ux-editor/src/converters/groupComponentConverters/externalGroupComponentToInternal.test.ts b/frontend/packages/ux-editor/src/converters/groupComponentConverters/externalGroupComponentToInternal.test.ts new file mode 100644 index 00000000000..734d87061c4 --- /dev/null +++ b/frontend/packages/ux-editor/src/converters/groupComponentConverters/externalGroupComponentToInternal.test.ts @@ -0,0 +1,30 @@ +import { ExternalGroupComponent } from '../../types/ExternalGroupComponent'; +import { externalGroupComponentToInternal } from './externalGroupComponentToInternal'; +import { ComponentType } from 'app-shared/types/ComponentType'; + +// Test data: +const id = '1'; +const children = ['childId']; +const customProperty = 'test'; + +describe('externalGroupComponentToInternal', () => { + it.each([null, 0, 1, 2])( + 'Correctly converts an external group component with page index set to %s', + (pageIndex) => { + const externalComponent: ExternalGroupComponent = { + id, + type: ComponentType.Group, + children, + customProperty, + }; + const result = externalGroupComponentToInternal(externalComponent, pageIndex); + expect(result).toEqual({ + id, + itemType: 'CONTAINER', + pageIndex, + propertyPath: 'definitions/groupComponent', + customProperty, + }); + }, + ); +}); diff --git a/frontend/packages/ux-editor/src/converters/groupComponentConverters/externalGroupComponentToInternal.ts b/frontend/packages/ux-editor/src/converters/groupComponentConverters/externalGroupComponentToInternal.ts new file mode 100644 index 00000000000..272ae79c944 --- /dev/null +++ b/frontend/packages/ux-editor/src/converters/groupComponentConverters/externalGroupComponentToInternal.ts @@ -0,0 +1,19 @@ +import { formItemConfigs } from '../../data/formItemConfig'; +import { ExternalGroupComponent } from '../../types/ExternalGroupComponent'; +import { FormContainer } from '../../types/FormContainer'; +import { ComponentType } from 'app-shared/types/ComponentType'; + +export const externalGroupComponentToInternal = ( + externalComponent: ExternalGroupComponent, + pageIndex: number | null, +): FormContainer => { + const propertiesToKeep = { ...externalComponent }; + delete propertiesToKeep.children; + delete propertiesToKeep.type; + return { + ...propertiesToKeep, + itemType: 'CONTAINER', + propertyPath: formItemConfigs[ComponentType.Group].defaultProperties.propertyPath, + pageIndex, + }; +}; diff --git a/frontend/packages/ux-editor/src/converters/groupComponentConverters/index.ts b/frontend/packages/ux-editor/src/converters/groupComponentConverters/index.ts new file mode 100644 index 00000000000..b9c440770d3 --- /dev/null +++ b/frontend/packages/ux-editor/src/converters/groupComponentConverters/index.ts @@ -0,0 +1,2 @@ +export { externalGroupComponentToInternal } from './externalGroupComponentToInternal'; +export { internalGroupComponentToExternal } from './internalGroupComponentToExternal'; diff --git a/frontend/packages/ux-editor/src/converters/groupComponentConverters/internalGroupComponentToExternal.test.ts b/frontend/packages/ux-editor/src/converters/groupComponentConverters/internalGroupComponentToExternal.test.ts new file mode 100644 index 00000000000..f028de79ce8 --- /dev/null +++ b/frontend/packages/ux-editor/src/converters/groupComponentConverters/internalGroupComponentToExternal.test.ts @@ -0,0 +1,26 @@ +import { ComponentType } from 'app-shared/types/ComponentType'; +import { internalGroupComponentToExternal } from './internalGroupComponentToExternal'; +import { FormContainer } from '../../types/FormContainer'; + +// Test data: +const id = '1'; +const children = ['childId']; +const customProperty = 'test'; + +describe('internalGroupComponentToExternal', () => { + it('Correctly converts an internal group component', () => { + const internalGroupComponent: FormContainer = { + id, + itemType: 'CONTAINER', + pageIndex: null, + customProperty, + }; + const result = internalGroupComponentToExternal(internalGroupComponent, children); + expect(result).toEqual({ + id, + children, + type: ComponentType.Group, + customProperty, + }); + }); +}); diff --git a/frontend/packages/ux-editor/src/converters/groupComponentConverters/internalGroupComponentToExternal.ts b/frontend/packages/ux-editor/src/converters/groupComponentConverters/internalGroupComponentToExternal.ts new file mode 100644 index 00000000000..1e4bedab920 --- /dev/null +++ b/frontend/packages/ux-editor/src/converters/groupComponentConverters/internalGroupComponentToExternal.ts @@ -0,0 +1,18 @@ +import { FormContainer } from '../../types/FormContainer'; +import { ExternalGroupComponent } from '../../types/ExternalGroupComponent'; +import { ComponentType } from 'app-shared/types/ComponentType'; + +export const internalGroupComponentToExternal = ( + internalGroupComponent: FormContainer, + children: string[], +): ExternalGroupComponent => { + const propertiesToKeep = { ...internalGroupComponent }; + delete propertiesToKeep.itemType; + delete propertiesToKeep.propertyPath; + delete propertiesToKeep.pageIndex; + return { + ...propertiesToKeep, + children, + type: ComponentType.Group, + }; +}; diff --git a/frontend/packages/ux-editor/src/converters/simpleComponentConverters/externalSimpleComponentToInternal.test.ts b/frontend/packages/ux-editor/src/converters/simpleComponentConverters/externalSimpleComponentToInternal.test.ts new file mode 100644 index 00000000000..11d708cc929 --- /dev/null +++ b/frontend/packages/ux-editor/src/converters/simpleComponentConverters/externalSimpleComponentToInternal.test.ts @@ -0,0 +1,32 @@ +import { ComponentType } from 'app-shared/types/ComponentType'; +import { ExternalSimpleComponent } from '../../types/ExternalSimpleComponent'; +import { externalSimpleComponentToInternal } from './externalSimpleComponentToInternal'; +import { formItemConfigs } from '../../data/formItemConfig'; + +// Test data: +const id = '1'; +const customProperty = 'test'; +const type: ComponentType = ComponentType.Input; +const propertyPath = formItemConfigs[type].defaultProperties.propertyPath; + +describe('externalSimpleComponentToInternal', () => { + it.each([null, 0, 1, 2])( + 'Correctly converts an external simple component with page index set to %s', + (pageIndex) => { + const externalComponent: ExternalSimpleComponent = { + id, + type, + customProperty, + }; + const result = externalSimpleComponentToInternal(externalComponent, pageIndex); + expect(result).toEqual({ + id, + itemType: 'COMPONENT', + pageIndex, + propertyPath, + type, + customProperty, + }); + }, + ); +}); diff --git a/frontend/packages/ux-editor/src/converters/simpleComponentConverters/externalSimpleComponentToInternal.ts b/frontend/packages/ux-editor/src/converters/simpleComponentConverters/externalSimpleComponentToInternal.ts new file mode 100644 index 00000000000..a8adff46bb5 --- /dev/null +++ b/frontend/packages/ux-editor/src/converters/simpleComponentConverters/externalSimpleComponentToInternal.ts @@ -0,0 +1,16 @@ +import { FormComponent } from '../../types/FormComponent'; +import { formItemConfigs } from '../../data/formItemConfig'; +import { ExternalSimpleComponent } from '../../types/ExternalSimpleComponent'; + +export const externalSimpleComponentToInternal = ( + externalComponent: ExternalSimpleComponent, + pageIndex: number | null, +): FormComponent => { + const { propertyPath } = formItemConfigs[externalComponent.type].defaultProperties; + return { + ...(propertyPath ? { propertyPath } : {}), + ...externalComponent, + itemType: 'COMPONENT', + pageIndex, + }; +}; diff --git a/frontend/packages/ux-editor/src/converters/simpleComponentConverters/index.ts b/frontend/packages/ux-editor/src/converters/simpleComponentConverters/index.ts new file mode 100644 index 00000000000..53b2ebc4fbb --- /dev/null +++ b/frontend/packages/ux-editor/src/converters/simpleComponentConverters/index.ts @@ -0,0 +1,2 @@ +export { externalSimpleComponentToInternal } from './externalSimpleComponentToInternal'; +export { internalSimpleComponentToExternal } from './internalSimpleComponentToExternal'; diff --git a/frontend/packages/ux-editor/src/converters/simpleComponentConverters/internalSimpleComponentToExternal.test.ts b/frontend/packages/ux-editor/src/converters/simpleComponentConverters/internalSimpleComponentToExternal.test.ts new file mode 100644 index 00000000000..7d347b594c6 --- /dev/null +++ b/frontend/packages/ux-editor/src/converters/simpleComponentConverters/internalSimpleComponentToExternal.test.ts @@ -0,0 +1,29 @@ +import { ComponentType } from 'app-shared/types/ComponentType'; +import { FormComponent } from '../../types/FormComponent'; +import { formItemConfigs } from '../../data/formItemConfig'; +import { internalSimpleComponentToExternal } from './internalSimpleComponentToExternal'; + +// Test data: +const id = '1'; +const customProperty = 'test'; +const type: ComponentType = ComponentType.Input; +const propertyPath = formItemConfigs[type].defaultProperties.propertyPath; + +describe('internalGroupComponentToExternal', () => { + it('Correctly converts an internal simple component', () => { + const internalSimpleComponent: FormComponent = { + id, + itemType: 'COMPONENT', + pageIndex: null, + propertyPath, + type, + customProperty, + }; + const result = internalSimpleComponentToExternal(internalSimpleComponent); + expect(result).toEqual({ + id, + type, + customProperty, + }); + }); +}); diff --git a/frontend/packages/ux-editor/src/converters/simpleComponentConverters/internalSimpleComponentToExternal.ts b/frontend/packages/ux-editor/src/converters/simpleComponentConverters/internalSimpleComponentToExternal.ts new file mode 100644 index 00000000000..e86e5547156 --- /dev/null +++ b/frontend/packages/ux-editor/src/converters/simpleComponentConverters/internalSimpleComponentToExternal.ts @@ -0,0 +1,13 @@ +import { FormComponent } from '../../types/FormComponent'; +import { ExternalSimpleComponent } from '../../types/ExternalSimpleComponent'; +import { SimpleComponentType } from '../../types/SimpleComponentType'; + +export const internalSimpleComponentToExternal = ( + internalComponent: FormComponent, +): ExternalSimpleComponent => { + const propertiesToKeep = { ...internalComponent }; + delete propertiesToKeep.itemType; + delete propertiesToKeep.pageIndex; + delete propertiesToKeep.propertyPath; + return propertiesToKeep; +}; diff --git a/frontend/packages/ux-editor/src/data/formItemConfig.ts b/frontend/packages/ux-editor/src/data/formItemConfig.ts index fda79e66e0f..9f9c694ad1c 100644 --- a/frontend/packages/ux-editor/src/data/formItemConfig.ts +++ b/frontend/packages/ux-editor/src/data/formItemConfig.ts @@ -1,6 +1,6 @@ import { ComponentType } from 'app-shared/types/ComponentType'; import { FormItem } from '../types/FormItem'; -import { FormPanelVariant } from '../types/FormComponent'; +import { FormPanelVariant } from 'app-shared/types/FormPanelVariant'; import React, { RefAttributes, SVGProps } from 'react'; import ActionButtonSchema from '../testing/schemas/json/component/ActionButton.schema.v1.json'; import { @@ -187,6 +187,7 @@ export const formItemConfigs: FormItemConfigs = { id: '', itemType: 'COMPONENT', type: ComponentType.FileUpload, + description: '', displayMode: 'list', hasCustomFileEndings: false, maxFileSizeInMB: 25, @@ -202,6 +203,7 @@ export const formItemConfigs: FormItemConfigs = { id: '', itemType: 'COMPONENT', type: ComponentType.FileUploadWithTag, + description: '', displayMode: 'list', hasCustomFileEndings: false, maxFileSizeInMB: 25, @@ -226,6 +228,7 @@ export const formItemConfigs: FormItemConfigs = { [ComponentType.Group]: { name: ComponentType.Group, defaultProperties: { + id: '', itemType: 'CONTAINER', propertyPath: 'definitions/groupComponent', }, diff --git a/frontend/packages/ux-editor/src/hooks/mutations/useAddFormContainerMutation.test.ts b/frontend/packages/ux-editor/src/hooks/mutations/useAddFormContainerMutation.test.ts index 78657ba05cb..0e0eb7e6d4d 100644 --- a/frontend/packages/ux-editor/src/hooks/mutations/useAddFormContainerMutation.test.ts +++ b/frontend/packages/ux-editor/src/hooks/mutations/useAddFormContainerMutation.test.ts @@ -12,6 +12,7 @@ const app = 'app'; const id = 'testid'; const selectedLayoutSet = 'test-layout-set'; const container: FormContainer = { + id, itemType: 'CONTAINER', } const defaultArgs: AddFormContainerMutationArgs = { diff --git a/frontend/packages/ux-editor/src/hooks/mutations/useAddLayoutMutation.ts b/frontend/packages/ux-editor/src/hooks/mutations/useAddLayoutMutation.ts index 46fd6faceea..39221769a30 100644 --- a/frontend/packages/ux-editor/src/hooks/mutations/useAddLayoutMutation.ts +++ b/frontend/packages/ux-editor/src/hooks/mutations/useAddLayoutMutation.ts @@ -3,7 +3,7 @@ import { useDispatch } from 'react-redux'; import { useMutation, useQueryClient } from '@tanstack/react-query'; import { FormLayoutActions } from '../../features/formDesigner/formLayout/formLayoutSlice'; import { deepCopy } from 'app-shared/pure'; -import { convertInternalToLayoutFormat, createEmptyLayout } from '../../utils/formLayoutUtils'; +import { createEmptyLayout } from '../../utils/formLayoutUtils'; import { IInternalLayout } from '../../types/global'; import { ExternalFormLayout } from 'app-shared/types/api/FormLayoutsResponse'; import { useServicesContext } from 'app-shared/contexts/ServicesContext'; @@ -12,6 +12,7 @@ import { useFormLayoutSettingsMutation } from './useFormLayoutSettingsMutation'; import { useFormLayoutSettingsQuery } from '../queries/useFormLayoutSettingsQuery'; import { ILayoutSettings } from 'app-shared/types/global'; import { addOrRemoveNavigationButtons } from '../../utils/formLayoutsUtils'; +import { internalLayoutToExternal } from '../../converters/formLayoutConverters'; export interface AddLayoutMutationArgs { layoutName: string; @@ -27,7 +28,7 @@ export const useAddLayoutMutation = (org: string, app: string, layoutSetName: st const queryClient = useQueryClient(); const save = async (updatedLayoutName: string, updatedLayout: IInternalLayout) => { - const convertedLayout: ExternalFormLayout = convertInternalToLayoutFormat(updatedLayout); + const convertedLayout: ExternalFormLayout = internalLayoutToExternal(updatedLayout); return await saveFormLayout(org, app, updatedLayoutName, layoutSetName, convertedLayout); }; diff --git a/frontend/packages/ux-editor/src/hooks/mutations/useAddWidgetMutation.ts b/frontend/packages/ux-editor/src/hooks/mutations/useAddWidgetMutation.ts index d25831fd8fc..8fa66022ec5 100644 --- a/frontend/packages/ux-editor/src/hooks/mutations/useAddWidgetMutation.ts +++ b/frontend/packages/ux-editor/src/hooks/mutations/useAddWidgetMutation.ts @@ -1,5 +1,9 @@ -import { IFormDesignerComponents, IFormLayouts, IInternalLayout, IWidget } from '../../types/global'; -import { convertFromLayoutToInternalFormat } from '../../utils/formLayoutUtils'; +import { + IFormDesignerComponents, + IFormLayouts, + IInternalLayout, + IWidget, +} from '../../types/global'; import { useMutation, useQueryClient } from '@tanstack/react-query'; import { useSelectedFormLayoutWithName } from '../useFormLayoutsSelector'; import { deepCopy } from 'app-shared/pure'; @@ -8,7 +12,11 @@ import { useFormLayoutMutation } from './useFormLayoutMutation'; import { QueryKey } from 'app-shared/types/QueryKey'; import { BASE_CONTAINER_ID } from 'app-shared/constants'; import { useUpsertTextResourcesMutation } from 'app-shared/hooks/mutations'; -import { extractLanguagesFromWidgetTexts, extractTextsFromWidgetTextsByLanguage } from '../../utils/widgetUtils'; +import { + extractLanguagesFromWidgetTexts, + extractTextsFromWidgetTextsByLanguage, +} from '../../utils/widgetUtils'; +import { externalLayoutToInternal } from '../../converters/formLayoutConverters'; export interface AddWidgetMutationArgs { widget: IWidget; @@ -23,7 +31,7 @@ export const useAddWidgetMutation = (org: string, app: string, layoutSetName: st const queryClient = useQueryClient(); return useMutation({ mutationFn: async ({ widget, position, containerId }: AddWidgetMutationArgs) => { - const internalComponents = convertFromLayoutToInternalFormat({ + const internalComponents = externalLayoutToInternal({ data: { layout: widget.components }, $schema: null }); diff --git a/frontend/packages/ux-editor/src/hooks/mutations/useDeleteLayoutMutation.ts b/frontend/packages/ux-editor/src/hooks/mutations/useDeleteLayoutMutation.ts index ed7cd5de354..13cf6bb0e99 100644 --- a/frontend/packages/ux-editor/src/hooks/mutations/useDeleteLayoutMutation.ts +++ b/frontend/packages/ux-editor/src/hooks/mutations/useDeleteLayoutMutation.ts @@ -10,11 +10,11 @@ import { ILayoutSettings } from 'app-shared/types/global'; import { useFormLayoutSettingsMutation } from './useFormLayoutSettingsMutation'; import { useFormLayoutsQuery } from '../queries/useFormLayoutsQuery'; import { addOrRemoveNavigationButtons } from '../../utils/formLayoutsUtils'; -import { convertInternalToLayoutFormat } from '../../utils/formLayoutUtils'; import { ExternalFormLayout } from 'app-shared/types/api/FormLayoutsResponse'; import { useAddLayoutMutation } from './useAddLayoutMutation'; import { useText } from '../useText'; import { selectedLayoutNameSelector } from '../../selectors/formLayoutSelectors'; +import { internalLayoutToExternal } from '../../converters/formLayoutConverters'; export const useDeleteLayoutMutation = (org: string, app: string, layoutSetName: string) => { const { deleteFormLayout, saveFormLayout } = useServicesContext(); @@ -30,7 +30,7 @@ export const useDeleteLayoutMutation = (org: string, app: string, layoutSetName: const queryClient = useQueryClient(); const saveLayout = async (updatedLayoutName: string, updatedLayout: IInternalLayout) => { - const convertedLayout: ExternalFormLayout = convertInternalToLayoutFormat(updatedLayout); + const convertedLayout: ExternalFormLayout = internalLayoutToExternal(updatedLayout); return await saveFormLayout(org, app, updatedLayoutName, layoutSetName, convertedLayout); }; diff --git a/frontend/packages/ux-editor/src/hooks/mutations/useFormLayoutMutation.test.tsx b/frontend/packages/ux-editor/src/hooks/mutations/useFormLayoutMutation.test.tsx index 331249837bd..d2975d2936b 100644 --- a/frontend/packages/ux-editor/src/hooks/mutations/useFormLayoutMutation.test.tsx +++ b/frontend/packages/ux-editor/src/hooks/mutations/useFormLayoutMutation.test.tsx @@ -22,11 +22,12 @@ const newLayout: IInternalLayout = { type: componentType, itemType: 'COMPONENT', dataModelBindings: {}, + pageIndex: null, }, }, containers: { - [baseContaierId]: { itemType: 'CONTAINER' }, - [containerId]: { itemType: 'CONTAINER' }, + [baseContaierId]: { id: baseContaierId, itemType: 'CONTAINER', pageIndex: null }, + [containerId]: { id: containerId, itemType: 'CONTAINER', pageIndex: null }, }, order: { [baseContaierId]: [containerId], @@ -86,10 +87,7 @@ describe('useFormLayoutMutation', () => { }); }); -const renderAndMutate = ( - layout: IInternalLayout, - appContext: Partial = {}, -) => +const renderAndMutate = (layout: IInternalLayout, appContext: Partial = {}) => renderHookWithMockStore( {}, {}, diff --git a/frontend/packages/ux-editor/src/hooks/mutations/useFormLayoutMutation.ts b/frontend/packages/ux-editor/src/hooks/mutations/useFormLayoutMutation.ts index 5bdf5a9f38f..33951b9ee7b 100644 --- a/frontend/packages/ux-editor/src/hooks/mutations/useFormLayoutMutation.ts +++ b/frontend/packages/ux-editor/src/hooks/mutations/useFormLayoutMutation.ts @@ -1,11 +1,11 @@ import { useMutation, useQueryClient } from '@tanstack/react-query'; import { IFormLayouts, IInternalLayout } from '../../types/global'; -import { convertInternalToLayoutFormat } from '../../utils/formLayoutUtils'; import { QueryKey } from 'app-shared/types/QueryKey'; import { useServicesContext } from 'app-shared/contexts/ServicesContext'; import { usePreviewConnection } from 'app-shared/providers/PreviewConnectionContext'; import { ExternalFormLayout } from 'app-shared/types/api/FormLayoutsResponse'; import { useAppContext } from '../useAppContext'; +import { internalLayoutToExternal } from '../../converters/formLayoutConverters'; export const useFormLayoutMutation = ( org: string, @@ -20,7 +20,7 @@ export const useFormLayoutMutation = ( return useMutation({ mutationFn: (layout: IInternalLayout) => { - const convertedLayout: ExternalFormLayout = convertInternalToLayoutFormat(layout); + const convertedLayout: ExternalFormLayout = internalLayoutToExternal(layout); return saveFormLayout(org, app, layoutName, layoutSetName, convertedLayout).then( () => layout, ); diff --git a/frontend/packages/ux-editor/src/hooks/useFormLayoutsSelector.test.ts b/frontend/packages/ux-editor/src/hooks/useFormLayoutsSelector.test.ts index 7e15543666f..85d1d5373d4 100644 --- a/frontend/packages/ux-editor/src/hooks/useFormLayoutsSelector.test.ts +++ b/frontend/packages/ux-editor/src/hooks/useFormLayoutsSelector.test.ts @@ -1,6 +1,6 @@ import { useFormLayouts, useFormLayout, useSelectedFormLayout, useSelectedFormLayoutWithName } from './useFormLayoutsSelector'; import { renderHookWithMockStore } from '../testing/mocks'; -import { useFormLayoutsQuery } from '../hooks/queries/useFormLayoutsQuery'; +import { useFormLayoutsQuery } from './queries/useFormLayoutsQuery'; import { externalLayoutsMock, layoutMock, layout1NameMock } from '../testing/layoutMock'; import { waitFor } from '@testing-library/react'; import { convertExternalLayoutsToInternalFormat } from '../utils/formLayoutsUtils'; diff --git a/frontend/packages/ux-editor/src/selectors/formLayoutSelectors.test.ts b/frontend/packages/ux-editor/src/selectors/formLayoutSelectors.test.ts index b16b77dc239..544940a3326 100644 --- a/frontend/packages/ux-editor/src/selectors/formLayoutSelectors.test.ts +++ b/frontend/packages/ux-editor/src/selectors/formLayoutSelectors.test.ts @@ -38,10 +38,12 @@ const formLayoutsData: IFormLayouts = { [layout1Name]: { containers: { [container0Id]: { + id: container0Id, index: 0, itemType: 'CONTAINER' }, [container1Id]: { + id: container1Id, index: 1, itemType: 'CONTAINER' } @@ -62,6 +64,7 @@ const formLayoutsData: IFormLayouts = { [layout2Name]: { containers: { [container2Id]: { + id: container2Id, index: 0, itemType: 'CONTAINER' }, diff --git a/frontend/packages/ux-editor/src/testing/componentMocks.ts b/frontend/packages/ux-editor/src/testing/componentMocks.ts index 22e68c19cba..c35825f3875 100644 --- a/frontend/packages/ux-editor/src/testing/componentMocks.ts +++ b/frontend/packages/ux-editor/src/testing/componentMocks.ts @@ -1,34 +1,13 @@ -import { - FormAddressComponent, - FormAttachmentListComponent, - FormButtonComponent, - FormCheckboxesComponent, - FormComponentBase, - FormDatepickerComponent, - FormDropdownComponent, - FormFileUploaderComponent, - FormFileUploaderWithTagComponent, - FormGroupComponent, - FormHeaderComponent, - FormImageComponent, - FormInputComponent, - FormMapComponent, - FormNavigationBarComponent, - FormPanelComponent, - FormPanelVariant, - FormParagraphComponent, - FormRadioButtonsComponent, - FormTextareaComponent, - FormThirdPartyComponent, -} from '../types/FormComponent'; +import { FormComponent, FormComponentBase } from '../types/FormComponent'; import { ComponentType } from 'app-shared/types/ComponentType'; +import { FormPanelVariant } from 'app-shared/types/FormPanelVariant'; const commonProps: Pick = { id: 'test', itemType: 'COMPONENT', dataModelBindings: {}, }; -const checkboxesComponent: FormCheckboxesComponent = { +const checkboxesComponent: FormComponent = { ...commonProps, type: ComponentType.Checkboxes, options: [ @@ -38,7 +17,7 @@ const checkboxesComponent: FormCheckboxesComponent = { ], optionsId: '', }; -const radiosComponent: FormRadioButtonsComponent = { +const radiosComponent: FormComponent = { ...commonProps, type: ComponentType.RadioButtons, options: [ @@ -48,38 +27,38 @@ const radiosComponent: FormRadioButtonsComponent = { ], optionsId: '', }; -const inputComponent: FormInputComponent = { +const inputComponent: FormComponent = { ...commonProps, type: ComponentType.Input, }; -const headerComponent: FormHeaderComponent = { +const headerComponent: FormComponent = { ...commonProps, type: ComponentType.Header, size: 'medium', }; -const paragraphComponent: FormParagraphComponent = { +const paragraphComponent: FormComponent = { ...commonProps, type: ComponentType.Paragraph, }; -const imageComponent: FormImageComponent = { +const imageComponent: FormComponent = { ...commonProps, type: ComponentType.Image, }; -const datePickerComponent: FormDatepickerComponent = { +const datePickerComponent: FormComponent = { ...commonProps, type: ComponentType.Datepicker, timeStamp: true, }; -const dropdownComponent: FormDropdownComponent = { +const dropdownComponent: FormComponent = { ...commonProps, type: ComponentType.Dropdown, optionsId: '', }; -const textareaComponent: FormTextareaComponent = { +const textareaComponent: FormComponent = { ...commonProps, type: ComponentType.TextArea, }; -const fileUploaderComponent: FormFileUploaderComponent = { +const fileUploaderComponent: FormComponent = { ...commonProps, type: ComponentType.FileUpload, description: 'test', @@ -89,7 +68,7 @@ const fileUploaderComponent: FormFileUploaderComponent = { maxNumberOfAttachments: 1, minNumberOfAttachments: 1, }; -const fileUploaderWithTagComponent: FormFileUploaderWithTagComponent = { +const fileUploaderWithTagComponent: FormComponent = { ...commonProps, type: ComponentType.FileUploadWithTag, description: 'test', @@ -100,41 +79,41 @@ const fileUploaderWithTagComponent: FormFileUploaderWithTagComponent = { minNumberOfAttachments: 1, optionsId: '', }; -const buttonComponent: FormButtonComponent = { +const buttonComponent: FormComponent = { ...commonProps, type: ComponentType.Button, onClickAction: jest.fn(), }; -const addressComponent: FormAddressComponent = { +const addressComponent: FormComponent = { ...commonProps, type: ComponentType.AddressComponent, simplified: true, }; -const groupComponent: FormGroupComponent = { +const groupComponent: FormComponent = { ...commonProps, type: ComponentType.Group, }; -const navigationBarComponent: FormNavigationBarComponent = { +const navigationBarComponent: FormComponent = { ...commonProps, type: ComponentType.NavigationBar, }; -const attachmentListComponent: FormAttachmentListComponent = { +const attachmentListComponent: FormComponent = { ...commonProps, type: ComponentType.AttachmentList, }; -const thirdPartyComponent: FormThirdPartyComponent = { +const thirdPartyComponent: FormComponent = { ...commonProps, type: ComponentType.Custom, tagName: 'test', framework: 'test', }; -const panelComponent: FormPanelComponent = { +const panelComponent: FormComponent = { ...commonProps, type: ComponentType.Panel, variant: FormPanelVariant.Info, showIcon: true, }; -const mapComponent: FormMapComponent = { +const mapComponent: FormComponent = { ...commonProps, type: ComponentType.Map, centerLocation: { diff --git a/frontend/packages/ux-editor/src/testing/layoutMock.ts b/frontend/packages/ux-editor/src/testing/layoutMock.ts index 0ad8aadf083..9f5e31cf303 100644 --- a/frontend/packages/ux-editor/src/testing/layoutMock.ts +++ b/frontend/packages/ux-editor/src/testing/layoutMock.ts @@ -17,6 +17,7 @@ export const component1Mock: FormComponent = { type: component1TypeMock, itemType: 'COMPONENT', propertyPath: 'definitions/inputComponent', + pageIndex: null, }; export const component2IdMock = 'Component-2'; export const component2TypeMock = ComponentType.Paragraph; @@ -24,7 +25,7 @@ export const component2Mock: FormComponent = { id: component2IdMock, type: component2TypeMock, itemType: 'COMPONENT', - propertyPath: undefined, + pageIndex: null, }; export const container1IdMock = 'Container-1'; export const customRootPropertiesMock: KeyValuePairs = { @@ -42,11 +43,15 @@ export const layoutMock: IInternalLayout = { }, containers: { [baseContainerIdMock]: { + id: baseContainerIdMock, itemType: 'CONTAINER', index: 0, + pageIndex: null, }, [container1IdMock]: { + id: container1IdMock, itemType: 'CONTAINER', + pageIndex: null, propertyPath: 'definitions/groupComponent', }, }, diff --git a/frontend/packages/ux-editor/src/testing/layoutWithMultiPageGroupMocks.ts b/frontend/packages/ux-editor/src/testing/layoutWithMultiPageGroupMocks.ts new file mode 100644 index 00000000000..d826841bd24 --- /dev/null +++ b/frontend/packages/ux-editor/src/testing/layoutWithMultiPageGroupMocks.ts @@ -0,0 +1,179 @@ +import { ExternalComponent, ExternalFormLayout } from 'app-shared/types/api'; +import { ComponentType } from 'app-shared/types/ComponentType'; +import { IInternalLayout } from '../types/global'; +import { FormComponent } from '../types/FormComponent'; +import { FormContainer } from '../types/FormContainer'; +import { customDataPropertiesMock, customRootPropertiesMock } from './layoutMock'; +import { BASE_CONTAINER_ID } from 'app-shared/constants'; + +export const component1Id = 'component1'; +export const component2Id = 'component2'; +export const component3Id = 'component3'; +export const component3_1Id = 'component3_1'; +export const component3_2Id = 'component3_2'; +export const component3_1_1Id = 'component3_1_1'; +export const component3_1_2Id = 'component3_1_2'; +export const component3_1_3Id = 'component3_1_3'; +export const component3_1_4Id = 'component3_1_4'; + +const externalComponent1: ExternalComponent = { + id: component1Id, + type: ComponentType.Paragraph, +}; +const internalComponent1: FormComponent = { + id: component1Id, + itemType: 'COMPONENT', + pageIndex: null, + type: ComponentType.Paragraph, +}; + +const externalComponent2: ExternalComponent = { + id: component2Id, + type: ComponentType.Input, +}; +const internalComponent2: FormComponent = { + id: component2Id, + itemType: 'COMPONENT', + pageIndex: null, + propertyPath: 'definitions/inputComponent', + type: ComponentType.Input, +}; + +const externalComponent3: ExternalComponent = { + id: component3Id, + type: ComponentType.Group, + children: [component3_1Id, component3_2Id], +}; +const internalComponent3: FormContainer = { + id: component3Id, + itemType: 'CONTAINER', + pageIndex: null, + propertyPath: 'definitions/groupComponent', +}; + +const externalComponent3_1: ExternalComponent = { + id: component3_1Id, + children: [ + '0:' + component3_1_1Id, + '0:' + component3_1_2Id, + '1:' + component3_1_3Id, + '1:' + component3_1_4Id, + ], + edit: { multiPage: true }, + type: ComponentType.Group, +}; +const internalComponent3_1: FormContainer = { + edit: { multiPage: true }, + id: component3_1Id, + itemType: 'CONTAINER', + pageIndex: null, + propertyPath: 'definitions/groupComponent', +}; + +const externalComponent3_1_1: ExternalComponent = { + id: component3_1_1Id, + type: ComponentType.Paragraph, +}; +const internalComponent3_1_1: FormComponent = { + id: component3_1_1Id, + type: ComponentType.Paragraph, + itemType: 'COMPONENT', + pageIndex: 0, +}; + +const externalComponent3_1_2: ExternalComponent = { + id: component3_1_2Id, + type: ComponentType.Group, +}; +const internalComponent3_1_2: FormContainer = { + id: component3_1_2Id, + itemType: 'CONTAINER', + pageIndex: 0, + propertyPath: 'definitions/groupComponent', +}; + +const externalComponent3_1_3: ExternalComponent = { + id: component3_1_3Id, + type: ComponentType.Group, + children: [], +}; +const internalComponent3_1_3: FormContainer = { + id: component3_1_3Id, + itemType: 'CONTAINER', + pageIndex: 1, + propertyPath: 'definitions/groupComponent', +}; + +const externalComponent3_1_4: ExternalComponent = { + id: component3_1_4Id, + type: ComponentType.Paragraph, +}; +const internalComponent3_1_4: FormComponent = { + id: component3_1_4Id, + itemType: 'COMPONENT', + pageIndex: 1, + type: ComponentType.Paragraph, +}; + +const externalComponent3_2: ExternalComponent = { + id: component3_2Id, + type: ComponentType.Paragraph, +}; +const internalComponent3_2: FormComponent = { + id: component3_2Id, + type: ComponentType.Paragraph, + itemType: 'COMPONENT', + pageIndex: null, +}; + +export const externalLayoutWithMultiPageGroup: ExternalFormLayout = { + $schema: 'https://altinncdn.no/schemas/json/layout/layout.schema.v1.json', + data: { + layout: [ + externalComponent1, + externalComponent2, + externalComponent3, + externalComponent3_1, + externalComponent3_2, + externalComponent3_1_1, + externalComponent3_1_2, + externalComponent3_1_3, + externalComponent3_1_4, + ], + ...customDataPropertiesMock, + }, + ...customRootPropertiesMock, +}; + +const baseContainer: FormContainer = { + id: BASE_CONTAINER_ID, + index: 0, + itemType: 'CONTAINER', + pageIndex: null, +}; + +export const internalLayoutWithMultiPageGroup: IInternalLayout = { + components: { + [component1Id]: internalComponent1, + [component2Id]: internalComponent2, + [component3_1_1Id]: internalComponent3_1_1, + [component3_1_4Id]: internalComponent3_1_4, + [component3_2Id]: internalComponent3_2, + }, + containers: { + [BASE_CONTAINER_ID]: baseContainer, + [component3Id]: internalComponent3, + [component3_1Id]: internalComponent3_1, + [component3_1_2Id]: internalComponent3_1_2, + [component3_1_3Id]: internalComponent3_1_3, + }, + order: { + [BASE_CONTAINER_ID]: [component1Id, component2Id, component3Id], + [component3Id]: [component3_1Id, component3_2Id], + [component3_1Id]: [component3_1_1Id, component3_1_2Id, component3_1_3Id, component3_1_4Id], + [component3_1_2Id]: [], + [component3_1_3Id]: [], + }, + customRootProperties: customRootPropertiesMock, + customDataProperties: customDataPropertiesMock, +}; diff --git a/frontend/packages/ux-editor/src/types/ExternalGroupComponent.ts b/frontend/packages/ux-editor/src/types/ExternalGroupComponent.ts new file mode 100644 index 00000000000..4afa1ef2021 --- /dev/null +++ b/frontend/packages/ux-editor/src/types/ExternalGroupComponent.ts @@ -0,0 +1,4 @@ +import { ExternalComponent } from 'app-shared/types/api'; +import { ComponentType } from 'app-shared/types/ComponentType'; + +export type ExternalGroupComponent = ExternalComponent; diff --git a/frontend/packages/ux-editor/src/types/ExternalSimpleComponent.ts b/frontend/packages/ux-editor/src/types/ExternalSimpleComponent.ts new file mode 100644 index 00000000000..59607f4397c --- /dev/null +++ b/frontend/packages/ux-editor/src/types/ExternalSimpleComponent.ts @@ -0,0 +1,5 @@ +import { ExternalComponent } from 'app-shared/types/api'; +import { SimpleComponentType } from './SimpleComponentType'; + +export type ExternalSimpleComponent = + ExternalComponent; diff --git a/frontend/packages/ux-editor/src/types/FormComponent.ts b/frontend/packages/ux-editor/src/types/FormComponent.ts index 432ac95f82f..c3a4b7c1772 100644 --- a/frontend/packages/ux-editor/src/types/FormComponent.ts +++ b/frontend/packages/ux-editor/src/types/FormComponent.ts @@ -1,5 +1,7 @@ import { ComponentType } from 'app-shared/types/ComponentType'; import { IDataModelBindings, ITextResourceBindings, IOption } from './global'; +import { ComponentSpecificConfig } from 'app-shared/types/ComponentSpecificConfig'; +import { FormComponent } from '../components/FormComponent'; export interface FormComponentBase { id: string; @@ -9,6 +11,7 @@ export interface FormComponentBase { name?: string; size?: string; options?: IOption[]; + pageIndex?: number; dataModelBindings?: IDataModelBindings; textResourceBindings?: ITextResourceBindings; customType?: string; @@ -27,153 +30,17 @@ export interface FormComponentBase { propertyPath?: string; } -export interface FormAlertComponent extends FormComponentBase { - severity: 'success' | 'info' | 'warning' | 'danger'; -} - -export type FormAccordionComponent = FormComponentBase; -export type FormAccordionGroupComponent = FormComponentBase; -interface FormOptionsComponentBase extends FormComponentBase { - options?: IOption[]; - preselectedOptionIndex?: number; - optionsId?: string; -} - -export interface FormHeaderComponent extends FormComponentBase { - size: string; // Todo: We need to distinguish between size and level -} - -export type FormParagraphComponent = FormComponentBase; - -export interface FormInputComponent extends FormComponentBase { - disabled?: boolean; -} - -export interface FormImageComponent extends FormComponentBase { - image?: { - src?: { - [lang: string]: string; - }; - align?: string | null; - width?: string; - }; -} - -export interface FormDatepickerComponent extends FormComponentBase { - timeStamp: boolean; -} - -export interface FormDropdownComponent extends FormComponentBase { - optionsId: string; -} - -export type FormCheckboxesComponent = FormOptionsComponentBase; -export type FormRadioButtonsComponent = FormOptionsComponentBase; -export type FormTextareaComponent = FormComponentBase; - -export interface FormFileUploaderComponent extends FormComponentBase { - hasCustomFileEndings: boolean; - maxFileSizeInMB: number; - displayMode: string; - maxNumberOfAttachments: number; - minNumberOfAttachments: number; - validFileEndings?: string; -} - -export interface FormFileUploaderWithTagComponent - extends FormComponentBase { - hasCustomFileEndings: boolean; - maxFileSizeInMB: number; - maxNumberOfAttachments: number; - minNumberOfAttachments: number; - validFileEndings?: string; - optionsId: string; -} - -export interface FormButtonComponent - extends FormComponentBase { - onClickAction: () => void; -} - -export interface FormNavigationButtonsComponent extends FormButtonComponent { - showBackButton?: boolean; - showPrev?: boolean; -} - -export interface FormAddressComponent extends FormComponentBase { - simplified: boolean; -} - -export type FormGroupComponent = FormComponentBase; -export type FormNavigationBarComponent = FormComponentBase; -export type FormAttachmentListComponent = FormComponentBase; - -export interface FormThirdPartyComponent extends FormComponentBase { - tagName: string; - framework: string; - [id: string]: any; -} - -export enum FormPanelVariant { - Info = 'info', - Warning = 'warning', - Success = 'success', -} - -export interface FormPanelComponent extends FormComponentBase { - variant: FormPanelVariant; - showIcon: boolean; -} - -export interface FormMapLayer { - url: string; - attribution?: string; - subdomains?: string[]; -} - -export interface FormMapComponent extends FormComponentBase { - centerLocation: { - latitude: number; - longitude: number; - }; - zoom: number; - layers?: FormMapLayer[]; -} +export type FormImageComponent = FormComponent; +export type FormCheckboxesComponent = FormComponent; +export type FormRadioButtonsComponent = FormComponent; +export type FormFileUploaderComponent = FormComponent; +export type FormFileUploaderWithTagComponent = FormComponent; +export type FormButtonComponent = FormComponent< + ComponentType.Button | ComponentType.NavigationButtons +>; +export type FormAddressComponent = FormComponent; export type FormComponent = { - [ComponentType.Alert]: FormAlertComponent; - [ComponentType.Accordion]: FormAccordionComponent; - [ComponentType.AccordionGroup]: FormAccordionGroupComponent; - [ComponentType.ActionButton]: FormComponentBase; - [ComponentType.AddressComponent]: FormAddressComponent; - [ComponentType.AttachmentList]: FormAttachmentListComponent; - [ComponentType.Button]: FormButtonComponent; - [ComponentType.ButtonGroup]: FormComponentBase; - [ComponentType.Checkboxes]: FormCheckboxesComponent; - [ComponentType.Custom]: FormThirdPartyComponent; - [ComponentType.Datepicker]: FormDatepickerComponent; - [ComponentType.Dropdown]: FormDropdownComponent; - [ComponentType.FileUploadWithTag]: FormFileUploaderWithTagComponent; - [ComponentType.FileUpload]: FormFileUploaderComponent; - [ComponentType.Grid]: FormComponentBase; - [ComponentType.Group]: FormGroupComponent; - [ComponentType.Header]: FormHeaderComponent; - [ComponentType.IFrame]: FormComponentBase; - [ComponentType.Image]: FormImageComponent; - [ComponentType.Input]: FormInputComponent; - [ComponentType.InstanceInformation]: FormComponentBase; - [ComponentType.InstantiationButton]: FormComponentBase; - [ComponentType.Likert]: FormComponentBase; - [ComponentType.Link]: FormComponentBase; - [ComponentType.List]: FormComponentBase; - [ComponentType.Map]: FormMapComponent; - [ComponentType.MultipleSelect]: FormComponentBase; - [ComponentType.NavigationBar]: FormNavigationBarComponent; - [ComponentType.NavigationButtons]: FormNavigationButtonsComponent; - [ComponentType.Panel]: FormPanelComponent; - [ComponentType.Paragraph]: FormParagraphComponent; - [ComponentType.PrintButton]: FormComponentBase; - [ComponentType.RadioButtons]: FormRadioButtonsComponent; - [ComponentType.Summary]: FormComponentBase; - [ComponentType.TextArea]: FormTextareaComponent; + [componentType in ComponentType]: FormComponentBase & + ComponentSpecificConfig; }[T]; diff --git a/frontend/packages/ux-editor/src/types/FormContainer.ts b/frontend/packages/ux-editor/src/types/FormContainer.ts index 59b9655b0b2..1b006ccb652 100644 --- a/frontend/packages/ux-editor/src/types/FormContainer.ts +++ b/frontend/packages/ux-editor/src/types/FormContainer.ts @@ -3,10 +3,11 @@ import { KeyValuePairs } from 'app-shared/types/KeyValuePairs'; export interface FormContainer { dataModelBindings?: IDataModelBindings; - id?: string; + id: string; index?: number; itemType: 'CONTAINER'; maxCount?: number; + pageIndex?: number; tableHeaders?: string[]; textResourceBindings?: ITextResourceBindings; propertyPath?: string; diff --git a/frontend/packages/ux-editor/src/types/SimpleComponentType.ts b/frontend/packages/ux-editor/src/types/SimpleComponentType.ts new file mode 100644 index 00000000000..2c42dbff7ee --- /dev/null +++ b/frontend/packages/ux-editor/src/types/SimpleComponentType.ts @@ -0,0 +1,3 @@ +import { ComponentType } from 'app-shared/types/ComponentType'; + +export type SimpleComponentType = Exclude; diff --git a/frontend/packages/ux-editor/src/types/global.ts b/frontend/packages/ux-editor/src/types/global.ts index 478759878e9..4044c1cb8bc 100644 --- a/frontend/packages/ux-editor/src/types/global.ts +++ b/frontend/packages/ux-editor/src/types/global.ts @@ -34,6 +34,9 @@ export interface IInternalLayout { customDataProperties: KeyValuePairs; } +export type InternalLayoutData = Omit; +export type InternalLayoutComponents = Omit; + export interface IInternalLayoutWithName { layout: IInternalLayout; layoutName: string; diff --git a/frontend/packages/ux-editor/src/utils/component.ts b/frontend/packages/ux-editor/src/utils/component.ts index 8c7a5edbf7b..ba375b62838 100644 --- a/frontend/packages/ux-editor/src/utils/component.ts +++ b/frontend/packages/ux-editor/src/utils/component.ts @@ -92,7 +92,7 @@ export const generateRandomOption = (): IOption => */ export const generateFormItem = (type: T, id: string): FormItem => { const { defaultProperties } = formItemConfigs[type]; - return type === ComponentType.Group ? defaultProperties : { ...defaultProperties, id }; + return { ...defaultProperties, id }; }; /** diff --git a/frontend/packages/ux-editor/src/utils/formLayoutUtils.test.tsx b/frontend/packages/ux-editor/src/utils/formLayoutUtils.test.tsx index 78519b3fd18..bce2c787382 100644 --- a/frontend/packages/ux-editor/src/utils/formLayoutUtils.test.tsx +++ b/frontend/packages/ux-editor/src/utils/formLayoutUtils.test.tsx @@ -3,10 +3,7 @@ import { addContainer, addItemOfType, addNavigationButtons, - convertFromLayoutToInternalFormat, - convertInternalToLayoutFormat, createEmptyLayout, - extractChildrenFromGroup, findParentId, getDepth, hasNavigationButtons, @@ -22,24 +19,26 @@ import { ComponentType } from 'app-shared/types/ComponentType'; import { IInternalLayout } from '../types/global'; import { BASE_CONTAINER_ID } from 'app-shared/constants'; import { customDataPropertiesMock, customRootPropertiesMock } from '../testing/layoutMock'; -import type { - FormButtonComponent, - FormComponent, - FormHeaderComponent, - FormParagraphComponent, -} from '../types/FormComponent'; +import type { FormComponent } from '../types/FormComponent'; import { FormContainer } from '../types/FormContainer'; -import { ExternalComponent, ExternalFormLayout } from 'app-shared/types/api/FormLayoutsResponse'; import { deepCopy } from 'app-shared/pure'; +import { + internalLayoutWithMultiPageGroup, + component3_1_1Id, + component3_1Id, + component3_2Id, + component3Id, +} from '../testing/layoutWithMultiPageGroupMocks'; // Test data: const baseContainer: FormContainer = { + id: BASE_CONTAINER_ID, index: 0, itemType: 'CONTAINER', }; const customProperty = 'some-custom-property'; const headerId = '46882e2b-8097-4170-ad4c-32cdc156634e'; -const headerComponent: FormHeaderComponent = { +const headerComponent: FormComponent = { id: headerId, type: ComponentType.Header, itemType: 'COMPONENT', @@ -50,7 +49,7 @@ const headerComponent: FormHeaderComponent = { size: 'L', }; const paragraphId = 'ede0b05d-2c53-4feb-bdd4-4c61b89bd729'; -const paragraphComponent: FormParagraphComponent = { +const paragraphComponent: FormComponent = { id: paragraphId, type: ComponentType.Paragraph, itemType: 'COMPONENT', @@ -63,10 +62,11 @@ const paragraphComponent: FormParagraphComponent = { const groupId = 'group-container'; const groupContainer: FormContainer = { dataModelBindings: {}, + id: groupId, itemType: 'CONTAINER', }; const paragraphInGroupId = 'group-paragraph'; -const paragraphInGroupComponent: FormParagraphComponent = { +const paragraphInGroupComponent: FormComponent = { id: paragraphInGroupId, type: ComponentType.Paragraph, itemType: 'COMPONENT', @@ -78,10 +78,11 @@ const paragraphInGroupComponent: FormParagraphComponent = { const groupInGroupId = 'group-child-container'; const groupInGroupContainer: FormContainer = { dataModelBindings: {}, + id: groupInGroupId, itemType: 'CONTAINER', }; const paragraphInGroupInGroupId = 'group-child-paragraph'; -const paragraphInGroupInGroupComponent: FormParagraphComponent = { +const paragraphInGroupInGroupComponent: FormComponent = { id: paragraphInGroupInGroupId, type: ComponentType.Paragraph, itemType: 'COMPONENT', @@ -112,320 +113,10 @@ const mockInternal: IInternalLayout = { }; describe('formLayoutUtils', () => { - let mockLayout: ExternalFormLayout; - - beforeEach(() => { - mockLayout = { - $schema: null, - data: { - layout: [ - { - id: '17314adc-f75d-4a49-b726-242e2ae32ad2', - type: ComponentType.Input, - textResourceBindings: { - title: 'Input', - }, - dataModelBindings: {}, - required: false, - readOnly: false, - }, - { - id: '68a15abf-3a55-4cc6-b9cc-9bfa5fe9b51a', - type: ComponentType.Input, - textResourceBindings: { - title: 'Input', - }, - dataModelBindings: {}, - required: false, - readOnly: false, - customProperty, - }, - ], - ...customDataPropertiesMock, - }, - hidden: undefined, - ...customRootPropertiesMock, - }; - }); - - describe('convertFromLayoutToInternalFormat', () => { - it('should convert to correct format', () => { - const convertedLayout = convertFromLayoutToInternalFormat(mockLayout); - const expectedResult: IInternalLayout = { - components: { - '17314adc-f75d-4a49-b726-242e2ae32ad2': { - dataModelBindings: {}, - id: '17314adc-f75d-4a49-b726-242e2ae32ad2', - itemType: 'COMPONENT', - propertyPath: 'definitions/inputComponent', - readOnly: false, - required: false, - textResourceBindings: { - title: 'Input', - }, - type: ComponentType.Input, - }, - '68a15abf-3a55-4cc6-b9cc-9bfa5fe9b51a': { - dataModelBindings: {}, - id: '68a15abf-3a55-4cc6-b9cc-9bfa5fe9b51a', - itemType: 'COMPONENT', - propertyPath: 'definitions/inputComponent', - readOnly: false, - required: false, - textResourceBindings: { - title: 'Input', - }, - type: ComponentType.Input, - customProperty, - }, - }, - containers: { - [BASE_CONTAINER_ID]: { - itemType: 'CONTAINER', - }, - }, - order: { - [BASE_CONTAINER_ID]: [ - '17314adc-f75d-4a49-b726-242e2ae32ad2', - '68a15abf-3a55-4cc6-b9cc-9bfa5fe9b51a', - ], - }, - customDataProperties: customDataPropertiesMock, - customRootProperties: customRootPropertiesMock, - }; - - expect(convertedLayout.components).toEqual(expectedResult.components); - }); - - it('should initiate a form layout with a base container', () => { - const convertedLayout = convertFromLayoutToInternalFormat(null); - expect(Object.keys(convertedLayout.containers).length).toEqual(1); - expect(Object.keys(convertedLayout.components).length).toEqual(0); - expect(Object.keys(convertedLayout.order).length).toEqual(1); - }); - - it('if the element contains children, extractChildrenFromContainer should run', () => { - mockLayout.data.layout = [ - { - id: 'mockChildID_1', - type: ComponentType.Group, - children: ['mockChildID_2'], - }, - { - id: 'mockChildID_3', - type: ComponentType.Group, - children: ['mockChildID_4', 'mockChildID_6'], - }, - { - id: 'mockChildID_2', - type: ComponentType.Header, - dataModelBindings: {}, - someProp: '2', - size: 'normal', - }, - { - id: 'mockChildID_4', - type: ComponentType.Paragraph, - dataModelBindings: {}, - someProp: '4', - }, - { - id: 'mockChildID_5', - type: ComponentType.Dropdown, - dataModelBindings: {}, - someProp: '5', - optionsId: 'mockChildID_5_options', - }, - { - id: 'mockChildID_6', - type: ComponentType.Group, - children: ['mockChildID_7'], - }, - { - id: 'mockChildID_7', - type: ComponentType.Input, - dataModelBindings: {}, - someProp: '7', - }, - ]; - const expectedComponentResult: IInternalLayout = { - containers: { - mockChildID_1: { itemType: 'CONTAINER' }, - mockChildID_3: { itemType: 'CONTAINER' }, - mockChildID_6: { itemType: 'CONTAINER' }, - }, - components: { - mockChildID_2: { - id: 'mockChildID_2', - someProp: '2', - type: ComponentType.Header, - itemType: 'COMPONENT', - propertyPath: 'definitions/headerComponent', - size: 'normal', - dataModelBindings: {}, - }, - mockChildID_4: { - id: 'mockChildID_4', - someProp: '4', - type: ComponentType.Paragraph, - itemType: 'COMPONENT', - propertyPath: undefined, - dataModelBindings: {}, - }, - mockChildID_5: { - id: 'mockChildID_5', - someProp: '5', - type: ComponentType.Dropdown, - itemType: 'COMPONENT', - propertyPath: 'definitions/selectionComponents', - optionsId: 'mockChildID_5_options', - dataModelBindings: {}, - }, - mockChildID_7: { - id: 'mockChildID_7', - someProp: '7', - type: ComponentType.Input, - itemType: 'COMPONENT', - propertyPath: 'definitions/inputComponent', - dataModelBindings: {}, - }, - }, - order: { - [BASE_CONTAINER_ID]: ['mockChildID_1', 'mockChildID_3', 'mockChildID_5'], - mockChildID_1: ['mockChildID_2'], - mockChildID_3: ['mockChildID_4', 'mockChildID_6'], - mockChildID_6: ['mockChildID_7'], - }, - customRootProperties: customRootPropertiesMock, - customDataProperties: customDataPropertiesMock, - }; - - const convertedLayout = convertFromLayoutToInternalFormat(mockLayout); - expect(convertedLayout.components).toEqual(expectedComponentResult.components); - expect(Object.keys(convertedLayout.order).length).toEqual(4); - }); - }); - - describe('convertInternalToLayoutFormat', () => { - it('should convert to correct format', () => { - const convertedLayout = convertInternalToLayoutFormat(mockInternal); - const expectedResult: ExternalFormLayout = { - $schema: 'https://altinncdn.no/schemas/json/layout/layout.schema.v1.json', - data: { - layout: [ - { - id: headerId, - type: ComponentType.Header, - textResourceBindings: { title: 'ServiceName' }, - dataModelBindings: {}, - size: 'L', - }, - { - id: paragraphId, - type: ComponentType.Paragraph, - textResourceBindings: { title: 'ServiceName' }, - dataModelBindings: {}, - customProperty, - }, - { - id: groupId, - type: ComponentType.Group, - dataModelBindings: {}, - children: [paragraphInGroupId, groupInGroupId], - }, - { - id: paragraphInGroupId, - type: ComponentType.Paragraph, - textResourceBindings: { title: 'ServiceName' }, - dataModelBindings: {}, - }, - { - id: groupInGroupId, - type: ComponentType.Group, - dataModelBindings: {}, - children: [paragraphInGroupInGroupId], - }, - { - id: paragraphInGroupInGroupId, - type: ComponentType.Paragraph, - textResourceBindings: { title: 'ServiceName' }, - dataModelBindings: {}, - }, - ], - ...customDataPropertiesMock, - }, - ...customRootPropertiesMock, - }; - expect(Array.isArray(convertedLayout.data.layout)).toBe(true); - expect(convertedLayout).toEqual(expectedResult); - }); - }); - - describe('extractChildrenFromGroup', () => { - it('should return all children from a container', () => { - const mockGroup: ExternalComponent = { - id: 'mock-group-id', - type: ComponentType.Group, - children: ['mock-component-1', 'mock-component-2'], - }; - const mockComponents: ExternalComponent[] = [ - { - id: 'mock-component-1', - type: ComponentType.Header, - dataModelBindings: {}, - someProp: '1', - size: 'normal', - }, - { - id: 'mock-component-2', - type: ComponentType.Paragraph, - dataModelBindings: {}, - someProp: '2', - }, - ]; - const mockConvertedLayout: IInternalLayout = { - containers: {}, - components: {}, - order: {}, - customRootProperties: {}, - customDataProperties: {}, - }; - const expectedConvertedLayoutResult: IInternalLayout = { - containers: { - 'mock-group-id': { itemType: 'CONTAINER', propertyPath: 'definitions/groupComponent' }, - }, - components: { - 'mock-component-1': { - someProp: '1', - itemType: 'COMPONENT', - propertyPath: 'definitions/headerComponent', - type: ComponentType.Header, - id: 'mock-component-1', - size: 'normal', - dataModelBindings: {}, - }, - 'mock-component-2': { - someProp: '2', - itemType: 'COMPONENT', - propertyPath: undefined, - type: ComponentType.Paragraph, - id: 'mock-component-2', - dataModelBindings: {}, - }, - }, - order: { 'mock-group-id': ['mock-component-1', 'mock-component-2'] }, - customRootProperties: {}, - customDataProperties: {}, - }; - extractChildrenFromGroup(mockGroup, mockComponents, mockConvertedLayout); - expect(mockConvertedLayout).toEqual(expectedConvertedLayoutResult); - }); - }); - describe('hasNavigationButtons', () => { it('Returns true if navigation buttons are present', () => { const navigationButtonsId = 'navigationButtons'; - const navigationButtonsComponent: FormButtonComponent = { + const navigationButtonsComponent: FormComponent = { id: navigationButtonsId, itemType: 'COMPONENT', onClickAction: jest.fn(), @@ -472,7 +163,7 @@ describe('formLayoutUtils', () => { }); describe('addComponent', () => { - const newComponent: FormParagraphComponent = { + const newComponent: FormComponent = { id: 'newComponent', type: ComponentType.Paragraph, itemType: 'COMPONENT', @@ -495,11 +186,34 @@ describe('formLayoutUtils', () => { expect(layout.order[groupId][position]).toEqual(newComponent.id); expect(layout.order[groupId].length).toEqual(mockInternal.order[groupId].length + 1); }); + + it('Sets pageIndex to null if the parent element is not multipage', () => { + const layout = addComponent(mockInternal, newComponent, groupId); + expect(layout.components[newComponent.id].pageIndex).toBeNull(); + }); + + it.each([ + [undefined, 1], + [0, 0], + [1, 0], + [3, 1], + ])( + 'Adds component to the same page as the previous element in the same group when the position is %s', + (position, expectedPageIndex) => { + const layout = addComponent( + internalLayoutWithMultiPageGroup, + newComponent, + component3_1Id, + position, + ); + expect(layout.components[newComponent.id].pageIndex).toEqual(expectedPageIndex); + }, + ); }); describe('addContainer', () => { const id = 'testId'; - const newContainer: FormContainer = { itemType: 'CONTAINER' }; + const newContainer: FormContainer = { id, itemType: 'CONTAINER' }; it('Adds container to the end of the base container by default', () => { const layout = addContainer(mockInternal, newContainer, id); @@ -519,6 +233,30 @@ describe('formLayoutUtils', () => { expect(layout.order[groupId].length).toEqual(mockInternal.order[groupId].length + 1); expect(layout.order[id]).toEqual([]); }); + + it('Sets pageIndex to null if the parent element is not multipage', () => { + const layout = addContainer(mockInternal, newContainer, id, groupId); + expect(layout.containers[id].pageIndex).toBeNull(); + }); + + it.each([ + [undefined, 1], + [0, 0], + [1, 0], + [3, 1], + ])( + 'Adds container to the same page as the previous element in the same group when the position is %s', + (position, expectedPageIndex) => { + const layout = addContainer( + internalLayoutWithMultiPageGroup, + newContainer, + id, + component3_1Id, + position, + ); + expect(layout.containers[id].pageIndex).toEqual(expectedPageIndex); + }, + ); }); describe('updateContainer', () => { @@ -584,6 +322,7 @@ describe('formLayoutUtils', () => { 'showBackButton', 'textResourceBindings', 'type', + 'pageIndex', ]; expect(Object.keys(navButtonsComponent)).toEqual(expectedProperties); @@ -599,6 +338,26 @@ describe('formLayoutUtils', () => { paragraphInGroupId, ]); // Checks that paragraphInGroupId is now in the desired position of the target group }); + + it('Adds page index if the item is moved to a multipage group', () => { + const updatedLayout = moveLayoutItem( + internalLayoutWithMultiPageGroup, + component3_2Id, + component3_1Id, + 1, + ); + expect(updatedLayout.components[component3_2Id].pageIndex).toEqual(0); + }); + + it('Removes page index if the item is moved to a regular group', () => { + const updatedLayout = moveLayoutItem( + internalLayoutWithMultiPageGroup, + component3_1_1Id, + component3Id, + 0, + ); + expect(updatedLayout.components[component3_1_1Id].pageIndex).toBeNull(); + }); }); describe('addItemOfType', () => { @@ -652,13 +411,15 @@ describe('formLayoutUtils', () => { }); it('Returns 1 if there is a group', () => { - const container: FormContainer = { itemType: 'CONTAINER' }; - const layout: IInternalLayout = addContainer(createEmptyLayout(), container, 'test'); + const id = 'test'; + const container: FormContainer = { id, itemType: 'CONTAINER', pageIndex: null }; + const layout: IInternalLayout = addContainer(createEmptyLayout(), container, id); expect(getDepth(layout)).toBe(1); }); it('Returns 1 if there is a group with components only', () => { - const container: FormContainer = { itemType: 'CONTAINER' }; + const id = 'test'; + const container: FormContainer = { id, itemType: 'CONTAINER', pageIndex: null }; const containerId = 'sometestgroup'; const component: FormComponent = { itemType: 'COMPONENT', @@ -677,7 +438,10 @@ describe('formLayoutUtils', () => { it('Returns 3 if there is a group within a group within a group', () => { let layout = deepCopy(mockInternal); - const container: FormContainer = { itemType: 'CONTAINER' }; + const container: FormContainer = { + id: groupInGroupId, + itemType: 'CONTAINER', + }; layout = addContainer(layout, container, 'groupingroupingroup', groupInGroupId); expect(getDepth(layout)).toBe(3); }); @@ -690,7 +454,10 @@ describe('formLayoutUtils', () => { it('Returns false if the depth is invalid', () => { let layout = deepCopy(mockInternal); - const container: FormContainer = { itemType: 'CONTAINER' }; + const container: FormContainer = { + id: groupInGroupId, + itemType: 'CONTAINER', + }; layout = addContainer(layout, container, 'groupingroupingroup', groupInGroupId); expect(validateDepth(layout)).toBe(false); }); diff --git a/frontend/packages/ux-editor/src/utils/formLayoutUtils.ts b/frontend/packages/ux-editor/src/utils/formLayoutUtils.ts index c32c33f9841..d1364789694 100644 --- a/frontend/packages/ux-editor/src/utils/formLayoutUtils.ts +++ b/frontend/packages/ux-editor/src/utils/formLayoutUtils.ts @@ -2,203 +2,19 @@ import { ComponentType } from 'app-shared/types/ComponentType'; import type { IFormDesignerComponents, IFormDesignerContainers, - IFormLayoutOrder, IInternalLayout, + InternalLayoutComponents, + InternalLayoutData, IToolbarElement, } from '../types/global'; import { BASE_CONTAINER_ID, MAX_NESTED_GROUP_LEVEL } from 'app-shared/constants'; import { deepCopy } from 'app-shared/pure'; import { insertArrayElementAtPos, removeItemByValue } from 'app-shared/utils/arrayUtils'; -import { layoutSchemaUrl } from 'app-shared/cdn-paths'; -import { KeyValuePairs } from 'app-shared/types/KeyValuePairs'; import { FormComponent } from '../types/FormComponent'; import { generateFormItem } from './component'; -import { FormItemConfigs, formItemConfigs } from '../data/formItemConfig'; +import { FormItemConfigs } from '../data/formItemConfig'; import { FormContainer } from '../types/FormContainer'; -import { ExternalComponent, ExternalFormLayout } from 'app-shared/types/api/FormLayoutsResponse'; - -export function convertFromLayoutToInternalFormat(formLayout: ExternalFormLayout): IInternalLayout { - const convertedLayout: IInternalLayout = createEmptyLayout(); - - if (!formLayout || !formLayout.data) return convertedLayout; - - const formLayoutCopy: ExternalFormLayout = deepCopy(formLayout); - const { data, $schema, ...customRootProperties } = formLayoutCopy; - const { layout, ...customDataProperties } = data; - - for (const element of topLevelComponents(layout)) { - if (element.type !== ComponentType.Group) { - const { id, ...rest } = element; - if (!rest.type && rest.component) { - rest.type = rest.component; - delete rest.component; - } - rest.itemType = 'COMPONENT'; - rest.propertyPath = formItemConfigs[rest.type].defaultProperties.propertyPath; - convertedLayout.components[id] = { - id, - ...rest, - } as FormComponent; - convertedLayout.order[BASE_CONTAINER_ID].push(id); - } else { - extractChildrenFromGroup(element, layout, convertedLayout); - convertedLayout.order[BASE_CONTAINER_ID].push(element.id); - } - } - return { - ...convertedLayout, - customRootProperties, - customDataProperties, - }; -} - -/** - * Takes a layout and removes the components in it that belong to groups. This returns - * only the top-level layout components. - */ -export function topLevelComponents(layout: ExternalComponent[]): ExternalComponent[] { - const inGroup = new Set(); - layout.forEach((component) => { - if (component.type === ComponentType.Group) { - const childList = component.edit?.multiPage - ? component.children?.map((childId) => childId.split(':')[1] || childId) - : component.children; - childList?.forEach((childId) => inGroup.add(childId)); - } - }); - return layout.filter((component) => !inGroup.has(component.id)); -} - -/** - * Creates an external form layout with the given components. - * @param layout The components to add to the layout. - * @param customRootProperties Custom properties to add to the root of the layout. - * @param customDataProperties Custom properties to add to the data object of the layout. - * @returns The external form layout. - */ -const createExternalLayout = ( - layout: ExternalComponent[], - customRootProperties: KeyValuePairs, - customDataProperties: KeyValuePairs, -): ExternalFormLayout => ({ - ...customRootProperties, - $schema: layoutSchemaUrl(), - data: { ...customDataProperties, layout }, -}); - -export function convertInternalToLayoutFormat(internalFormat: IInternalLayout): ExternalFormLayout { - const formLayout: ExternalComponent[] = []; - - if (!internalFormat) return createExternalLayout(formLayout, {}, {}); - - const { components, containers, order, customRootProperties, customDataProperties } = - deepCopy(internalFormat); - - if (!containers) - return createExternalLayout(formLayout, customRootProperties, customDataProperties); - - const containerIds = Object.keys(containers); - if (!containerIds.length) - return createExternalLayout(formLayout, customRootProperties, customDataProperties); - - let groupChildren: string[] = []; - Object.keys(order).forEach((groupKey: string) => { - if (groupKey !== BASE_CONTAINER_ID) { - groupChildren = groupChildren.concat(order[groupKey]); - } - }); - - for (const id of order[BASE_CONTAINER_ID]) { - if (components[id] && !groupChildren.includes(id)) { - delete components[id].itemType; - delete components[id].propertyPath; - formLayout.push({ - id, - type: components[id].type, - ...components[id], - }); - } else if (containers[id]) { - const { itemType, propertyPath, ...restOfGroup } = containers[id]; - formLayout.push({ - id, - type: ComponentType.Group, - children: order[id], - ...restOfGroup, - }); - order[id].forEach((componentId: string) => { - if (components[componentId]) { - delete components[componentId].itemType; - delete components[componentId].propertyPath; - formLayout.push({ - id: componentId, - type: components[componentId].type, - ...components[componentId], - }); - } else { - extractChildrenFromGroupInternal(components, containers, order, formLayout, componentId); - } - }); - } - } - return createExternalLayout(formLayout, customRootProperties, customDataProperties); -} - -function extractChildrenFromGroupInternal( - components: IFormDesignerComponents, - containers: IFormDesignerContainers, - order: IFormLayoutOrder, - formLayout: ExternalComponent[], - groupId: string, -) { - // eslint-disable-next-line @typescript-eslint/no-unused-vars - const { itemType, propertyPath, ...restOfGroup } = containers[groupId]; - formLayout.push({ - id: groupId, - type: ComponentType.Group, - children: order[groupId], - ...restOfGroup, - }); - order[groupId].forEach((childId: string) => { - if (components[childId]) { - delete components[childId].itemType; - delete components[childId].propertyPath; - formLayout.push({ - id: childId, - ...components[childId], - }); - } else { - extractChildrenFromGroupInternal(components, containers, order, formLayout, childId); - } - }); -} - -export function extractChildrenFromGroup( - group: ExternalComponent, - components: ExternalComponent[], - convertedLayout: IInternalLayout, -) { - const { id, children, type, ...restOfGroup } = group; - convertedLayout.containers[id] = { - ...restOfGroup, - itemType: 'CONTAINER', - propertyPath: formItemConfigs[type].defaultProperties.propertyPath, - }; - convertedLayout.order[id] = children || []; - children?.forEach((componentId: string) => { - const component: ExternalComponent = components.find( - (candidate: ExternalComponent) => candidate.id === componentId, - ); - if (component.type === 'Group') { - extractChildrenFromGroup(component, components, convertedLayout); - } else { - convertedLayout.components[componentId] = { - ...component, - itemType: 'COMPONENT', - propertyPath: formItemConfigs[component.type].defaultProperties.propertyPath, - } as FormComponent; - } - }); -} +import { FormItem } from '../types/FormItem'; export const mapComponentToToolbarElement = ( c: FormItemConfigs[T], @@ -257,12 +73,50 @@ export const addComponent = ( position: number = -1, ): IInternalLayout => { const newLayout = deepCopy(layout); + component.pageIndex = calculateNewPageIndex(newLayout, containerId, position); newLayout.components[component.id] = component; if (position < 0) newLayout.order[containerId].push(component.id); else newLayout.order[containerId].splice(position, 0, component.id); return newLayout; }; +/** + * Returns a page index for the new component if it is inside a multi page container. + * Currently we do not support managing page indices in Studio, so this will default to the page index of the previous component. + * @param layout + * @param containerId + * @param position + */ +const calculateNewPageIndex = ( + layout: IInternalLayout, + containerId: string, + position: number, +): number => { + const parent = layout.containers[containerId]; + const isParentMultiPage = parent?.edit?.multiPage; + if (!isParentMultiPage) return null; + const previousComponentPosition = findPositionOfPreviousComponent(layout, containerId, position); + if (previousComponentPosition === undefined) return 0; + const previousComponentId = layout.order[containerId][previousComponentPosition]; + const previousComponent = findItem(layout, previousComponentId); + return previousComponent?.pageIndex; +}; + +const findPositionOfPreviousComponent = ( + layout: IInternalLayout, + containerId: string, + position: number, +): number | undefined => { + switch (position) { + case 0: + return undefined; + case -1: + return layout.order[containerId].length - 1; + default: + return position - 1; + } +}; + /** * Adds a container to a layout. * @param layout The layout to add the container to. @@ -280,6 +134,7 @@ export const addContainer = ( position: number = -1, ): IInternalLayout => { const newLayout = deepCopy(layout); + container.pageIndex = calculateNewPageIndex(newLayout, parentId, position); newLayout.containers[id] = container; newLayout.order[id] = []; if (position < 0) newLayout.order[parentId].push(id); @@ -393,18 +248,28 @@ export const addNavigationButtons = (layout: IInternalLayout, id: string): IInte * @returns The empty layout. */ export const createEmptyLayout = (): IInternalLayout => ({ + ...createEmptyLayoutData(), + customRootProperties: {}, +}); + +export const createEmptyLayoutData = (): InternalLayoutData => ({ + ...createEmptyComponentStructure(), + customDataProperties: {}, +}); + +export const createEmptyComponentStructure = (): InternalLayoutComponents => ({ components: {}, containers: { [BASE_CONTAINER_ID]: { + id: BASE_CONTAINER_ID, index: 0, itemType: 'CONTAINER', + pageIndex: null, }, }, order: { [BASE_CONTAINER_ID]: [], }, - customRootProperties: {}, - customDataProperties: {}, }); /** @@ -423,6 +288,8 @@ export const moveLayoutItem = ( ): IInternalLayout => { const newLayout = deepCopy(layout); const oldContainerId = findParentId(layout, id); + const item = findItem(newLayout, id); + item.pageIndex = calculateNewPageIndex(newLayout, newContainerId, newPosition); if (oldContainerId) { newLayout.order[oldContainerId] = removeItemByValue(newLayout.order[oldContainerId], id); newLayout.order[newContainerId] = insertArrayElementAtPos( @@ -434,6 +301,11 @@ export const moveLayoutItem = ( return newLayout; }; +const findItem = (layout: IInternalLayout, id: string): FormComponent | FormContainer => { + const { components, containers } = layout; + return components[id] || containers[id]; +}; + /** * Adds a component of a given type to a layout. * @param layout The layout to add the component to. @@ -450,9 +322,10 @@ export const addItemOfType = ( parentId: string = BASE_CONTAINER_ID, position: number = -1, ): IInternalLayout => { - const newItem = generateFormItem(componentType, id); - if (newItem.itemType === 'COMPONENT') return addComponent(layout, newItem, parentId, position); - else return addContainer(layout, newItem, id, parentId, position); + const newItem: FormItem = generateFormItem(componentType, id); + return (newItem.itemType === 'COMPONENT') + ? addComponent(layout, newItem as FormComponent, parentId, position) + : addContainer(layout, newItem, id, parentId, position); }; /** diff --git a/frontend/packages/ux-editor/src/utils/formLayoutsUtils.test.ts b/frontend/packages/ux-editor/src/utils/formLayoutsUtils.test.ts index 7cecbfd8965..0d959c50091 100644 --- a/frontend/packages/ux-editor/src/utils/formLayoutsUtils.test.ts +++ b/frontend/packages/ux-editor/src/utils/formLayoutsUtils.test.ts @@ -69,7 +69,7 @@ describe('formLayoutsUtils', () => { const layouts: IFormLayouts = { [layoutId]: { components: { [navButtonsId]: navButtonsComponent }, - containers: { [BASE_CONTAINER_ID]: { itemType: 'CONTAINER' } }, + containers: { [BASE_CONTAINER_ID]: { id: BASE_CONTAINER_ID, itemType: 'CONTAINER' } }, order: { [BASE_CONTAINER_ID]: [navButtonsId] }, customRootProperties: {}, customDataProperties: {}, @@ -133,7 +133,7 @@ describe('formLayoutsUtils', () => { const layouts: IFormLayouts = { [layoutId]: { components: { [navButtonsId]: navButtonsComponent }, - containers: { [BASE_CONTAINER_ID]: { itemType: 'CONTAINER' } }, + containers: { [BASE_CONTAINER_ID]: { id: BASE_CONTAINER_ID, itemType: 'CONTAINER' } }, order: { [BASE_CONTAINER_ID]: [navButtonsId] }, customRootProperties: {}, customDataProperties: {}, @@ -161,7 +161,7 @@ describe('formLayoutsUtils', () => { const layouts: IFormLayouts = { [layoutId]: { components: { [navButtonsId]: navButtonsComponent }, - containers: { [BASE_CONTAINER_ID]: { itemType: 'CONTAINER' } }, + containers: { [BASE_CONTAINER_ID]: { id: BASE_CONTAINER_ID, itemType: 'CONTAINER' } }, order: { [BASE_CONTAINER_ID]: [navButtonsId] }, customRootProperties: {}, customDataProperties: {}, @@ -172,7 +172,7 @@ describe('formLayoutsUtils', () => { layouts, callback, null, - layoutReceiptId + layoutReceiptId, ); const layout1Components = Object.values(updatedLayouts[layoutId].components); expect(layout1Components.length).toBe(0); @@ -195,7 +195,7 @@ describe('formLayoutsUtils', () => { const layouts: IFormLayouts = { [layoutId]: { components: { [navButtonsId]: navButtonsComponent }, - containers: { [BASE_CONTAINER_ID]: { itemType: 'CONTAINER' } }, + containers: { [BASE_CONTAINER_ID]: { id: BASE_CONTAINER_ID, itemType: 'CONTAINER' } }, order: { [BASE_CONTAINER_ID]: [navButtonsId] }, customRootProperties: {}, customDataProperties: {}, @@ -206,7 +206,7 @@ describe('formLayoutsUtils', () => { layouts, callback, null, - layoutReceiptId + layoutReceiptId, ); const layout1Components = Object.values(updatedLayouts[layoutId].components); const layoutReceiptComponents = Object.values(updatedLayouts[layoutReceiptId].components); @@ -228,7 +228,7 @@ describe('formLayoutsUtils', () => { layouts, callback, layoutReceiptId, - layoutReceiptId + layoutReceiptId, ); expect(callback).toHaveBeenCalledTimes(1); expect(callback).toHaveBeenCalledWith(layoutReceiptId, updatedLayouts[layoutReceiptId]); diff --git a/frontend/packages/ux-editor/src/utils/formLayoutsUtils.ts b/frontend/packages/ux-editor/src/utils/formLayoutsUtils.ts index 32e72c2cf7f..9798319c17c 100644 --- a/frontend/packages/ux-editor/src/utils/formLayoutsUtils.ts +++ b/frontend/packages/ux-editor/src/utils/formLayoutsUtils.ts @@ -1,7 +1,6 @@ import { IFormLayouts, IInternalLayout } from '../types/global'; import { addNavigationButtons, - convertFromLayoutToInternalFormat, createEmptyLayout, hasNavigationButtons, removeComponentsByType, @@ -12,6 +11,7 @@ import { deepCopy } from 'app-shared/pure'; import { DEFAULT_SELECTED_LAYOUT_NAME } from 'app-shared/constants'; import { FormLayoutsResponse } from 'app-shared/types/api/FormLayoutsResponse'; import { removeDuplicates } from 'app-shared/utils/arrayUtils'; +import { externalLayoutToInternal } from '../converters/formLayoutConverters'; /** * Update layouts to have navigation buttons if there are multiple layouts, or remove them if this is the only one. @@ -82,7 +82,7 @@ export const convertExternalLayoutsToInternalFormat = ( convertedLayouts[name] = createEmptyLayout(); } else { try { - convertedLayouts[name] = convertFromLayoutToInternalFormat(layouts[name]); + convertedLayouts[name] = externalLayoutToInternal(layouts[name]); } catch { invalidLayouts.push(name); } diff --git a/frontend/packages/ux-editor/src/utils/generateId.test.tsx b/frontend/packages/ux-editor/src/utils/generateId.test.tsx index b66cdcab2aa..f40f291e558 100644 --- a/frontend/packages/ux-editor/src/utils/generateId.test.tsx +++ b/frontend/packages/ux-editor/src/utils/generateId.test.tsx @@ -7,6 +7,7 @@ describe('generateComponentId', () => { layout1: { containers: { container1: { + id: 'container1', index: 0, itemType: 'CONTAINER', }, @@ -28,6 +29,7 @@ describe('generateComponentId', () => { layout2: { containers: { container2: { + id: 'container2', index: 0, itemType: 'CONTAINER', },