Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 28 additions & 42 deletions src/features/expressions/shared-functions.test.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import React from 'react';
import type { PropsWithChildren } from 'react';

import { jest } from '@jest/globals';
import { screen } from '@testing-library/react';
Expand Down Expand Up @@ -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';
Expand All @@ -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 <InnerExpressionRunner {...props} />;
}

// 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') {
Expand All @@ -84,44 +76,38 @@ function ExpressionRunner(props: Props) {
);
}

let result = <InnerExpressionRunner {...props} />;
for (const [i, parentId] of parentIds.entries()) {
const reverseIndex = props.context.rowIndices.length - i - 1;
const rowIndex = props.context.rowIndices[reverseIndex];

result = (
<DataModelLocationFor
id={parentId}
rowIndex={rowIndex}
>
{result}
</DataModelLocationFor>
);
}
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<RepeatingComponents>;
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<T extends RepeatingComponents>({
id,
rowIndex,
children,
}: PropsWithChildren<{ id: string; rowIndex: number }>) {
const component = useExternalItem(id) as { type: T };
const bindings = useDataModelBindingsFor(id) as IDataModelBindings<T>;
const groupBinding = getRepeatingBinding(component.type, bindings);

if (!groupBinding) {
throw new Error(`No group binding found for ${id}`);
fieldSegments.push(`${segmentName}[${rowIndex}]`);
}

return (
<DataModelLocationProvider
groupBinding={groupBinding}
rowIndex={rowIndex}
<NestedDataModelLocationProviders
reference={{
dataType: 'default',
field: fieldSegments.join('.'),
}}
>
{children}
</DataModelLocationProvider>
<InnerExpressionRunner {...props} />
</NestedDataModelLocationProviders>
);
}

Expand Down
60 changes: 60 additions & 0 deletions src/features/formData/FormDataWrite.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -666,6 +666,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) =>
Expand Down Expand Up @@ -778,6 +816,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
Expand Down
Loading
Loading