From 2d3b72dd96dda86fdd54f9545d34e0c48902a7ee Mon Sep 17 00:00:00 2001 From: Ole Martin Handeland Date: Wed, 24 Sep 2025 13:50:39 +0200 Subject: [PATCH 1/7] Refactoring ExpressionValidation, with lots of help from claude --- .../expressions/shared-functions.test.tsx | 70 +++--- src/features/formData/FormDataWrite.tsx | 60 +++++ .../ExpressionValidation.tsx | 229 +++++++++++------- src/utils/layout/DataModelLocation.tsx | 78 +++++- 4 files changed, 297 insertions(+), 140 deletions(-) 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 17052e74b8..9b7f22af13 100644 --- a/src/features/formData/FormDataWrite.tsx +++ b/src/features/formData/FormDataWrite.tsx @@ -664,6 +664,44 @@ 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]; + const nextData = data?.[part]; + + if (nextData === undefined || nextData === null) { + return; + } + + 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 +814,28 @@ 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[] { + return useShallowSelector((v) => { + if (!reference) { + return emptyArray; + } + + const formData = v.dataModels[reference.dataType]?.debouncedCurrentData; + if (!formData) { + return emptyArray; + } + + const paths: string[] = []; + collectMatchingFieldPaths(formData, reference.field.split('.'), '', 0, paths); + return 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/validation/expressionValidation/ExpressionValidation.tsx b/src/features/validation/expressionValidation/ExpressionValidation.tsx index 6183286d6d..d2aa242ed7 100644 --- a/src/features/validation/expressionValidation/ExpressionValidation.tsx +++ b/src/features/validation/expressionValidation/ExpressionValidation.tsx @@ -1,26 +1,32 @@ -import React, { useEffect } from 'react'; +import React, { useEffect, useMemo, useState } from 'react'; import { FrontendValidationSource, ValidationMask } from '..'; +import type { FieldValidations, IExpressionValidation } from '..'; import { DataModels } from 'src/features/datamodel/DataModelsProvider'; import { evalExpr } from 'src/features/expressions'; import { ExprVal } from 'src/features/expressions/types'; import { FD } from 'src/features/formData/FormDataWrite'; import { Validation } from 'src/features/validation/validationContext'; -import { getKeyWithoutIndex } from 'src/utils/databindings'; -import { NodesInternal } from 'src/utils/layout/NodesContext'; +import { NestedDataModelLocationProviders } from 'src/utils/layout/DataModelLocation'; import { useExpressionDataSources } from 'src/utils/layout/useExpressionDataSources'; import type { ExprValToActualOrExpr, ExprValueArgs } from 'src/features/expressions/types'; import type { IDataModelReference } from 'src/layout/common.generated'; import type { ExpressionDataSources } from 'src/utils/layout/useExpressionDataSources'; +// This collects single-field validation updates to store in a big object containing all expression field validations +// for a given data type. +type ValidationCollectorApi = { + setFieldValidations: (fieldKey: string, validations: FieldValidations[string]) => void; +}; + export function ExpressionValidation() { const writableDataTypes = DataModels.useWritableDataTypes(); return ( <> {writableDataTypes.map((dataType) => ( - @@ -29,102 +35,141 @@ 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 = { + 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({ + dataType, + dataElementId, + validationDefs, + baseFieldReference, + collector, +}: { + dataType: string; + dataElementId: string; + validationDefs: IExpressionValidation[]; + baseFieldReference: IDataModelReference; + collector: ValidationCollectorApi; +}) { + const actualFieldPaths = FD.useDebouncedAllPaths(baseFieldReference); + + return ( + <> + {actualFieldPaths.map((fieldPath) => { + const reference = { dataType: baseFieldReference.dataType, field: fieldPath }; + return ( + + + + ); + })} + + ); +} + +function FieldExpressionValidation({ + dataType, + dataElementId, + reference, + validationDefs, + collector, +}: { + dataType: string; + dataElementId: string; + reference: IDataModelReference; + validationDefs: IExpressionValidation[]; + collector: ValidationCollectorApi; +}) { + const fieldData = FD.useDebouncedPick(reference); + const baseDataSources = useExpressionDataSources(validationDefs); + const dataSources: ExpressionDataSources = useMemo( + () => ({ + ...baseDataSources, + defaultDataType: dataType, + }), + [baseDataSources, 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, fieldData, dataElementId, dataSources, reference, dataType]); return null; } diff --git a/src/utils/layout/DataModelLocation.tsx b/src/utils/layout/DataModelLocation.tsx index 738e44c0dc..ec002a8c53 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,71 @@ 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, + ); +} From 539f5d95a2381c713ec084d0655a895adf402663 Mon Sep 17 00:00:00 2001 From: Ole Martin Handeland Date: Wed, 24 Sep 2025 15:04:59 +0200 Subject: [PATCH 2/7] This error message was broken --- src/features/saveToGroup/layoutValidation.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 Date: Wed, 24 Sep 2025 16:17:44 +0200 Subject: [PATCH 3/7] Safeguarding against no paths found in FD.useDebouncedAllPaths(). Luckily this made a test fail, but I went down the wrong track when debugging it. The function was always supposed to return the input if nothing else was found. Also, the current value of the field is not really needed as an input for useEffect() --- src/features/formData/FormDataWrite.tsx | 4 +- .../ExpressionValidation.tsx | 67 +++++++++---------- 2 files changed, 34 insertions(+), 37 deletions(-) diff --git a/src/features/formData/FormDataWrite.tsx b/src/features/formData/FormDataWrite.tsx index 9b7f22af13..1a8b627852 100644 --- a/src/features/formData/FormDataWrite.tsx +++ b/src/features/formData/FormDataWrite.tsx @@ -827,12 +827,12 @@ export const FD = { const formData = v.dataModels[reference.dataType]?.debouncedCurrentData; if (!formData) { - return emptyArray; + return [reference.field]; } const paths: string[] = []; collectMatchingFieldPaths(formData, reference.field.split('.'), '', 0, paths); - return paths.sort(); + return paths.length === 0 ? [reference.field] : paths.sort(); }); }, diff --git a/src/features/validation/expressionValidation/ExpressionValidation.tsx b/src/features/validation/expressionValidation/ExpressionValidation.tsx index d2aa242ed7..8e00292959 100644 --- a/src/features/validation/expressionValidation/ExpressionValidation.tsx +++ b/src/features/validation/expressionValidation/ExpressionValidation.tsx @@ -63,11 +63,10 @@ function DataTypeValidation({ dataType }: { dataType: string }) { <> {Object.keys(expressionValidationConfig).map((field) => ( ))} @@ -76,69 +75,67 @@ function DataTypeValidation({ dataType }: { dataType: string }) { } function BaseFieldExpressionValidation({ - dataType, dataElementId, validationDefs, - baseFieldReference, + reference, collector, }: { - dataType: string; dataElementId: string; validationDefs: IExpressionValidation[]; - baseFieldReference: IDataModelReference; + reference: IDataModelReference; collector: ValidationCollectorApi; }) { - const actualFieldPaths = FD.useDebouncedAllPaths(baseFieldReference); + const allPaths = FD.useDebouncedAllPaths(reference); + + if (allPaths.length === 0 || (allPaths.length === 1 && allPaths[0] === reference.field)) { + return ( + + ); + } return ( <> - {actualFieldPaths.map((fieldPath) => { - const reference = { dataType: baseFieldReference.dataType, field: fieldPath }; - return ( - - - - ); - })} + {allPaths.map((field) => ( + + + + ))} ); } function FieldExpressionValidation({ - dataType, dataElementId, reference, validationDefs, collector, }: { - dataType: string; dataElementId: string; reference: IDataModelReference; validationDefs: IExpressionValidation[]; collector: ValidationCollectorApi; }) { - const fieldData = FD.useDebouncedPick(reference); const baseDataSources = useExpressionDataSources(validationDefs); const dataSources: ExpressionDataSources = useMemo( - () => ({ - ...baseDataSources, - defaultDataType: dataType, - }), - [baseDataSources, dataType], + () => ({ ...baseDataSources, defaultDataType: reference.dataType }), + [baseDataSources, reference.dataType], ); useEffect(() => { const field = reference.field; - const validations: FieldValidations[string] = []; for (const validationDef of validationDefs) { @@ -169,7 +166,7 @@ function FieldExpressionValidation({ } collector.setFieldValidations(reference.field, validations); - }, [collector, validationDefs, fieldData, dataElementId, dataSources, reference, dataType]); + }, [collector, validationDefs, dataElementId, dataSources, reference.field, reference.dataType]); return null; } From d0988ae9fd920d510e18b8b897f6b577967f4f9a Mon Sep 17 00:00:00 2001 From: Ole Martin Handeland Date: Wed, 24 Sep 2025 16:30:44 +0200 Subject: [PATCH 4/7] Adding cypress regression test --- .../expression-validation.ts | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/test/e2e/integration/expression-validation-test/expression-validation.ts b/test/e2e/integration/expression-validation-test/expression-validation.ts index 5396ab83bf..60cb912ad9 100644 --- a/test/e2e/integration/expression-validation-test/expression-validation.ts +++ b/test/e2e/integration/expression-validation-test/expression-validation.ts @@ -249,4 +249,26 @@ describe('Expression validation', () => { cy.findByRole('button', { name: /send inn/i }).click(); cy.get(appFrontend.receipt.container).should('be.visible'); }); + + it('should handle date validation with "still employed" checkbox', () => { + 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'); + }); }); From d77219ce2afe379b9a90d10d2e3ad799f2bf83b6 Mon Sep 17 00:00:00 2001 From: Ole Martin Handeland Date: Wed, 24 Sep 2025 16:52:03 +0200 Subject: [PATCH 5/7] Reverting my previous change and doing something smarter. We actually do not want to run expression validation for a row when there are no rows, so the last solution didn't work as we wanted (it caused lots of errors in the developer tools log) --- src/features/formData/FormDataWrite.tsx | 15 +++++++++++++-- .../expressionValidation/ExpressionValidation.tsx | 11 ----------- 2 files changed, 13 insertions(+), 13 deletions(-) diff --git a/src/features/formData/FormDataWrite.tsx b/src/features/formData/FormDataWrite.tsx index 1a8b627852..44234a71ec 100644 --- a/src/features/formData/FormDataWrite.tsx +++ b/src/features/formData/FormDataWrite.tsx @@ -820,19 +820,30 @@ export const FD = { * 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; } + if (lookupErr?.error !== 'missingRepeatingGroup') { + // This is hacky. We use the lookup tool to determine if the base path hits a 'repeating group' structure in + // the data model. Meaning, we should have had indexes somewhere in the path. + // If there's no array to be found anywhere in this path, it's safe to just return the same path. + // Only when there is repeating stuff should we continue looking at how many rows there are. + return [reference?.field]; + } + const formData = v.dataModels[reference.dataType]?.debouncedCurrentData; if (!formData) { - return [reference.field]; + return []; } const paths: string[] = []; collectMatchingFieldPaths(formData, reference.field.split('.'), '', 0, paths); - return paths.length === 0 ? [reference.field] : paths.sort(); + return paths.sort(); }); }, diff --git a/src/features/validation/expressionValidation/ExpressionValidation.tsx b/src/features/validation/expressionValidation/ExpressionValidation.tsx index 8e00292959..6553debe4a 100644 --- a/src/features/validation/expressionValidation/ExpressionValidation.tsx +++ b/src/features/validation/expressionValidation/ExpressionValidation.tsx @@ -87,17 +87,6 @@ function BaseFieldExpressionValidation({ }) { const allPaths = FD.useDebouncedAllPaths(reference); - if (allPaths.length === 0 || (allPaths.length === 1 && allPaths[0] === reference.field)) { - return ( - - ); - } - return ( <> {allPaths.map((field) => ( From 214db67cbdc44c7af148bda4b88b600f7d218cbe Mon Sep 17 00:00:00 2001 From: Ole Martin Handeland Date: Tue, 30 Sep 2025 15:33:42 +0200 Subject: [PATCH 6/7] Fixing usage of lookup tool in tests where we don't have a data model schema, just data --- src/features/formData/FormDataWrite.tsx | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/src/features/formData/FormDataWrite.tsx b/src/features/formData/FormDataWrite.tsx index 44234a71ec..ddcd13a8ad 100644 --- a/src/features/formData/FormDataWrite.tsx +++ b/src/features/formData/FormDataWrite.tsx @@ -828,14 +828,15 @@ export const FD = { return emptyArray; } - if (lookupErr?.error !== 'missingRepeatingGroup') { - // This is hacky. We use the lookup tool to determine if the base path hits a 'repeating group' structure in - // the data model. Meaning, we should have had indexes somewhere in the path. - // If there's no array to be found anywhere in this path, it's safe to just return the same path. - // Only when there is repeating stuff should we continue looking at how many rows there are. + // 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 []; @@ -843,7 +844,7 @@ export const FD = { const paths: string[] = []; collectMatchingFieldPaths(formData, reference.field.split('.'), '', 0, paths); - return paths.sort(); + return paths.length === 0 ? [reference.field] : paths.sort(); }); }, From a19e4ed57e0b59aa81b4238238da6ebbbc183ca9 Mon Sep 17 00:00:00 2001 From: Ole Martin Handeland Date: Tue, 30 Sep 2025 15:51:34 +0200 Subject: [PATCH 7/7] Intercepting and adding component lookups --- .../expression-validation.ts | 36 ++++++++++++++++++- 1 file changed, 35 insertions(+), 1 deletion(-) diff --git a/test/e2e/integration/expression-validation-test/expression-validation.ts b/test/e2e/integration/expression-validation-test/expression-validation.ts index 60cb912ad9..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', () => { @@ -250,7 +253,38 @@ describe('Expression validation', () => { cy.get(appFrontend.receipt.container).should('be.visible'); }); - it('should handle date validation with "still employed" checkbox', () => { + 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();