diff --git a/src/features/expressions/shared-functions.test.tsx b/src/features/expressions/shared-functions.test.tsx index fe1fc30a25..ded2f94c4a 100644 --- a/src/features/expressions/shared-functions.test.tsx +++ b/src/features/expressions/shared-functions.test.tsx @@ -1,5 +1,4 @@ import React from 'react'; -import type { PropsWithChildren } from 'react'; import { jest } from '@jest/globals'; import { screen } from '@testing-library/react'; @@ -27,9 +26,8 @@ import { renderWithoutInstanceAndLayout, StatelessRouter, } from 'src/test/renderWithProviders'; -import { DataModelLocationProvider } from 'src/utils/layout/DataModelLocation'; +import { NestedDataModelLocationProviders } from 'src/utils/layout/DataModelLocation'; import { useEvalExpression } from 'src/utils/layout/generator/useEvalExpression'; -import { useDataModelBindingsFor, useExternalItem } from 'src/utils/layout/hooks'; import type { ExprPositionalArgs, ExprValToActualOrExpr, ExprValueArgs } from 'src/features/expressions/types'; import type { ExternalApisResult } from 'src/features/externalApi/useExternalApi'; import type { IRawOption } from 'src/layout/common.generated'; @@ -56,17 +54,11 @@ function InnerExpressionRunner({ expression, positionalArguments, valueArguments } function ExpressionRunner(props: Props) { + const layoutLookups = useLayoutLookups(); if (props.context === undefined || props.context.rowIndices === undefined || props.context.rowIndices.length === 0) { return ; } - // Skipping this hook to make sure we can eval expressions without a layout as well. Some tests need to run - // without layout, and in those cases this hook will crash when we're missing the context. Breaking the rule of hooks - // eslint rule makes this conditional, and that's fine here. - // eslint-disable-next-line react-compiler/react-compiler - // eslint-disable-next-line react-hooks/rules-of-hooks - const layoutLookups = useLayoutLookups(); - const parentIds: string[] = []; let currentParent = layoutLookups.componentToParent[props.context.component]; while (currentParent && currentParent.type === 'node') { @@ -84,44 +76,38 @@ function ExpressionRunner(props: Props) { ); } - let result = ; - for (const [i, parentId] of parentIds.entries()) { - const reverseIndex = props.context.rowIndices.length - i - 1; - const rowIndex = props.context.rowIndices[reverseIndex]; - - result = ( - - {result} - - ); - } + const fieldSegments: string[] = []; + for (let level = 0; level < parentIds.length; level++) { + const parentId = parentIds[parentIds.length - 1 - level]; // Get outermost parent first + const rowIndex = props.context.rowIndices[level]; + const component = layoutLookups.getComponent(parentId); + const bindings = component.dataModelBindings as IDataModelBindings; + const groupBinding = getRepeatingBinding(component.type as RepeatingComponents, bindings); + if (!groupBinding) { + throw new Error(`No group binding found for ${parentId}`); + } - return result; -} + const currentPath = fieldSegments.join('.'); + let segmentName = groupBinding.field; + if (currentPath) { + const currentFieldPath = currentPath.replace(/\[\d+]/g, ''); // Remove all [index] parts + if (segmentName.startsWith(`${currentFieldPath}.`)) { + segmentName = segmentName.substring(currentFieldPath.length + 1); + } + } -function DataModelLocationFor({ - id, - rowIndex, - children, -}: PropsWithChildren<{ id: string; rowIndex: number }>) { - const component = useExternalItem(id) as { type: T }; - const bindings = useDataModelBindingsFor(id) as IDataModelBindings; - const groupBinding = getRepeatingBinding(component.type, bindings); - - if (!groupBinding) { - throw new Error(`No group binding found for ${id}`); + fieldSegments.push(`${segmentName}[${rowIndex}]`); } return ( - - {children} - + + ); } diff --git a/src/features/formData/FormDataWrite.tsx b/src/features/formData/FormDataWrite.tsx index 9bb6ef070b..00d12ed426 100644 --- a/src/features/formData/FormDataWrite.tsx +++ b/src/features/formData/FormDataWrite.tsx @@ -664,6 +664,43 @@ function getFreshNumRows(state: FormDataContext, reference: IDataModelReference const emptyObject = {}; const emptyArray = []; +/** + * Recursively traverses form data to find all actual field paths that match a base field pattern. + * + * For example, given a base field "names.name" and form data containing: + * { names: [{ name: "John" }, { name: "Jane" }] } + * + * This will collect paths: ["names[0].name", "names[1].name"] + */ +function collectMatchingFieldPaths( + data: unknown, + fieldParts: string[], + currentPath: string, + partIndex: number, + results: string[], +) { + if (partIndex >= fieldParts.length) { + results.push(currentPath); + return; + } + + const part = fieldParts[partIndex]; + if (typeof data !== 'object' || data === null || data[part] === undefined || data[part] === null) { + return; + } + + const nextData = data[part]; + const nextPath = currentPath ? `${currentPath}.${part}` : part; + + if (Array.isArray(nextData)) { + for (let i = 0; i < nextData.length; i++) { + collectMatchingFieldPaths(nextData[i], fieldParts, `${nextPath}[${i}]`, partIndex + 1, results); + } + } else { + collectMatchingFieldPaths(nextData, fieldParts, nextPath, partIndex + 1, results); + } +} + const currentSelector = (reference: IDataModelReference) => (state: FormDataContext) => dot.pick(reference.field, state.dataModels[reference.dataType]?.currentData); const debouncedSelector = (reference: IDataModelReference) => (state: FormDataContext) => @@ -776,6 +813,40 @@ export const FD = { ); }, + /** + * This will find all actual field paths that match a base pattern. For example, given "form.names.name", + * it might return ["form.names[0].name", "form.names[1].name"] if those paths exist in the form data. + * This is useful for finding all instances of a field in repeating groups. + */ + useDebouncedAllPaths(reference: IDataModelReference | undefined): string[] { + const lookupTool = DataModels.useLookupBinding(); + const [, lookupErr] = (reference ? lookupTool?.(reference) : undefined) ?? [undefined, undefined]; + + return useShallowSelector((v) => { + if (!reference) { + return emptyArray; + } + + // When lookupTool is available and doesn't report a missing repeating group error, we know there's no + // repeating group structure in this path, so we can return the field as-is. + const foundInDataModel = lookupTool && (!lookupErr || lookupErr.error !== 'missingProperty'); + if (foundInDataModel && lookupErr?.error !== 'missingRepeatingGroup') { + return [reference?.field]; + } + + // If lookupTool is not available (e.g., in tests), or if there's a missingRepeatingGroup error, + // we need to check the actual data to find all matching paths. + const formData = v.dataModels[reference.dataType]?.debouncedCurrentData; + if (!formData) { + return []; + } + + const paths: string[] = []; + collectMatchingFieldPaths(formData, reference.field.split('.'), '', 0, paths); + return paths.length === 0 ? [reference.field] : paths.sort(); + }); + }, + /** * This returns multiple values, as picked from the form data. The values in the input object is expected to be * dot-separated paths, and the return value will be an object with the same keys, but with the values picked diff --git a/src/features/saveToGroup/layoutValidation.ts b/src/features/saveToGroup/layoutValidation.ts index b0f1273d05..6b1a94228a 100644 --- a/src/features/saveToGroup/layoutValidation.ts +++ b/src/features/saveToGroup/layoutValidation.ts @@ -53,7 +53,7 @@ export function useValidateSimpleBindingWithOptionalGroup void; +}; + export function ExpressionValidation() { const writableDataTypes = DataModels.useWritableDataTypes(); return ( <> {writableDataTypes.map((dataType) => ( - @@ -29,102 +35,130 @@ export function ExpressionValidation() { ); } -type Binding = Record; - -function IndividualExpressionValidation({ dataType }: { dataType: string }) { +function DataTypeValidation({ dataType }: { dataType: string }) { const updateDataModelValidations = Validation.useUpdateDataModelValidations(); - const formData = FD.useDebounced(dataType); + const dataElementId = DataModels.useDataElementIdForDataType(dataType); const expressionValidationConfig = DataModels.useExpressionValidationConfig(dataType); - const dataSources = useExpressionDataSources(expressionValidationConfig); - const dataElementId = DataModels.useDataElementIdForDataType(dataType) ?? dataType; // stateless does not have dataElementId - const allBindings = NodesInternal.useMemoSelector((state) => { - const out: Binding[] = []; - - for (const nodeData of Object.values(state.nodeData)) { - if (nodeData.dataModelBindings) { - out.push(nodeData.dataModelBindings as Binding); - } + + const [allFieldValidations, setAllFieldValidations] = useState({}); + const collector: ValidationCollectorApi = useMemo( + () => ({ + setFieldValidations: (fieldKey, validations) => { + setAllFieldValidations((prev) => ({ ...prev, [fieldKey]: validations })); + }, + }), + [], + ); + + useEffect(() => { + if (!dataElementId) { + return; } - return out; - }); + updateDataModelValidations('expression', dataElementId, allFieldValidations); + }, [allFieldValidations, updateDataModelValidations, dataElementId]); + + if (!dataElementId || !expressionValidationConfig) { + return null; + } + + return ( + <> + {Object.keys(expressionValidationConfig).map((field) => ( + + ))} + + ); +} + +function BaseFieldExpressionValidation({ + dataElementId, + validationDefs, + reference, + collector, +}: { + dataElementId: string; + validationDefs: IExpressionValidation[]; + reference: IDataModelReference; + collector: ValidationCollectorApi; +}) { + const allPaths = FD.useDebouncedAllPaths(reference); + + return ( + <> + {allPaths.map((field) => ( + + + + ))} + + ); +} + +function FieldExpressionValidation({ + dataElementId, + reference, + validationDefs, + collector, +}: { + dataElementId: string; + reference: IDataModelReference; + validationDefs: IExpressionValidation[]; + collector: ValidationCollectorApi; +}) { + const baseDataSources = useExpressionDataSources(validationDefs); + const dataSources: ExpressionDataSources = useMemo( + () => ({ ...baseDataSources, defaultDataType: reference.dataType }), + [baseDataSources, reference.dataType], + ); useEffect(() => { - if (expressionValidationConfig && Object.keys(expressionValidationConfig).length > 0 && formData) { - const validations = {}; - - for (const dmb of allBindings) { - for (const reference of Object.values(dmb)) { - if (reference.dataType !== dataType) { - continue; - } - - const field = reference.field; - - /** - * Should not run validations on the same field multiple times - */ - if (validations[field]) { - continue; - } - - const baseField = getKeyWithoutIndex(field); - const validationDefs = expressionValidationConfig[baseField]; - if (!validationDefs) { - continue; - } - - for (const validationDef of validationDefs) { - const valueArguments: ExprValueArgs<{ field: string }> = { data: { field }, defaultKey: 'field' }; - const modifiedDataSources: ExpressionDataSources = { - ...dataSources, - defaultDataType: dataType, - currentDataModelPath: reference, - }; - const isInvalid = evalExpr( - validationDef.condition as ExprValToActualOrExpr, - modifiedDataSources, - { - returnType: ExprVal.Boolean, - defaultValue: false, - positionalArguments: [field], - valueArguments, - }, - ); - const evaluatedMessage = evalExpr(validationDef.message, modifiedDataSources, { - returnType: ExprVal.String, - defaultValue: '', - positionalArguments: [field], - valueArguments, - }); - - if (isInvalid) { - if (!validations[field]) { - validations[field] = []; - } - - validations[field].push({ - field, - source: FrontendValidationSource.Expression, - message: { key: evaluatedMessage }, - severity: validationDef.severity, - category: validationDef.showImmediately ? 0 : ValidationMask.Expression, - }); - } - } - } + const field = reference.field; + const validations: FieldValidations[string] = []; + + for (const validationDef of validationDefs) { + const valueArguments: ExprValueArgs<{ field: string }> = { data: { field }, defaultKey: 'field' }; + const isInvalid = evalExpr(validationDef.condition as ExprValToActualOrExpr, dataSources, { + returnType: ExprVal.Boolean, + defaultValue: false, + positionalArguments: [field], + valueArguments, + }); + const evaluatedMessage = evalExpr(validationDef.message, dataSources, { + returnType: ExprVal.String, + defaultValue: '', + positionalArguments: [field], + valueArguments, + }); + + if (isInvalid) { + validations.push({ + field, + dataElementId, + source: FrontendValidationSource.Expression, + message: { key: evaluatedMessage }, + severity: validationDef.severity, + category: validationDef.showImmediately ? 0 : ValidationMask.Expression, + }); } - updateDataModelValidations('expression', dataElementId, validations); } - }, [ - expressionValidationConfig, - formData, - dataElementId, - updateDataModelValidations, - dataSources, - dataType, - allBindings, - ]); + + collector.setFieldValidations(reference.field, validations); + }, [collector, validationDefs, dataElementId, dataSources, reference.field, reference.dataType]); return null; } diff --git a/src/features/validation/expressionValidation/shared-expression-validation-tests/component-lookup-hidden.json b/src/features/validation/expressionValidation/shared-expression-validation-tests/component-lookup-hidden.json new file mode 100644 index 0000000000..8ae7a2e23d --- /dev/null +++ b/src/features/validation/expressionValidation/shared-expression-validation-tests/component-lookup-hidden.json @@ -0,0 +1,80 @@ +{ + "name": "Should return null when looking up hidden component", + "expects": [ + { + "message": "Navnet ditt kan ikke være feil.", + "severity": "error", + "field": "personer[0].navn" + } + ], + "validationConfig": { + "$schema": "https://altinncdn.no/toolkits/altinn-app-frontend/4/schemas/json/validation/validation.schema.v1.json", + "validations": { + "personer.navn": [ + { + "message": "Navnet ditt kan ikke være feil.", + "severity": "error", + "condition": ["equals", ["component", "person-navn"], "feil"] + } + ] + } + }, + "formData": { + "personer": [ + { + "altinnRowId": "person0", + "navn": "feil", + "utenNavn": false + }, + { + "altinnRowId": "person1", + "navn": "feil", + "utenNavn": true + }, + { + "altinnRowId": "person2", + "navn": "riktig", + "utenNavn": false + } + ] + }, + "layouts": { + "Page": { + "$schema": "https://altinncdn.no/schemas/json/layout/layout.schema.v1.json", + "data": { + "layout": [ + { + "id": "personer", + "type": "RepeatingGroup", + "dataModelBindings": { + "group": "personer" + }, + "children": [ + "person-navn", + "uten-navn" + ] + }, + { + "id": "person-navn", + "type": "Input", + "dataModelBindings": { + "simpleBinding": "personer.navn" + }, + "hidden": ["equals", ["component", "uten-navn"], true] + }, + { + "id": "uten-navn", + "type": "Checkboxes", + "dataModelBindings": { + "simpleBinding": "personer.utenNavn" + }, + "options": [ + { "label": "Ja", "value": true }, + { "label": "Nei", "value": false } + ] + } + ] + } + } + } +} diff --git a/src/utils/layout/DataModelLocation.tsx b/src/utils/layout/DataModelLocation.tsx index 738e44c0dc..99355cdc28 100644 --- a/src/utils/layout/DataModelLocation.tsx +++ b/src/utils/layout/DataModelLocation.tsx @@ -22,14 +22,12 @@ const { Provider, useCtx } = createContext({ export const useCurrentDataModelLocation = () => useCtx()?.reference; -export function DataModelLocationProvider({ - groupBinding, - rowIndex, - children, -}: PropsWithChildren<{ +interface LocationProps { groupBinding: IDataModelReference; rowIndex: number; -}>) { +} + +export function DataModelLocationProvider({ groupBinding, rowIndex, children }: PropsWithChildren) { const parentCtx = useCtx(); const value = useMemo( () => ({ @@ -120,3 +118,72 @@ export function useIndexedId(baseId: unknown, skipLastMutator = false) { const idMutator = useComponentIdMutator(skipLastMutator); return useMemo(() => (typeof baseId === 'string' ? idMutator(baseId) : baseId), [baseId, idMutator]); } + +/** + * Parses a field path to extract group binding contexts for all array indexes found. + * + * For example, given "people[0].addresses[1].street", this returns: + * [ + * { groupBinding: { dataType, field: "people" }, rowIndex: 0 }, + * { groupBinding: { dataType, field: "people[0].addresses" }, rowIndex: 1 } + * ] + */ +function parseGroupContexts(reference: IDataModelReference) { + const contexts: LocationProps[] = []; + + const parts = reference.field.split('.'); + let currentPath = ''; + + for (const part of parts) { + if (currentPath) { + currentPath += '.'; + } + + // Check if this part contains an array index + const arrayMatch = part.match(/^(.+)\[(\d+)]$/); + if (arrayMatch) { + const [, arrayName, indexStr] = arrayMatch; + const groupPath = currentPath + arrayName; + const rowIndex = parseInt(indexStr, 10); + + contexts.push({ + groupBinding: { dataType: reference.dataType, field: groupPath }, + rowIndex, + }); + + currentPath += part; + } else { + currentPath += part; + } + } + + return contexts; +} + +interface NestedLocationProps { + reference: IDataModelReference; +} + +/** + * Component to render nested DataModelLocationProviders for field paths with repeating group indexes. + */ +export function NestedDataModelLocationProviders({ reference, children }: PropsWithChildren) { + const groupContexts = parseGroupContexts(reference); + if (groupContexts.length === 0) { + return children; + } + + // Recursively nest the providers from outermost to innermost + return groupContexts.reduceRight( + (child, { groupBinding, rowIndex }) => ( + + {child} + + ), + children as React.ReactElement, + ); +} diff --git a/test/e2e/integration/expression-validation-test/expression-validation.ts b/test/e2e/integration/expression-validation-test/expression-validation.ts index 5396ab83bf..fbbafec189 100644 --- a/test/e2e/integration/expression-validation-test/expression-validation.ts +++ b/test/e2e/integration/expression-validation-test/expression-validation.ts @@ -1,5 +1,8 @@ import { AppFrontend } from 'test/e2e/pageobjects/app-frontend'; +import type { Expression } from 'src/features/expressions/types'; +import type { IExpressionValidationConfig } from 'src/features/validation'; + const appFrontend = new AppFrontend(); describe('Expression validation', () => { @@ -249,4 +252,57 @@ describe('Expression validation', () => { cy.findByRole('button', { name: /send inn/i }).click(); cy.get(appFrontend.receipt.container).should('be.visible'); }); + + it('should handle component lookups and hidden state in repeating groups', () => { + // App-backend currently does not support component lookups in expression validation, but app-frontend does. For + // that reason, the validation file on the backend uses plain dataModel lookups instead, but here we can run + // expressions that rely on the hidden result for the dato-til/dato-fra components. + cy.intercept('GET', '**/api/validationconfig/skjema', (req) => { + req.on('response', (res) => { + const body: IExpressionValidationConfig = JSON.parse(res.body); + body.validations['root.arbeidserfaring.fra'] = [ + { + message: 'validation-from', + condition: [ + 'compare', + ['component', 'dato-fra'], + 'isAfter', + ['component', 'dato-til'], + ] as unknown as Expression, + }, + ]; + body.validations['root.arbeidserfaring.til'] = [ + { + message: 'validation-to', + condition: [ + 'compare', + ['component', 'dato-til'], + 'isBefore', + ['component', 'dato-fra'], + ] as unknown as Expression, + }, + ]; + res.send(body); + }); + }); + cy.gotoNavPage('CV'); + + cy.findByRole('button', { name: /legg til ny arbeidserfaring/i }).click(); + cy.findByRole('textbox', { name: /arbeidsgiver/i }).type('Test Company AS'); + cy.findByRole('textbox', { name: /stilling/i }).type('Developer'); + + cy.findByRole('textbox', { name: /fra/i }).type('01.12.2023'); + cy.findByRole('textbox', { name: /^til/i }).type('01.06.2023'); // Intentionally invalid + cy.findByRole('button', { name: /lagre og lukk/i }).click(); + + cy.get(appFrontend.errorReport).should('contain.text', 'Startdatoen må være før sluttdato'); + cy.get(appFrontend.errorReport).should('contain.text', 'Sluttdato må være etter startdato'); + + cy.findByRole('checkbox', { name: /jeg er fortsatt ansatt her/i }).dsCheck(); + cy.findByRole('button', { name: /lagre og lukk/i }).click(); + cy.get(appFrontend.errorReport).findAllByRole('listitem').should('have.length', 1); + + cy.get(appFrontend.errorReport).should('not.contain.text', 'Startdatoen må være før sluttdato'); + cy.get(appFrontend.errorReport).should('not.contain.text', 'Sluttdato må være etter startdato'); + }); });