diff --git a/src/altinn-app-frontend/src/components/GenericComponent.tsx b/src/altinn-app-frontend/src/components/GenericComponent.tsx index c586bb1ff1..0ae37337f3 100644 --- a/src/altinn-app-frontend/src/components/GenericComponent.tsx +++ b/src/altinn-app-frontend/src/components/GenericComponent.tsx @@ -6,6 +6,7 @@ import classNames from 'classnames'; import { useAppDispatch, useAppSelector } from 'src/common/hooks'; import components, { FormComponentContext } from 'src/components'; +import { useExpressionsForComponent } from 'src/features/expressions/useExpressions'; import Description from 'src/features/form/components/Description'; import Label from 'src/features/form/components/Label'; import Legend from 'src/features/form/components/Legend'; @@ -29,11 +30,13 @@ import type { IFormComponentContext, PropsFromGenericComponent, } from 'src/components'; +import type { ExprResolved } from 'src/features/expressions/types'; import type { ComponentExceptGroup, ComponentTypes, IGridStyling, ILayoutCompBase, + ILayoutComponent, } from 'src/features/form/layout'; import type { IComponentValidations, ILabelSettings } from 'src/types'; @@ -101,8 +104,14 @@ const useStyles = makeStyles((theme) => ({ })); export function GenericComponent( - props: IActualGenericComponentProps, + _props: IActualGenericComponentProps, ) { + const props = useExpressionsForComponent( + _props as ILayoutComponent, + ) as ExprResolved> & { + type: Type; + }; + const { id, ...passThroughProps } = props; const dispatch = useAppDispatch(); const classes = useStyles(props); diff --git a/src/altinn-app-frontend/src/components/hooks/index.ts b/src/altinn-app-frontend/src/components/hooks/index.ts index d221dd4c90..436a5a9c1c 100644 --- a/src/altinn-app-frontend/src/components/hooks/index.ts +++ b/src/altinn-app-frontend/src/components/hooks/index.ts @@ -2,6 +2,7 @@ import { useEffect, useState } from 'react'; import { shallowEqual } from 'react-redux'; import { useAppSelector } from 'src/common/hooks'; +import { buildInstanceContext } from 'src/utils/instanceContext'; import { getOptionLookupKey, getRelevantFormDataForOptionSource, @@ -9,7 +10,6 @@ import { } from 'src/utils/options'; import type { IMapping, IOption, IOptionSource } from 'src/types'; -import { buildInstanceContext } from 'altinn-shared/utils/instanceContext'; import type { IDataSources, IInstanceContext } from 'altinn-shared/types'; interface IUseGetOptionsParams { diff --git a/src/altinn-app-frontend/src/components/index.ts b/src/altinn-app-frontend/src/components/index.ts index 094c5ca8fa..bc98525138 100644 --- a/src/altinn-app-frontend/src/components/index.ts +++ b/src/altinn-app-frontend/src/components/index.ts @@ -26,6 +26,7 @@ import { TextAreaComponent } from 'src/components/base/TextAreaComponent'; import CustomComponent from 'src/components/custom/CustomWebComponent'; import { NavigationButtons as NavigationButtonsComponent } from 'src/components/presentation/NavigationButtons'; import type { IGenericComponentProps } from 'src/components/GenericComponent'; +import type { ExprResolved } from 'src/features/expressions/types'; import type { ComponentExceptGroup, ComponentExceptGroupAndSummary, @@ -84,7 +85,7 @@ export interface IComponentProps extends IGenericComponentProps { } export type PropsFromGenericComponent = - IComponentProps & Omit, 'type'>; + IComponentProps & ExprResolved, 'type'>>; export interface IFormComponentContext { grid?: IGrid; diff --git a/src/altinn-app-frontend/src/components/summary/SummaryComponent.tsx b/src/altinn-app-frontend/src/components/summary/SummaryComponent.tsx index 382ca89fe9..c121e1ff45 100644 --- a/src/altinn-app-frontend/src/components/summary/SummaryComponent.tsx +++ b/src/altinn-app-frontend/src/components/summary/SummaryComponent.tsx @@ -7,6 +7,7 @@ import cn from 'classnames'; import { useAppDispatch, useAppSelector } from 'src/common/hooks'; import ErrorPaper from 'src/components/message/ErrorPaper'; import SummaryComponentSwitch from 'src/components/summary/SummaryComponentSwitch'; +import { useExpressionsForComponent } from 'src/features/expressions/useExpressions'; import { FormLayoutActions } from 'src/features/form/layout/formLayoutSlice'; import { makeGetHidden } from 'src/selectors/getLayoutData'; import { @@ -80,9 +81,11 @@ export function SummaryComponent({ const attachments = useAppSelector( (state: IRuntimeState) => state.attachments.attachments, ); - const formComponent = useAppSelector((state) => { + const _formComponent = useAppSelector((state) => { return state.formLayout.layouts[pageRef].find((c) => c.id === componentRef); }); + const formComponent = useExpressionsForComponent(_formComponent); + const goToCorrectPageLinkText = useAppSelector((state) => { return getTextFromAppOrDefault( 'form_filler.summary_go_to_correct_page', diff --git a/src/altinn-app-frontend/src/components/summary/SummaryComponentSwitch.tsx b/src/altinn-app-frontend/src/components/summary/SummaryComponentSwitch.tsx index 1e6f465b4e..b45754787a 100644 --- a/src/altinn-app-frontend/src/components/summary/SummaryComponentSwitch.tsx +++ b/src/altinn-app-frontend/src/components/summary/SummaryComponentSwitch.tsx @@ -7,6 +7,7 @@ import MultipleChoiceSummary from 'src/components/summary/MultipleChoiceSummary' import SingleInputSummary from 'src/components/summary/SingleInputSummary'; import SummaryBoilerplate from 'src/components/summary/SummaryBoilerplate'; import SummaryGroupComponent from 'src/components/summary/SummaryGroupComponent'; +import type { ExprResolved } from 'src/features/expressions/types'; import type { ILayoutComponent, ILayoutCompSummary, @@ -19,7 +20,7 @@ export interface ISummaryComponentSwitch onChangeClick: () => void; changeText: string; }; - formComponent: ILayoutComponent | ILayoutGroup; + formComponent: ExprResolved; hasValidationMessages?: boolean; label?: any; formData?: any; diff --git a/src/altinn-app-frontend/src/features/expressions/index.ts b/src/altinn-app-frontend/src/features/expressions/index.ts index caa2fd675f..3409fd930b 100644 --- a/src/altinn-app-frontend/src/features/expressions/index.ts +++ b/src/altinn-app-frontend/src/features/expressions/index.ts @@ -549,6 +549,7 @@ export const ExprDefaultsForGroup: ExprDefaultValues = { addButton: true, deleteButton: true, saveButton: true, + alertOnDelete: false, saveAndNextButton: false, }, }; diff --git a/src/altinn-app-frontend/src/features/expressions/useExpressions.ts b/src/altinn-app-frontend/src/features/expressions/useExpressions.ts index 75b2001a58..def37f1acb 100644 --- a/src/altinn-app-frontend/src/features/expressions/useExpressions.ts +++ b/src/altinn-app-frontend/src/features/expressions/useExpressions.ts @@ -8,6 +8,7 @@ import { ExprDefaultsForComponent, ExprDefaultsForGroup, } from 'src/features/expressions/index'; +import { getInstanceContextSelector } from 'src/utils/instanceContext'; import { useLayoutsAsNodes } from 'src/utils/layout/useLayoutsAsNodes'; import type { ContextDataSources } from 'src/features/expressions/ExprContext'; import type { EvalExprInObjArgs } from 'src/features/expressions/index'; @@ -17,7 +18,7 @@ import type { } from 'src/features/expressions/types'; import type { ILayoutComponentOrGroup } from 'src/features/form/layout'; -import { buildInstanceContext } from 'altinn-shared/utils/instanceContext'; +import type { IInstanceContext } from 'altinn-shared/types'; export interface UseExpressionsOptions { /** @@ -42,20 +43,28 @@ export interface UseExpressionsOptions { * @param input Any input, object, value from the layout definitions, possibly containing expressions somewhere. * This hook will look through the input (and recurse through objects), looking for expressions and resolve * them to provide you with the base out value for the current component context. - * @param options Optional options (see their own docs) + * @param _options Optional options (see their own docs) */ export function useExpressions( input: T, - options?: UseExpressionsOptions, + _options?: UseExpressionsOptions, ): ExprResolved { + // The options argument is an object, so it's natural to create a new one each time this function is called. As + // the equality function in React will assume a new object reference is an entirely new object, we'll memoize this + // argument as to prevent infinite looping when given a new (but identical) options argument. + // eslint-disable-next-line react-hooks/exhaustive-deps + const options = useMemo(() => _options, [JSON.stringify(_options)]); + const component = useContext(FormComponentContext); const nodes = useLayoutsAsNodes(); const formData = useAppSelector((state) => state.formData?.formData); const applicationSettings = useAppSelector( (state) => state.applicationSettings?.applicationSettings, ); - const instance = useAppSelector((state) => state.instanceData?.instance); - const instanceContext = buildInstanceContext(instance); + const instanceContextSelector = getInstanceContextSelector(); + const instanceContext: IInstanceContext = useAppSelector( + instanceContextSelector, + ); const id = (options && options.forComponentId) || component.id; const node = useMemo(() => { @@ -93,16 +102,9 @@ const componentDefaults: any = { export function useExpressionsForComponent( input: T, - options?: Omit, 'defaults'>, ): ExprResolved { - const newOptions = useMemo( - () => ({ - ...options, - defaults: componentDefaults, - }), - // eslint-disable-next-line react-hooks/exhaustive-deps - Object.values(options), - ); - - return useExpressions(input, newOptions); + return useExpressions(input, { + forComponentId: input.id, + defaults: componentDefaults, + }); } diff --git a/src/altinn-app-frontend/src/features/form/containers/GroupContainer.tsx b/src/altinn-app-frontend/src/features/form/containers/GroupContainer.tsx index 925db7fef5..0da2d9aac6 100644 --- a/src/altinn-app-frontend/src/features/form/containers/GroupContainer.tsx +++ b/src/altinn-app-frontend/src/features/form/containers/GroupContainer.tsx @@ -4,6 +4,8 @@ import { Grid, makeStyles } from '@material-ui/core'; import { useAppDispatch, useAppSelector } from 'src/common/hooks'; import ErrorPaper from 'src/components/message/ErrorPaper'; +import { ExprDefaultsForGroup } from 'src/features/expressions'; +import { useExpressions } from 'src/features/expressions/useExpressions'; import { RepeatingGroupAddButton } from 'src/features/form/components/RepeatingGroupAddButton'; import { RepeatingGroupsEditContainer } from 'src/features/form/containers/RepeatingGroupsEditContainer'; import { RepeatingGroupsLikertContainer } from 'src/features/form/containers/RepeatingGroupsLikertContainer'; @@ -70,6 +72,11 @@ export function GroupContainer({ JSON.stringify(components), ); + const edit = useExpressions(container.edit, { + forComponentId: id, + defaults: ExprDefaultsForGroup.edit, + }); + const editIndex = useAppSelector( (state: IRuntimeState) => state.formLayout.uiConfig.repeatingGroups[id]?.editIndex ?? -1, @@ -109,13 +116,8 @@ export function GroupContainer({ const textResources = useAppSelector( (state) => state.textResources.resources, ); - const getRepeatingGroupIndex = (containerId: string) => { - if (repeatingGroups && repeatingGroups[containerId]) { - return repeatingGroups[containerId].index; - } - return -1; - }; - const repeatingGroupIndex = getRepeatingGroupIndex(id); + const repeatingGroupIndex = + repeatingGroups && repeatingGroups[id] ? repeatingGroups[id].index : -1; const repeatingGroupDeepCopyComponents = useMemo( () => createRepeatingGroupComponents( @@ -157,16 +159,16 @@ export function GroupContainer({ React.useEffect(() => { const filteredIndexList = getRepeatingGroupFilteredIndices( formData, - container.edit?.filter, + edit?.filter, ); if (filteredIndexList) { setFilteredIndexList(filteredIndexList); } - }, [formData, container]); + }, [formData, edit]); const onClickAdd = useCallback(() => { dispatch(FormLayoutActions.updateRepeatingGroups({ layoutElementId: id })); - if (container.edit?.mode !== 'showAll') { + if (edit?.mode !== 'showAll') { dispatch( FormLayoutActions.updateRepeatingGroupsEditIndex({ group: id, @@ -174,7 +176,7 @@ export function GroupContainer({ }), ); } - }, [container.edit?.mode, dispatch, id, repeatingGroupIndex]); + }, [edit?.mode, dispatch, id, repeatingGroupIndex]); React.useEffect(() => { const { edit } = container; @@ -242,7 +244,7 @@ export function GroupContainer({ return null; } - if (container.edit?.mode === 'likert') { + if (edit?.mode === 'likert') { return ( <> - {(!container.edit?.mode || - container.edit?.mode === 'showTable' || - (container.edit?.mode === 'hideTable' && editIndex < 0)) && ( + {(!edit?.mode || + edit?.mode === 'showTable' || + (edit?.mode === 'hideTable' && editIndex < 0)) && ( - {container.edit?.mode !== 'showAll' && - container.edit?.addButton !== false && + {edit?.mode !== 'showAll' && + edit?.addButton !== false && editIndex < 0 && repeatingGroupIndex + 1 < container.maxCount && ( )} - {editIndex >= 0 && container.edit?.mode === 'hideTable' && ( + {editIndex >= 0 && edit?.mode === 'hideTable' && ( )} - {container.edit?.mode === 'showAll' && + {edit?.mode === 'showAll' && // Generate array of length repeatingGroupIndex and iterate over indexes Array(repeatingGroupIndex + 1) .fill(0) @@ -369,12 +371,12 @@ export function GroupContainer({ repeatingGroupDeepCopyComponents } hideSaveButton={true} - hideDeleteButton={container.edit?.deleteButton === false} + hideDeleteButton={edit?.deleteButton === false} /> ); })} - {container.edit?.mode === 'showAll' && - container.edit?.addButton !== false && + {edit?.mode === 'showAll' && + edit?.addButton !== false && repeatingGroupIndex + 1 < container.maxCount && ( c.baseComponentId || c.id) || @@ -312,7 +319,7 @@ export function RepeatingGroupTable({ }; const handleDeleteClick = (index: number) => { - if (container.edit?.alertOnDelete) { + if (edit?.alertOnDelete) { onOpenChange(index); } else { onClickRemove(index); @@ -391,10 +398,10 @@ export function RepeatingGroupTable({ layout={layout} onClickSave={handleSaveClick} repeatingGroupDeepCopyComponents={repeatingGroupDeepCopyComponents} - hideSaveButton={container.edit?.saveButton === false} + hideSaveButton={edit?.saveButton === false} multiPageIndex={multiPageIndex} setMultiPageIndex={setMultiPageIndex} - showSaveAndNextButton={container.edit?.saveAndNextButton === true} + showSaveAndNextButton={edit?.saveAndNextButton === true} filteredIndexes={filteredIndexes} /> ) diff --git a/src/altinn-app-frontend/src/features/form/data/update/updateFormDataSagas.ts b/src/altinn-app-frontend/src/features/form/data/update/updateFormDataSagas.ts index 494dbb85c8..f141524aec 100644 --- a/src/altinn-app-frontend/src/features/form/data/update/updateFormDataSagas.ts +++ b/src/altinn-app-frontend/src/features/form/data/update/updateFormDataSagas.ts @@ -43,9 +43,7 @@ export function* updateFormDataSaga({ ); } - if (state.formDynamics.conditionalRendering) { - yield put(FormDynamicsActions.checkIfConditionalRulesShouldRun({})); - } + yield put(FormDynamicsActions.checkIfConditionalRulesShouldRun({})); } catch (error) { console.error(error); yield put(FormDataActions.updateRejected({ error })); diff --git a/src/altinn-app-frontend/src/features/form/dynamics/conditionalRendering/conditionalRenderingSagas.ts b/src/altinn-app-frontend/src/features/form/dynamics/conditionalRendering/conditionalRenderingSagas.ts index 96d0c82e6d..3beccf9d19 100644 --- a/src/altinn-app-frontend/src/features/form/dynamics/conditionalRendering/conditionalRenderingSagas.ts +++ b/src/altinn-app-frontend/src/features/form/dynamics/conditionalRendering/conditionalRenderingSagas.ts @@ -1,23 +1,40 @@ import { call, put, select } from 'redux-saga/effects'; import type { SagaIterator } from 'redux-saga'; +import { evalExpr } from 'src/features/expressions'; import { FormLayoutActions } from 'src/features/form/layout/formLayoutSlice'; import { ValidationActions } from 'src/features/form/validation/validationSlice'; import { runConditionalRenderingRules } from 'src/utils/conditionalRendering'; +import { + dataSourcesFromState, + resolvedLayoutsFromState, +} from 'src/utils/layout/hierarchy'; +import { selectNotNull } from 'src/utils/sagas'; +import type { ContextDataSources } from 'src/features/expressions/ExprContext'; import type { IFormData } from 'src/features/form/data'; import type { IConditionalRenderingRules } from 'src/features/form/dynamics'; -import type { IRuntimeState, IUiConfig, IValidations } from 'src/types'; +import type { + IHiddenLayoutsExpressions, + IRuntimeState, + IUiConfig, + IValidations, +} from 'src/types'; +import type { LayoutRootNodeCollection } from 'src/utils/layout/hierarchy'; -export const ConditionalRenderingSelector: (store: IRuntimeState) => any = ( - store: IRuntimeState, -) => store.formDynamics.conditionalRendering; -export const FormDataSelector: (store: IRuntimeState) => IFormData = (store) => - store.formData.formData; -export const UiConfigSelector: (store: IRuntimeState) => IUiConfig = (store) => - store.formLayout.uiConfig; -export const FormValidationSelector: (store: IRuntimeState) => IValidations = ( - store, -) => store.formValidations.validations; +export const ConditionalRenderingSelector = (store: IRuntimeState) => + store.formDynamics.conditionalRendering; +export const FormDataSelector = (state: IRuntimeState) => + state.formData.formData; +export const RepeatingGroupsSelector = (state: IRuntimeState) => + state.formLayout.uiConfig.repeatingGroups; +export const UiConfigSelector = (state: IRuntimeState) => + state.formLayout.uiConfig; +export const FormValidationSelector = (state: IRuntimeState) => + state.formValidations.validations; +export const ResolvedNodesSelector = (state: IRuntimeState) => + resolvedLayoutsFromState(state); +export const DataSourcesSelector = (state: IRuntimeState) => + dataSourcesFromState(state); export function* checkIfConditionalRulesShouldRunSaga(): SagaIterator { try { @@ -26,41 +43,105 @@ export function* checkIfConditionalRulesShouldRunSaga(): SagaIterator { ); const formData: IFormData = yield select(FormDataSelector); const formValidations: IValidations = yield select(FormValidationSelector); + const repeatingGroups = yield selectNotNull(RepeatingGroupsSelector); const uiConfig: IUiConfig = yield select(UiConfigSelector); - const componentsToHide: string[] = runConditionalRenderingRules( + const resolvedNodes: LayoutRootNodeCollection<'resolved'> = yield select( + ResolvedNodesSelector, + ); + const dataSources = yield select(DataSourcesSelector); + + const hiddenFields = new Set(uiConfig.hiddenFields); + const futureHiddenFields = runConditionalRenderingRules( conditionalRenderingState, formData, - uiConfig.repeatingGroups, + repeatingGroups, ); - if (shouldHiddenFieldsUpdate(uiConfig.hiddenFields, componentsToHide)) { - yield put(FormLayoutActions.updateHiddenComponents({ componentsToHide })); - componentsToHide.forEach((componentId) => { - if (formValidations[componentId]) { - const newFormValidations = formValidations; - delete formValidations[componentId]; - ValidationActions.updateValidations({ - validations: newFormValidations, - }); + runExpressionRules(resolvedNodes, hiddenFields, futureHiddenFields); + + if (shouldUpdate(hiddenFields, futureHiddenFields)) { + yield put( + FormLayoutActions.updateHiddenComponents({ + componentsToHide: [...futureHiddenFields.values()], + }), + ); + + const newFormValidations = { ...formValidations }; + let validationsChanged = false; + futureHiddenFields.forEach((componentId) => { + if (newFormValidations[componentId]) { + delete newFormValidations[componentId]; + validationsChanged = true; } }); + if (validationsChanged) { + ValidationActions.updateValidations({ + validations: newFormValidations, + }); + } + } + + const hiddenLayouts = new Set(uiConfig.tracks.hidden); + const futureHiddenLayouts = runExpressionsForLayouts( + resolvedNodes, + uiConfig.tracks.hiddenExpr, + dataSources, + ); + + if (shouldUpdate(hiddenLayouts, futureHiddenLayouts)) { + yield put( + FormLayoutActions.updateHiddenLayouts({ + hiddenLayouts: [...futureHiddenLayouts.values()], + }), + ); } } catch (err) { yield call(console.error, err); } } -function shouldHiddenFieldsUpdate( - currentList: string[], - newList: string[], -): boolean { - if (!currentList || currentList.length !== newList.length) { - return true; +function runExpressionRules( + layouts: LayoutRootNodeCollection<'resolved'>, + present: Set, + future: Set, +) { + for (const layout of Object.values(layouts.all())) { + for (const node of layout.flat(true)) { + if (node.item.hidden === true) { + future.add(node.item.id); + } + } } +} - if (!currentList && newList && newList.length > 0) { +function runExpressionsForLayouts( + nodes: LayoutRootNodeCollection<'resolved'>, + hiddenLayoutsExpr: IHiddenLayoutsExpressions, + dataSources: ContextDataSources, +): Set { + const hiddenLayouts: Set = new Set(); + for (const key of Object.keys(hiddenLayoutsExpr)) { + let isHidden = hiddenLayoutsExpr[key]; + if (typeof isHidden === 'object' && isHidden !== null) { + isHidden = evalExpr(isHidden, nodes.findLayout(key), dataSources, { + defaultValue: false, + }); + } + if (isHidden === true) { + hiddenLayouts.add(key); + } + } + + return hiddenLayouts; +} + +function shouldUpdate(currentList: Set, newList: Set): boolean { + if (currentList.size !== newList.size) { return true; } - return !!currentList.find((element) => !newList.includes(element)); + const present = [...currentList.values()].sort(); + const future = [...newList.values()].sort(); + + return JSON.stringify(present) !== JSON.stringify(future); } diff --git a/src/altinn-app-frontend/src/features/form/layout/fetch/fetchFormLayoutSagas.test.ts b/src/altinn-app-frontend/src/features/form/layout/fetch/fetchFormLayoutSagas.test.ts index e5a678abaa..bbacd5e501 100644 --- a/src/altinn-app-frontend/src/features/form/layout/fetch/fetchFormLayoutSagas.test.ts +++ b/src/altinn-app-frontend/src/features/form/layout/fetch/fetchFormLayoutSagas.test.ts @@ -15,6 +15,7 @@ import type { ILayoutCompSummary, ILayoutGroup, } from 'src/features/form/layout'; +import type { IHiddenLayoutsExpressions } from 'src/types'; import type { IApplication, IInstance } from 'altinn-shared/types'; @@ -47,6 +48,7 @@ describe('fetchFormLayoutSagas', () => { const mockResponse = { page1: { data: { + hidden: ['equals', true, false], layout: [], }, }, @@ -55,10 +57,27 @@ describe('fetchFormLayoutSagas', () => { ...mockResponse, page2: { data: { + hidden: ['equals', 1, 2], layout: [], }, }, }; + const mockResponseTwoLayoutsNoHidden = { + ...mockResponse, + page2: { + data: { + layout: [], + }, + }, + }; + + const hiddenExprPage1: IHiddenLayoutsExpressions = { + page1: ['equals', true, false], + }; + + const hiddenExprPage2: IHiddenLayoutsExpressions = { + page2: ['equals', 1, 2], + }; it('should call relevant actions when layout is fetched successfully', () => { jest.spyOn(networking, 'get').mockResolvedValue(mockResponse); @@ -74,6 +93,7 @@ describe('fetchFormLayoutSagas', () => { FormLayoutActions.fetchFulfilled({ layouts: { page1: [] }, navigationConfig: { page1: undefined }, + hiddenLayoutsExpressions: { ...hiddenExprPage1 }, }), ) .put(FormLayoutActions.updateAutoSave({ autoSave: undefined })) @@ -86,6 +106,32 @@ describe('fetchFormLayoutSagas', () => { .run(); }); + it('should work when a single layout is returned', () => { + jest.spyOn(networking, 'get').mockResolvedValue(mockResponse.page1); + + return expectSaga(fetchLayoutSaga) + .provide([ + [select(layoutSetsSelector), undefined], + [select(instanceSelector), instance], + [select(applicationMetadataSelector), application], + ]) + .put( + FormLayoutActions.fetchFulfilled({ + layouts: { FormLayout: [] }, + navigationConfig: {}, + hiddenLayoutsExpressions: { FormLayout: hiddenExprPage1['page1'] }, + }), + ) + .put(FormLayoutActions.updateAutoSave({ autoSave: undefined })) + .put( + FormLayoutActions.updateCurrentView({ + newView: 'FormLayout', + skipPageCaching: true, + }), + ) + .run(); + }); + it('should call fetchRejected when fetching layout fails', () => { jest.spyOn(networking, 'get').mockRejectedValue(new Error('some error')); @@ -104,7 +150,9 @@ describe('fetchFormLayoutSagas', () => { }); it('should set current view to cached key if key exists in fetched layout', () => { - jest.spyOn(networking, 'get').mockResolvedValue(mockResponseTwoLayouts); + jest + .spyOn(networking, 'get') + .mockResolvedValue(mockResponseTwoLayoutsNoHidden); jest.spyOn(window.localStorage.__proto__, 'getItem'); window.localStorage.__proto__.getItem = jest .fn() @@ -121,6 +169,10 @@ describe('fetchFormLayoutSagas', () => { FormLayoutActions.fetchFulfilled({ layouts: { page1: [], page2: [] }, navigationConfig: { page1: undefined, page2: undefined }, + hiddenLayoutsExpressions: { + ...hiddenExprPage1, + page2: undefined, + }, }), ) .put(FormLayoutActions.updateAutoSave({ autoSave: undefined })) @@ -151,6 +203,10 @@ describe('fetchFormLayoutSagas', () => { FormLayoutActions.fetchFulfilled({ layouts: { page1: [], page2: [] }, navigationConfig: { page1: undefined, page2: undefined }, + hiddenLayoutsExpressions: { + ...hiddenExprPage1, + ...hiddenExprPage2, + }, }), ) .put(FormLayoutActions.updateAutoSave({ autoSave: undefined })) @@ -176,6 +232,7 @@ describe('fetchFormLayoutSagas', () => { FormLayoutActions.fetchFulfilled({ layouts: { page1: [] }, navigationConfig: { page1: undefined }, + hiddenLayoutsExpressions: { ...hiddenExprPage1 }, }), ) .put(FormLayoutActions.updateAutoSave({ autoSave: undefined })) @@ -201,6 +258,7 @@ describe('fetchFormLayoutSagas', () => { FormLayoutActions.fetchFulfilled({ layouts: { page1: [] }, navigationConfig: { page1: undefined }, + hiddenLayoutsExpressions: { ...hiddenExprPage1 }, }), ) .put(FormLayoutActions.updateAutoSave({ autoSave: undefined })) diff --git a/src/altinn-app-frontend/src/features/form/layout/fetch/fetchFormLayoutSagas.ts b/src/altinn-app-frontend/src/features/form/layout/fetch/fetchFormLayoutSagas.ts index 0cb76212ef..cce9b3343b 100644 --- a/src/altinn-app-frontend/src/features/form/layout/fetch/fetchFormLayoutSagas.ts +++ b/src/altinn-app-frontend/src/features/form/layout/fetch/fetchFormLayoutSagas.ts @@ -2,6 +2,10 @@ import { all, call, put, select, take } from 'redux-saga/effects'; import type { SagaIterator } from 'redux-saga'; import components from 'src/components'; +import { + preProcessItem, + preProcessLayout, +} from 'src/features/expressions/validation'; import { FormDataActions } from 'src/features/form/data/formDataSlice'; import { FormLayoutActions } from 'src/features/form/layout/formLayoutSlice'; import { QueueActions } from 'src/shared/resources/queue/queueSlice'; @@ -18,7 +22,12 @@ import type { ILayouts, } from 'src/features/form/layout'; import type { IApplicationMetadata } from 'src/shared/resources/applicationMetadata'; -import type { ILayoutSets, ILayoutSettings, IRuntimeState } from 'src/types'; +import type { + IHiddenLayoutsExpressions, + ILayoutSets, + ILayoutSettings, + IRuntimeState, +} from 'src/types'; import type { IInstance } from 'altinn-shared/types'; @@ -47,10 +56,14 @@ function getCaseMapping(): typeof componentTypeCaseMapping { export function cleanLayout(layout: ILayout): ILayout { const mapping = getCaseMapping(); - return layout.map((component) => ({ + const newLayout = layout.map((component) => ({ ...component, type: mapping[component.type.toLowerCase()] || component.type, })) as ILayout; + + preProcessLayout(newLayout); + + return newLayout; } export function* fetchLayoutSaga(): SagaIterator { @@ -68,10 +81,12 @@ export function* fetchLayoutSaga(): SagaIterator { const layoutResponse: any = yield call(get, getLayoutsUrl(layoutSetId)); const layouts: ILayouts = {}; const navigationConfig: any = {}; + const hiddenLayoutsExpressions: IHiddenLayoutsExpressions = {}; let autoSave: boolean; let firstLayoutKey: string; if (layoutResponse.data?.layout) { layouts.FormLayout = layoutResponse.data.layout; + hiddenLayoutsExpressions.FormLayout = layoutResponse.data.hidden; firstLayoutKey = 'FormLayout'; autoSave = layoutResponse.data.autoSave; } else { @@ -92,12 +107,28 @@ export function* fetchLayoutSaga(): SagaIterator { orderedLayoutKeys.forEach((key) => { layouts[key] = cleanLayout(layoutResponse[key].data.layout); + hiddenLayoutsExpressions[key] = layoutResponse[key].data.hidden; navigationConfig[key] = layoutResponse[key].data.navigation; autoSave = layoutResponse[key].data.autoSave; }); } - yield put(FormLayoutActions.fetchFulfilled({ layouts, navigationConfig })); + for (const key of Object.keys(hiddenLayoutsExpressions)) { + hiddenLayoutsExpressions[key] = preProcessItem( + hiddenLayoutsExpressions[key], + { hidden: false }, + ['hidden'], + key, + ); + } + + yield put( + FormLayoutActions.fetchFulfilled({ + layouts, + navigationConfig, + hiddenLayoutsExpressions, + }), + ); yield put(FormLayoutActions.updateAutoSave({ autoSave })); yield put( FormLayoutActions.updateCurrentView({ diff --git a/src/altinn-app-frontend/src/features/form/layout/formLayoutSlice.test.ts b/src/altinn-app-frontend/src/features/form/layout/formLayoutSlice.test.ts index ec1a9438e6..de9195e818 100644 --- a/src/altinn-app-frontend/src/features/form/layout/formLayoutSlice.test.ts +++ b/src/altinn-app-frontend/src/features/form/layout/formLayoutSlice.test.ts @@ -8,6 +8,7 @@ describe('layoutSlice', () => { describe('fetchLayoutFulfilled', () => { const layouts = {}; const navigationConfig = {}; + const hiddenLayoutsExpressions = {}; it('should set layout state accordingly', () => { const nextState = slice.reducer( @@ -15,6 +16,7 @@ describe('layoutSlice', () => { FormLayoutActions.fetchFulfilled({ layouts, navigationConfig, + hiddenLayoutsExpressions, }), ); @@ -40,6 +42,7 @@ describe('layoutSlice', () => { FormLayoutActions.fetchFulfilled({ layouts, navigationConfig, + hiddenLayoutsExpressions, }), ); @@ -56,6 +59,7 @@ describe('layoutSlice', () => { FormLayoutActions.fetchFulfilled({ layouts, navigationConfig, + hiddenLayoutsExpressions, }), ); diff --git a/src/altinn-app-frontend/src/features/form/layout/formLayoutSlice.ts b/src/altinn-app-frontend/src/features/form/layout/formLayoutSlice.ts index 82008050d0..821f871ec6 100644 --- a/src/altinn-app-frontend/src/features/form/layout/formLayoutSlice.ts +++ b/src/altinn-app-frontend/src/features/form/layout/formLayoutSlice.ts @@ -67,10 +67,12 @@ const formLayoutSlice = createSagaSlice( }), fetchFulfilled: mkAction({ reducer: (state, action) => { - const { layouts, navigationConfig } = action.payload; + const { layouts, navigationConfig, hiddenLayoutsExpressions } = + action.payload; state.layouts = layouts; state.uiConfig.navigationConfig = navigationConfig; state.uiConfig.tracks.order = Object.keys(layouts); + state.uiConfig.tracks.hiddenExpr = hiddenLayoutsExpressions; state.error = null; state.uiConfig.repeatingGroups = null; }, diff --git a/src/altinn-app-frontend/src/features/form/layout/formLayoutTypes.ts b/src/altinn-app-frontend/src/features/form/layout/formLayoutTypes.ts index b7666c6579..7e7e4d27b4 100644 --- a/src/altinn-app-frontend/src/features/form/layout/formLayoutTypes.ts +++ b/src/altinn-app-frontend/src/features/form/layout/formLayoutTypes.ts @@ -1,6 +1,7 @@ import type { ILayouts } from 'src/features/form/layout'; import type { IFileUploadersWithTag, + IHiddenLayoutsExpressions, ILayoutSets, ILayoutSettings, INavigationConfig, @@ -14,6 +15,7 @@ export interface IFormLayoutActionRejected { export interface IFetchLayoutFulfilled { layouts: ILayouts; navigationConfig?: INavigationConfig; + hiddenLayoutsExpressions: IHiddenLayoutsExpressions; } export interface IFetchLayoutSetsFulfilled { diff --git a/src/altinn-app-frontend/src/features/form/layout/index.ts b/src/altinn-app-frontend/src/features/form/layout/index.ts index 86857278e9..6f58624247 100644 --- a/src/altinn-app-frontend/src/features/form/layout/index.ts +++ b/src/altinn-app-frontend/src/features/form/layout/index.ts @@ -5,6 +5,7 @@ import type { } from '@altinn/altinn-design-system'; import type { GridJustification, GridSize } from '@material-ui/core'; +import type { ExpressionOr } from 'src/features/expressions/types'; import type { ILabelSettings, IMapping, @@ -61,9 +62,9 @@ export interface IGroupReference { export interface ILayoutCompBase extends ILayoutEntry { dataModelBindings?: IDataModelBindings; - readOnly?: boolean; - required?: boolean; - hidden?: any; // Temporary while merging expressions PRs + readOnly?: ExpressionOr<'boolean'>; + required?: ExpressionOr<'boolean'>; + hidden?: ExpressionOr<'boolean'>; textResourceBindings?: ITextResourceBindings; grid?: IGrid; triggers?: Triggers[]; @@ -328,13 +329,13 @@ export interface IGridStyling { export interface IGroupEditProperties { mode?: 'hideTable' | 'showTable' | 'showAll' | 'likert'; filter?: IGroupFilter[]; - addButton?: boolean; - saveButton?: boolean; - deleteButton?: boolean; + addButton?: ExpressionOr<'boolean'>; + saveButton?: ExpressionOr<'boolean'>; + deleteButton?: ExpressionOr<'boolean'>; // TODO: Make expressions resolve per-row multiPage?: boolean; openByDefault?: boolean | 'first' | 'last'; - alertOnDelete?: boolean; - saveAndNextButton?: boolean; + alertOnDelete?: ExpressionOr<'boolean'>; // TODO: Make expressions resolve per-row + saveAndNextButton?: ExpressionOr<'boolean'>; } export interface IGroupFilter { diff --git a/src/altinn-app-frontend/src/shared/resources/textResources/replace/replaceTextResourcesSagas.ts b/src/altinn-app-frontend/src/shared/resources/textResources/replace/replaceTextResourcesSagas.ts index 6d26a01927..9805b781e9 100644 --- a/src/altinn-app-frontend/src/shared/resources/textResources/replace/replaceTextResourcesSagas.ts +++ b/src/altinn-app-frontend/src/shared/resources/textResources/replace/replaceTextResourcesSagas.ts @@ -4,11 +4,11 @@ import type { SagaIterator } from 'redux-saga'; import { FormDataActions } from 'src/features/form/data/formDataSlice'; import { FormLayoutActions } from 'src/features/form/layout/formLayoutSlice'; import { TextResourcesActions } from 'src/shared/resources/textResources/textResourcesSlice'; +import { buildInstanceContext } from 'src/utils/instanceContext'; import type { IFormData } from 'src/features/form/data'; import type { ITextResourcesState } from 'src/shared/resources/textResources'; import type { IRepeatingGroups, IRuntimeState } from 'src/types'; -import { buildInstanceContext } from 'altinn-shared/utils/instanceContext'; import { replaceTextResourceParams } from 'altinn-shared/utils/language'; import type { IApplicationSettings, diff --git a/src/altinn-app-frontend/src/types/index.ts b/src/altinn-app-frontend/src/types/index.ts index 003d7ccba8..1ed8cb8eea 100644 --- a/src/altinn-app-frontend/src/types/index.ts +++ b/src/altinn-app-frontend/src/types/index.ts @@ -1,5 +1,6 @@ import type Ajv from 'ajv/dist/core'; +import type { ExpressionOr } from 'src/features/expressions/types'; import type { IFormData } from 'src/features/form/data'; import type { IKeepComponentScrollPos } from 'src/features/form/layout/formLayoutTypes'; import type { RootState } from 'src/store'; @@ -151,7 +152,7 @@ export interface IValidationIssue { } export interface IHiddenLayoutsExpressions { - [layoutKey: string]: never; // Will be set in a later PR + [layoutKey: string]: ExpressionOr<'boolean'>; } export interface IUiConfig { diff --git a/src/altinn-app-frontend/src/utils/conditionalRendering.test.ts b/src/altinn-app-frontend/src/utils/conditionalRendering.test.ts index f96d5217df..f7fc8a9096 100644 --- a/src/altinn-app-frontend/src/utils/conditionalRendering.test.ts +++ b/src/altinn-app-frontend/src/utils/conditionalRendering.test.ts @@ -78,7 +78,7 @@ describe('conditionalRendering', () => { mockHideRules, mockValidFormData, ); - expect(result.findIndex((e) => e === 'layoutElement_2') >= 0).toBe(true); + expect(result.has('layoutElement_2')).toBe(true); }); it('should SHOW element when rule is set to HIDE and condition is FALSE', () => { @@ -86,7 +86,7 @@ describe('conditionalRendering', () => { mockHideRules, mockInvalidFormData, ); - expect(result.findIndex((e) => e === 'layoutElement_2') >= 0).toBe(false); + expect(result.has('layoutElement_2')).toBe(false); }); it('should SHOW element when rule is set to SHOW and condition is TRUE', () => { @@ -94,7 +94,7 @@ describe('conditionalRendering', () => { mockShowRules, mockValidFormData, ); - expect(result.findIndex((e) => e === 'layoutElement_1') >= 0).toBe(false); + expect(result.has('layoutElement_1')).toBe(false); }); it('should HIDE element when rule is set to SHOW and condition is FALSE', () => { @@ -102,7 +102,7 @@ describe('conditionalRendering', () => { mockShowRules, mockInvalidFormData, ); - expect(result.findIndex((e) => e === 'layoutElement_1') >= 0).toBe(true); + expect(result.has('layoutElement_1')).toBe(true); }); it('conditional rendering rules should only return elements to hide', () => { @@ -110,7 +110,7 @@ describe('conditionalRendering', () => { mockShowRules, mockValidFormData, ); - expect(result.length).toBe(0); + expect(result.size).toBe(0); }); it('conditional rendering rules with several targets should be applied to all connected elements', () => { @@ -118,14 +118,14 @@ describe('conditionalRendering', () => { mockHideRules, mockValidFormData, ); - expect(result.length).toBe(2); - expect(result[0]).toBe('layoutElement_2'); - expect(result[1]).toBe('layoutElement_3'); + expect(result.size).toBe(2); + expect(result.has('layoutElement_2')).toBe(true); + expect(result.has('layoutElement_3')).toBe(true); }); it('should run and return empty result array on null values', () => { const result = runConditionalRenderingRules(null, null); - expect(result.length).toBe(0); + expect(result.size).toBe(0); }); it('conditional rendering rules should run as expected for repeating groups', () => { @@ -160,7 +160,10 @@ describe('conditionalRendering', () => { formData, repeatingGroups, ); - expect(result).toEqual(['layoutElement_2-0', 'layoutElement_3-0']); + expect([...result.values()]).toEqual([ + 'layoutElement_2-0', + 'layoutElement_3-0', + ]); }); it('conditional rendering rules should run as expected for nested repeating groups', () => { @@ -208,7 +211,7 @@ describe('conditionalRendering', () => { repeatingGroups, ); - expect(result).toEqual([ + expect([...result.values()]).toEqual([ 'someField-0-0', 'someOtherField-0-0', 'someField-1-2', diff --git a/src/altinn-app-frontend/src/utils/conditionalRendering.ts b/src/altinn-app-frontend/src/utils/conditionalRendering.ts index 47f544f775..fd575dadb6 100644 --- a/src/altinn-app-frontend/src/utils/conditionalRendering.ts +++ b/src/altinn-app-frontend/src/utils/conditionalRendering.ts @@ -18,8 +18,8 @@ export function runConditionalRenderingRules( rules: IConditionalRenderingRules, formData: IFormData, repeatingGroups?: IRepeatingGroups, -): any[] { - let componentsToHide: string[] = []; +): Set { + const componentsToHide = new Set(); if (!(window as Window as IAltinnWindow).conditionalRuleHandlerHelper) { // rules have not been initialized return componentsToHide; @@ -80,19 +80,17 @@ export function runConditionalRenderingRules( index: childIndex, nested: true, }); - componentsToHide = componentsToHide.concat( - runConditionalRenderingRule(connectionNestedCopy, formData), + runConditionalRenderingRule( + connectionNestedCopy, + formData, + componentsToHide, ); } } - componentsToHide = componentsToHide.concat( - runConditionalRenderingRule(connectionCopy, formData), - ); + runConditionalRenderingRule(connectionCopy, formData, componentsToHide); } } else { - componentsToHide = componentsToHide.concat( - runConditionalRenderingRule(connection, formData), - ); + runConditionalRenderingRule(connection, formData, componentsToHide); } }); @@ -126,9 +124,9 @@ function mapRepeatingGroupIndex({ function runConditionalRenderingRule( rule: IConditionalRenderingRule, formData: IFormData, + hiddenFields: Set, ) { const functionToRun = rule.selectedFunction; - const componentsToHide: string[] = []; const objectToUpdate = ( window as Window as IAltinnWindow ).conditionalRuleHandlerHelper[functionToRun](); @@ -148,15 +146,7 @@ function runConditionalRenderingRule( Object.keys(rule.selectedFields).forEach((elementToPerformActionOn) => { if (elementToPerformActionOn && hide) { const elementId = rule.selectedFields[elementToPerformActionOn]; - addElementToList(componentsToHide, elementId); + hiddenFields.add(elementId); } }); - - return componentsToHide; -} - -function addElementToList(list: string[], elementToAdd: string) { - if (list.findIndex((element) => element === elementToAdd) === -1) { - list.push(elementToAdd); - } } diff --git a/src/altinn-app-frontend/src/utils/formLayout.test.ts b/src/altinn-app-frontend/src/utils/formLayout.test.ts index 3b9455f397..1766c6a3e8 100644 --- a/src/altinn-app-frontend/src/utils/formLayout.test.ts +++ b/src/altinn-app-frontend/src/utils/formLayout.test.ts @@ -833,7 +833,7 @@ describe('findChildren', () => { ]; const result1 = findChildren(layout, { - matching: (c) => c.required, + matching: (c) => c.required === true, rootGroupId: 'group1', }); @@ -884,7 +884,7 @@ describe('findChildren', () => { ]; const result1 = findChildren(layout, { - matching: (c) => c.required, + matching: (c) => c.required === true, }); expect(result1).toHaveLength(2); diff --git a/src/altinn-app-frontend/src/utils/formLayout.ts b/src/altinn-app-frontend/src/utils/formLayout.ts index b24bf98548..936a38fe11 100644 --- a/src/altinn-app-frontend/src/utils/formLayout.ts +++ b/src/altinn-app-frontend/src/utils/formLayout.ts @@ -438,8 +438,14 @@ export function getVariableTextKeysForRepeatingGroupComponent( return copyTextResourceBindings; } -export function hasRequiredFields(layout: ILayout) { - return layout.find((c: ILayoutComponent) => c.required); +/** + * Checks if there are required fields in this layout (or fields that potentially can be marked as required if some + * dynamic behaviour dictates it). + */ +export function hasRequiredFields(layout: ILayout): boolean { + return !!layout.find( + (c: ILayoutComponent) => c.required === true || Array.isArray(c.required), + ); } /** @@ -450,6 +456,7 @@ export function hasRequiredFields(layout: ILayout) { * @param options.matching Function which should return true for every component to be included in the returned list. * If not provided, all components are returned. * @param options.rootGroupId Component id for a group to use as root, instead of iterating the entire layout. + * @deprecated Use nodesInLayout() instead. TODO: Rewrite usages */ export function findChildren( layout: ILayout, diff --git a/src/shared/src/utils/instanceContext.test.ts b/src/altinn-app-frontend/src/utils/instanceContext.test.ts similarity index 86% rename from src/shared/src/utils/instanceContext.test.ts rename to src/altinn-app-frontend/src/utils/instanceContext.test.ts index a6c70e6bd2..d965e63b22 100644 --- a/src/shared/src/utils/instanceContext.test.ts +++ b/src/altinn-app-frontend/src/utils/instanceContext.test.ts @@ -1,5 +1,6 @@ -import type { IInstanceContext, IInstance } from '../../src/types'; -import { buildInstanceContext } from './instanceContext'; +import { buildInstanceContext } from 'src/utils/instanceContext'; + +import type { IInstance, IInstanceContext } from 'altinn-shared/types'; describe('instanceContext', () => { it('should build a valid instance context', () => { diff --git a/src/altinn-app-frontend/src/utils/instanceContext.ts b/src/altinn-app-frontend/src/utils/instanceContext.ts new file mode 100644 index 0000000000..85789fa5d0 --- /dev/null +++ b/src/altinn-app-frontend/src/utils/instanceContext.ts @@ -0,0 +1,32 @@ +import { createSelector } from 'reselect'; + +import type { IRuntimeState } from 'src/types'; + +import type { IInstance, IInstanceContext } from 'altinn-shared/types'; + +const getInstance = (state: IRuntimeState) => state.instanceData.instance; + +export function buildInstanceContext(instance: IInstance): IInstanceContext { + if (!instance) { + return null; + } + + return { + appId: instance.appId, + instanceId: instance.id, + instanceOwnerPartyId: instance.instanceOwner.partyId, + }; +} + +let selector: any = undefined; +export const getInstanceContextSelector = () => { + if (selector) { + return selector; + } + + selector = createSelector([getInstance], (instance) => + buildInstanceContext(instance), + ); + + return selector; +}; diff --git a/src/altinn-app-frontend/src/utils/layout/hierarchy.test.ts b/src/altinn-app-frontend/src/utils/layout/hierarchy.test.ts index a7223c5c29..ec3e375da7 100644 --- a/src/altinn-app-frontend/src/utils/layout/hierarchy.test.ts +++ b/src/altinn-app-frontend/src/utils/layout/hierarchy.test.ts @@ -1,3 +1,4 @@ +import { getRepeatingGroups } from 'src/utils/formLayout'; import { layoutAsHierarchy, layoutAsHierarchyWithRows, @@ -267,28 +268,48 @@ describe('Hierarchical layout tools', () => { { ...components.group2, rows: [ - [ - ...commonComponents(0), - { - ...components.group2n, - id: `${components.group2n.id}-0`, - baseComponentId: components.group2n.id, - baseDataModelBindings: components.group2n.dataModelBindings, - dataModelBindings: { group: 'MyModel.Group2[0].Nested' }, - rows: [nestedComponents(0, 0), nestedComponents(0, 1)], - }, - ], - [ - ...commonComponents(1), - { - ...components.group2n, - id: `${components.group2n.id}-1`, - baseComponentId: components.group2n.id, - baseDataModelBindings: components.group2n.dataModelBindings, - dataModelBindings: { group: 'MyModel.Group2[1].Nested' }, - rows: [nestedComponents(1, 0)], - }, - ], + { + index: 0, + items: [ + ...commonComponents(0), + { + ...components.group2n, + id: `${components.group2n.id}-0`, + baseComponentId: components.group2n.id, + baseDataModelBindings: components.group2n.dataModelBindings, + dataModelBindings: { group: 'MyModel.Group2[0].Nested' }, + rows: [ + { + index: 0, + items: nestedComponents(0, 0), + }, + { + index: 1, + items: nestedComponents(0, 1), + }, + ], + }, + ], + }, + { + index: 1, + items: [ + ...commonComponents(1), + { + ...components.group2n, + id: `${components.group2n.id}-1`, + baseComponentId: components.group2n.id, + baseDataModelBindings: components.group2n.dataModelBindings, + dataModelBindings: { group: 'MyModel.Group2[1].Nested' }, + rows: [ + { + index: 0, + items: nestedComponents(1, 0), + }, + ], + }, + ], + }, ], }, { ...components.group3, rows: [] }, @@ -299,8 +320,8 @@ describe('Hierarchical layout tools', () => { describe('nodesInLayout', () => { it('should resolve a very simple layout', () => { const root = new LayoutRootNode(); - const top1 = new LayoutNode(components.top1, root); - const top2 = new LayoutNode(components.top2, root); + const top1 = new LayoutNode(components.top1, root, root); + const top2 = new LayoutNode(components.top2, root, root); root._addChild(top1); root._addChild(top2); @@ -460,6 +481,68 @@ describe('Hierarchical layout tools', () => { otherDeepComponent.closest((c) => c.id === 'not-found'), ).toBeUndefined(); }); + + it('should support indexes when using start/stop in groups', () => { + const dataModel = { + 'Group[0].Title': 'title0', + 'Group[1].Title': 'title1', + 'Group[2].Title': 'title2', + 'Group[3].Title': 'title3', + 'Group[4].Title': 'title4', + 'Group[5].Title': 'title5', + 'Group[6].Title': 'title6', + 'Group[7].Title': 'title7', + 'Group[8].Title': 'title8', + }; + const layout: ILayout = [ + { + id: 'g1', + type: 'Group', + maxCount: 99, + children: ['g1c'], + dataModelBindings: { group: 'Group' }, + edit: { + filter: [ + { key: 'start', value: '0' }, + { key: 'stop', value: '3' }, + ], + }, + }, + { + id: 'g1c', + type: 'Input', + dataModelBindings: { simpleBinding: 'Group.Title' }, + }, + { + id: 'g2', + type: 'Group', + maxCount: 99, + children: ['g2c'], + dataModelBindings: { group: 'Group' }, + edit: { + filter: [ + { key: 'start', value: '3' }, + { key: 'stop', value: '6' }, + ], + }, + }, + { + id: 'g2c', + type: 'Input', + dataModelBindings: { simpleBinding: 'Group.Title' }, + }, + ]; + const nodes = nodesInLayout( + layout, + getRepeatingGroups(layout, dataModel), + ); + + expect(nodes.findAllById('g1c').length).toEqual(3); + expect(nodes.findAllById('g2c').length).toEqual(3); + + expect(nodes.findById('g1c-0').rowIndex).toEqual(0); + expect(nodes.findById('g2c-3').rowIndex).toEqual(3); + }); }); describe('resolvedNodesInLayout', () => { @@ -504,11 +587,11 @@ describe('Hierarchical layout tools', () => { expect(uniqueHidden(nodes.children())).toEqual(plain); if (group2.item.type === 'Group' && 'rows' in group2.item) { - expect(group2.item.rows[0][1].hidden).toEqual(true); - expect(group2.item.rows[0][2].hidden).toEqual(true); - const group2n = group2.item.rows[0][2]; + expect(group2.item.rows[0].items[1].hidden).toEqual(true); + expect(group2.item.rows[0].items[2].hidden).toEqual(true); + const group2n = group2.item.rows[0].items[2]; if (group2n.type === 'Group' && 'rows' in group2n) { - expect(group2n.rows[0][1].hidden).toEqual(true); + expect(group2n.rows[0].items[1].hidden).toEqual(true); } else { expect(false).toEqual(true); } diff --git a/src/altinn-app-frontend/src/utils/layout/hierarchy.ts b/src/altinn-app-frontend/src/utils/layout/hierarchy.ts index a728fe8c32..d6be9a8ea1 100644 --- a/src/altinn-app-frontend/src/utils/layout/hierarchy.ts +++ b/src/altinn-app-frontend/src/utils/layout/hierarchy.ts @@ -5,6 +5,7 @@ import { } from 'src/features/expressions'; import { DataBinding } from 'src/utils/databindings/DataBinding'; import { getRepeatingGroupStartStopIndex } from 'src/utils/formLayout'; +import { buildInstanceContext } from 'src/utils/instanceContext'; import type { ContextDataSources } from 'src/features/expressions/ExprContext'; import type { ILayout, @@ -28,8 +29,6 @@ import type { RepeatingGroupLayoutComponent, } from 'src/utils/layout/hierarchy.types'; -import { buildInstanceContext } from 'altinn-shared/utils/instanceContext'; - export const childrenWithoutMultiPagePrefix = (group: ILayoutGroup) => group.edit?.multiPage ? group.children.map((componentId) => componentId.replace(/^\d+:/g, '')) @@ -130,7 +129,7 @@ interface HierarchyParent { */ export function layoutAsHierarchyWithRows( formLayout: ILayout, - repeatingGroups: IRepeatingGroups, + repeatingGroups: IRepeatingGroups | null, ): HierarchyWithRows[] { const rewriteBindings = ( main: LayoutGroupHierarchy, @@ -164,11 +163,11 @@ export function layoutAsHierarchyWithRows( if (main.type === 'Group' && main.maxCount > 1) { const rows: RepeatingGroupHierarchy['rows'] = []; const { startIndex, stopIndex } = getRepeatingGroupStartStopIndex( - repeatingGroups[main.id]?.index, + (repeatingGroups || {})[main.id]?.index, main.edit, ); for (let index = startIndex; index <= stopIndex; index++) { - const row = main.childComponents.map((child) => { + const items = main.childComponents.map((child) => { const suffix = parent ? `-${parent.index}-${index}` : `-${index}`; const newId = `${child.id}${suffix}`; const newChild: RepeatingGroupLayoutComponent = { @@ -186,7 +185,7 @@ export function layoutAsHierarchyWithRows( binding: main.dataModelBindings?.group, }); }); - rows.push(row); + rows.push({ items, index }); } const out: RepeatingGroupHierarchy = { ...main, rows }; @@ -343,6 +342,7 @@ export class LayoutNode< public constructor( public item: Item, public parent: AnyParentNode, + public top: LayoutRootNode, public readonly rowIndex?: number, ) {} @@ -388,20 +388,22 @@ export class LayoutNode< return parents; } - private childrenAsList(onlyInRowIndex?: number) { - let list: AnyItem[]; + private childrenIdsAsList(onlyInRowIndex?: number) { + let list: AnyItem[] = []; if (this.item.type === 'Group' && 'rows' in this.item) { if (typeof onlyInRowIndex === 'number') { - list = this.item.rows[onlyInRowIndex]; + list = this.item.rows.find((r) => r.index === onlyInRowIndex).items; } else { // Beware: In most cases this will just match the first row. - list = this.item.rows.flat(); + list = Object.values(this.item.rows) + .map((r) => r.items) + .flat(); } } else if (this.item.type === 'Group' && 'childComponents' in this.item) { list = this.item.childComponents; } - return list; + return list.map((item) => item.id); } /** @@ -418,19 +420,20 @@ export class LayoutNode< matching?: (item: AnyItem) => boolean, onlyInRowIndex?: number, ): any { - const list = this.childrenAsList(onlyInRowIndex); + const list = this.childrenIdsAsList(onlyInRowIndex); if (!matching) { if (!list) { return []; } - return list.map((item) => new LayoutNode(item, this)); + return list.map((id) => this.top.findById(id)); } if (typeof list !== 'undefined') { - for (const item of list) { - if (matching(item)) { - return new LayoutNode(item, this); + for (const id of list) { + const node = this.top.findById(id); + if (matching(node.item)) { + return node; } } } @@ -586,7 +589,7 @@ export class LayoutNode< */ export function nodesInLayout( formLayout: ILayout, - repeatingGroups: IRepeatingGroups, + repeatingGroups: IRepeatingGroups | null, ): LayoutRootNode { const root = new LayoutRootNode(); @@ -600,18 +603,17 @@ export function nodesInLayout( const group: AnyParentNode = new LayoutNode( component, parent, + root, rowIndex, ); - component.rows.forEach((row, rowIndex) => - recurse(row, group, rowIndex), - ); + component.rows.forEach((row) => recurse(row.items, group, row.index)); root._addChild(group); } else if (component.type === 'Group' && 'childComponents' in component) { - const group = new LayoutNode(component, parent, rowIndex); + const group = new LayoutNode(component, parent, root, rowIndex); recurse(component.childComponents, group); root._addChild(group); } else { - const node = new LayoutNode(component, parent, rowIndex); + const node = new LayoutNode(component, parent, root, rowIndex); root._addChild(node); } } @@ -630,7 +632,7 @@ export function nodesInLayout( */ export function resolvedNodesInLayout( formLayout: ILayout, - repeatingGroups: IRepeatingGroups, + repeatingGroups: IRepeatingGroups | null, dataSources: ContextDataSources, ): LayoutRootNode<'resolved'> { // A full copy is needed here because formLayout comes from the redux store, and in production code (not the diff --git a/src/altinn-app-frontend/src/utils/layout/hierarchy.types.d.ts b/src/altinn-app-frontend/src/utils/layout/hierarchy.types.d.ts index 440ec51002..728e1a9299 100644 --- a/src/altinn-app-frontend/src/utils/layout/hierarchy.types.d.ts +++ b/src/altinn-app-frontend/src/utils/layout/hierarchy.types.d.ts @@ -34,12 +34,17 @@ export interface RepeatingGroupExtensions { export type RepeatingGroupLayoutComponent = RepeatingGroupExtensions & ComponentOf; +export type RepeatingGroupHierarchyRow = { + index: number; + items: HierarchyWithRowsChildren[]; +}; + export type RepeatingGroupHierarchy = Omit< LayoutGroupHierarchy, 'childComponents' | 'children' > & RepeatingGroupExtensions & { - rows: HierarchyWithRowsChildren[][]; + rows: RepeatingGroupHierarchyRow[]; }; /** diff --git a/src/altinn-app-frontend/src/utils/validation/runClientSideValidation.ts b/src/altinn-app-frontend/src/utils/validation/runClientSideValidation.ts index 16b889b4a3..1bd496c2ab 100644 --- a/src/altinn-app-frontend/src/utils/validation/runClientSideValidation.ts +++ b/src/altinn-app-frontend/src/utils/validation/runClientSideValidation.ts @@ -1,6 +1,7 @@ import { getLayoutOrderFromTracks } from 'src/selectors/getLayoutOrder'; import { getCurrentDataTypeId } from 'src/utils/appMetadata'; import { convertDataBindingToModel } from 'src/utils/databindings'; +import { resolvedLayoutsFromState } from 'src/utils/layout/hierarchy'; import { getValidator, validateEmptyFields, @@ -25,13 +26,15 @@ export function runClientSideValidation(state: IRuntimeState) { state.formDataModel.schemas, ); + const hiddenFields = new Set(state.formLayout.uiConfig.hiddenFields); const layoutOrder = getLayoutOrderFromTracks( state.formLayout.uiConfig.tracks, ); + const layouts = resolvedLayoutsFromState(state); const validationResult = validateFormData( model, - state.formLayout.layouts, + layouts, layoutOrder, validator, state.language.language, @@ -39,20 +42,18 @@ export function runClientSideValidation(state: IRuntimeState) { ); const componentSpecificValidations = validateFormComponents( state.attachments.attachments, - state.formLayout.layouts, + layouts, layoutOrder, state.formData.formData, state.language.language, - state.formLayout.uiConfig.hiddenFields, - state.formLayout.uiConfig.repeatingGroups, + hiddenFields, ); const emptyFieldsValidations = validateEmptyFields( state.formData.formData, - state.formLayout.layouts, + layouts, layoutOrder, state.language.language, - state.formLayout.uiConfig.hiddenFields, - state.formLayout.uiConfig.repeatingGroups, + hiddenFields, state.textResources.resources, ); return { diff --git a/src/altinn-app-frontend/src/utils/validation/validation.test.ts b/src/altinn-app-frontend/src/utils/validation/validation.test.ts index 6932d100bf..ef8f46de58 100644 --- a/src/altinn-app-frontend/src/utils/validation/validation.test.ts +++ b/src/altinn-app-frontend/src/utils/validation/validation.test.ts @@ -5,11 +5,23 @@ import * as refOnRootSchema from '__mocks__/json-schema/ref-on-root.json'; import { getMockValidationState } from '__mocks__/validationStateMock'; import Ajv from 'ajv'; import Ajv2020 from 'ajv/dist/2020'; +import dot from 'dot-object'; import { Severity } from 'src/types'; -import { createRepeatingGroupComponents } from 'src/utils/formLayout'; +import { + createRepeatingGroupComponents, + getRepeatingGroups, +} from 'src/utils/formLayout'; +import { + LayoutRootNodeCollection, + nodesInLayout, +} from 'src/utils/layout/hierarchy'; import * as validation from 'src/utils/validation/validation'; -import type { ILayoutComponent, ILayoutGroup } from 'src/features/form/layout'; +import type { + ILayoutComponent, + ILayoutGroup, + ILayouts, +} from 'src/features/form/layout'; import type { IComponentBindingValidation, IComponentValidations, @@ -20,12 +32,44 @@ import type { IValidationIssue, IValidations, } from 'src/types'; +import type { LayoutRootNode } from 'src/utils/layout/hierarchy'; import { getParsedLanguageFromKey, getTextResourceByKey, } from 'altinn-shared/index'; +function toCollection( + mockLayout: ILayouts, + repeatingGroups: IRepeatingGroups = {}, +) { + const asNodes = {}; + for (const key of Object.keys(mockLayout)) { + asNodes[key] = nodesInLayout( + mockLayout[key], + repeatingGroups, + ) as unknown as LayoutRootNode<'resolved'>; + } + return new LayoutRootNodeCollection<'resolved'>( + Object.keys(mockLayout)[0] as keyof typeof asNodes, + asNodes, + ); +} + +function toCollectionFromData(mockLayout: ILayouts, formDataAsObject: any) { + const formData = dot.dot(formDataAsObject); + let repeatingGroups = {}; + + for (const layout of Object.values(mockLayout)) { + repeatingGroups = { + ...repeatingGroups, + ...getRepeatingGroups(layout, formData), + }; + } + + return toCollection(mockLayout, repeatingGroups); +} + describe('utils > validation', () => { let mockLayout: any; let mockReduxFormat: IValidations; @@ -93,6 +137,16 @@ describe('utils > validation', () => { id: 'c6Title', value: 'component_6', }, + { + id: 'withGroupVariables', + value: '{0}', + variables: [ + { + key: 'group_1[{0}].dataModelField_4', + dataSource: 'dataModel.default', + }, + ], + }, ]; mockComponent4 = { @@ -721,14 +775,13 @@ describe('utils > validation', () => { describe('validateFormComponents', () => { it('should return error on fileUpload if its not enough files', () => { - const componentSpesificValidations = validation.validateFormComponents( + const componentSpecificValidations = validation.validateFormComponents( mockFormAttachments.attachments, - mockLayoutState.layouts, + toCollection(mockLayout), Object.keys(mockLayoutState.layouts), mockFormData, mockLanguage.language, - [], - {}, + new Set(), ); const mockResult = { @@ -742,21 +795,20 @@ describe('utils > validation', () => { }, }; - expect(componentSpesificValidations).toEqual(mockResult); + expect(componentSpecificValidations).toEqual(mockResult); }); it('should return error on fileUpload if its no file', () => { mockFormAttachments = { attachments: null, }; - const componentSpesificValidations = validation.validateFormComponents( + const componentSpecificValidations = validation.validateFormComponents( mockFormAttachments.attachments, - mockLayoutState.layouts, + toCollection(mockLayout), Object.keys(mockLayoutState.layouts), mockFormData, mockLanguage.language, - [], - {}, + new Set(), ); const mockResult = { @@ -770,7 +822,7 @@ describe('utils > validation', () => { }, }; - expect(componentSpesificValidations).toEqual(mockResult); + expect(componentSpecificValidations).toEqual(mockResult); }); it('should not return error on fileUpload if its enough files', () => { @@ -785,21 +837,20 @@ describe('utils > validation', () => { }, ], }; - const componentSpesificValidations = validation.validateFormComponents( + const componentSpecificValidations = validation.validateFormComponents( mockFormAttachments.attachments, - mockLayout, + toCollection(mockLayout), Object.keys(mockLayout), mockFormData, mockLanguage.language, - [], - {}, + new Set(), ); const mockResult = { FormLayout: {}, }; - expect(componentSpesificValidations).toEqual(mockResult); + expect(componentSpecificValidations).toEqual(mockResult); }); it('should not return error if element is hidden', () => { @@ -814,21 +865,20 @@ describe('utils > validation', () => { }, ], }; - const componentSpesificValidations = validation.validateFormComponents( + const componentSpecificValidations = validation.validateFormComponents( mockFormAttachments.attachments, - mockLayout, + toCollection(mockLayout), Object.keys(mockLayout), mockFormData, mockLanguage.language, - ['componentId_4'], - {}, + new Set(['componentId_4']), ); const mockResult = { FormLayout: {}, }; - expect(componentSpesificValidations).toEqual(mockResult); + expect(componentSpecificValidations).toEqual(mockResult); }); it('should not return error if element is part of layout not present in layoutOrder (sporvalg)', () => { @@ -843,35 +893,34 @@ describe('utils > validation', () => { }, ], }; - const componentSpesificValidations = validation.validateFormComponents( + const componentSpecificValidations = validation.validateFormComponents( mockFormAttachments.attachments, - mockLayout, + toCollection(mockLayout), [], mockFormData, mockLanguage.language, - [], - {}, + new Set(), ); - expect(componentSpesificValidations).toEqual({}); + expect(componentSpecificValidations).toEqual({}); }); }); describe('validateEmptyFields', () => { + const repeatingGroups = { + group1: { + index: 0, + editIndex: -1, + }, + }; + it('should return error if empty fields are required', () => { - const repeatingGroups = { - group1: { - index: 0, - editIndex: -1, - }, - }; - const componentSpesificValidations = validation.validateEmptyFields( + const componentSpecificValidations = validation.validateEmptyFields( mockFormData, - mockLayout, + toCollection(mockLayout, repeatingGroups), Object.keys(mockLayout), mockLanguage.language, - [], - repeatingGroups, + new Set(), mockTextResources, ); @@ -903,23 +952,16 @@ describe('utils > validation', () => { }, }; - expect(componentSpesificValidations).toEqual(mockResult); + expect(componentSpecificValidations).toEqual(mockResult); }); it('should not return error for repeating group if child is hidden', () => { - const repeatingGroups = { - group1: { - index: 0, - editIndex: -1, - }, - }; - const componentSpesificValidations = validation.validateEmptyFields( + const componentSpecificValidations = validation.validateEmptyFields( mockFormData, - mockLayout, + toCollection(mockLayout, repeatingGroups), Object.keys(mockLayout), mockLanguage.language, - ['componentId_4-0'], - repeatingGroups, + new Set(['componentId_4-0']), mockTextResources, ); @@ -945,38 +987,29 @@ describe('utils > validation', () => { }, }; - expect(componentSpesificValidations).toEqual(mockResult); + expect(componentSpecificValidations).toEqual(mockResult); }); it('should not return error if component is not part of layout order (sporvalg)', () => { - const repeatingGroups = { - group1: { - index: 0, - editIndex: -1, - }, - }; - const componentSpesificValidations = validation.validateEmptyFields( + const componentSpecificValidations = validation.validateEmptyFields( mockFormData, - mockLayout, + toCollection(mockLayout, repeatingGroups), [], mockLanguage.language, - [], - repeatingGroups, + new Set(), mockTextResources, ); - expect(componentSpesificValidations).toEqual({}); + expect(componentSpecificValidations).toEqual({}); }); it('should add error to validations if supplied field is required', () => { - const component = mockLayout.FormLayout.find( - (c) => c.id === 'componentId_3', - ); + const collection = toCollection(mockLayout, repeatingGroups); + const component = collection.findComponentById('componentId_3'); const validations = {}; - validations[component.id] = validation.validateEmptyField( + validations[component.item.id] = validation.validateEmptyField( mockFormData, - component.dataModelBindings, - component.textResourceBindings, + component, mockTextResources, mockLanguage.language, ); @@ -994,14 +1027,12 @@ describe('utils > validation', () => { }); it('should find all errors in an AddressComponent', () => { - const component = mockLayout.FormLayout.find( - (c) => c.id === 'componentId_6', - ); + const collection = toCollection(mockLayout, repeatingGroups); + const component = collection.findComponentById('componentId_6'); const validations = {}; - validations[component.id] = validation.validateEmptyField( + validations[component.item.id] = validation.validateEmptyField( mockFormData, - component.dataModelBindings, - component.textResourceBindings, + component, mockTextResources, mockLanguage.language, ); @@ -1016,21 +1047,50 @@ describe('utils > validation', () => { expect(validations).toEqual(mockResult); }); + + it('should replace variables in text resources', () => { + const validations: any = validation.validateEmptyFields( + mockFormData, + toCollection( + { + FormLayout: [ + mockGroup1, + { + ...mockComponent4, + textResourceBindings: { + title: 'withGroupVariables', + }, + }, + mockGroup2, + mockComponent5, + ], + }, + repeatingGroups, + ), + Object.keys(mockLayout), + mockLanguage.language, + new Set(), + mockTextResources, + ); + + expect( + validations.FormLayout['componentId_4-0'].simpleBinding.errors, + ).toEqual(['Du må fylle ut withGroupVariables-0']); + }); }); - describe('validateEmptyFieldsForLayout', () => { + describe('validateEmptyFieldsForNodes', () => { const _with = ({ formData = {}, formLayout = mockLayout.FormLayout, hiddenFields = [], repeatingGroups = {}, }) => - validation.validateEmptyFieldsForLayout( + validation.validateEmptyFieldsForNodes( formData, - formLayout, + toCollection({ FormLayout: formLayout }, repeatingGroups).current(), mockLanguage.language, - hiddenFields, - repeatingGroups, + new Set(hiddenFields), mockTextResources, ); @@ -1302,7 +1362,7 @@ describe('utils > validation', () => { const mockValidator = validation.createValidator(mockJsonSchema); const mockResult = validation.validateFormData( mockFormData, - mockLayoutState.layouts, + toCollectionFromData(mockLayout, mockFormData), Object.keys(mockLayoutState.layouts), mockValidator, mockLanguage.language, @@ -1315,7 +1375,7 @@ describe('utils > validation', () => { const mockValidator = validation.createValidator(mockJsonSchema); const mockResult = validation.validateFormData( mockValidFormData, - mockLayoutState.layouts, + toCollectionFromData(mockLayout, mockValidFormData), Object.keys(mockLayoutState.layouts), mockValidator, mockLanguage, @@ -1336,7 +1396,7 @@ describe('utils > validation', () => { const mockResult = validation.validateFormData( formData, - mockLayoutState.layouts, + toCollection(mockLayout), Object.keys(mockLayoutState.layouts), mockValidator, mockLanguage, @@ -1360,7 +1420,7 @@ describe('utils > validation', () => { const mockValidator = validation.createValidator(mockJsonSchema); const mockResult = validation.validateFormData( data, - mockLayoutState.layouts, + toCollection(mockLayout), Object.keys(mockLayoutState.layouts), mockValidator, mockLanguage, @@ -1373,7 +1433,7 @@ describe('utils > validation', () => { const mockValidator = validation.createValidator(mockJsonSchema); const mockResult = validation.validateFormData( mockFormData, - mockLayoutState.layouts, + toCollection(mockLayout), [], mockValidator, mockLanguage.language, @@ -1648,9 +1708,9 @@ describe('utils > validation', () => { describe('validation.mapToComponentValidations', () => { it('should map validation to correct component', () => { const validations = {}; - validation.mapToComponentValidations( + validation.mapToComponentValidationsGivenNode( 'FormLayout', - mockLayout.FormLayout, + toCollection(mockLayout).current(), 'dataModelField_2', 'some error', validations, @@ -1669,9 +1729,14 @@ describe('utils > validation', () => { it('should map validation to correct component for component in a repeating group', () => { const validations = {}; - validation.mapToComponentValidations( + validation.mapToComponentValidationsGivenNode( 'FormLayout', - mockLayout.FormLayout, + toCollection(mockLayout, { + group1: { + index: 0, + editIndex: -1, + }, + }).current(), 'group_1[0].dataModelField_4', 'some error', validations, @@ -1690,9 +1755,18 @@ describe('utils > validation', () => { it('should map validation to correct component for component in a nested repeating group', () => { const validations = {}; - validation.mapToComponentValidations( + validation.mapToComponentValidationsGivenNode( 'FormLayout', - mockLayout.FormLayout, + toCollection(mockLayout, { + group1: { + index: 0, + editIndex: -1, + }, + 'group2-0': { + index: 0, + editIndex: -1, + }, + }).current(), 'group_1[0].group_2[0].dataModelField_5', 'some error', validations, @@ -1801,6 +1875,9 @@ describe('utils > validation', () => { }, instanceData: { instance: { + instanceOwner: { + partyId: 12345, + }, process: { currentTask: { elementId: 'default', @@ -1837,6 +1914,10 @@ describe('utils > validation', () => { index: 0, editIndex: -1, }, + 'group2-0': { + index: 1, + editIndex: -1, + }, }, }, } as any, @@ -1891,6 +1972,9 @@ describe('utils > validation', () => { }, instanceData: { instance: { + instanceOwner: { + partyId: 12345, + }, process: { currentTask: { elementId: 'default', @@ -1927,8 +2011,8 @@ describe('utils > validation', () => { index: 0, editIndex: -1, }, - group2: { - index: 0, + 'group2-0': { + index: 1, editIndex: -1, }, }, diff --git a/src/altinn-app-frontend/src/utils/validation/validation.ts b/src/altinn-app-frontend/src/utils/validation/validation.ts index e6d3f253e1..61eb89152e 100644 --- a/src/altinn-app-frontend/src/utils/validation/validation.ts +++ b/src/altinn-app-frontend/src/utils/validation/validation.ts @@ -22,11 +22,11 @@ import { } from 'src/utils/formComponentUtils'; import { createRepeatingGroupComponents, - getRepeatingGroupStartStopIndex, getVariableTextKeysForRepeatingGroupComponent, - splitDashedKey, } from 'src/utils/formLayout'; +import { buildInstanceContext } from 'src/utils/instanceContext'; import { matchLayoutComponent, setupGroupComponents } from 'src/utils/layout'; +import { resolvedNodesInLayout } from 'src/utils/layout/hierarchy'; import type { IFormData } from 'src/features/form/data'; import type { ILayout, @@ -48,11 +48,16 @@ import type { IRuntimeState, ISchemaValidator, ITextResource, - ITextResourceBindings, IValidationIssue, IValidationResult, IValidations, } from 'src/types'; +import type { + LayoutNode, + LayoutObject, + LayoutRootNode, + LayoutRootNodeCollection, +} from 'src/utils/layout/hierarchy'; import { getLanguageFromKey, @@ -207,185 +212,51 @@ export const errorMessageKeys = { export function validateEmptyFields( formData: IFormData, - layouts: ILayouts, + layouts: LayoutRootNodeCollection<'resolved'>, layoutOrder: string[], language: ILanguage, - hiddenFields: string[], - repeatingGroups: IRepeatingGroups, + hiddenFields: Set, textResources: ITextResource[], ) { const validations = {}; - Object.keys(layouts).forEach((id) => { + const allLayouts = layouts.all(); + for (const id of Object.keys(allLayouts)) { if (layoutOrder.includes(id)) { - validations[id] = validateEmptyFieldsForLayout( + validations[id] = validateEmptyFieldsForNodes( formData, - layouts[id], + allLayouts[id], language, hiddenFields, - repeatingGroups, textResources, ); } - }); - return validations; -} - -interface IteratedComponent { - component: ILayoutComponent; - groupDataModelBinding?: string; - index?: number; -} - -export function* iterateFieldsInLayout( - formLayout: ILayout, - repeatingGroups: IRepeatingGroups, - hiddenFields?: string[], - filter?: (component: ILayoutComponent) => boolean, -): Generator { - const allGroups = formLayout.filter( - (c) => c.type === 'Group', - ) as ILayoutGroup[]; - const childrenWithoutMultiPagePrefix = (group: ILayoutGroup) => - group.edit?.multiPage - ? group.children.map((componentId) => componentId.replace(/^\d+:/g, '')) - : group.children; - - const fieldsInGroup = allGroups.map(childrenWithoutMultiPagePrefix).flat(); - const groupsToCheck = allGroups.filter( - (group) => !hiddenFields?.includes(group.id), - ); - const fieldsToCheck = formLayout.filter( - (component) => - component.type !== 'Group' && - !hiddenFields?.includes(component.id) && - (filter ? filter(component) : true) && - !fieldsInGroup.includes(component.id), - ) as ILayoutComponent[]; - - for (const component of fieldsToCheck) { - yield { component }; - } - - for (const group of groupsToCheck) { - const componentsToCheck = formLayout.filter( - (component) => - component.type !== 'Group' && - (filter ? filter(component) : true) && - childrenWithoutMultiPagePrefix(group).indexOf(component.id) > -1 && - !hiddenFields?.includes(component.id), - ) as ILayoutComponent[]; - - for (const component of componentsToCheck) { - if (group.maxCount > 1) { - const parentGroup = getParentGroup(group.id, formLayout); - if (parentGroup) { - // If we have a parent group there can exist several instances of the child group. - const allGroupIds = Object.keys(repeatingGroups).filter((key) => - key.startsWith(group.id), - ); - for (const childGroupId of allGroupIds) { - const splitId = splitDashedKey(childGroupId); - const parentIndex = splitId.depth[splitId.depth.length - 1]; - const parentDataBinding = parentGroup.dataModelBindings?.group; - const indexedParentDataBinding = `${parentDataBinding}[${parentIndex}]`; - const indexedGroupDataBinding = - group.dataModelBindings?.group.replace( - parentDataBinding, - indexedParentDataBinding, - ); - const dataModelBindings = {}; - for (const key of Object.keys(component.dataModelBindings)) { - dataModelBindings[key] = component.dataModelBindings[key].replace( - parentDataBinding, - indexedParentDataBinding, - ); - } - - for ( - let index = 0; - index <= repeatingGroups[childGroupId]?.index; - index++ - ) { - const componentToCheck = { - ...component, - id: `${component.id}-${parentIndex}-${index}`, - dataModelBindings, - } as ILayoutComponent; - if (!hiddenFields?.includes(componentToCheck.id)) { - yield { - component: componentToCheck, - groupDataModelBinding: indexedGroupDataBinding, - index: index, - }; - } - } - } - } else { - const groupDataModelBinding = group.dataModelBindings.group; - const { startIndex, stopIndex } = getRepeatingGroupStartStopIndex( - repeatingGroups[group.id]?.index, - group.edit, - ); - for (let index = startIndex; index <= stopIndex; index++) { - const componentToCheck = { - ...component, - id: `${component.id}-${index}`, - } as ILayoutComponent; - if (!hiddenFields?.includes(componentToCheck.id)) { - yield { - component: componentToCheck, - groupDataModelBinding, - index, - }; - } - } - } - } else { - yield { component }; - } - } } + return validations; } -/* - Fetches validations for fields without data -*/ -export function validateEmptyFieldsForLayout( +export function validateEmptyFieldsForNodes( formData: IFormData, - formLayout: ILayout, + nodes: LayoutRootNode<'resolved'> | LayoutNode<'resolved'>, language: ILanguage, - hiddenFields: string[], - repeatingGroups: IRepeatingGroups, + hiddenFields: Set, textResources: ITextResource[], ): ILayoutValidations { const validations: any = {}; - const generator = iterateFieldsInLayout( - formLayout, - repeatingGroups, - hiddenFields, - (component) => component.required, - ); - for (const { component, groupDataModelBinding, index } of generator) { + for (const node of nodes.flat(false)) { if ( - component.type === 'FileUpload' || - component.type === 'FileUploadWithTag' - ) { // These components have their own validation in validateFormComponents(). With data model bindings enabled for // attachments, the empty field validations would interfere. + node.item.type === 'FileUpload' || + node.item.type === 'FileUploadWithTag' || + node.item.required === false || + node.isHidden(hiddenFields) + ) { continue; } - const result = validateEmptyField( - formData, - component.dataModelBindings, - component.textResourceBindings, - textResources, - language, - groupDataModelBinding, - index, - ); + const result = validateEmptyField(formData, node, textResources, language); if (result !== null) { - validations[component.id] = result; + validations[node.item.id] = result; } } @@ -423,27 +294,22 @@ export function getGroupChildren( export function validateEmptyField( formData: any, - dataModelBindings: IDataModelBindings, - textResourceBindings: ITextResourceBindings, + node: LayoutNode<'resolved'>, textResources: ITextResource[], language: ILanguage, - groupDataBinding?: string, - index?: number, ): IComponentValidations { - if (!dataModelBindings) { + if (!node.item.dataModelBindings) { return null; } const fieldKeys = Object.keys( - dataModelBindings, + node.item.dataModelBindings, ) as (keyof IDataModelBindings)[]; const componentValidations: IComponentValidations = {}; fieldKeys.forEach((fieldKey) => { const value = getFormDataFromFieldKey( fieldKey, - dataModelBindings, + node.item.dataModelBindings, formData, - groupDataBinding, - index, ); if (!value && fieldKey) { componentValidations[fieldKey] = { @@ -451,16 +317,17 @@ export function validateEmptyField( warnings: [], }; - const textResourceBindingsOrTextKeysForRepeatingGroups = groupDataBinding - ? getVariableTextKeysForRepeatingGroupComponent( - textResources, - textResourceBindings, - index, - ) - : textResourceBindings; + const textResource = + node.rowIndex === undefined + ? node.item.textResourceBindings + : getVariableTextKeysForRepeatingGroupComponent( + textResources, + node.item.textResourceBindings, + node.rowIndex, + ); const fieldName = getFieldName( - textResourceBindingsOrTextKeysForRepeatingGroups, + textResource, textResources, language, fieldKey !== 'simpleBinding' ? fieldKey : undefined, @@ -483,26 +350,25 @@ export function validateEmptyField( export function validateFormComponents( attachments: IAttachments, - layouts: ILayouts, + nodeLayout: LayoutRootNodeCollection<'resolved'>, layoutOrder: string[], formData: IFormData, language: ILanguage, - hiddenFields: string[], - repeatingGroups: IRepeatingGroups, + hiddenFields: Set, ) { const validations: any = {}; - Object.keys(layouts).forEach((id) => { + const layouts = nodeLayout.all(); + for (const id of Object.keys(layouts)) { if (layoutOrder.includes(id)) { - validations[id] = validateFormComponentsForLayout( + validations[id] = validateFormComponentsForNodes( attachments, layouts[id], formData, language, hiddenFields, - repeatingGroups, ); } - }); + } return validations; } @@ -510,55 +376,58 @@ export function validateFormComponents( /* Fetches component specific validations */ -export function validateFormComponentsForLayout( +function validateFormComponentsForNodes( attachments: IAttachments, - formLayout: ILayout, + nodes: LayoutRootNode<'resolved'> | LayoutNode<'resolved'>, formData: IFormData, language: ILanguage, - hiddenFields: string[], - repeatingGroups: IRepeatingGroups, + hiddenFields: Set, ): ILayoutValidations { const validations: ILayoutValidations = {}; const fieldKey: keyof IDataModelBindings = 'simpleBinding'; - for (const { component } of iterateFieldsInLayout( - formLayout, - repeatingGroups, - hiddenFields, - )) { - if (component.type === 'FileUpload') { - if (!attachmentsValid(attachments, component)) { - validations[component.id] = { - [fieldKey]: { - errors: [], - warnings: [], - }, - }; - validations[component.id][fieldKey].errors.push( - `${getLanguageFromKey( - 'form_filler.file_uploader_validation_error_file_number_1', - language, - )} ${component.minNumberOfAttachments} ${getLanguageFromKey( - 'form_filler.file_uploader_validation_error_file_number_2', - language, - )}`, - ); - } - } else if (component.type === 'FileUploadWithTag') { - validations[component.id] = { + const flatNodes = nodes.flat(false); + + for (const node of flatNodes) { + if (node.isHidden(hiddenFields)) { + continue; + } + + if ( + node.item.type === 'FileUpload' && + !attachmentsValid(attachments, node.item) + ) { + validations[node.item.id] = { + [fieldKey]: { + errors: [], + warnings: [], + }, + }; + validations[node.item.id][fieldKey].errors.push( + `${getLanguageFromKey( + 'form_filler.file_uploader_validation_error_file_number_1', + language, + )} ${node.item.minNumberOfAttachments} ${getLanguageFromKey( + 'form_filler.file_uploader_validation_error_file_number_2', + language, + )}`, + ); + } + if (node.item.type === 'FileUploadWithTag') { + validations[node.item.id] = { [fieldKey]: { errors: [], warnings: [], }, }; - if (attachmentsValid(attachments, component)) { - const missingTagAttachments = attachments[component.id] + if (attachmentsValid(attachments, node.item)) { + const missingTagAttachments = attachments[node.item.id] ?.filter((attachment) => attachmentIsMissingTag(attachment)) .map((attachment) => attachment.id); if (missingTagAttachments?.length > 0) { missingTagAttachments.forEach((missingId) => { - validations[component.id][fieldKey].errors.push( + validations[node.item.id][fieldKey].errors.push( `${ missingId + AsciiUnitSeparator + @@ -567,50 +436,45 @@ export function validateFormComponentsForLayout( language, ) } ${( - component.textResourceBindings.tagTitle || '' + node.item.textResourceBindings.tagTitle || '' ).toLowerCase()}.`, ); }); } } else { - validations[component.id][fieldKey].errors.push( + validations[node.item.id][fieldKey].errors.push( `${getLanguageFromKey( 'form_filler.file_uploader_validation_error_file_number_1', language, - )} ${component.minNumberOfAttachments} ${getLanguageFromKey( + )} ${node.item.minNumberOfAttachments} ${getLanguageFromKey( 'form_filler.file_uploader_validation_error_file_number_2', language, )}`, ); } } - } - for (const component of formLayout) { - if (hiddenFields.includes(component.id)) { - continue; - } - if (component.type === 'DatePicker') { + if (node.item.type === 'DatePicker') { let componentValidations: IComponentValidations = {}; const date = getFormDataForComponent( formData, - component.dataModelBindings, + node.item.dataModelBindings, ); const flagBasedMinDate = - getFlagBasedDate(component.minDate as DateFlags) ?? component.minDate; + getFlagBasedDate(node.item.minDate as DateFlags) ?? node.item.minDate; const flagBasedMaxDate = - getFlagBasedDate(component.maxDate as DateFlags) ?? component.maxDate; + getFlagBasedDate(node.item.maxDate as DateFlags) ?? node.item.maxDate; const datepickerValidations = validateDatepickerFormData( date?.simpleBinding, flagBasedMinDate, flagBasedMaxDate, - component.format, + node.item.format, language, ); componentValidations = { [fieldKey]: datepickerValidations, }; - validations[component.id] = componentValidations; + validations[node.item.id] = componentValidations; } } @@ -699,10 +563,11 @@ export function validateComponentFormData( !formData || formData === '' || validator.validate(`schema${rootElementPath}`, data); + const id = componentIdWithIndex || component.id; const validationResult: IValidationResult = { validations: { [layoutId]: { - [componentIdWithIndex || component.id]: { + [id]: { [fieldKey]: { errors: [], warnings: [], @@ -750,22 +615,19 @@ export function validateComponentFormData( ); } - mapToComponentValidations( + mapToComponentValidationsGivenComponent( layoutId, - null, + { ...component, id }, getKeyWithoutIndex(dataModelField), errorMessage, validationResult.validations, - { ...component, id: componentIdWithIndex || component.id }, ); }); } if ( existingValidationErrors || - validationResult.validations[layoutId][ - componentIdWithIndex || component.id - ][fieldKey].errors.length > 0 + validationResult.validations[layoutId][id][fieldKey].errors.length > 0 ) { return validationResult; } @@ -816,21 +678,22 @@ export function getSchemaPart(schemaPath: string, jsonSchema: object): any { } export function validateFormData( - formData: any, - layouts: ILayouts, + formDataAsObject: any, + layouts: LayoutRootNodeCollection<'resolved'>, layoutOrder: string[], schemaValidator: ISchemaValidator, language: ILanguage, textResources: ITextResource[], ): IValidationResult { - const validations: any = {}; + const validations: IValidations = {}; let invalidDataTypes = false; - Object.keys(layouts).forEach((id) => { + const allLayouts = layouts.all(); + for (const id of Object.keys(allLayouts)) { if (layoutOrder.includes(id)) { const result = validateFormDataForLayout( - formData, - layouts[id], + formDataAsObject, + allLayouts[id], id, schemaValidator, language, @@ -841,7 +704,7 @@ export function validateFormData( invalidDataTypes = result.invalidDataTypes; } } - }); + } return { validations, invalidDataTypes }; } @@ -849,16 +712,19 @@ export function validateFormData( /* Validates the entire formData and returns an IValidations object with validations mapped for all components */ -export function validateFormDataForLayout( - formData: any, - layout: ILayout, +function validateFormDataForLayout( + formDataAsObject: any, + node: LayoutRootNode<'resolved'> | LayoutNode<'resolved'>, layoutKey: string, schemaValidator: ISchemaValidator, language: ILanguage, textResources: ITextResource[], ): IValidationResult { const { validator, rootElementPath, schema } = schemaValidator; - const valid = validator.validate(`schema${rootElementPath}`, formData); + const valid = validator.validate( + `schema${rootElementPath}`, + formDataAsObject, + ); const result: IValidationResult = { validations: {}, invalidDataTypes: false, @@ -868,21 +734,23 @@ export function validateFormDataForLayout( return result; } - validator.errors.forEach((error) => { + for (const error of validator.errors) { // Required fields are handled separately if (error.keyword === 'required') { - return; + continue; } result.invalidDataTypes = - error.keyword === 'type' || error.keyword === 'format'; + error.keyword === 'type' || + error.keyword === 'format' || + result.invalidDataTypes; let errorParams = error.params[errorMessageKeys[error.keyword].paramKey]; if (Array.isArray(errorParams)) { errorParams = errorParams.join(', '); } - const dataBindingName = processInstancePath(error.instancePath); + const dataBinding = processInstancePath(error.instancePath); // backward compatible if we are validating against a sub scheme. const fieldSchema = rootElementPath ? getSchemaPartOldGenerator(error.schemaPath, schema, rootElementPath) @@ -902,14 +770,14 @@ export function validateFormDataForLayout( ); } - mapToComponentValidations( + mapToComponentValidationsGivenNode( layoutKey, - layout, - dataBindingName, + node, + dataBinding, errorMessage, result.validations, ); - }); + } return result; } @@ -923,84 +791,81 @@ export function processInstancePath(path: string): string { return result; } -export function mapToComponentValidations( +function addErrorToValidations( + validations: ILayoutValidations, layoutId: string, - layout: ILayout, - dataBindingName: string, + id: string, + fieldKey: string, + errorMessage: string, +) { + if (!validations[layoutId]) { + validations[layoutId] = {}; + } + if (!validations[layoutId][id]) { + validations[layoutId][id] = {}; + } + if (!validations[layoutId][id][fieldKey]) { + validations[layoutId][id][fieldKey] = {}; + } + if (!validations[layoutId][id][fieldKey].errors) { + validations[layoutId][id][fieldKey].errors = []; + } + if (!validations[layoutId][id][fieldKey].errors.includes(errorMessage)) { + validations[layoutId][id][fieldKey].errors.push(errorMessage); + } +} + +function mapToComponentValidationsGivenComponent( + layoutId: string, + component: ILayoutComponent | ILayoutGroup, + dataBinding: string, errorMessage: string, validations: ILayoutValidations, - validatedComponent?: ILayoutComponent | ILayoutGroup, ) { - let dataModelFieldKey = validatedComponent - ? Object.keys( - (validatedComponent as ILayoutComponent).dataModelBindings, - ).find((name) => { - return ( - (validatedComponent as ILayoutComponent).dataModelBindings[name] === - dataBindingName - ); - }) - : null; - - const layoutComponent = - validatedComponent || - layout.find((c) => { - const component = c as unknown as ILayoutComponent; - if (component.dataModelBindings) { - dataModelFieldKey = Object.keys(component.dataModelBindings).find( - (key) => { - const dataBindingWithoutIndex = getKeyWithoutIndex( - dataBindingName.toLowerCase(), - ); - return ( - key && - component.dataModelBindings[key] && - component.dataModelBindings[key].toLowerCase() === - dataBindingWithoutIndex - ); - }, - ); - } - return !!dataModelFieldKey; - }); + const fieldKey = Object.keys(component.dataModelBindings).find((name) => { + return component.dataModelBindings[name] === dataBinding; + }); - if (!dataModelFieldKey) { + if (!fieldKey) { return; } - if (layoutComponent) { - const index = getIndex(dataBindingName); - const componentId = index - ? `${layoutComponent.id}-${index}` - : layoutComponent.id; - if (!validations[layoutId]) { - validations[layoutId] = {}; - } - if (validations[layoutId][componentId]) { - if (validations[layoutId][componentId][dataModelFieldKey]) { - if ( - validations[layoutId][componentId][dataModelFieldKey].errors.includes( - errorMessage, - ) - ) { - return; - } - validations[layoutId][componentId][dataModelFieldKey].errors.push( - errorMessage, + const index = getIndex(dataBinding); + const id = index ? `${component.id}-${index}` : component.id; + addErrorToValidations(validations, layoutId, id, fieldKey, errorMessage); +} + +export function mapToComponentValidationsGivenNode( + layoutId: string, + node: LayoutObject<'resolved'>, + dataBinding: string, + errorMessage: string, + validations: ILayoutValidations, +) { + let fieldKey = null; + const component = node.flat(true).find((item) => { + if (item.item.dataModelBindings) { + fieldKey = Object.keys(item.item.dataModelBindings).find((key) => { + return ( + item.item.dataModelBindings[key].toLowerCase() === + dataBinding.toLowerCase() ); - } else { - validations[layoutId][componentId][dataModelFieldKey] = { - errors: [errorMessage], - }; - } - } else { - validations[layoutId][componentId] = { - [dataModelFieldKey]: { - errors: [errorMessage], - }, - }; + }); } + return !!fieldKey; + }); + + if (!fieldKey || !component) { + return; } + + addErrorToValidations( + validations, + layoutId, + component.item.id, + fieldKey, + errorMessage, + ); } /* @@ -1607,7 +1472,7 @@ function removeFixedValidations( } /** - * Validates a specific group. Validates all child components and child groups. + * Validates a specific group. Validates all rows, with all child components and child groups. * @param groupId the group to validate * @param state the current state * @returns validations for a given group @@ -1618,55 +1483,26 @@ export function validateGroup( ): IValidations { const language = state.language.language; const textResources = state.textResources.resources; - const hiddenFields = state.formLayout.uiConfig.hiddenFields; + const hiddenFields = new Set(state.formLayout.uiConfig.hiddenFields); const attachments = state.attachments.attachments; const repeatingGroups = state.formLayout.uiConfig.repeatingGroups || {}; const formData = state.formData.formData; const jsonFormData = convertDataBindingToModel(formData); const currentView = state.formLayout.uiConfig.currentView; const currentLayout = state.formLayout.layouts[currentView]; - const groups = currentLayout.filter( - (layoutElement) => layoutElement.type === 'Group', - ); - const childGroups: string[] = []; - groups.forEach((groupCandidate: ILayoutGroup) => { - groupCandidate?.children?.forEach((childId: string) => { - currentLayout - .filter((element) => element.id === childId && element.type === 'Group') - .forEach((childGroup) => childGroups.push(childGroup.id)); - }); + const instanceContext = buildInstanceContext(state.instanceData?.instance); + const resolvedLayout = resolvedNodesInLayout(currentLayout, repeatingGroups, { + formData, + instanceContext, + applicationSettings: state.applicationSettings?.applicationSettings, }); - const group: ILayoutGroup = currentLayout.find( - (element) => element.id === groupId, - ) as ILayoutGroup; - // only validate elements that are part of the group or part of child groups - const filteredLayout = []; - currentLayout.forEach((element) => { - if (childGroups?.includes(element.id)) { - filteredLayout.push(element); - const childGroup = element as ILayoutGroup; - childGroup.children?.forEach((childId) => { - let actualChildId = childId; - if (childGroup.edit?.multiPage) { - actualChildId = childId.split(':')[1]; - } - filteredLayout.push( - currentLayout.find( - (childComponent) => childComponent.id === actualChildId, - ), - ); - }); - } - const plainChildIds = - group?.children?.map((childId) => - group.edit?.multiPage ? childId.split(':')[1] || childId : childId, - ) || []; - if (plainChildIds.includes(element.id) || element.id === groupId) { - filteredLayout.push(element); - } - }); + const node = resolvedLayout.findById(groupId); + if (!node) { + return {}; + } + const currentDataTaskDataTypeId = getCurrentDataTypeId( state.applicationMetadata.applicationMetadata, state.instanceData.instance, @@ -1676,32 +1512,29 @@ export function validateGroup( currentDataTaskDataTypeId, state.formDataModel.schemas, ); - const emptyFieldsValidations: ILayoutValidations = - validateEmptyFieldsForLayout( - formData, - filteredLayout, - language, - hiddenFields, - repeatingGroups, - textResources, - ); - const componentValidations: ILayoutValidations = - validateFormComponentsForLayout( - attachments, - filteredLayout, - formData, - language, - hiddenFields, - repeatingGroups, - ); - const formDataValidations: IValidations = validateFormDataForLayout( + const emptyFieldsValidations = validateEmptyFieldsForNodes( + formData, + node, + language, + hiddenFields, + textResources, + ); + const componentValidations = validateFormComponentsForNodes( + attachments, + node, + formData, + language, + hiddenFields, + ); + const formDataValidations = validateFormDataForLayout( jsonFormData, - filteredLayout, + node, currentView, validator, language, textResources, ).validations; + return mergeValidationObjects( { [currentView]: emptyFieldsValidations }, { [currentView]: componentValidations }, diff --git a/src/shared/src/utils/instanceContext.ts b/src/shared/src/utils/instanceContext.ts deleted file mode 100644 index bd1e510a13..0000000000 --- a/src/shared/src/utils/instanceContext.ts +++ /dev/null @@ -1,15 +0,0 @@ -import type { IInstance, IInstanceContext } from '../types'; - -export function buildInstanceContext(instance: IInstance): IInstanceContext { - if (!instance) { - return null; - } - - const instanceContext: IInstanceContext = { - appId: instance.appId, - instanceId: instance.id, - instanceOwnerPartyId: instance.instanceOwner.partyId, - }; - - return instanceContext; -} diff --git a/test/cypress/e2e/config/local.json b/test/cypress/e2e/config/local.json index 7f2e94ede3..515eddd141 100644 --- a/test/cypress/e2e/config/local.json +++ b/test/cypress/e2e/config/local.json @@ -1,6 +1,6 @@ { "$schema": "https://on.cypress.io/cypress.schema.json", - "baseUrl": "http://altinn3local.no", + "baseUrl": "http://local.altinn.cloud", "env": { "userFullName": "Ola Nordmann", "firstName": "Ola"