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

Filtering support for all options-components #2806

Merged
merged 20 commits into from
Dec 16, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
9e97170
Configuration, sharing expression data sources
Nov 26, 2024
4bc18c9
Implementing options filtering, along with 'value arguments' for expr…
Nov 29, 2024
eacebbc
Allowing usage for ["value"] in expression validation
Dec 2, 2024
d93e39e
Adding warning when selected option is filtered out, along with warni…
Dec 2, 2024
7382cf8
Adding shared tests for ["value"] function
Dec 2, 2024
d37c410
Merge branch 'main' into feat/filterOptions
Dec 2, 2024
c1071c9
Adding cypress test
Dec 2, 2024
98ec5af
Merge branch 'main' into feat/filterOptions
Dec 9, 2024
635c941
Merge branch 'main' into feat/filterOptions
Dec 10, 2024
c1fca84
Combining duplicate-filtering and verification (thus avoiding iterati…
Dec 10, 2024
80f8a83
Expanded the test to check for logic around preselectedOptionIndex to…
Dec 10, 2024
105f5cc
Ugh, I tried to add minItems/maxItems to the schema for all functions…
Dec 11, 2024
5072e98
Merge branch 'main' into feat/filterOptions
Dec 11, 2024
cd9682f
Removing console.log
Dec 11, 2024
19953c9
Combining two tests, they are testing pretty much the same thing. No …
Dec 11, 2024
0c36903
Minor cleanup, using safe selected values instead of unsafe ones
Dec 11, 2024
55d5f62
Using deepEqual to make sure options are set correctly. This fixes th…
Dec 11, 2024
f7e4921
Finding preselectedOption before filtering in a backwards-compatible …
Dec 11, 2024
8a8d483
Fixing data model check
Dec 11, 2024
8f52769
I broke deduplication, which I should have noticed much earlier. Addi…
Dec 11, 2024
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
13 changes: 12 additions & 1 deletion schemas/json/layout/expression.schema.v1.json
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,8 @@
{ "$ref": "#/definitions/func-lowerCase" },
{ "$ref": "#/definitions/func-upperCase" },
{ "$ref": "#/definitions/func-_experimentalSelectAndMap" },
{ "$ref": "#/definitions/func-argv"}
{ "$ref": "#/definitions/func-argv"},
{ "$ref": "#/definitions/func-value"}
]
},
"boolean": {
Expand Down Expand Up @@ -505,6 +506,16 @@
{ "$ref": "#/definitions/number" }
],
"additionalItems": false
},
"func-value": {
"title": "Value argument function",
"description": "This function returns the value of argument(s) passed to the expression. The optional argument to this function should be a string naming other value arguments to return.",
"type": "array",
"items": [
{ "const": "value" },
{ "$ref": "#/definitions/string" }
],
"additionalItems": false
}
}
}
9 changes: 9 additions & 0 deletions src/codegen/Common.ts
Original file line number Diff line number Diff line change
Expand Up @@ -364,6 +364,15 @@ const common = {
.optional(),
),
new CG.prop('source', CG.common('IOptionSource').optional()),
new CG.prop(
'optionFilter',
new CG.expr(ExprVal.Boolean)
.optional()
.setTitle('Filter options (using an expression)')
.setDescription(
'Setting this to an expression allows you to filter the list of options (the expression should return true to keep the option, false to remove it). To get the option value, use ["value"]. You can also use ["value", "label"] to get the label text resource id, likewise also "description" and "helpText".',
),
),
),
ISelectionComponentFull: () =>
new CG.obj(
Expand Down
31 changes: 31 additions & 0 deletions src/features/expressions/expression-functions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,37 @@ export const ExprFunctions = {
args: [ExprVal.Number] as const,
returns: ExprVal.Any,
}),
value: defineFunc({
impl(key) {
const config = this.valueArguments;
if (!config) {
throw new ExprRuntimeError(this.expr, this.path, 'No value arguments available');
}

const realKey = key ?? config.defaultKey;
if (!realKey || typeof realKey !== 'string') {
throw new ExprRuntimeError(
this.expr,
this.path,
`Invalid key (expected string, got ${realKey ? typeof realKey : 'null'})`,
);
}

if (!Object.prototype.hasOwnProperty.call(config.data, realKey)) {
throw new ExprRuntimeError(
this.expr,
this.path,
`Unknown key ${realKey}, Valid keys are: ${Object.keys(config.data).join(', ')}`,
);
}

const value = config.data[realKey];
return value ?? null;
},
minArguments: 0,
args: [ExprVal.String] as const,
returns: ExprVal.Any,
}),
equals: defineFunc({
impl: (arg1, arg2) => arg1 === arg2,
args: [ExprVal.String, ExprVal.String] as const,
Expand Down
4 changes: 4 additions & 0 deletions src/features/expressions/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import type {
ExprPositionalArgs,
ExprValToActual,
ExprValToActualOrExpr,
ExprValueArgs,
} from 'src/features/expressions/types';
import type { LayoutNode } from 'src/utils/layout/LayoutNode';
import type { LayoutPage } from 'src/utils/layout/LayoutPage';
Expand All @@ -32,6 +33,7 @@ export interface EvalExprOptions {
onBeforeFunctionCall?: BeforeFuncCallback;
onAfterFunctionCall?: AfterFuncCallback;
positionalArguments?: ExprPositionalArgs;
valueArguments?: ExprValueArgs;
}

export type SimpleEval<T extends ExprVal> = (
Expand All @@ -47,6 +49,7 @@ export type EvaluateExpressionParams = {
node: LayoutNode | LayoutPage | NodeNotFoundWithoutContext;
dataSources: ExpressionDataSources;
positionalArguments?: ExprPositionalArgs;
valueArguments?: ExprValueArgs;
};

/**
Expand Down Expand Up @@ -87,6 +90,7 @@ export function evalExpr(
node,
dataSources,
positionalArguments: options?.positionalArguments,
valueArguments: options?.valueArguments,
};

try {
Expand Down
6 changes: 6 additions & 0 deletions src/features/expressions/schema.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,12 @@ describe('expression schema tests', () => {
expect(expressionSchema.definitions[`func-${name}`].type).toBe('array');
expect(expressionSchema.definitions[`func-${name}`].items[0]).toEqual({ const: name });

// It might be tempting to add these into the definitions to make the schema stricter and properly validate
// min/max arguments, but this would make the schema less useful for the user, as they would not get
// autocompletion in vscode until they had the minimum number of arguments.
expect(expressionSchema.definitions[`func-${name}`].minItems).toBe(undefined);
expect(expressionSchema.definitions[`func-${name}`].maxItems).toBe(undefined);

if (returns === ExprVal.Any) {
// At least one of the definitions should be a match
// eslint-disable-next-line @typescript-eslint/no-explicit-any
Expand Down
20 changes: 17 additions & 3 deletions src/features/expressions/shared-functions.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,17 +17,27 @@ import { renderWithNode } from 'src/test/renderWithProviders';
import { useEvalExpression } from 'src/utils/layout/generator/useEvalExpression';
import { useExpressionDataSources } from 'src/utils/layout/useExpressionDataSources';
import type { SharedTestFunctionContext } from 'src/features/expressions/shared';
import type { ExprValToActualOrExpr } from 'src/features/expressions/types';
import type { ExprPositionalArgs, ExprValToActualOrExpr, ExprValueArgs } from 'src/features/expressions/types';
import type { ExternalApisResult } from 'src/features/externalApi/useExternalApi';
import type { ILayoutCollection } from 'src/layout/layout';
import type { IData, IDataType } from 'src/types/shared';
import type { LayoutNode } from 'src/utils/layout/LayoutNode';

jest.mock('src/features/externalApi/useExternalApi');

function ExpressionRunner({ node, expression }: { node: LayoutNode; expression: ExprValToActualOrExpr<ExprVal.Any> }) {
interface Props {
node: LayoutNode;
expression: ExprValToActualOrExpr<ExprVal.Any>;
positionalArguments?: ExprPositionalArgs;
valueArguments?: ExprValueArgs;
}

function ExpressionRunner({ node, expression, positionalArguments, valueArguments }: Props) {
const dataSources = useExpressionDataSources();
const result = useEvalExpression(ExprVal.Any, node, expression, null, dataSources);
const result = useEvalExpression(ExprVal.Any, node, expression, null, dataSources, {
positionalArguments,
valueArguments,
});
return <div data-testid='expr-result'>{JSON.stringify(result)}</div>;
}

Expand Down Expand Up @@ -100,6 +110,8 @@ describe('Expressions shared function tests', () => {
textResources,
profileSettings,
externalApis,
positionalArguments,
valueArguments,
} = test;

if (disabledFrontend) {
Expand Down Expand Up @@ -223,6 +235,8 @@ describe('Expressions shared function tests', () => {
<ExpressionRunner
node={node}
expression={expression}
positionalArguments={positionalArguments}
valueArguments={valueArguments}
/>
),
inInstance: !!instance,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
{
"name": "Should return list of keys when using the wrong key",
"expression": ["value", "b"],
"expectsFailure": "Unknown key b, Valid keys are: v, a",
"valueArguments": {
"data": {
"v": "foo",
"a": "bar"
},
"defaultKey": "v"
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{
"name": "Should return 'foo' when explicitly asking for default key",
"expression": ["value", "v"],
"expects": "foo",
"valueArguments": {
"data": {
"v": "foo"
},
"defaultKey": "v"
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{
"name": "Should return 'foo' when asked for default key",
"expression": ["value"],
"expects": "foo",
"valueArguments": {
"data": {
"value": "foo"
},
"defaultKey": "value"
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
{
"name": "Should return 'bar' when asking for non-default key",
"expression": ["value", "a"],
"expects": "bar",
"valueArguments": {
"data": {
"v": "foo",
"a": "bar"
},
"defaultKey": "v"
}
}
4 changes: 3 additions & 1 deletion src/features/expressions/shared.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import fs from 'node:fs';

import type { IAttachmentsMap, UploadedAttachment } from 'src/features/attachments';
import type { ExprVal, ExprValToActualOrExpr } from 'src/features/expressions/types';
import type { ExprPositionalArgs, ExprVal, ExprValToActualOrExpr, ExprValueArgs } from 'src/features/expressions/types';
import type { ExternalApisResult } from 'src/features/externalApi/useExternalApi';
import type { IRawTextResource } from 'src/features/language/textResources';
import type { ILayoutCollection } from 'src/layout/layout';
Expand Down Expand Up @@ -52,6 +52,8 @@ export interface FunctionTest extends SharedTest {
expects?: unknown;
expectsFailure?: string;
context?: SharedTestFunctionContext;
positionalArguments?: ExprPositionalArgs;
valueArguments?: ExprValueArgs;
}

export interface LayoutPreprocessorTest {
Expand Down
6 changes: 6 additions & 0 deletions src/features/expressions/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,3 +109,9 @@ export interface ExprConfig<V extends ExprVal = ExprVal> {
}

export type ExprPositionalArgs = ExprValToActual<ExprVal.Any>[];

// eslint-disable-next-line @typescript-eslint/no-explicit-any
export type ExprValueArgs<T extends object = any> = {
data: T;
defaultKey: keyof T;
};
18 changes: 14 additions & 4 deletions src/features/options/StoreOptionsInNode.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,15 @@
import React from 'react';

import deepEqual from 'fast-deep-equal';

import { EffectPreselectedOptionIndex } from 'src/features/options/effects/EffectPreselectedOptionIndex';
import { EffectRemoveStaleValues } from 'src/features/options/effects/EffectRemoveStaleValues';
import { EffectSetDownstreamParameters } from 'src/features/options/effects/EffectSetDownstreamParameters';
import { EffectStoreLabel } from 'src/features/options/effects/EffectStoreLabel';
import { useFetchOptions, useSortedOptions } from 'src/features/options/useGetOptions';
import { useFetchOptions, useFilteredAndSortedOptions } from 'src/features/options/useGetOptions';
import { NodesStateQueue } from 'src/utils/layout/generator/CommitQueue';
import { GeneratorInternal } from 'src/utils/layout/generator/GeneratorContext';
import { GeneratorData } from 'src/utils/layout/generator/GeneratorDataSources';
import { GeneratorCondition, StageFetchOptions } from 'src/utils/layout/generator/GeneratorStages';
import { NodesInternal } from 'src/utils/layout/NodesContext';
import type { OptionsValueType } from 'src/features/options/useGetOptions';
Expand Down Expand Up @@ -34,12 +37,19 @@ function StoreOptionsInNodeWorker({ valueType }: GeneratorOptionProps) {
const node = GeneratorInternal.useParent() as LayoutNode<CompWithBehavior<'canHaveOptions'>>;
const dataModelBindings = item.dataModelBindings as IDataModelBindingsOptionsSimple | undefined;

const { unsorted, isFetching, downstreamParameters } = useFetchOptions({ node, item });
const { options, preselectedOption } = useSortedOptions({ unsorted, valueType, item });
const dataSources = GeneratorData.useExpressionDataSources();
const { unsorted, isFetching, downstreamParameters } = useFetchOptions({ node, item, dataSources });
const { options, preselectedOption } = useFilteredAndSortedOptions({
unsorted,
valueType,
node,
item,
dataSources,
});

const hasBeenSet = NodesInternal.useNodeData(
node,
(data) => data.options === options && data.isFetchingOptions === isFetching,
(data) => deepEqual(data.options, options) && data.isFetchingOptions === isFetching,
);

NodesStateQueue.useSetNodeProp({ node, prop: 'options', value: options }, !hasBeenSet && !isFetching);
Expand Down
6 changes: 3 additions & 3 deletions src/features/options/effects/EffectStoreLabel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,15 +28,15 @@ export function EffectStoreLabel({ valueType, options }: Props) {
const { langAsString } = useLanguage();
const dataModelBindings = item.dataModelBindings as IDataModelBindingsOptionsSimple | undefined;
const { formData, setValue } = useDataModelBindings(dataModelBindings);
const unsafeSelectedValues = useSetOptions(valueType, dataModelBindings, options).unsafeSelectedValues;
const { selectedValues } = useSetOptions(valueType, dataModelBindings, options);

const translatedLabels = useMemo(
() =>
options
?.filter((option) => unsafeSelectedValues.includes(option.value))
.filter((option) => selectedValues.includes(option.value))
.map((option) => option.label)
.map((label) => langAsString(label)),
[langAsString, options, unsafeSelectedValues],
[langAsString, options, selectedValues],
);

const labelsHaveChanged = !deepEqual(translatedLabels, 'label' in formData ? formData.label : undefined);
Expand Down
Loading
Loading