Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Supporting component and dataModel lookups in optionFilter when using source options #2839

Merged
merged 5 commits into from
Dec 19, 2024
Merged
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
9 changes: 9 additions & 0 deletions src/features/expressions/expression-functions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { addError } from 'src/features/expressions/validation';
import { SearchParams } from 'src/features/routing/AppRoutingContext';
import { implementsDisplayData } from 'src/layout';
import { buildAuthContext } from 'src/utils/authContext';
import { transposeDataBinding } from 'src/utils/databindings/DataBinding';
import { formatDateLocale } from 'src/utils/formatDateLocale';
import { BaseLayoutNode } from 'src/utils/layout/LayoutNode';
import { LayoutPage } from 'src/utils/layout/LayoutPage';
Expand Down Expand Up @@ -306,6 +307,14 @@ export const ExprFunctions = {
}

const reference: IDataModelReference = { dataType, field: propertyPath };
if (this.dataSources.currentDataModelPath && this.dataSources.currentDataModelPath.dataType === dataType) {
const newReference = transposeDataBinding({
subject: reference,
currentLocation: this.dataSources.currentDataModelPath,
});
return pickSimpleValue(newReference, this);
}

const node = ensureNode(this.node);
if (node instanceof BaseLayoutNode) {
const newReference = this.dataSources.transposeSelector(node as LayoutNode, reference);
Expand Down
14 changes: 13 additions & 1 deletion src/features/options/castOptionsToStrings.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,19 @@
import type { IRawOption } from 'src/layout/common.generated';
import type { IDataModelReference, IRawOption } from 'src/layout/common.generated';
import type { LayoutNode } from 'src/utils/layout/LayoutNode';

export interface IOptionInternal extends Omit<IRawOption, 'value'> {
value: string;

/**
* When fetching options from the data model, if the source path is bound to a RepeatingGroup component, this may
* be set to the first node in each row, representing a node object per option. This is useful in `optionFilter`
* (and is currently only set when that property is present), so that the filter can work per-row as well as
* per-option.
*
* @see useSourceOptions
*/
rowNode?: LayoutNode;
dataModelLocation?: IDataModelReference;
}

type ReplaceWithStrings<T> = T extends IRawOption[] ? IOptionInternal[] : T;
Expand Down
93 changes: 88 additions & 5 deletions src/features/options/useGetOptions.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,18 +12,20 @@ import { renderWithNode } from 'src/test/renderWithProviders';
import type { ExprVal, ExprValToActualOrExpr } from 'src/features/expressions/types';
import type { IOptionInternal } from 'src/features/options/castOptionsToStrings';
import type { IRawOption, ISelectionComponentFull } from 'src/layout/common.generated';
import type { ILayout } from 'src/layout/layout';
import type { fetchOptions } from 'src/queries/queries';
import type { LayoutNode } from 'src/utils/layout/LayoutNode';

interface RenderProps {
type: 'single' | 'multi';
via: 'layout' | 'api' | 'repeatingGroups';
options?: IRawOption[];
options?: (IRawOption & Record<string, unknown>)[];
mapping?: Record<string, string>;
optionFilter?: ExprValToActualOrExpr<ExprVal.Boolean>;
selected?: string;
preselectedOptionIndex?: number;
fetchOptions?: jest.Mock<typeof fetchOptions>;
extraLayout?: ILayout;
}

function TestOptions({ node }: { node: LayoutNode<'Dropdown' | 'MultipleSelect'> }) {
Expand Down Expand Up @@ -69,6 +71,7 @@ async function render(props: RenderProps) {
FormLayout: {
data: {
layout: [
...(props.extraLayout ?? []),
{
type: props.type === 'single' ? 'Dropdown' : 'MultipleSelect',
id: 'myComponent',
Expand Down Expand Up @@ -96,7 +99,12 @@ async function render(props: RenderProps) {
props.fetchOptions ??
(async () =>
({
data: props.options,
data: props.options?.map((option) => ({
value: option.value,
label: option.label,
description: option.description,
helpText: option.helpText,
})),
headers: {},
}) as AxiosResponse<IRawOption[]>),
fetchTextResources: async () => ({
Expand Down Expand Up @@ -214,7 +222,7 @@ describe('useGetOptions', () => {
});

await waitFor(() => {
expect(screen.getByTestId('options').textContent).toEqual(JSON.stringify(unfilteredOptions));
expect(JSON.parse(screen.getByTestId('options').textContent ?? 'null')).toEqual(unfilteredOptions);
});

expect(window.logWarnOnce).toHaveBeenCalledWith(
Expand All @@ -232,11 +240,86 @@ describe('useGetOptions', () => {
options: [remainingOption, { label: 'second', value: 'foo' }],
});

await waitFor(() => expect(screen.getByTestId('options').textContent).toEqual(JSON.stringify([remainingOption])));
await waitFor(() =>
expect(JSON.parse(screen.getByTestId('options').textContent ?? 'null')).toEqual([remainingOption]),
);

expect(window.logWarnOnce).toHaveBeenCalledWith(
'Option was duplicate value (and was removed). With duplicate values, it is impossible to tell which of the options the user selected.\n',
JSON.stringify({ label: 'second', value: 'foo' }, null, 2),
JSON.stringify({ value: 'foo', label: 'second' }, null, 2),
);
});

it('dataModel lookups per-row when using repeatingGroups should work', async () => {
await render({
type: 'single',
via: 'repeatingGroups',
options: [
// These are actually rows in the data model
{ label: 'first', value: 'foo', useInOptions: 'keep' },
{ label: 'second', value: 'bar', useInOptions: 'scrap' },
{ label: 'third', value: 'baz', useInOptions: 'keep' },
],
optionFilter: ['equals', ['dataModel', 'Group.useInOptions'], 'keep'],
});

await waitFor(() =>
expect(JSON.parse(screen.getByTestId('options').textContent ?? 'null')).toEqual([
{ value: 'foo', label: 'first' },
{ value: 'baz', label: 'third' },
]),
);
});

it('component lookups per-row when using repeatingGroups should work', async () => {
await render({
type: 'single',
via: 'repeatingGroups',
options: [
// These are actually rows in the data model
{ label: 'first', value: 'foo', useInOptions: 'keep' },
{ label: 'second', value: 'bar', useInOptions: 'scrap' },
{ label: 'third', value: 'baz', useInOptions: 'keep' },
],
optionFilter: ['equals', ['component', 'ShouldUseInOptions'], 'keep'],
extraLayout: [
{
id: 'someRepGroup',
type: 'RepeatingGroup',
dataModelBindings: {
group: {
dataType: defaultDataTypeMock,
field: 'Group',
},
},
children: ['FirstInside', 'ShouldUseInOptions'],
},
{
id: 'FirstInside',
type: 'Header',
textResourceBindings: {
title: 'This title is not important',
},
size: 'L',
},
{
id: 'ShouldUseInOptions',
type: 'Input',
dataModelBindings: {
simpleBinding: {
dataType: defaultDataTypeMock,
field: 'Group.useInOptions',
},
},
},
],
});

await waitFor(() =>
expect(JSON.parse(screen.getByTestId('options').textContent ?? 'null')).toEqual([
{ value: 'foo', label: 'first' },
{ value: 'baz', label: 'third' },
]),
);
});
});
20 changes: 15 additions & 5 deletions src/features/options/useGetOptions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import { useLanguage } from 'src/features/language/useLanguage';
import { castOptionsToStrings } from 'src/features/options/castOptionsToStrings';
import { useGetOptionsQuery, useGetOptionsUrl } from 'src/features/options/useGetOptionsQuery';
import { useNodeOptions } from 'src/features/options/useNodeOptions';
import { useSourceOptions } from 'src/hooks/useSourceOptions';
import { useSourceOptions } from 'src/features/options/useSourceOptions';
import { useNodeItem } from 'src/utils/layout/useNodeItem';
import { verifyAndDeduplicateOptions } from 'src/utils/options';
import type { ExprValueArgs } from 'src/features/expressions/types';
Expand Down Expand Up @@ -121,10 +121,10 @@ function useOptionsUrl(
}

export function useFetchOptions({ node, item, dataSources }: FetchOptionsProps) {
const { options, optionsId, source } = item;
const { options, optionsId, source, optionFilter } = item;
const url = useOptionsUrl(node, item, dataSources);

const sourceOptions = useSourceOptions({ source, node, dataSources });
const sourceOptions = useSourceOptions({ source, node, dataSources, addRowInfo: !!optionFilter });
const staticOptions = useMemo(() => (optionsId ? undefined : castOptionsToStrings(options)), [options, optionsId]);
const { data, isFetching, error } = useGetOptionsQuery(url);
useLogFetchError(error, item);
Expand Down Expand Up @@ -180,12 +180,18 @@ export function useFilteredAndSortedOptions({
let options = verifyAndDeduplicateOptions(unsorted, valueType === 'multi');

if (optionFilter !== undefined && ExprValidation.isValid(optionFilter)) {
options = options.filter((option) => {
options = options.filter((o) => {
const { rowNode, dataModelLocation, ...option } = o;
const valueArguments: ExprValueArgs<IOptionInternal> = {
data: option,
defaultKey: 'value',
};
const keep = evalExpr(optionFilter, node, dataSources, { valueArguments });
const keep = evalExpr(
optionFilter,
rowNode ?? node,
{ ...dataSources, currentDataModelPath: dataModelLocation },
{ valueArguments },
);
if (!keep && selectedValues.includes(option.value)) {
window.logWarnOnce(
`Node '${node.id}': Option with value "${option.value}" was selected, but the option filter ` +
Expand All @@ -211,6 +217,10 @@ export function useFilteredAndSortedOptions({
options.sort(compareOptionAlphabetically(langAsString, sortOrder, selectedLanguage));
}

// Always remove the rowNode and dataModelLocation at this point. It is only to be used in the filtering
// process, and will not ruin the comparison later to make sure the state is set in zustand.
options = options.map((option) => ({ ...option, rowNode: undefined, dataModelLocation: undefined }));

return { options, preselectedOption };
}, [
unsorted,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,15 +13,21 @@ interface IUseSourceOptionsArgs {
source: IOptionSource | undefined;
node: LayoutNode;
dataSources: ExpressionDataSources;
addRowInfo: boolean;
}

export const useSourceOptions = ({ source, node, dataSources }: IUseSourceOptionsArgs): IOptionInternal[] | undefined =>
export const useSourceOptions = ({
source,
node,
dataSources,
addRowInfo,
}: IUseSourceOptionsArgs): IOptionInternal[] | undefined =>
useMemoDeepEqual(() => {
if (!source) {
return undefined;
}

const { formDataRowsSelector, formDataSelector, langToolsSelector } = dataSources;
const { formDataRowsSelector, formDataSelector, langToolsSelector, nodeTraversal } = dataSources;
const output: IOptionInternal[] = [];
const langTools = langToolsSelector(node);
const { group, value, label, helpText, description, dataType } = source;
Expand All @@ -43,8 +49,26 @@ export const useSourceOptions = ({ source, node, dataSources }: IUseSourceOption
return output;
}

let repGroupNode: LayoutNode<'RepeatingGroup'> | undefined;
if (addRowInfo) {
repGroupNode = nodeTraversal(
(t) =>
t.allNodes(
(n) =>
n.type === 'node' &&
n.layout.type === 'RepeatingGroup' &&
n.layout.dataModelBindings &&
'group' in n.layout.dataModelBindings &&
n.layout.dataModelBindings.group.field === groupReference.field &&
n.layout.dataModelBindings.group.dataType === groupReference.dataType,
)?.[0] as LayoutNode<'RepeatingGroup'> | undefined,
[groupDataType, groupReference.field, groupReference.dataType],
);
}

for (const idx in groupRows) {
const path = `${groupReference.field}[${idx}]`;
const index = parseInt(idx, 10);
const path = `${groupReference.field}[${index}]`;
const nonTransposed = { dataType: groupDataType, field: path };
const transposed = transposeDataBinding({
subject: valueReference,
Expand Down Expand Up @@ -72,16 +96,23 @@ export const useSourceOptions = ({ source, node, dataSources }: IUseSourceOption
}),
};

let rowNode: LayoutNode | undefined;
if (repGroupNode) {
rowNode = nodeTraversal((t) => t.with(repGroupNode).children(undefined, index)?.[0], [repGroupNode, index]);
}

output.push({
value: String(formDataSelector(transposed)),
label: resolveText(label, node, modifiedDataSources, nonTransposed) as string,
description: resolveText(description, node, modifiedDataSources, nonTransposed),
helpText: resolveText(helpText, node, modifiedDataSources, nonTransposed),
rowNode,
dataModelLocation: addRowInfo ? transposed : undefined,
});
}

return output;
}, [source, node, dataSources]);
}, [source, node, dataSources, addRowInfo]);

function resolveText(
text: ExprValToActualOrExpr<ExprVal.String> | undefined,
Expand Down
3 changes: 2 additions & 1 deletion src/utils/layout/useExpressionDataSources.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ import type { ExternalApisResult } from 'src/features/externalApi/useExternalApi
import type { IUseLanguage } from 'src/features/language/useLanguage';
import type { NodeOptionsSelector } from 'src/features/options/OptionsStorePlugin';
import type { FormDataRowsSelector, FormDataSelector } from 'src/layout';
import type { ILayoutSet } from 'src/layout/common.generated';
import type { IDataModelReference, ILayoutSet } from 'src/layout/common.generated';
import type { IApplicationSettings, IInstanceDataSources, IProcess } from 'src/types/shared';
import type { LayoutNode } from 'src/utils/layout/LayoutNode';
import type { NodeDataSelector } from 'src/utils/layout/NodesContext';
Expand All @@ -45,6 +45,7 @@ export interface ExpressionDataSources {
nodeTraversal: NodeTraversalSelector;
transposeSelector: DataModelTransposeSelector;
externalApis: ExternalApisResult;
currentDataModelPath?: IDataModelReference;
}

export function useExpressionDataSources(): ExpressionDataSources {
Expand Down
4 changes: 2 additions & 2 deletions src/utils/layout/useNodeTraversal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -171,8 +171,8 @@ export class NodeTraversal<T extends Node = LayoutPages> {
/**
* Selects all nodes in the hierarchy, starting from the root node.
*/
allNodes(): LayoutNode[] {
return this.rootNode.allNodes(new TraversalTask(this.state, this.rootNode, undefined, undefined));
allNodes(matching?: TraversalMatcher, restriction?: TraversalRestriction): LayoutNode[] {
return this.rootNode.allNodes(new TraversalTask(this.state, this.rootNode, matching, restriction));
}

/**
Expand Down
Loading
Loading