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');
+ });
});