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

Expression validation #1540

Merged
merged 32 commits into from
Oct 9, 2023
Merged
Show file tree
Hide file tree
Changes from 29 commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
733dd88
experimenting with expression validation formats
bjosttveit Aug 2, 2023
27b5d32
runExpressionValidationsOnNode
bjosttveit Aug 2, 2023
8d3b121
simplify by removing field override
bjosttveit Aug 2, 2023
8227ec5
Merge branch 'main' into expression-validation
bjosttveit Aug 10, 2023
99080bb
integrated with backend
bjosttveit Aug 10, 2023
b5e04dd
Merge branch 'main' into expression-validation
bjosttveit Aug 14, 2023
72ff9ec
use argv syntax for field
bjosttveit Aug 15, 2023
281057d
argv function impl
bjosttveit Aug 15, 2023
576f3da
rename types
bjosttveit Aug 15, 2023
31a59f3
Merge branch 'main' into expression-validation
bjosttveit Aug 16, 2023
e6d97e3
Merge branch 'main' into expression-validation
bjosttveit Aug 17, 2023
8669c66
started making shared tests
bjosttveit Aug 25, 2023
8d8f478
Merge branch 'main' into expression-validation
bjosttveit Aug 25, 2023
847f8cc
use layouts instead of single layout
bjosttveit Aug 29, 2023
f6411ed
null check and more tests
bjosttveit Sep 6, 2023
02985ab
Merge branch 'main' into expression-validation
bjosttveit Sep 6, 2023
61035ee
Merge branch 'main' into expression-validation
bjosttveit Sep 12, 2023
dea445e
improve test runner and add hiddenRow tests
bjosttveit Sep 13, 2023
612a849
Merge branch 'main' into expression-validation
bjosttveit Sep 18, 2023
9c49260
started adding cypress tests
bjosttveit Sep 18, 2023
44ad546
group test
bjosttveit Sep 19, 2023
5768346
add hiddenRow test
bjosttveit Sep 20, 2023
8a42772
Merge branch 'main' into expression-validation
bjosttveit Sep 27, 2023
a841e8e
remove feature flag
bjosttveit Sep 27, 2023
2df8a34
Merge branch 'main' into expression-validation
bjosttveit Oct 4, 2023
7c75a70
upgrade expression schema
bjosttveit Oct 4, 2023
aedebf8
fix query logic
bjosttveit Oct 4, 2023
2fb7b73
breaking change
bjosttveit Oct 5, 2023
f5f9c60
Merge branch 'main' into expression-validation
bjosttveit Oct 5, 2023
6cbbb76
minor changes
bjosttveit Oct 9, 2023
49953a1
Merge branch 'main' into expression-validation
bjosttveit Oct 9, 2023
8b5b474
validate expressions
bjosttveit Oct 9, 2023
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 @@ -73,7 +73,8 @@
{ "$ref": "#/definitions/func-text" },
{ "$ref": "#/definitions/func-language" },
{ "$ref": "#/definitions/func-lowerCase" },
{ "$ref": "#/definitions/func-upperCase" }
{ "$ref": "#/definitions/func-upperCase" },
{ "$ref": "#/definitions/func-argv"}
]
},
"boolean": {
Expand Down Expand Up @@ -430,6 +431,16 @@
{ "$ref": "#/definitions/string" }
],
"additionalItems": false
},
"func-argv": {
"title": "Positional argument function",
"description": "This function returns the value of the positional argument at the specified index passed to the expression (currently only available for validation conditions)",
"type": "array",
"items": [
{ "const": "argv" },
{ "$ref": "#/definitions/string" }
bjosttveit marked this conversation as resolved.
Show resolved Hide resolved
],
"additionalItems": false
}
}
}
86 changes: 86 additions & 0 deletions schemas/json/validation/validation.schema.v1.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
{
"$id": "https://altinncdn.no/schemas/json/validation/validation.schema.v1.json",
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "Altinn 3 Validation",
"description": "Schema that describes the custom validation configuration for Altinn applications.",
"type": "object",
"additionalProperties": false,
"properties": {
"validations": {
"type": "object",
"patternProperties": {
"^.*$": {
"type": "array",
"items": {
"oneOf": [
{
"$ref": "#/definitions/validation"
},
{
"type": "string",
"title": "Reference to another validation",
"description": "A reference to another validation to extend from. The referenced validation must be defined in the same validation configuration file."
}
]
}
}
}
},
"definitions": {
"patternProperties": {
"^.*$": {
"ref": "#/definitions/validation"
}
}
}
},
"required": [
"validations"
],
"definitions": {
"validation": {
"type": "object",
"additionalProperties": false,
"properties": {
"message": {
"title": "Validation message",
"description": "The message to display when the validation fails.",
"type": "string"
},
"condition": {
"title": "Expression returning a boolean value",
"description": "The expression must return a boolean value. The expression can contain references to other fields in the form.",
"$ref": "../layout/expression.schema.v1.json#/definitions/boolean"
},
"severity": {
"title": "Severity",
"description": "The severity of the validation message.",
"type": "string",
"default": "errors",
"enum": [
"errors",
"warnings",
"info",
"success"
]
},
"ref": {
"title": "Reference to another validation",
"description": "A reference to another validation to extend from. The referenced validation must be defined in the same validation configuration file.",
"type": "string"
}
},
"if": {
"required": [
"ref"
]
},
"else": {
"required": [
"message",
"condition"
]
}
}
}
}
2 changes: 2 additions & 0 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { useAllOptionsInitiallyLoaded } from 'src/features/options/useAllOptions
import { QueueActions } from 'src/features/queue/queueSlice';
import { useApplicationMetadataQuery } from 'src/hooks/queries/useApplicationMetadataQuery';
import { useApplicationSettingsQuery } from 'src/hooks/queries/useApplicationSettingsQuery';
import { useCustomValidationConfig } from 'src/hooks/queries/useCustomValidationConfig';
import { useFooterLayoutQuery } from 'src/hooks/queries/useFooterLayoutQuery';
import { useFormDataQuery } from 'src/hooks/queries/useFormDataQuery';
import { useCurrentPartyQuery } from 'src/hooks/queries/useGetCurrentPartyQuery';
Expand Down Expand Up @@ -62,6 +63,7 @@ type AppInternalProps = {
};

const AppInternal = ({ applicationSettings }: AppInternalProps): JSX.Element | null => {
useCustomValidationConfig();
const allowAnonymousSelector = makeGetAllowAnonymousSelector();
const allowAnonymous = useAppSelector(allowAnonymousSelector);

Expand Down
4 changes: 4 additions & 0 deletions src/__mocks__/initialStateMock.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,10 @@ export function getInitialStateMock(customStates?: Partial<IRuntimeState>): IRun
attachments: {
attachments: {},
},
customValidation: {
customValidation: null,
error: null,
},
devTools: {
activeTab: DevToolsTab.General,
isOpen: false,
Expand Down
33 changes: 33 additions & 0 deletions src/features/customValidation/customValidationSlice.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { createSagaSlice } from 'src/redux/sagaSlice';
import type { ICustomValidationState } from 'src/features/customValidation/types';
import type { ActionsFromSlice, MkActionType } from 'src/redux/sagaSlice';
import type { IExpressionValidations } from 'src/utils/validation/types';

const initialState: ICustomValidationState = {
customValidation: null,
error: null,
};

export let CustomValidationActions: ActionsFromSlice<typeof customValidationSlice>;
export const customValidationSlice = () => {
const slice = createSagaSlice((mkAction: MkActionType<ICustomValidationState>) => ({
name: 'customValidation',
initialState,
actions: {
fetchCustomValidationsFulfilled: mkAction<IExpressionValidations | null>({
reducer: (state, action) => {
state.customValidation = action.payload;
state.error = null;
},
}),
fetchCustomValidationsRejected: mkAction<Error | null>({
reducer: (state, action) => {
state.customValidation = null;
state.error = action.payload;
},
}),
},
}));
CustomValidationActions = slice.actions;
return slice;
};
6 changes: 6 additions & 0 deletions src/features/customValidation/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import type { IExpressionValidations } from 'src/utils/validation/types';

export type ICustomValidationState = {
customValidation: IExpressionValidations | null;
error: Error | null;
};
7 changes: 5 additions & 2 deletions src/features/expressions/ExprContext.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { ExprRuntimeError, NodeNotFound, NodeNotFoundWithoutContext } from 'src/
import { prettyErrors, prettyErrorsToConsole } from 'src/features/expressions/prettyErrors';
import type { IAttachments } from 'src/features/attachments';
import type { EvalExprOptions } from 'src/features/expressions/index';
import type { ExprConfig, Expression } from 'src/features/expressions/types';
import type { ExprConfig, Expression, ExprPositionalArgs } from 'src/features/expressions/types';
import type { IFormData } from 'src/features/formData';
import type { AllOptionsMap } from 'src/features/options/useAllOptions';
import type { IUseLanguage } from 'src/hooks/useLanguage';
Expand Down Expand Up @@ -42,6 +42,7 @@ export class ExprContext {
public node: LayoutNode | LayoutPage | NodeNotFoundWithoutContext,
public dataSources: ContextDataSources,
public callbacks: Pick<EvalExprOptions, 'onBeforeFunctionCall' | 'onAfterFunctionCall'>,
public positionalArguments?: ExprPositionalArgs,
) {}

/**
Expand All @@ -52,8 +53,9 @@ export class ExprContext {
node: LayoutNode | LayoutPage | NodeNotFoundWithoutContext,
dataSources: ContextDataSources,
callbacks: Pick<EvalExprOptions, 'onBeforeFunctionCall' | 'onAfterFunctionCall'>,
positionalArguments?: ExprPositionalArgs,
): ExprContext {
return new ExprContext(expr, node, dataSources, callbacks);
return new ExprContext(expr, node, dataSources, callbacks, positionalArguments);
}

/**
Expand All @@ -66,6 +68,7 @@ export class ExprContext {
prevInstance.node,
prevInstance.dataSources,
prevInstance.callbacks,
prevInstance.positionalArguments,
);
newInstance.path = newPath;

Expand Down
31 changes: 27 additions & 4 deletions src/features/expressions/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import type {
Expression,
ExprFunction,
ExprObjConfig,
ExprPositionalArgs,
ExprResolved,
ExprValToActual,
FuncDef,
Expand All @@ -34,6 +35,7 @@ export interface EvalExprOptions {
errorIntroText?: string;
onBeforeFunctionCall?: (path: string[], func: ExprFunction, args: any[]) => void;
onAfterFunctionCall?: (path: string[], func: ExprFunction, args: any[], result: any) => void;
positionalArguments?: ExprPositionalArgs;
}

export interface EvalExprInObjArgs<T> {
Expand Down Expand Up @@ -179,10 +181,16 @@ export function evalExpr(
dataSources: ContextDataSources,
options?: EvalExprOptions,
) {
let ctx = ExprContext.withBlankPath(expr, node, dataSources, {
onBeforeFunctionCall: options?.onBeforeFunctionCall,
onAfterFunctionCall: options?.onAfterFunctionCall,
});
let ctx = ExprContext.withBlankPath(
expr,
node,
dataSources,
{
onBeforeFunctionCall: options?.onBeforeFunctionCall,
onAfterFunctionCall: options?.onAfterFunctionCall,
},
options?.positionalArguments,
);
try {
const result = innerEvalExpr(ctx);
if ((result === null || result === undefined) && options && options.config) {
Expand Down Expand Up @@ -334,6 +342,21 @@ const authContextKeys: { [key in keyof IAuthContext]: true } = {
* All the functions available to execute inside expressions
*/
export const ExprFunctions = {
argv: defineFunc({
impl(idx) {
if (!this.positionalArguments?.length) {
throw new ExprRuntimeError(this, 'No positional arguments available');
}

if (typeof idx !== 'number' || idx < 0 || idx >= this.positionalArguments.length) {
throw new ExprRuntimeError(this, 'Invalid argv index');
}

return this.positionalArguments[idx];
},
args: [ExprVal.Number] as const,
returns: ExprVal.Any,
}),
equals: defineFunc({
impl: (arg1, arg2) => arg1 === arg2,
args: [ExprVal.String, ExprVal.String] as const,
Expand Down
2 changes: 2 additions & 0 deletions src/features/expressions/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -174,3 +174,5 @@ export type ExprObjConfig<
: OmitNeverKeys<{
[P in keyof Required<T>]: OmitNeverArrays<DistributiveExprConfig<Exclude<T[P], undefined>, Iterations>>;
}>;

export type ExprPositionalArgs = ExprValToActual<ExprVal.Any>[];
48 changes: 48 additions & 0 deletions src/hooks/queries/useCustomValidationConfig.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { useQuery } from '@tanstack/react-query';
import type { UseQueryResult } from '@tanstack/react-query';
import type { AxiosError } from 'axios';

import { useAppQueries } from 'src/contexts/appQueriesContext';
import { CustomValidationActions } from 'src/features/customValidation/customValidationSlice';
import { useAppDispatch } from 'src/hooks/useAppDispatch';
import { useAppSelector } from 'src/hooks/useAppSelector';
import { getCurrentDataTypeForApplication } from 'src/utils/appMetadata';
import { resolveExpressionValidationConfig } from 'src/utils/validation/expressionValidation';
import type { IApplicationMetadata } from 'src/features/applicationMetadata';
import type { ILayoutSets } from 'src/types';
import type { IInstance } from 'src/types/shared';
import type { IExpressionValidationConfig } from 'src/utils/validation/types';

export const useCustomValidationConfig = (): UseQueryResult<IExpressionValidationConfig | null> => {
const dispatch = useAppDispatch();
const { fetchCustomValidationConfig } = useAppQueries();

const appMetadata: IApplicationMetadata | null = useAppSelector(
(state) => state.applicationMetadata.applicationMetadata,
);
const instance: IInstance | null = useAppSelector((state) => state.instanceData.instance);
const layoutSets: ILayoutSets | null = useAppSelector((state) => state.formLayout.layoutsets);

const dataTypeId =
getCurrentDataTypeForApplication({
application: appMetadata,
instance,
layoutSets,
}) ?? '';
bjosttveit marked this conversation as resolved.
Show resolved Hide resolved

return useQuery(['fetchCustomValidationConfig', dataTypeId], () => fetchCustomValidationConfig(dataTypeId), {
enabled: Boolean(dataTypeId?.length),
onSuccess: (customValidationConfig) => {
if (customValidationConfig) {
const validationDefinition = resolveExpressionValidationConfig(customValidationConfig);
dispatch(CustomValidationActions.fetchCustomValidationsFulfilled(validationDefinition));
} else {
dispatch(CustomValidationActions.fetchCustomValidationsFulfilled(null));
}
},
onError: (error: AxiosError) => {
dispatch(CustomValidationActions.fetchCustomValidationsRejected(error));
window.logError('Fetching validation configuration failed:\n', error);
},
});
};
6 changes: 6 additions & 0 deletions src/queries/queries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
applicationSettingsApiUrl,
currentPartyUrl,
getActiveInstancesUrl,
getCustomValidationConfigUrl,
getFooterLayoutUrl,
getJsonSchemaUrl,
getLayoutSetsUrl,
Expand All @@ -24,6 +25,7 @@ import type { IPartyValidationResponse } from 'src/features/party';
import type { IOption } from 'src/layout/common.generated';
import type { ILayoutSets, ISimpleInstance } from 'src/types';
import type { IAltinnOrgs, IApplicationSettings, IProfile } from 'src/types/shared';
import type { IExpressionValidationConfig } from 'src/utils/validation/types';

export const doPartyValidation = async (partyId: string): Promise<IPartyValidationResponse> =>
(await httpPost(getPartyValidationUrl(partyId))).data;
Expand Down Expand Up @@ -54,7 +56,11 @@ export const fetchParties = () => httpGet(validPartiesUrl);

export const fetchRefreshJwtToken = () => httpGet(refreshJwtTokenUrl);

export const fetchCustomValidationConfig = (dataTypeId: string): Promise<IExpressionValidationConfig | null> =>
httpGet(getCustomValidationConfigUrl(dataTypeId));

export const fetchUserProfile = (): Promise<IProfile> => httpGet(profileApiUrl);

export const fetchDataModelSchema = (dataTypeName: string): Promise<JSONSchema7> =>
httpGet(getJsonSchemaUrl() + dataTypeName);

Expand Down
2 changes: 2 additions & 0 deletions src/redux/reducers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { combineReducers } from 'redux';
import { applicationMetadataSlice } from 'src/features/applicationMetadata/applicationMetadataSlice';
import { applicationSettingsSlice } from 'src/features/applicationSettings/applicationSettingsSlice';
import { attachmentSlice } from 'src/features/attachments/attachmentSlice';
import { customValidationSlice } from 'src/features/customValidation/customValidationSlice';
import { dataListsSlice } from 'src/features/dataLists/dataListsSlice';
import { formDataModelSlice } from 'src/features/datamodel/datamodelSlice';
import { devToolsSlice } from 'src/features/devtools/data/devToolsSlice';
Expand Down Expand Up @@ -31,6 +32,7 @@ const slices = [
applicationMetadataSlice,
applicationSettingsSlice,
attachmentSlice,
customValidationSlice,
dataListsSlice,
devToolsSlice,
footerLayoutSlice,
Expand Down
1 change: 1 addition & 0 deletions src/selectors/getErrors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ const getHasErrorsSelector = (state: IRuntimeState) => {
state.textResources.error ||
state.formDynamics.error ||
state.formRules.error ||
state.customValidation.error ||
// 403 in formData handles with MissingRolesError, see Entrypoint.tsx
exceptIfIncludes(state.formData.error, '403');

Expand Down
1 change: 1 addition & 0 deletions src/test/renderWithProviders.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ export const renderWithProviders = (
fetchDataModelSchema: () => Promise.resolve({}),
fetchParties: () => Promise.resolve({}),
fetchRefreshJwtToken: () => Promise.resolve({}),
fetchCustomValidationConfig: () => Promise.resolve(null),
fetchFormData: () => Promise.resolve({}),
fetchOptions: () => Promise.resolve([]),
fetchDataList: () => Promise.resolve({} as unknown as IDataList),
Expand Down
Loading