diff --git a/schemas/json/layout/expression.schema.v1.json b/schemas/json/layout/expression.schema.v1.json index 198a8e75c4..e31c0fee5a 100644 --- a/schemas/json/layout/expression.schema.v1.json +++ b/schemas/json/layout/expression.schema.v1.json @@ -169,10 +169,11 @@ }, "func-dataModel": { "title": "Data model lookup function", - "description": "This function will look up a value in the data model, using the JSON dot notation for referencing the data model structure. Relative positioning inside repeating groups will be resolved automatically if no positional indexes are specified.", + "description": "This function will look up a value in the data model, using the JSON dot notation for referencing the data model structure. Relative positioning inside repeating groups will be resolved automatically if no positional indexes are specified. The final parameter is optional and specifies the data type to look up.", "type": "array", "items": [ { "const": "dataModel" }, + { "$ref": "#/definitions/string" }, { "$ref": "#/definitions/string" } ], "additionalItems": false diff --git a/src/__mocks__/getApplicationMetadataMock.ts b/src/__mocks__/getApplicationMetadataMock.ts index 15d5da6223..2256ab92a2 100644 --- a/src/__mocks__/getApplicationMetadataMock.ts +++ b/src/__mocks__/getApplicationMetadataMock.ts @@ -48,7 +48,6 @@ export const getIncomingApplicationMetadataMock = ( autoCreate: true, classRef: 'Altinn.App.Models.Skjema2', }, - taskId: 'Task_0', maxCount: 1, minCount: 1, }, @@ -60,7 +59,6 @@ export const getIncomingApplicationMetadataMock = ( classRef: 'Altinn.App.Models.Skjema3', allowAnonymousOnStateless: true, }, - taskId: 'Task_0', maxCount: 1, minCount: 1, }, diff --git a/src/__mocks__/getExpressionDataSourcesMock.ts b/src/__mocks__/getExpressionDataSourcesMock.ts index a13e464dae..4cf15c752e 100644 --- a/src/__mocks__/getExpressionDataSourcesMock.ts +++ b/src/__mocks__/getExpressionDataSourcesMock.ts @@ -1,4 +1,5 @@ import { getApplicationSettingsMock } from 'src/__mocks__/getApplicationSettingsMock'; +import { defaultDataTypeMock } from 'src/__mocks__/getLayoutSetsMock'; import { staticUseLanguageForTests } from 'src/features/language/useLanguage'; import type { IInstanceDataSources } from 'src/types/shared'; import type { ExpressionDataSources } from 'src/utils/layout/useExpressionDataSources'; @@ -13,9 +14,11 @@ export function getExpressionDataSourcesMock(): ExpressionDataSources { }, optionsSelector: () => ({ isFetching: false, options: [] }), applicationSettings: getApplicationSettingsMock(), + dataModelNames: [defaultDataTypeMock], instanceDataSources: {} as IInstanceDataSources | null, langToolsSelector: () => staticUseLanguageForTests(), currentLanguage: 'nb', + currentLayoutSet: { id: 'form', dataType: 'data', tasks: ['task1'] }, isHiddenSelector: () => false, nodeFormDataSelector: (() => ({})) as unknown as NodeFormDataSelector, nodeDataSelector: () => { diff --git a/src/__mocks__/getFormLayoutGroupMock.ts b/src/__mocks__/getFormLayoutGroupMock.ts index d452c110b5..5a3ac5c55c 100644 --- a/src/__mocks__/getFormLayoutGroupMock.ts +++ b/src/__mocks__/getFormLayoutGroupMock.ts @@ -1,3 +1,4 @@ +import { defaultDataTypeMock } from 'src/__mocks__/getLayoutSetsMock'; import type { CompRepeatingGroupExternal } from 'src/layout/RepeatingGroup/config.generated'; export const getFormLayoutRepeatingGroupMock = ( @@ -8,7 +9,7 @@ export const getFormLayoutRepeatingGroupMock = ( children: ['field1', 'field2', 'field3', 'field4'], maxCount: 8, dataModelBindings: { - group: 'some-group', + group: { dataType: defaultDataTypeMock, field: 'some-group' }, }, ...customMock, }); diff --git a/src/__mocks__/getFormLayoutMock.ts b/src/__mocks__/getFormLayoutMock.ts deleted file mode 100644 index 89634ad19f..0000000000 --- a/src/__mocks__/getFormLayoutMock.ts +++ /dev/null @@ -1,42 +0,0 @@ -import type { ILayout } from 'src/layout/layout'; - -export function getFormLayoutMock(): ILayout { - return [ - { - id: 'field1', - type: 'Input', - dataModelBindings: { - simpleBinding: 'Group.prop1', - }, - textResourceBindings: { - title: 'Title', - }, - readOnly: false, - required: false, - }, - { - id: 'field2', - type: 'Input', - dataModelBindings: { - simpleBinding: 'Group.prop2', - }, - textResourceBindings: { - title: 'Title', - }, - readOnly: false, - required: false, - }, - { - id: 'field3', - type: 'Input', - dataModelBindings: { - simpleBinding: 'Group.prop3', - }, - textResourceBindings: { - title: 'Title', - }, - readOnly: false, - required: false, - }, - ]; -} diff --git a/src/__mocks__/getInstanceDataMock.ts b/src/__mocks__/getInstanceDataMock.ts index 30b756bc71..0827bae074 100644 --- a/src/__mocks__/getInstanceDataMock.ts +++ b/src/__mocks__/getInstanceDataMock.ts @@ -1,5 +1,7 @@ import type { IInstance } from 'src/types/shared'; +export const defaultMockDataElementId = '4f2610c9-911a-46a3-bc2d-5191602193f4'; + export function getInstanceDataMock(mutate?: (data: IInstance) => void): IInstance { const out: IInstance = { instanceOwner: { @@ -10,7 +12,7 @@ export function getInstanceDataMock(mutate?: (data: IInstance) => void): IInstan created: new Date('2020-01-01').toISOString(), data: [ { - id: '4f2610c9-911a-46a3-bc2d-5191602193f4', + id: defaultMockDataElementId, instanceGuid: '91cefc5e-c47b-40ff-a8a4-05971205f783', dataType: 'test-data-model', contentType: 'application/xml', diff --git a/src/__mocks__/getLayoutSetsMock.ts b/src/__mocks__/getLayoutSetsMock.ts index 3a6846354d..a0f1146aa2 100644 --- a/src/__mocks__/getLayoutSetsMock.ts +++ b/src/__mocks__/getLayoutSetsMock.ts @@ -1,21 +1,21 @@ import type { ILayoutSets } from 'src/layout/common.generated'; +export const defaultDataTypeMock = 'test-data-model'; +export const statelessDataTypeMock = 'stateless'; export function getLayoutSetsMock(): ILayoutSets { return { sets: [ { id: 'stateless', - dataType: 'stateless', - tasks: ['Task_0'], + dataType: statelessDataTypeMock, }, { id: 'stateless-anon', dataType: 'stateless-anon', - tasks: ['Task_0'], }, { id: 'some-data-task', - dataType: 'test-data-model', + dataType: defaultDataTypeMock, tasks: ['Task_1'], }, ], diff --git a/src/__mocks__/getMultiPageGroupMock.ts b/src/__mocks__/getMultiPageGroupMock.ts index 6c7aab74cb..43749369ef 100644 --- a/src/__mocks__/getMultiPageGroupMock.ts +++ b/src/__mocks__/getMultiPageGroupMock.ts @@ -1,10 +1,11 @@ +import { defaultDataTypeMock } from 'src/__mocks__/getLayoutSetsMock'; import type { CompRepeatingGroupExternal } from 'src/layout/RepeatingGroup/config.generated'; export const getMultiPageGroupMock = (overrides: Partial): CompRepeatingGroupExternal => ({ id: 'myMultiPageGroup', type: 'RepeatingGroup', dataModelBindings: { - group: 'multipageGroup', + group: { dataType: defaultDataTypeMock, field: 'multipageGroup' }, }, maxCount: 2, edit: { diff --git a/src/codegen/CG.ts b/src/codegen/CG.ts index 1764eaf4fe..40c8c6f685 100644 --- a/src/codegen/CG.ts +++ b/src/codegen/CG.ts @@ -3,6 +3,7 @@ import { GenerateArray } from 'src/codegen/dataTypes/GenerateArray'; import { GenerateBoolean } from 'src/codegen/dataTypes/GenerateBoolean'; import { GenerateCommonImport } from 'src/codegen/dataTypes/GenerateCommonImport'; import { GenerateConst } from 'src/codegen/dataTypes/GenerateConst'; +import { GenerateDataModelBinding } from 'src/codegen/dataTypes/GenerateDataModelBinding'; import { GenerateEnum } from 'src/codegen/dataTypes/GenerateEnum'; import { GenerateExpressionOr } from 'src/codegen/dataTypes/GenerateExpressionOr'; import { GenerateImportedSymbol } from 'src/codegen/dataTypes/GenerateImportedSymbol'; @@ -42,6 +43,7 @@ export const CG = { obj: GenerateObject, prop: GenerateProperty, trb: GenerateTextResourceBinding, + dataModelBinding: GenerateDataModelBinding, // Known values that we have types for elsewhere, or other imported types common: generateCommonImport, diff --git a/src/codegen/CodeGenerator.ts b/src/codegen/CodeGenerator.ts index 639d5253be..5438a92c28 100644 --- a/src/codegen/CodeGenerator.ts +++ b/src/codegen/CodeGenerator.ts @@ -6,6 +6,8 @@ export interface JsonSchemaExt { title: string | undefined; description: string | undefined; examples: T[]; + deprecated: boolean | undefined; + deprecationWarning: string | undefined; } export interface TypeScriptExt { @@ -40,20 +42,36 @@ export abstract class CodeGenerator { title: undefined, description: undefined, examples: [], + deprecated: undefined, + deprecationWarning: undefined, }, typeScript: {}, optional: false, frozen: false, }; + /** + * Gets the schemas description, and prepends a deprecation warning if the property is deprecated + */ + private getSchemaDescription(): string | undefined { + const description = this.internal.jsonSchema.description; + if (this.internal.jsonSchema.deprecated) { + const deprecationMessage = `**Deprecated**: ${this.internal.jsonSchema.deprecationWarning}`; + return description ? [deprecationMessage, description].join('\n') : deprecationMessage; + } + return description; + } + protected getInternalJsonSchema(): JSONSchema7 { this.freeze('getInternalJsonSchema'); return { title: this.internal.jsonSchema.title || this.internal.symbol?.name || undefined, - description: this.internal.jsonSchema.description, + description: this.getSchemaDescription(), // eslint-disable-next-line @typescript-eslint/no-explicit-any examples: this.internal.jsonSchema.examples.length ? (this.internal.jsonSchema.examples as any) : undefined, default: this.internal.optional ? (this.internal.optional.default as JSONSchema7Type) : undefined, + // @ts-expect-error Although not included in JSON schema version 7, this is implemented in more recent versions, and works in VS Code + deprecated: this.internal.jsonSchema.deprecated, }; } @@ -168,6 +186,13 @@ export abstract class MaybeOptionalCodeGenerator extends MaybeSymbolizedCodeG } export abstract class DescribableCodeGenerator extends MaybeOptionalCodeGenerator { + setDeprecated(warning: string): this { + this.ensureMutable(); + this.internal.jsonSchema.deprecated = true; + this.internal.jsonSchema.deprecationWarning = warning; + return this; + } + setTitle(title: string): this { this.ensureMutable(); this.internal.jsonSchema.title = title; diff --git a/src/codegen/Common.ts b/src/codegen/Common.ts index a562e21584..9b18b04c33 100644 --- a/src/codegen/Common.ts +++ b/src/codegen/Common.ts @@ -128,38 +128,61 @@ const common = { ), ), + IDataModelReference: () => + new CG.obj( + new CG.prop( + 'dataType', + new CG.str().setTitle('Data type').setDescription('The name of the datamodel type to reference'), + ), + new CG.prop( + 'field', + new CG.str().setTitle('Field').setDescription('The path to the property using dot-notation'), + ), + ), + IRawDataModelBinding: () => new CG.union(new CG.str(), CG.common('IDataModelReference')), + // Data model bindings: IDataModelBindingsSimple: () => - new CG.obj(new CG.prop('simpleBinding', new CG.str())) - .setTitle('Data model binding') - .setDescription( - 'Describes the location in the data model where the component should store its value(s). A simple ' + - 'binding is used for components that only store a single value, usually a string.', - ), - IDataModelBindingsOptionsSimple: () => new CG.obj( - new CG.prop('simpleBinding', new CG.str()), - new CG.prop('label', new CG.str().optional()), new CG.prop( - 'metadata', - new CG.str() - .optional() + 'simpleBinding', + new CG.dataModelBinding() + .setTitle('Data model binding') .setDescription( - 'Describes the location where metadata for the option based component should be stored in the datamodel.', + 'Describes the location in the data model where the component should store its value(s). ' + + 'A simple binding is used for components that only store a single value, usually a string.', ), ), - ) - .setTitle('Data model binding') - .setDescription( - 'Describes the location in the data model where the component should store its value(s). A simple ' + - 'binding is used for components that only store a single value, usually a string.', + ), + IDataModelBindingsOptionsSimple: () => + new CG.obj( + new CG.prop( + 'simpleBinding', + new CG.dataModelBinding() + .setTitle('Data model binding for value') + .setDescription('Describes the location in the data model where the component should store its values.'), + ), + new CG.prop( + 'label', + new CG.dataModelBinding() + .setTitle('Data model binding for label') + .setDescription('Describes the location in the data model where the component should store its labels') + .optional(), + ), + new CG.prop( + 'metadata', + new CG.dataModelBinding() + .setTitle('Data model binding for metadata') + .setDescription('Describes the location in the data model where the component should store its metadata') + .optional(), ), + ), IDataModelBindingsLikert: () => new CG.obj( new CG.prop( 'answer', - new CG.str() - .setTitle('Answer') + new CG.dataModelBinding() + .setTitle('Data model binding for answer') .setDescription( 'Dot notation location for the answers. This must point to a property of the objects inside the ' + 'question array. The answer for each question will be stored in the answer property of the ' + @@ -168,25 +191,24 @@ const common = { ), new CG.prop( 'questions', - new CG.str() - .setTitle('Questions') + new CG.dataModelBinding() + .setTitle('Data model binding for questions') .setDescription('Dot notation location for a likert structure (array of objects), where the data is stored'), ), - ) - .setTitle('Data model binding') - .setDescription( - 'Describes the location in the data model where the component should store its value(s). A list binding ' + - 'should be pointed to an array structure in the data model, and is used for components that store multiple ' + - 'simple values (e.g. a list of strings).', - ), + ), IDataModelBindingsList: () => - new CG.obj(new CG.prop('list', new CG.str())) - .setTitle('Data model binding') - .setDescription( - 'Describes the location in the data model where the component should store its value(s). A list binding ' + - 'should be pointed to an array structure in the data model, and is used for components that store multiple ' + - 'simple values (e.g. a list of strings).', + new CG.obj( + new CG.prop( + 'list', + new CG.dataModelBinding() + .setTitle('Data model binding for values') + .setDescription( + 'Describes the location in the data model where the component should store its values. A list binding ' + + 'should be pointed to an array structure in the data model, and is used for components that store multiple ' + + 'simple values (e.g. a list of strings).', + ), ), + ), // Text resource bindings: TRBSummarizable: () => @@ -256,13 +278,22 @@ const common = { ), IQueryParameters: () => new CG.obj() - .additionalProperties(new CG.str()) + .additionalProperties(new CG.expr(ExprVal.String)) .setTitle('Query parameters') .setDescription( 'A mapping of query string parameters to values. Will be appended to the URL when fetching options.', ), IOptionSource: () => new CG.obj( + new CG.prop( + 'dataType', + new CG.str() + .setTitle('Data type') + .setDescription( + 'The datamodel where the repeating group data is stored. If not specified, the data model defined in the layout-set will be used.', + ) + .optional(), + ), new CG.prop( 'group', new CG.str() @@ -318,7 +349,12 @@ const common = { .setTitle('Dynamic options (fetched from server)') .setDescription('ID of the option list to fetch from the server'), ), - new CG.prop('mapping', CG.common('IMapping').optional()), + new CG.prop( + 'mapping', + CG.common('IMapping') + .optional() + .setDeprecated('Will be removed in the next major version. Use `queryParameters` with expressions instead.'), + ), new CG.prop('queryParameters', CG.common('IQueryParameters').optional()), new CG.prop( 'options', diff --git a/src/codegen/dataTypes/GenerateCommonImport.ts b/src/codegen/dataTypes/GenerateCommonImport.ts index 75408c2554..e709c24832 100644 --- a/src/codegen/dataTypes/GenerateCommonImport.ts +++ b/src/codegen/dataTypes/GenerateCommonImport.ts @@ -1,10 +1,9 @@ import type { JSONSchema7 } from 'json-schema'; import { CG } from 'src/codegen/CG'; -import { MaybeOptionalCodeGenerator } from 'src/codegen/CodeGenerator'; +import { type CodeGeneratorWithProperties, DescribableCodeGenerator } from 'src/codegen/CodeGenerator'; import { getSourceForCommon } from 'src/codegen/Common'; import { GenerateObject } from 'src/codegen/dataTypes/GenerateObject'; -import type { CodeGeneratorWithProperties } from 'src/codegen/CodeGenerator'; import type { ValidCommonKeys } from 'src/codegen/Common'; import type { GenerateProperty } from 'src/codegen/dataTypes/GenerateProperty'; @@ -14,7 +13,7 @@ import type { GenerateProperty } from 'src/codegen/dataTypes/GenerateProperty'; */ export class GenerateCommonImport // eslint-disable-next-line @typescript-eslint/no-explicit-any - extends MaybeOptionalCodeGenerator + extends DescribableCodeGenerator implements CodeGeneratorWithProperties { public readonly realKey?: string; @@ -29,7 +28,10 @@ export class GenerateCommonImport toJsonSchema(): JSONSchema7 { this.freeze('toJsonSchema'); - return { $ref: `#/definitions/${this.key}` }; + return { + ...this.getInternalJsonSchema(), + $ref: `#/definitions/${this.key}`, + }; } toJsonSchemaDefinition(): JSONSchema7 { diff --git a/src/codegen/dataTypes/GenerateDataModelBinding.ts b/src/codegen/dataTypes/GenerateDataModelBinding.ts new file mode 100644 index 0000000000..ff4a593df8 --- /dev/null +++ b/src/codegen/dataTypes/GenerateDataModelBinding.ts @@ -0,0 +1,23 @@ +import type { JSONSchema7 } from 'json-schema'; + +import { CG } from 'src/codegen/CG'; +import { GenerateCommonImport } from 'src/codegen/dataTypes/GenerateCommonImport'; + +/** + * Generates a data model binding property. This is just a regular property, but this class is used as a + * helper to make sure you always provide a description and title. + */ +export class GenerateDataModelBinding extends GenerateCommonImport<'IDataModelReference'> { + private rawBinding = CG.common('IRawDataModelBinding'); + + constructor() { + super('IDataModelReference'); + } + + toJsonSchema(): JSONSchema7 { + // This tricks the schema to output a union of either string or object, although the typescript types are only + // objects. We rewrite incoming layouts to always be objects in LayoutsContext, so in practice this is always + // an object internally. + return this.rawBinding.toJsonSchema(); + } +} diff --git a/src/components/form/Form.test.tsx b/src/components/form/Form.test.tsx index a236d060be..935d099eb7 100644 --- a/src/components/form/Form.test.tsx +++ b/src/components/form/Form.test.tsx @@ -3,6 +3,8 @@ import React from 'react'; import { screen, within } from '@testing-library/react'; import { userEvent } from '@testing-library/user-event'; +import { defaultMockDataElementId } from 'src/__mocks__/getInstanceDataMock'; +import { defaultDataTypeMock } from 'src/__mocks__/getLayoutSetsMock'; import { Form } from 'src/components/form/Form'; import { type BackendValidationIssue, BackendValidationSeverity } from 'src/features/validation'; import { renderWithInstanceAndLayout } from 'src/test/renderWithProviders'; @@ -15,7 +17,7 @@ describe('Form', () => { id: 'field1', type: 'Input', dataModelBindings: { - simpleBinding: 'Group.prop1', + simpleBinding: { dataType: defaultDataTypeMock, field: 'Group.prop1' }, }, textResourceBindings: { title: 'First title', @@ -27,7 +29,7 @@ describe('Form', () => { id: 'field2', type: 'Input', dataModelBindings: { - simpleBinding: 'Group.prop2', + simpleBinding: { dataType: defaultDataTypeMock, field: 'Group.prop2' }, }, textResourceBindings: { title: 'Second title', @@ -39,7 +41,7 @@ describe('Form', () => { id: 'field3', type: 'Input', dataModelBindings: { - simpleBinding: 'Group.prop3', + simpleBinding: { dataType: defaultDataTypeMock, field: 'Group.prop3' }, }, textResourceBindings: { title: 'Third title', @@ -73,7 +75,7 @@ describe('Form', () => { id: 'non-rep-child', type: 'Input', dataModelBindings: { - simpleBinding: 'Group.prop3', + simpleBinding: { dataType: defaultDataTypeMock, field: 'Group.prop3' }, }, textResourceBindings: { title: 'Title from non repeating child', @@ -102,7 +104,7 @@ describe('Form', () => { id: 'panel-group-child', type: 'Input', dataModelBindings: { - simpleBinding: 'Group.prop3', + simpleBinding: { dataType: defaultDataTypeMock, field: 'Group.prop3' }, }, textResourceBindings: { title: 'Title from panel child', @@ -141,6 +143,7 @@ describe('Form', () => { { customTextKey: 'some error message', field: 'Group.prop1', + dataElementId: defaultMockDataElementId, source: 'custom', severity: BackendValidationSeverity.Error, showImmediately: true, @@ -166,6 +169,7 @@ describe('Form', () => { { code: 'some unmapped error message', field: 'Group[0].prop1', + dataElementId: defaultMockDataElementId, severity: BackendValidationSeverity.Error, source: 'custom', } as BackendValidationIssue, @@ -191,6 +195,7 @@ describe('Form', () => { { customTextKey: 'some error message', field: 'Group.prop1', + dataElementId: defaultMockDataElementId, source: 'custom', severity: BackendValidationSeverity.Error, showImmediately: true, diff --git a/src/components/message/ErrorReport.test.tsx b/src/components/message/ErrorReport.test.tsx index 6d52ff2353..4319dbe7a0 100644 --- a/src/components/message/ErrorReport.test.tsx +++ b/src/components/message/ErrorReport.test.tsx @@ -4,6 +4,8 @@ import { screen } from '@testing-library/react'; import { userEvent } from '@testing-library/user-event'; import type { AxiosError } from 'axios'; +import { defaultMockDataElementId } from 'src/__mocks__/getInstanceDataMock'; +import { defaultDataTypeMock } from 'src/__mocks__/getLayoutSetsMock'; import { Form } from 'src/components/form/Form'; import { type BackendValidationIssue, BackendValidationSeverity } from 'src/features/validation'; import { renderWithInstanceAndLayout } from 'src/test/renderWithProviders'; @@ -28,7 +30,7 @@ describe('ErrorReport', () => { id: 'input', type: 'Input', dataModelBindings: { - simpleBinding: 'boundField', + simpleBinding: { dataType: defaultDataTypeMock, field: 'boundField' }, }, }, ], @@ -92,6 +94,7 @@ describe('ErrorReport', () => { { customTextKey: 'some unbound mapped error', field: 'unboundField', + dataElementId: defaultMockDataElementId, severity: BackendValidationSeverity.Error, source: 'custom', } as BackendValidationIssue, @@ -111,6 +114,7 @@ describe('ErrorReport', () => { { customTextKey: 'some mapped error', field: 'boundField', + dataElementId: defaultMockDataElementId, severity: BackendValidationSeverity.Error, source: 'custom', } as BackendValidationIssue, diff --git a/src/core/queries/usePrefetchQuery.ts b/src/core/queries/usePrefetchQuery.ts index 9ca6e54f34..42bbbed07e 100644 --- a/src/core/queries/usePrefetchQuery.ts +++ b/src/core/queries/usePrefetchQuery.ts @@ -6,7 +6,6 @@ export type QueryDefinition = { queryFn: QueryFunction | SkipToken; enabled?: boolean; gcTime?: UseQueryOptions['gcTime']; - staleTime?: UseQueryOptions['staleTime']; refetchInterval?: UseQueryOptions['refetchInterval']; }; diff --git a/src/features/applicationMetadata/appMetadataUtils.test.ts b/src/features/applicationMetadata/appMetadataUtils.test.ts index e34268bd31..36be9db290 100644 --- a/src/features/applicationMetadata/appMetadataUtils.test.ts +++ b/src/features/applicationMetadata/appMetadataUtils.test.ts @@ -2,8 +2,8 @@ import { getIncomingApplicationMetadataMock } from 'src/__mocks__/getApplication import { getInstanceDataMock } from 'src/__mocks__/getInstanceDataMock'; import { getCurrentDataTypeForApplication, + getCurrentLayoutSet, getCurrentTaskDataElementId, - getLayoutSetIdForApplication, } from 'src/features/applicationMetadata/appMetadataUtils'; import type { ApplicationMetadata } from 'src/features/applicationMetadata/types'; import type { ILayoutSets } from 'src/layout/common.generated'; @@ -133,21 +133,21 @@ describe('appMetadata.ts', () => { }); }); - describe('getLayoutSetIdForApplication', () => { + describe('getCurrentLayoutSet', () => { it('should return correct layout set id if we have an instance', () => { - const result = getLayoutSetIdForApplication({ application: appMetadata, layoutSets, taskId: 'Task_1' }); + const result = getCurrentLayoutSet({ application: appMetadata, layoutSets, taskId: 'Task_1' }); const expected = 'datamodel'; - expect(result).toEqual(expected); + expect(result?.id).toEqual(expected); }); it('should return correct layout set id if we have a stateless app', () => { - const result = getLayoutSetIdForApplication({ + const result = getCurrentLayoutSet({ application: { ...appMetadata, isStatelessApp: true, onEntry: { show: 'stateless' } }, layoutSets, taskId: undefined, }); const expected = 'stateless'; - expect(result).toEqual(expected); + expect(result?.id).toEqual(expected); }); }); diff --git a/src/features/applicationMetadata/appMetadataUtils.ts b/src/features/applicationMetadata/appMetadataUtils.ts index fb2d9a0b79..5fc07f4a95 100644 --- a/src/features/applicationMetadata/appMetadataUtils.ts +++ b/src/features/applicationMetadata/appMetadataUtils.ts @@ -83,10 +83,10 @@ export const onEntryValuesThatHaveState: ShowTypes[] = ['new-instance', 'select- /** * Get the current layout set for application if it exists */ -export function getLayoutSetIdForApplication({ application, layoutSets, taskId }: CommonProps) { +export function getCurrentLayoutSet({ application, layoutSets, taskId }: CommonProps) { if (application.isStatelessApp) { // We have a stateless app with a layout set - return application.onEntry.show; + return layoutSets.sets.find((set) => set.id === application.onEntry.show); } const dataType = getCurrentDataTypeForApplication({ application, layoutSets, taskId }); @@ -117,7 +117,6 @@ export const getCurrentTaskDataElementId = (props: GetCurrentTaskDataElementIdPr return currentTaskDataElement?.id; }; -export function getFirstDataElementId(instance: IInstance, dataType: string) { - const currentTaskDataElement = (instance.data || []).find((element) => element.dataType === dataType); - return currentTaskDataElement?.id; +export function getFirstDataElementId(instance: IInstance | undefined, dataType: string) { + return (instance?.data ?? []).find((element) => element.dataType === dataType)?.id; } diff --git a/src/features/attachments/AttachmentsStorePlugin.tsx b/src/features/attachments/AttachmentsStorePlugin.tsx index 74a791746c..670d6be737 100644 --- a/src/features/attachments/AttachmentsStorePlugin.tsx +++ b/src/features/attachments/AttachmentsStorePlugin.tsx @@ -240,12 +240,12 @@ export class AttachmentsStorePlugin extends NodeDataPlugin { - const { fetchCustomValidationConfig } = useAppQueries(); - return { - queryKey: ['fetchCustomValidationConfig', dataTypeId], - queryFn: dataTypeId ? () => fetchCustomValidationConfig(dataTypeId) : skipToken, - enabled: !!dataTypeId, - }; -} - -const useCustomValidationConfigQuery = () => { - const dataTypeId = useCurrentDataModelName(); - - const queryDef = useCustomValidationConfigQueryDef(dataTypeId); - const utils = useQuery(queryDef); - - useEffect(() => { - utils.error && window.logError('Fetching validation configuration failed:\n', utils.error); - }, [utils.error]); - - return { - ...utils, - enabled: !!queryDef.enabled, - }; -}; - -const { Provider, useCtx } = delayedContext(() => - createQueryContext({ - name: 'CustomValidationContext', - required: false, - default: null, - query: useCustomValidationConfigQuery, - process: (queryData) => (queryData ? resolveExpressionValidationConfig(queryData) : null), - }), -); - -export const CustomValidationConfigProvider = Provider; -export const useCustomValidationConfig = () => useCtx(); diff --git a/src/features/customValidation/useCustomValidationQuery.ts b/src/features/customValidation/useCustomValidationQuery.ts new file mode 100644 index 0000000000..6472c5af33 --- /dev/null +++ b/src/features/customValidation/useCustomValidationQuery.ts @@ -0,0 +1,38 @@ +import { useEffect } from 'react'; + +import { skipToken, useQuery } from '@tanstack/react-query'; + +import { useAppQueries } from 'src/core/contexts/AppQueriesProvider'; +import { resolveExpressionValidationConfig } from 'src/features/customValidation/customValidationUtils'; +import type { QueryDefinition } from 'src/core/queries/usePrefetchQuery'; +import type { IExpressionValidationConfig } from 'src/features/validation'; + +// Also used for prefetching @see formPrefetcher.ts +export function useCustomValidationConfigQueryDef( + enabled: boolean, + dataTypeId?: string, +): QueryDefinition { + const { fetchCustomValidationConfig } = useAppQueries(); + return { + queryKey: ['fetchCustomValidationConfig', dataTypeId], + queryFn: dataTypeId ? () => fetchCustomValidationConfig(dataTypeId) : skipToken, + enabled: enabled && !!dataTypeId, + }; +} + +export const useCustomValidationConfigQuery = (enabled: boolean, dataTypeId: string) => { + const queryDef = useCustomValidationConfigQueryDef(enabled, dataTypeId); + const utils = useQuery({ + ...queryDef, + select: (config) => (config ? resolveExpressionValidationConfig(config) : null), + }); + + useEffect(() => { + utils.error && window.logError('Fetching validation configuration failed:\n', utils.error); + }, [utils.error]); + + return { + ...utils, + enabled: queryDef.enabled, + }; +}; diff --git a/src/features/dataLists/useDataListQuery.tsx b/src/features/dataLists/useDataListQuery.tsx index 04341df1ea..6418224b28 100644 --- a/src/features/dataLists/useDataListQuery.tsx +++ b/src/features/dataLists/useDataListQuery.tsx @@ -21,16 +21,20 @@ export const useDataListQuery = ( dataListId: string, secure?: boolean, mapping?: IMapping, + queryParameters?: Record, ): UseQueryResult => { const { fetchDataList } = useAppQueries(); const selectedLanguage = useCurrentLanguage(); const instanceId = useLaxInstance()?.instanceId; - const mappedData = FD.useMapping(mapping); + const mappingResult = FD.useMapping(mapping); const { pageSize, pageNumber, sortColumn, sortDirection } = filter || {}; const url = getDataListsUrl({ dataListId, - mappedData, + queryParameters: { + ...mappingResult, + ...queryParameters, + }, language: selectedLanguage, secure, instanceId, diff --git a/src/features/datamodel/DataModelsProvider.tsx b/src/features/datamodel/DataModelsProvider.tsx new file mode 100644 index 0000000000..e2261bd194 --- /dev/null +++ b/src/features/datamodel/DataModelsProvider.tsx @@ -0,0 +1,404 @@ +import React, { useCallback, useEffect, useMemo, useRef } from 'react'; +import type { PropsWithChildren } from 'react'; + +import { useIsFetching } from '@tanstack/react-query'; +import { createStore } from 'zustand'; +import type { JSONSchema7 } from 'json-schema'; + +import { createZustandContext } from 'src/core/contexts/zustandContext'; +import { DisplayError } from 'src/core/errorHandling/DisplayError'; +import { Loader } from 'src/core/loading/Loader'; +import { useApplicationMetadata } from 'src/features/applicationMetadata/ApplicationMetadataProvider'; +import { getFirstDataElementId } from 'src/features/applicationMetadata/appMetadataUtils'; +import { useCustomValidationConfigQuery } from 'src/features/customValidation/useCustomValidationQuery'; +import { useCurrentDataModelName, useDataModelUrl } from 'src/features/datamodel/useBindingSchema'; +import { useDataModelSchemaQuery } from 'src/features/datamodel/useDataModelSchemaQuery'; +import { + getAllReferencedDataTypes, + isDataTypeWritable, + MissingClassRefException, + MissingDataElementException, + MissingDataTypeException, +} from 'src/features/datamodel/utils'; +import { useLayouts } from 'src/features/form/layout/LayoutsContext'; +import { useFormDataQuery } from 'src/features/formData/useFormDataQuery'; +import { useLaxInstanceData } from 'src/features/instance/InstanceContext'; +import { MissingRolesError } from 'src/features/instantiate/containers/MissingRolesError'; +import { useBackendValidationQuery } from 'src/features/validation/backendValidation/backendValidationQuery'; +import { useShouldValidateInitial } from 'src/features/validation/backendValidation/backendValidationUtils'; +import { useIsPdf } from 'src/hooks/useIsPdf'; +import { isAxiosError } from 'src/utils/isAxiosError'; +import { HttpStatusCodes } from 'src/utils/network/networking'; +import type { SchemaLookupTool } from 'src/features/datamodel/useDataModelSchemaQuery'; +import type { BackendValidationIssue, IExpressionValidations } from 'src/features/validation'; +import type { IDataModelReference } from 'src/layout/common.generated'; + +interface DataModelsState { + defaultDataType: string | undefined; + allDataTypes: string[] | null; + writableDataTypes: string[] | null; + initialData: { [dataType: string]: object }; + dataElementIds: { [dataType: string]: string | null }; + initialValidations: BackendValidationIssue[] | null; + schemas: { [dataType: string]: JSONSchema7 }; + schemaLookup: { [dataType: string]: SchemaLookupTool }; + expressionValidationConfigs: { [dataType: string]: IExpressionValidations | null }; + error: Error | null; +} + +interface DataModelsMethods { + setDataTypes: (allDataTypes: string[], writableDataTypes: string[], defaultDataType: string | undefined) => void; + setInitialData: (dataType: string, initialData: object, dataElementId: string | null) => void; + setInitialValidations: (initialValidations: BackendValidationIssue[]) => void; + setDataModelSchema: (dataType: string, schema: JSONSchema7, lookupTool: SchemaLookupTool) => void; + setExpressionValidationConfig: (dataType: string, config: IExpressionValidations | null) => void; + setError: (error: Error) => void; +} + +function initialCreateStore() { + return createStore()((set) => ({ + defaultDataType: undefined, + allDataTypes: null, + writableDataTypes: null, + initialData: {}, + dataElementIds: {}, + initialValidations: null, + schemas: {}, + schemaLookup: {}, + expressionValidationConfigs: {}, + error: null, + + setDataTypes: (allDataTypes, writableDataTypes, defaultDataType) => { + set(() => ({ allDataTypes, writableDataTypes, defaultDataType })); + }, + setInitialData: (dataType, initialData, dataElementId) => { + set((state) => ({ + initialData: { + ...state.initialData, + [dataType]: initialData, + }, + dataElementIds: { + ...state.dataElementIds, + [dataType]: dataElementId, + }, + })); + }, + setInitialValidations: (initialValidations) => set({ initialValidations }), + setDataModelSchema: (dataType, schema, lookupTool) => { + set((state) => ({ + schemas: { + ...state.schemas, + [dataType]: schema, + }, + schemaLookup: { + ...state.schemaLookup, + [dataType]: lookupTool, + }, + })); + }, + setExpressionValidationConfig: (dataType, config) => { + set((state) => ({ + expressionValidationConfigs: { + ...state.expressionValidationConfigs, + [dataType]: config, + }, + })); + }, + setError(error: Error) { + set((state) => { + // Only set the first error, no need to overwrite if additional errors occur + if (!state.error) { + return { error }; + } + return {}; + }); + }, + })); +} + +const { Provider, useSelector, useMemoSelector, useLaxMemoSelector } = createZustandContext({ + name: 'DataModels', + required: true, + initialCreateStore, +}); + +export function DataModelsProvider({ children }: PropsWithChildren) { + return ( + + + {children} + + ); +} + +function DataModelsLoader() { + const applicationMetadata = useApplicationMetadata(); + const setDataTypes = useSelector((state) => state.setDataTypes); + const allDataTypes = useSelector((state) => state.allDataTypes); + const writableDataTypes = useSelector((state) => state.writableDataTypes); + const layouts = useLayouts(); + const defaultDataType = useCurrentDataModelName(); + const isStateless = useApplicationMetadata().isStatelessApp; + const instance = useLaxInstanceData(); + + // Find all data types referenced in dataModelBindings in the layout + useEffect(() => { + const referencedDataTypes = getAllReferencedDataTypes(layouts, defaultDataType); + const allValidDataTypes: string[] = []; + const writableDataTypes: string[] = []; + + // Verify that referenced data types are defined in application metadata, have a classRef, and have a corresponding data element in the instance data + for (const dataType of referencedDataTypes) { + const typeDef = applicationMetadata.dataTypes.find((dt) => dt.id === dataType); + + if (!typeDef) { + const error = new MissingDataTypeException(dataType); + window.logErrorOnce(error.message); + continue; + } + if (!typeDef?.appLogic?.classRef) { + const error = new MissingClassRefException(dataType); + window.logErrorOnce(error.message); + continue; + } + if (!isStateless && !instance?.data.find((data) => data.dataType === dataType)) { + const error = new MissingDataElementException(dataType); + window.logErrorOnce(error.message); + continue; + } + + allValidDataTypes.push(dataType); + + if (isDataTypeWritable(dataType, isStateless, instance)) { + writableDataTypes.push(dataType); + } + } + + setDataTypes(allValidDataTypes, writableDataTypes, defaultDataType); + }, [applicationMetadata, defaultDataType, isStateless, layouts, setDataTypes, instance]); + + // We should load form data and schema for all referenced data models, schema is used for dataModelBinding validation which we want to do even if it is readonly + // We only need to load validation and expression validation config for data types that are not readonly. Additionally, backend will error if we try to validate a model we are not supposed to + return ( + <> + {allDataTypes?.map((dataType) => ( + + + + + ))} + + {writableDataTypes?.map((dataType) => ( + + + + ))} + + ); +} + +function BlockUntilLoaded({ children }: PropsWithChildren) { + const { + allDataTypes, + writableDataTypes, + initialData, + initialValidations, + schemas, + expressionValidationConfigs, + error, + } = useSelector((state) => state); + const isPDF = useIsPdf(); + const shouldValidateInitial = useShouldValidateInitial(); + const isLoadingFormData = useIsLoadingFormData(); + + if (error) { + // Error trying to fetch data, if missing rights we display relevant page + if (isAxiosError(error) && error.response?.status === HttpStatusCodes.Forbidden) { + return ; + } + + return ; + } + + if (!allDataTypes || !writableDataTypes) { + return ; + } + + if (isLoadingFormData) { + return ; + } + + // in PDF mode, we do not load schema, validations, or expression validation config. So we should not block loading in that case + // Edit: Since #2244, layout and data model binding validations work differently, so enabling schema loading to make things work for now. + + for (const dataType of allDataTypes) { + if (!Object.keys(initialData).includes(dataType)) { + return ; + } + + if (!Object.keys(schemas).includes(dataType)) { + return ; + } + } + + if (shouldValidateInitial && !initialValidations) { + return ; + } + + for (const dataType of writableDataTypes) { + if (!isPDF && !Object.keys(expressionValidationConfigs).includes(dataType)) { + return ; + } + } + + return <>{children}; +} + +interface LoaderProps { + dataType: string; +} + +/** + * If you change the URL so the form context reloads, + * It is possible to render the provider with stale data while + * the new initial data is loading, which can cause FomDataEffects + * to patch with incorrect precondition, causing a crash. + */ +function useIsLoadingFormData() { + return useIsFetching({ queryKey: ['fetchFormData'] }) > 0; +} + +function LoadInitialData({ dataType }: LoaderProps) { + const setInitialData = useSelector((state) => state.setInitialData); + const setError = useSelector((state) => state.setError); + const instance = useLaxInstanceData(); + const dataElementId = getFirstDataElementId(instance, dataType); + const url = useDataModelUrl({ dataType, dataElementId, includeRowIds: true }); + const { data, error } = useFormDataQuery(url); + const hasBeenSet = useRef(false); + + useEffect(() => { + if (data && url && !hasBeenSet.current) { + setInitialData(dataType, data, dataElementId ?? null); + hasBeenSet.current = true; + } + }, [data, dataElementId, dataType, setInitialData, url]); + + useEffect(() => { + error && setError(error); + }, [error, setError]); + + return null; +} + +function LoadInitialValidations() { + const setInitialValidations = useSelector((state) => state.setInitialValidations); + const setError = useSelector((state) => state.setError); + // No need to load validations in PDF or stateless apps + const isStateless = useApplicationMetadata().isStatelessApp; + const enabled = useShouldValidateInitial(); + const { data, error } = useBackendValidationQuery(enabled); + + useEffect(() => { + if (isStateless) { + setInitialValidations([]); + } else if (data) { + setInitialValidations(data); + } + }, [data, isStateless, setInitialValidations]); + + useEffect(() => { + error && setError(error); + }, [error, setError]); + + return null; +} + +function LoadSchema({ dataType }: LoaderProps) { + const setDataModelSchema = useSelector((state) => state.setDataModelSchema); + const setError = useSelector((state) => state.setError); + // No need to load schema in PDF + // Edit: Since #2244, layout and data model binding validations work differently, so enabling schema loading to make things work for now. + // const enabled = !useIsPdf(); + const { data, error } = useDataModelSchemaQuery(true, dataType); + + useEffect(() => { + if (data) { + setDataModelSchema(dataType, data.schema, data.lookupTool); + } + }, [data, dataType, setDataModelSchema]); + + useEffect(() => { + error && setError(error); + }, [error, setError]); + + return null; +} + +function LoadExpressionValidationConfig({ dataType }: LoaderProps) { + const setExpressionValidationConfig = useSelector((state) => state.setExpressionValidationConfig); + const setError = useSelector((state) => state.setError); + // No need to load validation config in PDF + const enabled = !useIsPdf(); + const { data, isSuccess, error } = useCustomValidationConfigQuery(enabled, dataType); + + useEffect(() => { + if (isSuccess) { + setExpressionValidationConfig(dataType, data); + } + }, [data, dataType, isSuccess, setExpressionValidationConfig]); + + useEffect(() => { + error && setError(error); + }, [error, setError]); + + return null; +} + +export const DataModels = { + useFullState: () => useSelector((state) => state), + + useLaxDefaultDataType: () => useLaxMemoSelector((state) => state.defaultDataType), + + useReadableDataTypes: () => useMemoSelector((state) => state.allDataTypes ?? []), + useLaxReadableDataTypes: () => useLaxMemoSelector((state) => state.allDataTypes!), + + useWritableDataTypes: () => useMemoSelector((state) => state.writableDataTypes!), + + useInitialValidations: () => useMemoSelector((state) => state.initialValidations), + + useDataModelSchema: (dataType: string) => useSelector((state) => state.schemas[dataType]), + + useLookupBinding: () => { + const { schemaLookup, allDataTypes } = useSelector((state) => state); + return useMemo(() => { + if (allDataTypes?.every((dt) => schemaLookup[dt])) { + return (reference: IDataModelReference) => schemaLookup[reference.dataType].getSchemaForPath(reference.field); + } + return undefined; + }, [allDataTypes, schemaLookup]); + }, + + useExpressionValidationConfig: (dataType: string) => + useSelector((state) => state.expressionValidationConfigs[dataType]), + + /** + * Takes a dataElementId and returns the corresponding data type if we have it, + * it will return the default data type if undefined is provided, + * this is to be backwards compatible with validation issues where the data element id was + * sometimes not set. + */ + useGetDataTypeForDataElementId: () => { + const typeToElement = useMemoSelector((state) => state.dataElementIds); + const defaultDataType = useMemoSelector((state) => state.defaultDataType); + return useCallback( + (dataElementId: string | undefined) => + (dataElementId + ? Object.entries(typeToElement) + .find(([_, id]) => dataElementId === id) + ?.at(0) + : defaultDataType) ?? null, + [defaultDataType, typeToElement], + ); + }, +}; diff --git a/src/features/datamodel/useBindingSchema.tsx b/src/features/datamodel/useBindingSchema.tsx index 81b324f18b..f0d9b8fcc1 100644 --- a/src/features/datamodel/useBindingSchema.tsx +++ b/src/features/datamodel/useBindingSchema.tsx @@ -1,4 +1,4 @@ -import { useMemo } from 'react'; +import { useCallback, useMemo } from 'react'; import type { JSONSchema7 } from 'json-schema'; @@ -7,20 +7,21 @@ import { useApplicationMetadata } from 'src/features/applicationMetadata/Applica import { getCurrentDataTypeForApplication, getCurrentTaskDataElementId, - getFirstDataElementId, - useDataTypeByLayoutSetId, } from 'src/features/applicationMetadata/appMetadataUtils'; -import { useLaxCurrentDataModelSchemaLookup } from 'src/features/datamodel/DataModelSchemaProvider'; +import { DataModels } from 'src/features/datamodel/DataModelsProvider'; import { useLayoutSets } from 'src/features/form/layoutSets/LayoutSetsProvider'; -import { useCurrentLayoutSetId } from 'src/features/form/layoutSets/useCurrentLayoutSetId'; import { useLaxInstanceData } from 'src/features/instance/InstanceContext'; import { useProcessTaskId } from 'src/features/instance/useProcessTaskId'; +import { useCurrentLanguage } from 'src/features/language/LanguageProvider'; import { useAllowAnonymous } from 'src/features/stateless/getAllowAnonymous'; +import { useAsRef } from 'src/hooks/useAsRef'; import { getAnonymousStatelessDataModelUrl, - getDataModelUrl, + getStatefulDataModelUrl, getStatelessDataModelUrl, } from 'src/utils/urls/appUrlHelper'; +import { getUrlWithLanguage } from 'src/utils/urls/urlHelper'; +import type { IDataModelReference } from 'src/layout/common.generated'; import type { IDataModelBindings } from 'src/layout/layout'; export type AsSchema = { @@ -36,50 +37,81 @@ export function useCurrentDataModelGuid() { return getCurrentTaskDataElementId({ application, instance, taskId, layoutSets }); } -export function useCurrentDataModelUrl(includeRowIds: boolean) { - const isAnonymous = useAllowAnonymous(); - const instance = useLaxInstanceData(); - const layoutSetId = useCurrentLayoutSetId(); - const dataType = useDataTypeByLayoutSetId(layoutSetId); - const dataElementUuid = useCurrentDataModelGuid(); - const isStateless = useApplicationMetadata().isStatelessApp; +type DataModelDeps = { + language: string; + isAnonymous: boolean; + isStateless: boolean; + instanceId?: string; +}; +type DataModelProps = { + dataType?: string; + dataElementId?: string; + includeRowIds?: boolean; + language?: string; +}; + +function getDataModelUrl({ + dataType, + dataElementId, + includeRowIds = false, + language, + isAnonymous, + isStateless, + instanceId, +}: DataModelDeps & DataModelProps) { if (isStateless && isAnonymous && dataType) { - return getAnonymousStatelessDataModelUrl(dataType, includeRowIds); + return getUrlWithLanguage(getAnonymousStatelessDataModelUrl(dataType, includeRowIds), language); } if (isStateless && !isAnonymous && dataType) { - return getStatelessDataModelUrl(dataType, includeRowIds); + return getUrlWithLanguage(getStatelessDataModelUrl(dataType, includeRowIds), language); } - if (instance?.id && dataElementUuid) { - return getDataModelUrl(instance.id, dataElementUuid, includeRowIds); + if (instanceId && dataElementId) { + return getUrlWithLanguage(getStatefulDataModelUrl(instanceId, dataElementId, includeRowIds), language); } return undefined; } -export function useDataModelUrl(includeRowIds: boolean, dataType: string | undefined) { +export function useGetDataModelUrl() { const isAnonymous = useAllowAnonymous(); const isStateless = useApplicationMetadata().isStatelessApp; - const instance = useLaxInstanceData(); - - if (isStateless && isAnonymous && dataType) { - return getAnonymousStatelessDataModelUrl(dataType, includeRowIds); - } - - if (isStateless && !isAnonymous && dataType) { - return getStatelessDataModelUrl(dataType, includeRowIds); - } - - if (instance?.id && dataType) { - const uuid = getFirstDataElementId(instance, dataType); - if (uuid) { - return getDataModelUrl(instance.id, uuid, includeRowIds); - } - } + const instanceId = useLaxInstanceData()?.id; + const currentLanguage = useAsRef(useCurrentLanguage()); + + return useCallback( + ({ dataType, dataElementId, includeRowIds, language }: DataModelProps) => + getDataModelUrl({ + dataType, + dataElementId, + includeRowIds, + language: language ?? currentLanguage.current, + isAnonymous, + isStateless, + instanceId, + }), + [currentLanguage, instanceId, isAnonymous, isStateless], + ); +} - return undefined; +// We assume that the first data element of the correct type is the one we should use, same as isDataTypeWritable +export function useDataModelUrl({ dataType, dataElementId, includeRowIds, language }: DataModelProps) { + const isAnonymous = useAllowAnonymous(); + const isStateless = useApplicationMetadata().isStatelessApp; + const instanceId = useLaxInstanceData()?.id; + const currentLanguage = useAsRef(useCurrentLanguage()); + + return getDataModelUrl({ + dataType, + dataElementId, + includeRowIds, + language: language ?? currentLanguage.current, + isAnonymous, + isStateless, + instanceId, + }); } export function useCurrentDataModelName() { @@ -107,16 +139,21 @@ export function useCurrentDataModelType() { return application.dataTypes.find((dt) => dt.id === name); } +export function useDataModelType(dataType: string) { + const application = useApplicationMetadata(); + + return application.dataTypes.find((dt) => dt.id === dataType); +} + export function useBindingSchema(bindings: T): AsSchema | undefined { - const lookup = useLaxCurrentDataModelSchemaLookup(); + const lookupBinding = DataModels.useLookupBinding(); return useMemo(() => { const resolvedBindings = bindings && Object.values(bindings).length ? { ...bindings } : undefined; - if (resolvedBindings && lookup) { + if (lookupBinding && resolvedBindings) { const out = {} as AsSchema; - for (const [key, _value] of Object.entries(resolvedBindings)) { - const value = _value as string; - const [schema] = lookup.getSchemaForPath(value); + for (const [key, reference] of Object.entries(resolvedBindings as Record)) { + const [schema] = lookupBinding(reference); out[key] = schema || null; } @@ -124,5 +161,5 @@ export function useBindingSchema(bindi } return undefined; - }, [bindings, lookup]); + }, [bindings, lookupBinding]); } diff --git a/src/features/datamodel/DataModelSchemaProvider.tsx b/src/features/datamodel/useDataModelSchemaQuery.ts similarity index 63% rename from src/features/datamodel/DataModelSchemaProvider.tsx rename to src/features/datamodel/useDataModelSchemaQuery.ts index 5a88319958..6fdde03730 100644 --- a/src/features/datamodel/DataModelSchemaProvider.tsx +++ b/src/features/datamodel/useDataModelSchemaQuery.ts @@ -4,31 +4,27 @@ import { skipToken, useQuery } from '@tanstack/react-query'; import type { JSONSchema7 } from 'json-schema'; import { useAppQueries } from 'src/core/contexts/AppQueriesProvider'; -import { ContextNotProvided } from 'src/core/contexts/context'; -import { delayedContext } from 'src/core/contexts/delayedContext'; -import { createQueryContext } from 'src/core/contexts/queryContext'; import { dotNotationToPointer } from 'src/features/datamodel/notations'; import { lookupBindingInSchema } from 'src/features/datamodel/SimpleSchemaTraversal'; -import { useCurrentDataModelName, useCurrentDataModelType } from 'src/features/datamodel/useBindingSchema'; +import { useDataModelType } from 'src/features/datamodel/useBindingSchema'; import { getRootElementPath } from 'src/utils/schemaUtils'; import type { QueryDefinition } from 'src/core/queries/usePrefetchQuery'; import type { SchemaLookupResult } from 'src/features/datamodel/SimpleSchemaTraversal'; // Also used for prefetching @see formPrefetcher.ts -export function useDataModelSchemaQueryDef(dataTypeId?: string): QueryDefinition { +export function useDataModelSchemaQueryDef(enabled: boolean, dataTypeId?: string): QueryDefinition { const { fetchDataModelSchema } = useAppQueries(); return { queryKey: ['fetchDataModelSchemas', dataTypeId], queryFn: dataTypeId ? () => fetchDataModelSchema(dataTypeId) : skipToken, - enabled: !!dataTypeId, + enabled: enabled && !!dataTypeId, }; } -const useDataModelSchemaQuery = () => { - const dataModelName = useCurrentDataModelName(); - const dataType = useCurrentDataModelType(); +export const useDataModelSchemaQuery = (enabled: boolean, dataTypeId: string) => { + const dataType = useDataModelType(dataTypeId); - const queryDef = useDataModelSchemaQueryDef(dataModelName); + const queryDef = useDataModelSchemaQueryDef(enabled, dataTypeId); const utils = useQuery({ ...queryDef, select: (schema) => { @@ -77,22 +73,3 @@ export class SchemaLookupTool { return result; } } - -const { Provider, useCtx, useLaxCtx } = delayedContext(() => - createQueryContext({ - name: 'DataModelSchema', - required: true, - query: useDataModelSchemaQuery, - }), -); - -export const DataModelSchemaProvider = Provider; -export const useCurrentDataModelSchema = () => useCtx().schema; -export const useCurrentDataModelSchemaLookup = () => useCtx().lookupTool; -export const useLaxCurrentDataModelSchemaLookup = () => { - const ctx = useLaxCtx(); - if (ctx === ContextNotProvided) { - return undefined; - } - return ctx.lookupTool; -}; diff --git a/src/features/datamodel/utils.ts b/src/features/datamodel/utils.ts new file mode 100644 index 0000000000..72f7eca86b --- /dev/null +++ b/src/features/datamodel/utils.ts @@ -0,0 +1,126 @@ +import { isDataModelReference } from 'src/utils/databindings'; +import type { ILayouts } from 'src/layout/layout'; +import type { IInstance } from 'src/types/shared'; + +export class MissingDataTypeException extends Error { + public readonly dataType: string; + + constructor(dataType: string) { + super( + `Tried to reference the data type \`${dataType}\`, but no data type with this id was found in \`applicationmetadata.json\``, + ); + this.dataType = dataType; + } +} + +export class MissingClassRefException extends Error { + public readonly dataType: string; + + constructor(dataType: string) { + super( + `Tried to reference the data type \`${dataType}\`, but the data type in \`applicationmetadata.json\` was missing a \`classRef\``, + ); + this.dataType = dataType; + } +} + +export class MissingDataElementException extends Error { + public readonly dataType: string; + + constructor(dataType: string) { + super( + `Tried to reference the data type \`${dataType}\`, but no data element of this type was found in the instance data. This could be because the data type is missing a \`taskId\`, or it has \`autoCreate: false\` and no element has been created manually`, + ); + this.dataType = dataType; + } +} + +export class MissingDataModelException extends Error { + public readonly dataType: string; + + constructor(dataType: string) { + super(`Tried to reference the data type \`${dataType}\`, but this data type is not writable or does not exist.`); + this.dataType = dataType; + } +} + +/** + * Looks through all layouts and returns a list of unique data types that are referenced in dataModelBindings, + * it will also include the default data type, which is necessary in case there are string bindings + */ +export function getAllReferencedDataTypes(layouts: ILayouts, defaultDataType?: string) { + const dataTypes = new Set(); + + if (defaultDataType) { + dataTypes.add(defaultDataType); + } + + for (const layout of Object.values(layouts)) { + for (const component of layout ?? []) { + if ('dataModelBindings' in component && component.dataModelBindings) { + for (const binding of Object.values(component.dataModelBindings)) { + if (isDataModelReference(binding)) { + dataTypes.add(binding.dataType); + } + } + } + addDataTypesFromExpressionsRecursive(component, dataTypes); + } + } + + return [...dataTypes]; +} + +/** + * Recurse component properties and look for data types in expressions ["dataModel", "...", "dataType"] + * This will mutate the input Set and add the discovered data types directly + * Logs a warning if a non-string (e.g. nested expression) is found where the data type is expected, as we cannot resolve expressions at this point + */ +function addDataTypesFromExpressionsRecursive(obj: unknown, dataTypes: Set): void { + if (obj == null || typeof obj === 'string' || typeof obj === 'number' || typeof obj === 'boolean') { + return; + } + + if (Array.isArray(obj)) { + if (obj.at(0) === 'dataModel' && obj.length === 3) { + const maybeDataType = obj.at(2); + if (typeof maybeDataType === 'string') { + dataTypes.add(maybeDataType); + } else { + window.logWarnOnce( + 'A non-string value was found when looking for dataType references in expressions, the following dataType could not be determined:\n', + maybeDataType, + ); + } + } + + for (const child of obj) { + addDataTypesFromExpressionsRecursive(child, dataTypes); + } + } else if (typeof obj === 'object') { + for (const child of Object.values(obj)) { + addDataTypesFromExpressionsRecursive(child, dataTypes); + } + } +} + +/** + * Used to determine if the data type is writable or if it is read only + * If a data type is not writable, we cannot write to or validate it. + * Assumes the first dataElement of the correct type is the one to use, + * we also assume this when creating the url for loading and saving data models @see useDataModelUrl, getFirstDataElementId + */ +export function isDataTypeWritable( + dataType: string | undefined, + isStateless: boolean, + instance: IInstance | undefined, +) { + if (!dataType) { + return false; + } + if (isStateless) { + return true; + } + const dataElement = instance?.data.find((data) => data.dataType === dataType); + return !!dataElement && dataElement.locked === false; +} diff --git a/src/features/devtools/components/DownloadXMLButton/DownloadXMLButton.tsx b/src/features/devtools/components/DownloadXMLButton/DownloadXMLButton.tsx index ddc36bef95..70507f9d3b 100644 --- a/src/features/devtools/components/DownloadXMLButton/DownloadXMLButton.tsx +++ b/src/features/devtools/components/DownloadXMLButton/DownloadXMLButton.tsx @@ -5,7 +5,6 @@ import { Button, Fieldset } from '@digdir/designsystemet-react'; import { DownloadIcon, UploadIcon } from '@navikt/aksel-icons'; import axios from 'axios'; -import { useCurrentDataModelUrl } from 'src/features/datamodel/useBindingSchema'; import { useIsInFormContext } from 'src/features/form/FormContext'; import { useLaxInstanceData } from 'src/features/instance/InstanceContext'; @@ -20,7 +19,8 @@ export function DownloadXMLButton() { const InnerDownloadXMLButton = () => { const instance = useLaxInstanceData(); - const dataUrl = useCurrentDataModelUrl(false); + // TODO(Datamodels): How should this work with multiple data models? + const dataUrl = ''; //useCurrentDataModelUrl(false); const downloadXML = async () => { if (dataUrl) { @@ -57,6 +57,7 @@ const InnerDownloadXMLButton = () => { variant='secondary' size='small' onClick={downloadXML} + disabled={true} > { { })} variant='secondary' size='small' + disabled={true} > { { if (currentView) { window.queryClient.setQueriesData( - { queryKey: ['formLayouts', currentLayoutSetId, true] }, + { queryKey: ['formLayouts', currentLayoutSetId] }, (_queryData) => { const queryData = structuredClone(_queryData); if (!queryData?.layouts?.[currentView]) { diff --git a/src/features/devtools/components/NodeInspector/NodeInspectorDataModelBindings.tsx b/src/features/devtools/components/NodeInspector/NodeInspectorDataModelBindings.tsx index 2d494b4269..6ddbc620e6 100644 --- a/src/features/devtools/components/NodeInspector/NodeInspectorDataModelBindings.tsx +++ b/src/features/devtools/components/NodeInspector/NodeInspectorDataModelBindings.tsx @@ -4,6 +4,7 @@ import { useBindingSchema } from 'src/features/datamodel/useBindingSchema'; import classes from 'src/features/devtools/components/NodeInspector/NodeInspector.module.css'; import { Value } from 'src/features/devtools/components/NodeInspector/NodeInspectorDataField'; import { FD } from 'src/features/formData/FormDataWrite'; +import type { IDataModelReference } from 'src/layout/common.generated'; import type { IDataModelBindings } from 'src/layout/layout'; interface Props { @@ -12,9 +13,8 @@ interface Props { export function NodeInspectorDataModelBindings({ dataModelBindings }: Props) { const schema = useBindingSchema(dataModelBindings); - const bindings = dataModelBindings || {}; + const bindings = dataModelBindings as Record; const results = FD.useFreshBindings(bindings, 'raw'); - return ( - Råverdi: - {bindings[key]} + Datamodell: + {bindings[key].dataType} +
+ Sti: + {bindings[key].field}
Resultat:
{JSON.stringify(results[key], null, 2)}
diff --git a/src/features/devtools/layoutValidation/types.ts b/src/features/devtools/layoutValidation/types.ts index 8858e542d0..d7310fbe51 100644 --- a/src/features/devtools/layoutValidation/types.ts +++ b/src/features/devtools/layoutValidation/types.ts @@ -1,4 +1,5 @@ import type { lookupBindingInSchema } from 'src/features/datamodel/SimpleSchemaTraversal'; +import type { IDataModelReference } from 'src/layout/common.generated'; import type { CompIntermediate, CompTypes } from 'src/layout/layout'; import type { LayoutNode } from 'src/utils/layout/LayoutNode'; import type { NodeDataSelector } from 'src/utils/layout/NodesContext'; @@ -7,5 +8,5 @@ export interface LayoutValidationCtx { node: LayoutNode; item: CompIntermediate; nodeDataSelector: NodeDataSelector; - lookupBinding(binding: string): ReturnType; + lookupBinding(reference: IDataModelReference): ReturnType; } diff --git a/src/features/devtools/layoutValidation/useLayoutValidation.tsx b/src/features/devtools/layoutValidation/useLayoutValidation.tsx index 3fe89117eb..310eba168c 100644 --- a/src/features/devtools/layoutValidation/useLayoutValidation.tsx +++ b/src/features/devtools/layoutValidation/useLayoutValidation.tsx @@ -2,7 +2,7 @@ import { createStore } from 'zustand'; import { ContextNotProvided } from 'src/core/contexts/context'; import { createZustandContext } from 'src/core/contexts/zustandContext'; -import { useCurrentLayoutSetId } from 'src/features/form/layoutSets/useCurrentLayoutSetId'; +import { useCurrentLayoutSetId } from 'src/features/form/layoutSets/useCurrentLayoutSet'; import { useCurrentView } from 'src/hooks/useNavigatePage'; interface Context { diff --git a/src/features/expressions/expression-functions.ts b/src/features/expressions/expression-functions.ts index 88f305496e..09d3b13f48 100644 --- a/src/features/expressions/expression-functions.ts +++ b/src/features/expressions/expression-functions.ts @@ -16,7 +16,7 @@ import type { DisplayData } from 'src/features/displayData'; import type { EvaluateExpressionParams } from 'src/features/expressions'; import type { ExprValToActual } from 'src/features/expressions/types'; import type { ValidationContext } from 'src/features/expressions/validation'; -import type { FormDataSelector } from 'src/layout'; +import type { IDataModelReference } from 'src/layout/common.generated'; import type { IAuthContext, IInstanceDataSources } from 'src/types/shared'; import type { LayoutNode } from 'src/utils/layout/LayoutNode'; @@ -256,7 +256,7 @@ export const ExprFunctions = { return null; } - return pickSimpleValue(simpleBinding, this.dataSources.formDataSelector); + return pickSimpleValue(simpleBinding, this); } // Expressions can technically be used without having all the layouts available, which might lead to unexpected @@ -275,22 +275,29 @@ export const ExprFunctions = { }), dataModel: defineFunc({ // eslint-disable-next-line @typescript-eslint/no-explicit-any - impl(path): any { - if (path === null) { + impl(propertyPath, maybeDataType): any { + if (propertyPath === null) { throw new ExprRuntimeError(this.expr, this.path, `Cannot lookup dataModel null`); } + const dataType = maybeDataType ?? this.dataSources.currentLayoutSet?.dataType; + if (!dataType) { + throw new ExprRuntimeError(this.expr, this.path, `Cannot lookup dataType undefined`); + } + + const reference: IDataModelReference = { dataType, field: propertyPath }; const node = ensureNode(this.node); if (node instanceof BaseLayoutNode) { - const newPath = this.dataSources.transposeSelector(node as LayoutNode, path); - return pickSimpleValue(newPath, this.dataSources.formDataSelector); + const newReference = this.dataSources.transposeSelector(node as LayoutNode, reference); + return pickSimpleValue(newReference, this); } // No need to transpose the data model according to the location inside a repeating group when the context is // a LayoutPage (i.e., when we're resolving an expression directly on the layout definition). - return pickSimpleValue(path, this.dataSources.formDataSelector); + return pickSimpleValue(reference, this); }, - args: [ExprVal.String] as const, + args: [ExprVal.String, ExprVal.String] as const, + minArguments: 1, returns: ExprVal.Any, }), externalApi: defineFunc({ @@ -568,7 +575,12 @@ export const ExprFunctions = { if (path === null || propertyToSelect == null) { throw new ExprRuntimeError(this.expr, this.path, `Cannot lookup dataModel null`); } - const array = this.dataSources.formDataSelector(path); + + const dataType = this.dataSources.currentLayoutSet?.dataType; + if (!dataType) { + throw new ExprRuntimeError(this.expr, this.path, `Cannot lookup dataType undefined`); + } + const array = this.dataSources.formDataSelector({ field: path, dataType }); if (typeof array != 'object' || !Array.isArray(array)) { return ''; } @@ -588,12 +600,13 @@ export const ExprFunctions = { }), }; -function pickSimpleValue(path: string | undefined | null, selector: FormDataSelector) { - if (!path) { - return null; +function pickSimpleValue(path: IDataModelReference, params: EvaluateExpressionParams) { + const isValidDataType = params.dataSources.dataModelNames.includes(path.dataType); + if (!isValidDataType) { + throw new ExprRuntimeError(params.expr, params.path, `Unknown data model '${path.dataType}'`); } - const value = selector(path); + const value = params.dataSources.formDataSelector(path); if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') { return value; } diff --git a/src/features/expressions/shared-context.test.tsx b/src/features/expressions/shared-context.test.tsx index 77a793a2e3..feb0c7b91a 100644 --- a/src/features/expressions/shared-context.test.tsx +++ b/src/features/expressions/shared-context.test.tsx @@ -118,6 +118,7 @@ describe('Expressions shared context tests', () => { renderer: () => , queries: { fetchLayouts: async () => layouts!, + // TODO(Datamodels): add support for multiple data models fetchFormData: async () => dataModel ?? {}, ...(instance ? { fetchInstanceData: async () => instance } : {}), ...(frontendSettings ? { fetchApplicationSettings: async () => frontendSettings } : {}), diff --git a/src/features/expressions/shared-functions.test.tsx b/src/features/expressions/shared-functions.test.tsx index 5d723b1b65..de26876fce 100644 --- a/src/features/expressions/shared-functions.test.tsx +++ b/src/features/expressions/shared-functions.test.tsx @@ -18,6 +18,7 @@ import type { SharedTestFunctionContext } from 'src/features/expressions/shared' import type { ExprValToActualOrExpr } 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'); @@ -50,7 +51,7 @@ function getDefaultLayouts(): ILayoutCollection { id: 'default', type: 'Input', dataModelBindings: { - simpleBinding: 'mockField', + simpleBinding: { dataType: 'default', field: 'mockField' }, }, }, ], @@ -61,7 +62,14 @@ function getDefaultLayouts(): ILayoutCollection { describe('Expressions shared function tests', () => { beforeAll(() => { - jest.spyOn(window, 'logError').mockImplementation(() => {}); + jest + .spyOn(window, 'logError') + .mockImplementation(() => {}) + .mockName('window.logError'); + jest + .spyOn(window, 'logErrorOnce') + .mockImplementation(() => {}) + .mockName('window.logErrorOnce'); }); afterEach(() => { @@ -84,6 +92,7 @@ describe('Expressions shared function tests', () => { context, layouts, dataModel, + dataModels, instanceDataElements, instance: _instance, process: _process, @@ -129,7 +138,7 @@ describe('Expressions shared function tests', () => { : undefined; const applicationMetadata = getIncomingApplicationMetadataMock( - instance ? {} : { onEntry: { show: 'stateless' }, externalApiIds: ['testId'] }, + instance ? {} : { onEntry: { show: 'layout-set' }, externalApiIds: ['testId'] }, ); if (instanceDataElements) { for (const element of instanceDataElements) { @@ -143,12 +152,61 @@ describe('Expressions shared function tests', () => { } } } + if (dataModels) { + applicationMetadata.dataTypes.push( + ...(dataModels.map((dm) => ({ + id: dm.dataElement.dataType, + appLogic: { classRef: 'some-class' }, + taskId: 'Task_1', + })) as IDataType[]), + ); + } + if (!applicationMetadata.dataTypes.find((d) => d.id === 'default')) { + applicationMetadata.dataTypes.push({ + id: 'default', + appLogic: { classRef: 'some-class', taskId: 'Task_1' }, + } as unknown as IDataType); + } const profile = getProfileMock(); if (profileSettings?.language) { profile.profileSettingPreference.language = profileSettings.language; } + async function fetchFormData(url: string) { + if (!dataModels) { + return dataModel ?? {}; + } + + const statelessDataType = url.match(/dataType=([\w-]+)&/)?.[1]; + const statefulDataElementId = url.match(/data\/([a-f0-9-]+)\?/)?.[1]; + + const model = dataModels.find( + (dm) => dm.dataElement.dataType === statelessDataType || dm.dataElement.id === statefulDataElementId, + ); + if (model) { + return model.data; + } + throw new Error(`Datamodel ${url} not found in ${JSON.stringify(dataModels)}`); + } + + async function fetchInstanceData() { + let instanceData = getInstanceDataMock(); + if (instance) { + instanceData = { ...instanceData, ...instance }; + } + if (instanceDataElements) { + instanceData.data.push(...instanceDataElements); + } + if (dataModels) { + instanceData.data.push(...dataModels.map((dm) => dm.dataElement)); + } + if (!instanceData.data.find((d) => d.dataType === 'default')) { + instanceData.data.push({ id: 'abc', dataType: 'default' } as IData); + } + return instanceData; + } + // Clear localstorage, because LanguageProvider uses it to cache selected languages localStorage.clear(); @@ -166,9 +224,10 @@ describe('Expressions shared function tests', () => { ), inInstance: !!instance, queries: { + fetchLayoutSets: async () => ({ sets: [{ id: 'layout-set', dataType: 'default', tasks: ['Task_1'] }] }), fetchLayouts: async () => layouts ?? getDefaultLayouts(), - fetchFormData: async () => dataModel ?? {}, - ...(instance ? { fetchInstanceData: async () => instance } : {}), + fetchFormData, + fetchInstanceData, ...(process ? { fetchProcessState: async () => process } : {}), ...(frontendSettings ? { fetchApplicationSettings: async () => frontendSettings } : {}), fetchUserProfile: async () => profile, @@ -185,6 +244,7 @@ describe('Expressions shared function tests', () => { if (expectsFailure) { expect(errorMock).toHaveBeenCalledWith(expect.stringContaining(expectsFailure)); + expect(errorMock).toHaveBeenCalledTimes(1); } else { expect(errorMock).not.toHaveBeenCalled(); ExprValidation.throwIfInvalid(expression); diff --git a/src/features/expressions/shared-tests/functions/component/hidden-in-group-other-row.json b/src/features/expressions/shared-tests/functions/component/hidden-in-group-other-row.json index 9d65556035..fdf4ee9cb3 100644 --- a/src/features/expressions/shared-tests/functions/component/hidden-in-group-other-row.json +++ b/src/features/expressions/shared-tests/functions/component/hidden-in-group-other-row.json @@ -65,7 +65,8 @@ { "altinnRowId": "company0-employee0", "Navn": "Kaare", - "Alder": 24 + "Alder": 24, + "AlderSkjult": false }, { "altinnRowId": "company0-employee1", diff --git a/src/features/expressions/shared-tests/functions/component/hidden-in-group.json b/src/features/expressions/shared-tests/functions/component/hidden-in-group.json index d62c77893b..8e0e2afa98 100644 --- a/src/features/expressions/shared-tests/functions/component/hidden-in-group.json +++ b/src/features/expressions/shared-tests/functions/component/hidden-in-group.json @@ -65,7 +65,8 @@ { "altinnRowId": "company0-employee0", "Navn": "Kaare", - "Alder": 24 + "Alder": 24, + "AlderSkjult": true }, { "altinnRowId": "company0-employee1", diff --git a/src/features/expressions/shared-tests/functions/component/hide-group-component.json b/src/features/expressions/shared-tests/functions/component/hide-group-component.json index 44cb2faeec..8d4d86d4e2 100644 --- a/src/features/expressions/shared-tests/functions/component/hide-group-component.json +++ b/src/features/expressions/shared-tests/functions/component/hide-group-component.json @@ -50,11 +50,7 @@ "dataModelBindings": { "simpleBinding": "Bedrifter.Ansatte.Alder" }, - "hidden": [ - "if", - ["dataModel", "Bedrifter.Ansatte.AlderSkjult"], - true - ] + "hidden": ["if", ["dataModel", "Bedrifter.Ansatte.AlderSkjult"], true] }, { "id": "myndig", @@ -79,7 +75,8 @@ { "altinnRowId": "company0-emplyee0", "Navn": "Kaare", - "Alder": 24 + "Alder": 24, + "AlderSkjult": false }, { "altinnRowId": "company0-emplyee1", @@ -110,6 +107,6 @@ "context": { "component": "alder", "currentLayout": "Page1", - "rowIndices": [1,0] + "rowIndices": [1, 0] } } diff --git a/src/features/expressions/shared-tests/functions/dataModel/array-is-null.json b/src/features/expressions/shared-tests/functions/dataModel/array-is-null.json index b1b23acaa7..c11813c86c 100644 --- a/src/features/expressions/shared-tests/functions/dataModel/array-is-null.json +++ b/src/features/expressions/shared-tests/functions/dataModel/array-is-null.json @@ -2,16 +2,25 @@ "name": "Looking up an array returns null", "expression": ["dataModel", "a"], "expects": null, - "dataModel": { - "a": [ - { - "value": "ABC" + "dataModels": [ + { + "dataElement": { + "id": "345", + "dataType": "default" }, - { - "other": "DEF" + "data": { + "a": [ + { + "value": "ABC", + "other": null + }, + { + "other": "DEF" + } + ] } - ] - }, + } + ], "layouts": { "Page1": { "$schema": "https://altinncdn.no/schemas/json/layout/layout.schema.v1.json", diff --git a/src/features/expressions/shared-tests/functions/dataModel/direct-reference-in-group.json b/src/features/expressions/shared-tests/functions/dataModel/direct-reference-in-group.json index 3b01004cf3..0993d50145 100644 --- a/src/features/expressions/shared-tests/functions/dataModel/direct-reference-in-group.json +++ b/src/features/expressions/shared-tests/functions/dataModel/direct-reference-in-group.json @@ -51,18 +51,26 @@ } } }, - "dataModel": { - "Mennesker": [ - { - "altinnRowId": "person0", - "Navn": "Kåre", - "Alder": 24 + "dataModels": [ + { + "dataElement": { + "id": "345", + "dataType": "default" }, - { - "altinnRowId": "person1", - "Navn": "Arild", - "Alder": 14 + "data": { + "Mennesker": [ + { + "altinnRowId": "person0", + "Navn": "Kåre", + "Alder": 24 + }, + { + "altinnRowId": "person1", + "Navn": "Arild", + "Alder": 14 + } + ] } - ] - } + } + ] } diff --git a/src/features/expressions/shared-tests/functions/dataModel/direct-reference-in-nested-group.json b/src/features/expressions/shared-tests/functions/dataModel/direct-reference-in-nested-group.json index fd65099cee..9e1082ee2c 100644 --- a/src/features/expressions/shared-tests/functions/dataModel/direct-reference-in-nested-group.json +++ b/src/features/expressions/shared-tests/functions/dataModel/direct-reference-in-nested-group.json @@ -66,40 +66,48 @@ } } }, - "dataModel": { - "Bedrifter": [ - { - "altinnRowId": "company0", - "Navn": "Hell og lykke AS", - "Ansatte": [ - { - "altinnRowId": "company0-employee0", - "Navn": "Kaare", - "Alder": 55 - }, - { - "altinnRowId": "company0-employee1", - "Navn": "Per", - "Alder": 24 - } - ] + "dataModels": [ + { + "dataElement": { + "id": "345", + "dataType": "default" }, - { - "altinnRowId": "company1", - "Navn": "Nedtur og motgang AS", - "Ansatte": [ + "data": { + "Bedrifter": [ { - "altinnRowId": "company1-employee0", - "Navn": "Arne", - "Alder": 24 + "altinnRowId": "bedrift0", + "Navn": "Hell og lykke AS", + "Ansatte": [ + { + "altinnRowId": "person0", + "Navn": "Kaare", + "Alder": 55 + }, + { + "altinnRowId": "person1", + "Navn": "Per", + "Alder": 24 + } + ] }, { - "altinnRowId": "company1-employee1", - "Navn": "Vidar", - "Alder": 14 + "altinnRowId": "bedrift0", + "Navn": "Nedtur og motgang AS", + "Ansatte": [ + { + "altinnRowId": "person0", + "Navn": "Arne", + "Alder": 24 + }, + { + "altinnRowId": "person1", + "Navn": "Vidar", + "Alder": 14 + } + ] } ] } - ] - } + } + ] } diff --git a/src/features/expressions/shared-tests/functions/dataModel/direct-reference-in-nested-group2.json b/src/features/expressions/shared-tests/functions/dataModel/direct-reference-in-nested-group2.json index acc808735b..579efc4edd 100644 --- a/src/features/expressions/shared-tests/functions/dataModel/direct-reference-in-nested-group2.json +++ b/src/features/expressions/shared-tests/functions/dataModel/direct-reference-in-nested-group2.json @@ -66,40 +66,48 @@ } } }, - "dataModel": { - "Bedrifter": [ - { - "altinnRowId": "company0", - "Navn": "Hell og lykke AS", - "Ansatte": [ - { - "altinnRowId": "company0-employee0", - "Navn": "Kaare", - "Alder": 24 - }, - { - "altinnRowId": "company0-employee1", - "Navn": "Per", - "Alder": 25 - } - ] + "dataModels": [ + { + "dataElement": { + "id": "345", + "dataType": "default" }, - { - "altinnRowId": "company1", - "Navn": "Nedtur og motgang AS", - "Ansatte": [ + "data": { + "Bedrifter": [ { - "altinnRowId": "company1-employee0", - "Navn": "Arne", - "Alder": 26 + "altinnRowId": "bedrift0", + "Navn": "Hell og lykke AS", + "Ansatte": [ + { + "altinnRowId": "person0", + "Navn": "Kaare", + "Alder": 24 + }, + { + "altinnRowId": "person1", + "Navn": "Per", + "Alder": 25 + } + ] }, { - "altinnRowId": "company1-employee1", - "Navn": "Vidar", - "Alder": 14 + "altinnRowId": "bedrift1", + "Navn": "Nedtur og motgang AS", + "Ansatte": [ + { + "altinnRowId": "person0", + "Navn": "Arne", + "Alder": 26 + }, + { + "altinnRowId": "person1", + "Navn": "Vidar", + "Alder": 14 + } + ] } ] } - ] - } + } + ] } diff --git a/src/features/expressions/shared-tests/functions/dataModel/direct-reference-in-nested-group3.json b/src/features/expressions/shared-tests/functions/dataModel/direct-reference-in-nested-group3.json index 707961ecb3..4e3955ee48 100644 --- a/src/features/expressions/shared-tests/functions/dataModel/direct-reference-in-nested-group3.json +++ b/src/features/expressions/shared-tests/functions/dataModel/direct-reference-in-nested-group3.json @@ -66,40 +66,48 @@ } } }, - "dataModel": { - "Bedrifter": [ - { - "altinnRowId": "company0", - "Navn": "Hell og lykke AS", - "Ansatte": [ - { - "altinnRowId": "company0-employee0", - "Navn": "Kaare", - "Alder": 24 - }, - { - "altinnRowId": "company0-employee1", - "Navn": "Per", - "Alder": 25 - } - ] + "dataModels": [ + { + "dataElement": { + "id": "345", + "dataType": "default" }, - { - "altinnRowId": "company1", - "Navn": "Nedtur og motgang AS", - "Ansatte": [ + "data": { + "Bedrifter": [ { - "altinnRowId": "company1-employee0", - "Navn": "Arne", - "Alder": 26 + "altinnRowId": "bedrift0", + "Navn": "Hell og lykke AS", + "Ansatte": [ + { + "altinnRowId": "person0", + "Navn": "Kaare", + "Alder": 24 + }, + { + "altinnRowId": "person1", + "Navn": "Per", + "Alder": 25 + } + ] }, { - "altinnRowId": "company1-employee1", - "Navn": "Vidar", - "Alder": 14 + "altinnRowId": "bedrift1", + "Navn": "Nedtur og motgang AS", + "Ansatte": [ + { + "altinnRowId": "person0", + "Navn": "Arne", + "Alder": 26 + }, + { + "altinnRowId": "person1", + "Navn": "Vidar", + "Alder": 14 + } + ] } ] } - ] - } + } + ] } diff --git a/src/features/expressions/shared-tests/functions/dataModel/direct-reference-in-nested-group4.json b/src/features/expressions/shared-tests/functions/dataModel/direct-reference-in-nested-group4.json index a36e3a9a50..3a80da5b8a 100644 --- a/src/features/expressions/shared-tests/functions/dataModel/direct-reference-in-nested-group4.json +++ b/src/features/expressions/shared-tests/functions/dataModel/direct-reference-in-nested-group4.json @@ -66,40 +66,48 @@ } } }, - "dataModel": { - "Bedrifter": [ - { - "altinnRowId": "company0", - "Navn": "Hell og lykke AS", - "Ansatte": [ - { - "altinnRowId": "company0-employee0", - "Navn": "Kaare", - "Alder": 24 - }, - { - "altinnRowId": "company0-employee1", - "Navn": "Per", - "Alder": 25 - } - ] + "dataModels": [ + { + "dataElement": { + "id": "345", + "dataType": "default" }, - { - "altinnRowId": "company1", - "Navn": "Nedtur og motgang AS", - "Ansatte": [ + "data": { + "Bedrifter": [ { - "altinnRowId": "company1-employee0", - "Navn": "Arne", - "Alder": 26 + "altinnRowId": "bedrift0", + "Navn": "Hell og lykke AS", + "Ansatte": [ + { + "altinnRowId": "person0", + "Navn": "Kaare", + "Alder": 24 + }, + { + "altinnRowId": "person1", + "Navn": "Per", + "Alder": 25 + } + ] }, { - "altinnRowId": "company1-employee1", - "Navn": "Vidar", - "Alder": 14 + "altinnRowId": "bedrift0", + "Navn": "Nedtur og motgang AS", + "Ansatte": [ + { + "altinnRowId": "person0", + "Navn": "Arne", + "Alder": 26 + }, + { + "altinnRowId": "person1", + "Navn": "Vidar", + "Alder": 14 + } + ] } ] } - ] - } + } + ] } diff --git a/src/features/expressions/shared-tests/functions/dataModel/in-group.json b/src/features/expressions/shared-tests/functions/dataModel/in-group.json index 9f26f8ebf2..228207bf4d 100644 --- a/src/features/expressions/shared-tests/functions/dataModel/in-group.json +++ b/src/features/expressions/shared-tests/functions/dataModel/in-group.json @@ -51,18 +51,26 @@ } } }, - "dataModel": { - "Mennesker": [ - { - "altinnRowId": "person0", - "Navn": "Kåre", - "Alder": 24 + "dataModels": [ + { + "dataElement": { + "id": "345", + "dataType": "default" }, - { - "altinnRowId": "person1", - "Navn": "Arild", - "Alder": 14 + "data": { + "Mennesker": [ + { + "altinnRowId": "person0", + "Navn": "Kåre", + "Alder": 24 + }, + { + "altinnRowId": "person1", + "Navn": "Arild", + "Alder": 14 + } + ] } - ] - } + } + ] } diff --git a/src/features/expressions/shared-tests/functions/dataModel/in-nested-group.json b/src/features/expressions/shared-tests/functions/dataModel/in-nested-group.json index 0d1ae4c1f3..89fac647c5 100644 --- a/src/features/expressions/shared-tests/functions/dataModel/in-nested-group.json +++ b/src/features/expressions/shared-tests/functions/dataModel/in-nested-group.json @@ -66,40 +66,48 @@ } } }, - "dataModel": { - "Bedrifter": [ - { - "altinnRowId": "company0", - "Navn": "Hell og lykke AS", - "Ansatte": [ - { - "altinnRowId": "company0-employee0", - "Navn": "Kaare", - "Alder": 24 - }, - { - "altinnRowId": "company0-employee1", - "Navn": "Per", - "Alder": 24 - } - ] + "dataModels": [ + { + "dataElement": { + "id": "345", + "dataType": "default" }, - { - "altinnRowId": "company1", - "Navn": "Nedtur og motgang AS", - "Ansatte": [ + "data": { + "Bedrifter": [ { - "altinnRowId": "company1-employee0", - "Navn": "Arne", - "Alder": 24 + "altinnRowId": "bedrift0", + "Navn": "Hell og lykke AS", + "Ansatte": [ + { + "altinnRowId": "person0", + "Navn": "Kaare", + "Alder": 24 + }, + { + "altinnRowId": "person1", + "Navn": "Per", + "Alder": 24 + } + ] }, { - "altinnRowId": "company1-employee1", - "Navn": "Vidar", - "Alder": 14 + "altinnRowId": "bedrift1", + "Navn": "Nedtur og motgang AS", + "Ansatte": [ + { + "altinnRowId": "person0", + "Navn": "Arne", + "Alder": 24 + }, + { + "altinnRowId": "person1", + "Navn": "Vidar", + "Alder": 14 + } + ] } ] } - ] - } + } + ] } diff --git a/src/features/expressions/shared-tests/functions/dataModel/null-is-null.json b/src/features/expressions/shared-tests/functions/dataModel/null-is-null.json index 107636614f..cec55487ea 100644 --- a/src/features/expressions/shared-tests/functions/dataModel/null-is-null.json +++ b/src/features/expressions/shared-tests/functions/dataModel/null-is-null.json @@ -2,9 +2,17 @@ "name": "Looking up null returns null", "expression": ["dataModel", "a"], "expects": null, - "dataModel": { - "a": null - }, + "dataModels": [ + { + "dataElement": { + "id": "345", + "dataType": "default" + }, + "data": { + "a": null + } + } + ], "layouts": { "Page1": { "$schema": "https://altinncdn.no/schemas/json/layout/layout.schema.v1.json", diff --git a/src/features/expressions/shared-tests/functions/dataModel/null.json b/src/features/expressions/shared-tests/functions/dataModel/null.json index 698f605d50..ea5707692e 100644 --- a/src/features/expressions/shared-tests/functions/dataModel/null.json +++ b/src/features/expressions/shared-tests/functions/dataModel/null.json @@ -2,11 +2,19 @@ "name": "Looking up null", "expression": ["dataModel", null], "expectsFailure": "Cannot lookup dataModel null", - "dataModel": { - "a": { - "value": "ABC" + "dataModels": [ + { + "dataElement": { + "id": "345", + "dataType": "default" + }, + "data": { + "a": { + "value": "ABC" + } + } } - }, + ], "layouts": { "Page1": { "$schema": "https://altinncdn.no/schemas/json/layout/layout.schema.v1.json", diff --git a/src/features/expressions/shared-tests/functions/dataModel/object-is-null.json b/src/features/expressions/shared-tests/functions/dataModel/object-is-null.json index 5d9f649eb3..2302446c0e 100644 --- a/src/features/expressions/shared-tests/functions/dataModel/object-is-null.json +++ b/src/features/expressions/shared-tests/functions/dataModel/object-is-null.json @@ -2,11 +2,19 @@ "name": "Looking up an object returns null", "expression": ["dataModel", "a"], "expects": null, - "dataModel": { - "a": { - "value": "ABC" + "dataModels": [ + { + "dataElement": { + "id": "345", + "dataType": "default" + }, + "data": { + "a": { + "value": "ABC" + } + } } - }, + ], "layouts": { "Page1": { "$schema": "https://altinncdn.no/schemas/json/layout/layout.schema.v1.json", diff --git a/src/features/expressions/shared-tests/functions/dataModel/simple-lookup-equals.json b/src/features/expressions/shared-tests/functions/dataModel/simple-lookup-equals.json index 657f3f2494..6f52e19a61 100644 --- a/src/features/expressions/shared-tests/functions/dataModel/simple-lookup-equals.json +++ b/src/features/expressions/shared-tests/functions/dataModel/simple-lookup-equals.json @@ -2,14 +2,22 @@ "name": "Simple lookup equals other lookup", "expression": ["equals", ["dataModel", "a.value"], ["dataModel", "b.value"]], "expects": true, - "dataModel": { - "a": { - "value": "hello world" - }, - "b": { - "value": "hello world" + "dataModels": [ + { + "dataElement": { + "id": "345", + "dataType": "default" + }, + "data": { + "a": { + "value": "hello world" + }, + "b": { + "value": "hello world" + } + } } - }, + ], "layouts": { "Page1": { "$schema": "https://altinncdn.no/schemas/json/layout/layout.schema.v1.json", diff --git a/src/features/expressions/shared-tests/functions/dataModel/simple-lookup-is-null.json b/src/features/expressions/shared-tests/functions/dataModel/simple-lookup-is-null.json index bf791f569e..4c44d62205 100644 --- a/src/features/expressions/shared-tests/functions/dataModel/simple-lookup-is-null.json +++ b/src/features/expressions/shared-tests/functions/dataModel/simple-lookup-is-null.json @@ -2,11 +2,19 @@ "name": "Simple lookup for non-existing key equals null (1)", "expression": ["dataModel", "a.value.length"], "expects": null, - "dataModel": { - "a": { - "value": "ABC" + "dataModels": [ + { + "dataElement": { + "id": "345", + "dataType": "default" + }, + "data": { + "a": { + "value": "ABC" + } + } } - }, + ], "layouts": { "Page1": { "$schema": "https://altinncdn.no/schemas/json/layout/layout.schema.v1.json", diff --git a/src/features/expressions/shared-tests/functions/dataModel/simple-lookup-is-null2.json b/src/features/expressions/shared-tests/functions/dataModel/simple-lookup-is-null2.json index ccc645b146..1ebed41a2f 100644 --- a/src/features/expressions/shared-tests/functions/dataModel/simple-lookup-is-null2.json +++ b/src/features/expressions/shared-tests/functions/dataModel/simple-lookup-is-null2.json @@ -2,11 +2,19 @@ "name": "Simple lookup for non-existing key equals null (2)", "expression": ["dataModel", "a.otherValue.Count"], "expects": null, - "dataModel": { - "a": { - "value": "ABC" + "dataModels": [ + { + "dataElement": { + "id": "345", + "dataType": "default" + }, + "data": { + "a": { + "value": "ABC" + } + } } - }, + ], "layouts": { "Page1": { "$schema": "https://altinncdn.no/schemas/json/layout/layout.schema.v1.json", diff --git a/src/features/expressions/shared-tests/functions/dataModel/simple-lookup.json b/src/features/expressions/shared-tests/functions/dataModel/simple-lookup.json index 550fa71c18..004f25aa71 100644 --- a/src/features/expressions/shared-tests/functions/dataModel/simple-lookup.json +++ b/src/features/expressions/shared-tests/functions/dataModel/simple-lookup.json @@ -2,11 +2,19 @@ "name": "Simple lookup", "expression": ["dataModel", "a.value"], "expects": "ABC", - "dataModel": { - "a": { - "value": "ABC" + "dataModels": [ + { + "dataElement": { + "id": "345", + "dataType": "default" + }, + "data": { + "a": { + "value": "ABC" + } + } } - }, + ], "layouts": { "Page1": { "$schema": "https://altinncdn.no/schemas/json/layout/layout.schema.v1.json", diff --git a/src/features/expressions/shared-tests/functions/dataModelMultiple/component-lookup-non-default-model.json b/src/features/expressions/shared-tests/functions/dataModelMultiple/component-lookup-non-default-model.json new file mode 100644 index 0000000000..ba6f665e7b --- /dev/null +++ b/src/features/expressions/shared-tests/functions/dataModelMultiple/component-lookup-non-default-model.json @@ -0,0 +1,55 @@ +{ + "name": "Component lookup with binding to non-default model", + "expression": [ + "component", + "current-component" + ], + "expects": "valueFromNonDefaultModel", + "dataModels": [ + { + "dataElement": { + "id": "345", + "dataType": "default" + }, + "data": { + "a": { + "value": "ABC" + } + } + }, + { + "dataElement": { + "id": "123", + "dataType": "non-default" + }, + "data": { + "a": { + "value": "valueFromNonDefaultModel" + } + } + } + ], + "layouts": { + "Page1": { + "$schema": "https://altinncdn.no/schemas/json/layout/layout.schema.v1.json", + "data": { + "layout": [ + { + "id": "current-component", + "type": "Input", + "dataModelBindings": { + "simpleBinding": { + "dataType": "non-default", + "field": "a.value" + } + } + } + ] + } + } + }, + "context": { + "component": "current-component", + "currentLayout": "Page1" + } +} diff --git a/src/features/expressions/shared-tests/functions/dataModelMultiple/component-lookup-non-existant-model.json b/src/features/expressions/shared-tests/functions/dataModelMultiple/component-lookup-non-existant-model.json new file mode 100644 index 0000000000..af3770186d --- /dev/null +++ b/src/features/expressions/shared-tests/functions/dataModelMultiple/component-lookup-non-existant-model.json @@ -0,0 +1,55 @@ +{ + "name": "Lookup non-existing model returns null", + "expression": [ + "component", + "current-component" + ], + "expectsFailure": "Unknown data model 'non-existing'", + "dataModels": [ + { + "dataElement": { + "id": "345", + "dataType": "default" + }, + "data": { + "a": { + "value": "ABC" + } + } + }, + { + "dataElement": { + "id": "123", + "dataType": "non-default" + }, + "data": { + "a": { + "value": "valueFromNonDefaultModel" + } + } + } + ], + "layouts": { + "Page1": { + "$schema": "https://altinncdn.no/schemas/json/layout/layout.schema.v1.json", + "data": { + "layout": [ + { + "id": "current-component", + "type": "Input", + "dataModelBindings": { + "simpleBinding": { + "dataType": "non-existing", + "field": "a.value" + } + } + } + ] + } + } + }, + "context": { + "component": "current-component", + "currentLayout": "Page1" + } +} diff --git a/src/features/expressions/shared-tests/functions/dataModelMultiple/dataModel-non-default-model.json b/src/features/expressions/shared-tests/functions/dataModelMultiple/dataModel-non-default-model.json new file mode 100644 index 0000000000..17ddad4272 --- /dev/null +++ b/src/features/expressions/shared-tests/functions/dataModelMultiple/dataModel-non-default-model.json @@ -0,0 +1,52 @@ +{ + "name": "dataModel non default data type lookup", + "expression": ["dataModel", "a.value", "non-default"], + "expects": "valueFromNonDefaultModel", + "dataModels": [ + { + "dataElement": { + "id": "345", + "dataType": "default" + }, + "data": { + "a": { + "value": "ABC" + } + } + }, + { + "dataElement": { + "id": "123", + "dataType": "non-default" + }, + "data": { + "a": { + "value": "valueFromNonDefaultModel" + } + } + } + ], + "layouts": { + "Page1": { + "$schema": "https://altinncdn.no/schemas/json/layout/layout.schema.v1.json", + "data": { + "layout": [ + { + "id": "current-component", + "type": "Input", + "dataModelBindings": { + "simpleBinding": { + "dataType": "non-default", + "field": "a.value" + } + } + } + ] + } + } + }, + "context": { + "component": "current-component", + "currentLayout": "Page1" + } +} diff --git a/src/features/expressions/shared-tests/functions/dataModelMultiple/dataModel-non-existing-model.json b/src/features/expressions/shared-tests/functions/dataModelMultiple/dataModel-non-existing-model.json new file mode 100644 index 0000000000..68b50dfba2 --- /dev/null +++ b/src/features/expressions/shared-tests/functions/dataModelMultiple/dataModel-non-existing-model.json @@ -0,0 +1,56 @@ +{ + "name": "dataModel non-existing datamodel reference", + "expression": [ + "dataModel", + "a.value", + "non-existing" + ], + "expectsFailure": "Unknown data model 'non-existing'", + "dataModels": [ + { + "dataElement": { + "id": "345", + "dataType": "default" + }, + "data": { + "a": { + "value": "ABC" + } + } + }, + { + "dataElement": { + "id": "123", + "dataType": "non-default" + }, + "data": { + "a": { + "value": "valueFromNonDefaultModel" + } + } + } + ], + "layouts": { + "Page1": { + "$schema": "https://altinncdn.no/schemas/json/layout/layout.schema.v1.json", + "data": { + "layout": [ + { + "id": "current-component", + "type": "Input", + "dataModelBindings": { + "simpleBinding": { + "dataType": "non-default", + "field": "a.value" + } + } + } + ] + } + } + }, + "context": { + "component": "current-component", + "currentLayout": "Page1" + } +} diff --git a/src/features/expressions/shared.ts b/src/features/expressions/shared.ts index 0a69907d5c..a718a04a93 100644 --- a/src/features/expressions/shared.ts +++ b/src/features/expressions/shared.ts @@ -7,11 +7,17 @@ import type { IRawTextResource } from 'src/features/language/textResources'; import type { ILayoutCollection } from 'src/layout/layout'; import type { IApplicationSettings, IData, IInstance, IProcess, ITask } from 'src/types/shared'; +export type DataModelAndElement = { + dataElement: IData; + data: unknown; +}; + interface SharedTest { name: string; disabledFrontend?: boolean; layouts?: ILayoutCollection; dataModel?: unknown; + dataModels?: DataModelAndElement[]; instance?: IInstance; process?: IProcess; instanceDataElements?: IData[]; diff --git a/src/features/form/FormContext.tsx b/src/features/form/FormContext.tsx index cf6de9e578..9ec0c2ce86 100644 --- a/src/features/form/FormContext.tsx +++ b/src/features/form/FormContext.tsx @@ -1,15 +1,14 @@ import React from 'react'; import { ContextNotProvided, createContext } from 'src/core/contexts/context'; -import { CustomValidationConfigProvider } from 'src/features/customValidation/CustomValidationContext'; -import { DataModelSchemaProvider } from 'src/features/datamodel/DataModelSchemaProvider'; +import { DataModelsProvider } from 'src/features/datamodel/DataModelsProvider'; import { DynamicsProvider } from 'src/features/form/dynamics/DynamicsContext'; import { LayoutsProvider } from 'src/features/form/layout/LayoutsContext'; import { NavigateToNodeProvider } from 'src/features/form/layout/NavigateToNode'; import { PageNavigationProvider } from 'src/features/form/layout/PageNavigationContext'; import { LayoutSettingsProvider } from 'src/features/form/layoutSettings/LayoutSettingsContext'; import { RulesProvider } from 'src/features/form/rules/RulesContext'; -import { InitialFormDataProvider } from 'src/features/formData/InitialFormData'; +import { FormDataWriteProvider } from 'src/features/formData/FormDataWrite'; import { useHasProcessProvider } from 'src/features/instance/ProcessContext'; import { ProcessNavigationProvider } from 'src/features/instance/ProcessNavigationContext'; import { OrderDetailsProvider } from 'src/features/payment/OrderDetailsProvider'; @@ -38,38 +37,36 @@ export function FormProvider({ children }: React.PropsWithChildren) { <> - - + - - - - - - - - {hasProcess ? ( - - {children} - - ) : ( + + + + + + + {hasProcess ? ( + {children} - )} - - - - - - - + + ) : ( + {children} + )} + + + + + + - + + ); diff --git a/src/features/form/dynamics/DynamicsContext.tsx b/src/features/form/dynamics/DynamicsContext.tsx index ff835424b3..cc0efd7584 100644 --- a/src/features/form/dynamics/DynamicsContext.tsx +++ b/src/features/form/dynamics/DynamicsContext.tsx @@ -6,7 +6,7 @@ import { useAppQueries } from 'src/core/contexts/AppQueriesProvider'; import { ContextNotProvided } from 'src/core/contexts/context'; import { delayedContext } from 'src/core/contexts/delayedContext'; import { createQueryContext } from 'src/core/contexts/queryContext'; -import { useCurrentLayoutSetId } from 'src/features/form/layoutSets/useCurrentLayoutSetId'; +import { useCurrentLayoutSetId } from 'src/features/form/layoutSets/useCurrentLayoutSet'; import type { QueryDefinition } from 'src/core/queries/usePrefetchQuery'; import type { IFormDynamics } from 'src/features/form/dynamics'; diff --git a/src/features/form/dynamics/HiddenComponentsProvider.tsx b/src/features/form/dynamics/HiddenComponentsProvider.tsx index e34513c777..77b609c0f3 100644 --- a/src/features/form/dynamics/HiddenComponentsProvider.tsx +++ b/src/features/form/dynamics/HiddenComponentsProvider.tsx @@ -1,5 +1,6 @@ import { useEffect } from 'react'; +import { useCurrentDataModelName } from 'src/features/datamodel/useBindingSchema'; import { useDynamics } from 'src/features/form/dynamics/DynamicsContext'; import { FD } from 'src/features/formData/FormDataWrite'; import { NodesInternal } from 'src/utils/layout/NodesContext'; @@ -8,6 +9,7 @@ import { useNodeTraversalSelector } from 'src/utils/layout/useNodeTraversal'; import { splitDashedKey } from 'src/utils/splitDashedKey'; import type { IConditionalRenderingRule } from 'src/features/form/dynamics/index'; import type { FormDataSelector } from 'src/layout'; +import type { IDataModelReference } from 'src/layout/common.generated'; import type { LayoutNode } from 'src/utils/layout/LayoutNode'; import type { DataModelTransposeSelector } from 'src/utils/layout/useDataModelBindingTranspose'; @@ -37,13 +39,14 @@ function useLegacyHiddenComponents() { const nodeDataSelector = NodesInternal.useNodeDataSelector(); const traversalSelector = useNodeTraversalSelector(); const hiddenNodes: { [nodeId: string]: true } = {}; + const defaultDataType = useCurrentDataModelName() ?? ''; if (!window.conditionalRuleHandlerObject || !rules || Object.keys(rules).length === 0) { // Rules have not been initialized return hiddenNodes; } - const props = [hiddenNodes, formDataSelector, transposeSelector] as const; + const props = [defaultDataType, hiddenNodes, formDataSelector, transposeSelector] as const; const topLevelNode = traversalSelector((t) => t.allNodes()[0], []); for (const key of Object.keys(rules)) { if (!key) { @@ -95,6 +98,7 @@ function useLegacyHiddenComponents() { function runConditionalRenderingRule( rule: IConditionalRenderingRule, node: LayoutNode | undefined, + defaultDataType: string, hiddenNodes: { [nodeId: string]: true }, formDataSelector: FormDataSelector, transposeSelector: DataModelTransposeSelector, @@ -105,7 +109,8 @@ function runConditionalRenderingRule( const inputObj = {} as Record; for (const key of inputKeys) { const param = rule.inputParams[key].replace(/{\d+}/g, ''); - const transposed = (node ? transposeSelector(node, param) : undefined) ?? param; + const binding: IDataModelReference = { dataType: defaultDataType, field: param }; + const transposed = (node ? transposeSelector(node, binding) : undefined) ?? binding; const value = formDataSelector(transposed); if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') { diff --git a/src/features/form/layout/LayoutsContext.tsx b/src/features/form/layout/LayoutsContext.tsx index 17cef25ddd..45e6497ee3 100644 --- a/src/features/form/layout/LayoutsContext.tsx +++ b/src/features/form/layout/LayoutsContext.tsx @@ -6,10 +6,11 @@ import { useAppQueries } from 'src/core/contexts/AppQueriesProvider'; import { delayedContext } from 'src/core/contexts/delayedContext'; import { createQueryContext } from 'src/core/contexts/queryContext'; import { useTaskStore } from 'src/core/contexts/taskStoreContext'; +import { useCurrentDataModelName } from 'src/features/datamodel/useBindingSchema'; import { cleanLayout } from 'src/features/form/layout/cleanLayout'; import { applyLayoutQuirks } from 'src/features/form/layout/quirks'; import { useLayoutSets } from 'src/features/form/layoutSets/LayoutSetsProvider'; -import { useCurrentLayoutSetId } from 'src/features/form/layoutSets/useCurrentLayoutSetId'; +import { useCurrentLayoutSetId } from 'src/features/form/layoutSets/useCurrentLayoutSet'; import { useHasInstance } from 'src/features/instance/InstanceContext'; import { useLaxProcessData } from 'src/features/instance/ProcessContext'; import { useNavigationParam } from 'src/features/routing/AppRoutingContext'; @@ -24,12 +25,16 @@ export interface LayoutContextValue { } // Also used for prefetching @see formPrefetcher.ts -export function useLayoutQueryDef(enabled: boolean, layoutSetId?: string): QueryDefinition { +export function useLayoutQueryDef( + enabled: boolean, + defaultDataModelType: string, + layoutSetId?: string, +): QueryDefinition { const { fetchLayouts } = useAppQueries(); return { queryKey: ['formLayouts', layoutSetId, enabled], queryFn: layoutSetId - ? () => fetchLayouts(layoutSetId).then((layouts) => processLayouts(layouts, layoutSetId)) + ? () => fetchLayouts(layoutSetId).then((layouts) => processLayouts(layouts, layoutSetId, defaultDataModelType)) : skipToken, enabled: enabled && !!layoutSetId, }; @@ -39,10 +44,11 @@ function useLayoutQuery() { const hasInstance = useHasInstance(); const process = useLaxProcessData(); const currentLayoutSetId = useLayoutSetId(); + const defaultDataModel = useCurrentDataModelName() ?? 'unknown'; // Waiting to fetch layouts until we have an instance, if we're supposed to have one // We don't want to fetch form layouts for a process step which we are currently not on - const utils = useQuery(useLayoutQueryDef(hasInstance ? !!process : true, currentLayoutSetId)); + const utils = useQuery(useLayoutQueryDef(hasInstance ? !!process : true, defaultDataModel, currentLayoutSetId)); useEffect(() => { utils.error && window.logError('Fetching form layout failed:\n', utils.error); @@ -80,13 +86,13 @@ export const useHiddenLayoutsExpressions = () => useCtx().hiddenLayoutsExpressio export const useExpandedWidthLayouts = () => useCtx().expandedWidthLayouts; -function processLayouts(input: ILayoutCollection, layoutSetId: string): LayoutContextValue { +function processLayouts(input: ILayoutCollection, layoutSetId: string, dataModelType: string): LayoutContextValue { const layouts: ILayouts = {}; const hiddenLayoutsExpressions: IHiddenLayoutsExternal = {}; const expandedWidthLayouts: IExpandedWidthLayouts = {}; for (const key of Object.keys(input)) { const file = input[key]; - layouts[key] = cleanLayout(file.data.layout); + layouts[key] = cleanLayout(file.data.layout, dataModelType); hiddenLayoutsExpressions[key] = file.data.hidden; expandedWidthLayouts[key] = file.data.expandedWidth; } diff --git a/src/features/form/layout/cleanLayout.ts b/src/features/form/layout/cleanLayout.ts index 9acddb9032..d289cdaed5 100644 --- a/src/features/form/layout/cleanLayout.ts +++ b/src/features/form/layout/cleanLayout.ts @@ -1,4 +1,5 @@ import { getComponentConfigs } from 'src/layout/components.generated'; +import type { IDataModelReference } from 'src/layout/common.generated'; import type { CompTypes, ILayout } from 'src/layout/layout'; type ComponentTypeCaseMapping = { [key: string]: CompTypes }; @@ -14,10 +15,31 @@ function getCaseMapping(): ComponentTypeCaseMapping { return componentTypeCaseMapping; } -export function cleanLayout(layout: ILayout): ILayout { +export function cleanLayout(layout: ILayout, dataModelType: string): ILayout { const mapping = getCaseMapping(); - return layout.map((component) => ({ - ...component, - type: mapping[component.type.toLowerCase()] || component.type, - })) as ILayout; + return layout.map((component) => { + const out = { + ...component, + type: mapping[component.type.toLowerCase()] || component.type, + }; + + if (out.dataModelBindings) { + const rewrittenBindings: Record = {}; + + for (const [key, value] of Object.entries(out.dataModelBindings)) { + if (typeof value === 'string') { + rewrittenBindings[key] = { + dataType: dataModelType, + field: value, + }; + } else { + rewrittenBindings[key] = value; + } + } + + out.dataModelBindings = rewrittenBindings; + } + + return out; + }) as ILayout; } diff --git a/src/features/form/layoutSets/useCurrentLayoutSetId.ts b/src/features/form/layoutSets/useCurrentLayoutSet.ts similarity index 78% rename from src/features/form/layoutSets/useCurrentLayoutSetId.ts rename to src/features/form/layoutSets/useCurrentLayoutSet.ts index b30719e3a7..25a0fbdf1d 100644 --- a/src/features/form/layoutSets/useCurrentLayoutSetId.ts +++ b/src/features/form/layoutSets/useCurrentLayoutSet.ts @@ -1,26 +1,30 @@ import { ContextNotProvided } from 'src/core/contexts/context'; import { useTaskStore } from 'src/core/contexts/taskStoreContext'; import { useLaxApplicationMetadata } from 'src/features/applicationMetadata/ApplicationMetadataProvider'; -import { getLayoutSetIdForApplication } from 'src/features/applicationMetadata/appMetadataUtils'; +import { getCurrentLayoutSet } from 'src/features/applicationMetadata/appMetadataUtils'; import { useLaxLayoutSets } from 'src/features/form/layoutSets/LayoutSetsProvider'; import { useProcessTaskId } from 'src/features/instance/useProcessTaskId'; import type { ILayoutSet } from 'src/layout/common.generated'; export function useCurrentLayoutSetId() { + return useCurrentLayoutSet()?.id; +} + +export function useCurrentLayoutSet() { const application = useLaxApplicationMetadata(); const layoutSets = useLaxLayoutSets(); const taskId = useProcessTaskId(); const { overriddenLayoutSetId } = useTaskStore(({ overriddenLayoutSetId }) => ({ overriddenLayoutSetId })); - if (overriddenLayoutSetId) { - return overriddenLayoutSetId; - } - if (application === ContextNotProvided || layoutSets === ContextNotProvided) { return undefined; } - return getLayoutSetIdForApplication({ application, layoutSets, taskId }); + if (overriddenLayoutSetId) { + return layoutSets.sets.find((set) => set.id === overriddenLayoutSetId); + } + + return getCurrentLayoutSet({ application, layoutSets, taskId }); } export function useGetLayoutSetById(layoutSetId: string): ILayoutSet | undefined { diff --git a/src/features/form/rules/RulesContext.tsx b/src/features/form/rules/RulesContext.tsx index ad31e10611..66269cf9ec 100644 --- a/src/features/form/rules/RulesContext.tsx +++ b/src/features/form/rules/RulesContext.tsx @@ -5,7 +5,7 @@ import { skipToken, useQuery } from '@tanstack/react-query'; import { useAppQueries } from 'src/core/contexts/AppQueriesProvider'; import { delayedContext } from 'src/core/contexts/delayedContext'; import { createQueryContext } from 'src/core/contexts/queryContext'; -import { useCurrentLayoutSetId } from 'src/features/form/layoutSets/useCurrentLayoutSetId'; +import { useCurrentLayoutSetId } from 'src/features/form/layoutSets/useCurrentLayoutSet'; import type { QueryDefinition } from 'src/core/queries/usePrefetchQuery'; const RULES_SCRIPT_ID = 'rules-script'; diff --git a/src/features/formData/FormData.test.tsx b/src/features/formData/FormData.test.tsx index c0b2549bbb..149aa87d66 100644 --- a/src/features/formData/FormData.test.tsx +++ b/src/features/formData/FormData.test.tsx @@ -5,23 +5,30 @@ import type { PropsWithChildren } from 'react'; import { afterAll, beforeAll, expect, jest } from '@jest/globals'; import { act, screen, waitFor } from '@testing-library/react'; import { userEvent } from '@testing-library/user-event'; +import type { JSONSchema7 } from 'json-schema'; import { getIncomingApplicationMetadataMock } from 'src/__mocks__/getApplicationMetadataMock'; +import { defaultMockDataElementId } from 'src/__mocks__/getInstanceDataMock'; +import { defaultDataTypeMock, statelessDataTypeMock } from 'src/__mocks__/getLayoutSetsMock'; import { ApplicationMetadataProvider } from 'src/features/applicationMetadata/ApplicationMetadataProvider'; -import { DataModelSchemaProvider } from 'src/features/datamodel/DataModelSchemaProvider'; +import { DataModelsProvider } from 'src/features/datamodel/DataModelsProvider'; import { DynamicsProvider } from 'src/features/form/dynamics/DynamicsContext'; import { LayoutsProvider } from 'src/features/form/layout/LayoutsContext'; import { LayoutSetsProvider } from 'src/features/form/layoutSets/LayoutSetsProvider'; import { LayoutSettingsProvider } from 'src/features/form/layoutSettings/LayoutSettingsContext'; import { RulesProvider } from 'src/features/form/rules/RulesContext'; import { GlobalFormDataReadersProvider } from 'src/features/formData/FormDataReaders'; -import { FD } from 'src/features/formData/FormDataWrite'; +import { FD, FormDataWriteProvider } from 'src/features/formData/FormDataWrite'; import { FormDataWriteProxyProvider } from 'src/features/formData/FormDataWriteProxies'; -import { InitialFormDataProvider } from 'src/features/formData/InitialFormData'; import { useDataModelBindings } from 'src/features/formData/useDataModelBindings'; import { AppRoutingProvider, useNavigate } from 'src/features/routing/AppRoutingContext'; import { fetchApplicationMetadata } from 'src/queries/queries'; -import { makeFormDataMethodProxies, renderWithMinimalProviders } from 'src/test/renderWithProviders'; +import { + makeFormDataMethodProxies, + renderWithInstanceAndLayout, + renderWithMinimalProviders, +} from 'src/test/renderWithProviders'; +import type { IDataModelPatchRequest, IDataModelPatchResponse } from 'src/features/formData/types'; interface DataModelFlat { 'obj1.prop1': string; @@ -58,7 +65,34 @@ function NavigateBackButton() { ); } -async function genericRender(props: Partial[0]> = {}) { +const mockSchema: JSONSchema7 = { + type: 'object', + properties: { + obj1: { + type: 'object', + properties: { + prop1: { + type: 'string', + }, + prop2: { + type: 'string', + }, + }, + }, + obj2: { + type: 'object', + properties: { + prop1: { + type: 'string', + }, + }, + }, + }, +}; + +type MinimalRenderProps = Partial[0], 'renderer'>>; +type RenderProps = MinimalRenderProps & { renderer: React.ReactElement }; +async function statelessRender(props: RenderProps) { (fetchApplicationMetadata as jest.Mock).mockImplementationOnce(() => Promise.resolve( getIncomingApplicationMetadataMock({ @@ -99,49 +133,24 @@ async function genericRender(props: Partial - - - - + + + + - - {props.renderer && typeof props.renderer === 'function' ? props.renderer() : props.renderer} - + {props.renderer} - - - - + + + + ), queries: { - fetchDataModelSchema: async () => ({ - type: 'object', - properties: { - obj1: { - type: 'object', - properties: { - prop1: { - type: 'string', - }, - prop2: { - type: 'string', - }, - }, - }, - obj2: { - type: 'object', - properties: { - prop1: { - type: 'string', - }, - }, - }, - }, - }), + fetchDataModelSchema: async () => mockSchema, fetchFormData: async () => ({}), fetchLayouts: async () => ({}), ...props.queries, @@ -150,6 +159,22 @@ async function genericRender(props: Partial).mockImplementationOnce(() => + Promise.resolve(getIncomingApplicationMetadataMock()), + ); + return await renderWithInstanceAndLayout({ + ...props, + alwaysRouteToChildren: true, + queries: { + fetchDataModelSchema: async () => mockSchema, + fetchFormData: async () => ({}), + fetchLayouts: async () => ({}), + ...props.queries, + }, + }); +} + describe('FormData', () => { describe('Rendering and re-rendering', () => { function RenderCountingReader({ path, countKey, renderCounts }: Props) { @@ -157,7 +182,7 @@ describe('FormData', () => { const { formData: { simpleBinding: value }, } = useDataModelBindings({ - simpleBinding: path, + simpleBinding: { field: path, dataType: statelessDataTypeMock }, }); return
{value}
; @@ -169,7 +194,7 @@ describe('FormData', () => { formData: { simpleBinding: value }, setValue, } = useDataModelBindings({ - simpleBinding: path, + simpleBinding: { field: path, dataType: statelessDataTypeMock }, }); return ( @@ -181,7 +206,7 @@ describe('FormData', () => { ); } - async function render(props: Partial[0]> = {}) { + async function render(props: MinimalRenderProps = {}) { const renderCounts: RenderCounts = { ReaderObj1Prop1: 0, ReaderObj1Prop2: 0, @@ -192,8 +217,8 @@ describe('FormData', () => { WriterObj2Prop1: 0, }; - const utils = await genericRender({ - renderer: () => ( + const utils = await statelessRender({ + renderer: ( <> { await userEvent.type(screen.getByTestId('writer-obj1.prop1'), 'a'); expect(formDataMethods.setLeafValue).toHaveBeenCalledTimes(1); expect(formDataMethods.setLeafValue).toHaveBeenCalledWith({ - path: 'obj1.prop1', + reference: { field: 'obj1.prop1', dataType: statelessDataTypeMock }, newValue: 'value1a', }); @@ -277,12 +302,12 @@ describe('FormData', () => { }); }); - function SimpleWriter({ path }: { path: keyof DataModelFlat }) { + function SimpleWriter({ path, dataType = statelessDataTypeMock }: { path: keyof DataModelFlat; dataType?: string }) { const { formData: { simpleBinding: value }, setValue, } = useDataModelBindings({ - simpleBinding: path, + simpleBinding: { field: path, dataType }, }); return ( @@ -311,8 +336,8 @@ describe('FormData', () => { if (isLocked) { // Unlock with some pretend updated form data unlock({ - newDataModel: { obj1: { prop1: 'new value' } }, - validationIssues: { obj1: [] }, + updatedDataModels: { [defaultMockDataElementId]: { obj1: { prop1: 'new value' } } }, + updatedValidationIssues: { obj1: [] }, }); } else { await lock(); @@ -325,13 +350,22 @@ describe('FormData', () => { ); } - async function render(props: Partial[0]> = {}) { - return genericRender({ - renderer: () => ( + async function render(props: MinimalRenderProps = {}) { + return statefulRender({ + renderer: ( <> - - - + + + @@ -371,9 +405,9 @@ describe('FormData', () => { expect(screen.getByTestId('obj1.prop2')).toHaveValue('b'); // Locking prevents saving - expect(mutations.doPostStatelessFormData.mock).toHaveBeenCalledTimes(0); + expect(mutations.doPatchFormData.mock).toHaveBeenCalledTimes(0); act(() => jest.advanceTimersByTime(5000)); - expect(mutations.doPostStatelessFormData.mock).toHaveBeenCalledTimes(0); + expect(mutations.doPatchFormData.mock).toHaveBeenCalledTimes(0); // Unlock the form await user.click(screen.getByRole('button', { name: 'Unlock form data' })); @@ -385,15 +419,10 @@ describe('FormData', () => { // Saving is now allowed, so the form data we saved earlier is sent. The one value // we changed that was overwritten is now lost. act(() => jest.advanceTimersByTime(5000)); - await waitFor(() => expect(mutations.doPostStatelessFormData.mock).toHaveBeenCalledTimes(1)); + await waitFor(() => expect(mutations.doPatchFormData.mock).toHaveBeenCalledTimes(1)); - const dataModel = (mutations.doPostStatelessFormData.mock as jest.Mock).mock.calls[0][1]; - expect(dataModel).toEqual({ - obj1: { - prop1: 'new value', - prop2: 'b', - }, - }); + const patchReq = (mutations.doPatchFormData.mock as jest.Mock).mock.calls[0][1] as IDataModelPatchRequest; + expect(patchReq.patch).toEqual([{ op: 'add', path: '/obj1/prop2', value: 'b' }]); }); it('Locking will not trigger a save if no values have changed', async () => { @@ -404,9 +433,9 @@ describe('FormData', () => { await user.click(screen.getByRole('button', { name: 'Lock form data' })); await waitFor(() => expect(screen.getByTestId('isLocked')).toHaveTextContent('true')); - expect(mutations.doPostStatelessFormData.mock).toHaveBeenCalledTimes(0); + expect(mutations.doPatchFormData.mock).toHaveBeenCalledTimes(0); act(() => jest.advanceTimersByTime(5000)); - expect(mutations.doPostStatelessFormData.mock).toHaveBeenCalledTimes(0); + expect(mutations.doPatchFormData.mock).toHaveBeenCalledTimes(0); await user.click(screen.getByRole('button', { name: 'Unlock form data' })); await waitFor(() => expect(screen.getByTestId('isLocked')).toHaveTextContent('false')); @@ -416,7 +445,7 @@ describe('FormData', () => { act(() => jest.advanceTimersByTime(5000)); await waitFor(() => expect(screen.getByTestId('hasUnsavedChanges')).toHaveTextContent('false')); - expect(mutations.doPostStatelessFormData.mock).toHaveBeenCalledTimes(0); + expect(mutations.doPatchFormData.mock).toHaveBeenCalledTimes(0); }); it('Unsaved changes should be saved before locking', async () => { @@ -428,22 +457,21 @@ describe('FormData', () => { expect(screen.getByTestId('obj2.prop1')).toHaveValue('a'); expect(screen.getByTestId('hasUnsavedChanges')).toHaveTextContent('true'); - expect(mutations.doPostStatelessFormData.mock).toHaveBeenCalledTimes(0); + expect(mutations.doPatchFormData.mock).toHaveBeenCalledTimes(0); await user.click(screen.getByRole('button', { name: 'Lock form data' })); - expect(mutations.doPostStatelessFormData.mock).toHaveBeenCalledTimes(1); + expect(mutations.doPatchFormData.mock).toHaveBeenCalledTimes(1); expect(screen.getByTestId('isLocked')).toHaveTextContent('false'); // The save has not finished yet - const dataModel = (mutations.doPostStatelessFormData.mock as jest.Mock).mock.calls[0][1]; - expect(dataModel).toEqual({ - obj1: { - prop1: 'value1', - }, - obj2: { - prop1: 'a', - }, - }); + const patchReq = (mutations.doPatchFormData.mock as jest.Mock).mock.calls[0][1] as IDataModelPatchRequest; + expect(patchReq.patch).toEqual([{ op: 'add', path: '/obj2', value: { prop1: 'a' } }]); - mutations.doPostStatelessFormData.resolve(); + const response: IDataModelPatchResponse = { + newDataModel: { + obj2: { prop1: 'a' }, + }, + validationIssues: {}, + }; + mutations.doPatchFormData.resolve(response); await waitFor(() => expect(screen.getByTestId('isLocked')).toHaveTextContent('true')); }); }); @@ -462,9 +490,9 @@ describe('FormData', () => { ); } - async function render(props: Partial[0]> = {}) { - return genericRender({ - renderer: () => ( + async function render(props: MinimalRenderProps = {}) { + return statelessRender({ + renderer: ( <> @@ -515,27 +543,32 @@ describe('FormData', () => { const { mutations, queries } = await render(); await user.type(screen.getByTestId('obj2.prop1'), 'a'); + await user.tab(); expect(screen.getByTestId('obj2.prop1')).toHaveValue('a'); expect(screen.getByTestId('hasUnsavedChanges')).toHaveTextContent('true'); expect(queries.fetchFormData).toHaveBeenCalledTimes(1); + + // Pretending to handle the save operation + await waitFor(() => expect(mutations.doPostStatelessFormData.mock).toHaveBeenCalledTimes(1)); + mutations.doPostStatelessFormData.resolve({ obj2: { prop1: 'a' } }); + + await waitFor(() => expect(screen.getByTestId('hasUnsavedChanges')).toHaveTextContent('false')); + await user.click(screen.getByRole('button', { name: 'Navigate to a different page' })); await screen.findByText('something different'); - // We have to resolve the save operation, as otherwise 'hasUnsavedChanges' will be 'true' when we navigate back - // as otherwise it would still be working on saving the form data (and form data is marked as unsaved until the - // save operation is finished). - expect(mutations.doPostStatelessFormData.mock).toHaveBeenCalledTimes(1); - mutations.doPostStatelessFormData.resolve(); - await user.click(screen.getByRole('button', { name: 'Navigate back' })); await screen.findByTestId('obj2.prop1'); - expect(queries.fetchFormData).toHaveBeenCalledTimes(2); - // Our mock fetchFormData returns an empty object, so the form data should be reset. Realistically, the form data - // would be restored when fetching it from the server, as we asserted that it was saved before navigating away. - expect(screen.getByTestId('obj2.prop1')).toHaveValue(''); + // No need to re-fetch anymore, as the query cache is updated with the saved form data. This used to expect 2 + // calls to fetchFormData, but now it's only 1. + expect(queries.fetchFormData).toHaveBeenCalledTimes(1); + + // No need to save the form data again, as it was already saved and nothing has changed since then. + expect(screen.getByTestId('obj2.prop1')).toHaveValue('a'); expect(screen.getByTestId('hasUnsavedChanges')).toHaveTextContent('false'); + expect(mutations.doPostStatelessFormData.mock).toHaveBeenCalledTimes(1); }); }); }); diff --git a/src/features/formData/FormDataReaders.tsx b/src/features/formData/FormDataReaders.tsx index ab4af97b94..4a461be87d 100644 --- a/src/features/formData/FormDataReaders.tsx +++ b/src/features/formData/FormDataReaders.tsx @@ -4,13 +4,15 @@ import type { PropsWithChildren } from 'react'; import dot from 'dot-object'; import { ContextNotProvided, createContext } from 'src/core/contexts/context'; +import { getFirstDataElementId } from 'src/features/applicationMetadata/appMetadataUtils'; import { useAvailableDataModels } from 'src/features/datamodel/useAvailableDataModels'; import { useDataModelUrl } from 'src/features/datamodel/useBindingSchema'; import { useFormDataQuery } from 'src/features/formData/useFormDataQuery'; +import { useLaxInstanceData } from 'src/features/instance/InstanceContext'; import { useCurrentLanguage } from 'src/features/language/LanguageProvider'; import { useNavigationParam } from 'src/features/routing/AppRoutingContext'; import { useAsRef } from 'src/hooks/useAsRef'; -import { getUrlWithLanguage } from 'src/utils/urls/urlHelper'; +import type { IDataModelReference } from 'src/layout/common.generated'; type ReaderMap = { [name: string]: DataModelReader }; @@ -35,11 +37,11 @@ export class DataModelReader { protected status: Status = 'loading', ) {} - getAsString(path: string): string | undefined { - if (!this.model) { + getAsString(reference: IDataModelReference): string | undefined { + if (!this.model || this.name !== reference.dataType) { return undefined; } - const realValue = dot.pick(path, this.model); + const realValue = dot.pick(reference.field, this.model); if (typeof realValue === 'string' || typeof realValue === 'number' || typeof realValue === 'boolean') { return realValue.toString(); } @@ -193,7 +195,10 @@ export function DataModelFetcher() { } function SpecificDataModelFetcher({ reader, isAvailable }: { reader: DataModelReader; isAvailable: boolean }) { - const url = getUrlWithLanguage(useDataModelUrl(false, reader.getName()), useCurrentLanguage()); + const instance = useLaxInstanceData(); + const dataType = reader.getName(); + const dataElementId = getFirstDataElementId(instance, dataType); + const url = useDataModelUrl({ includeRowIds: false, dataType, dataElementId, language: useCurrentLanguage() }); const enabled = isAvailable && reader.isLoading(); const { data, error } = useFormDataQuery(enabled ? url : undefined); const { updateModel } = useCtx(); diff --git a/src/features/formData/FormDataWrite.tsx b/src/features/formData/FormDataWrite.tsx index 3b4eb9d002..fc4857dab3 100644 --- a/src/features/formData/FormDataWrite.tsx +++ b/src/features/formData/FormDataWrite.tsx @@ -9,23 +9,33 @@ import { useAppMutations } from 'src/core/contexts/AppQueriesProvider'; import { ContextNotProvided } from 'src/core/contexts/context'; import { createZustandContext } from 'src/core/contexts/zustandContext'; import { useApplicationMetadata } from 'src/features/applicationMetadata/ApplicationMetadataProvider'; -import { useCurrentDataModelSchemaLookup } from 'src/features/datamodel/DataModelSchemaProvider'; +import { DataModels } from 'src/features/datamodel/DataModelsProvider'; +import { useCurrentDataModelName, useGetDataModelUrl } from 'src/features/datamodel/useBindingSchema'; import { useRuleConnections } from 'src/features/form/dynamics/DynamicsContext'; +import { usePageSettings } from 'src/features/form/layoutSettings/LayoutSettingsContext'; import { useFormDataWriteProxies } from 'src/features/formData/FormDataWriteProxies'; import { createFormDataWriteStore } from 'src/features/formData/FormDataWriteStateMachine'; import { createPatch } from 'src/features/formData/jsonPatch/createPatch'; import { ALTINN_ROW_ID } from 'src/features/formData/types'; -import { useCurrentLanguage } from 'src/features/language/LanguageProvider'; +import { getFormDataQueryKey } from 'src/features/formData/useFormDataQuery'; +import { useLaxInstance } from 'src/features/instance/InstanceContext'; +import { type BackendValidationIssueGroups, IgnoredValidators } from 'src/features/validation'; import { useAsRef } from 'src/hooks/useAsRef'; import { useWaitForState } from 'src/hooks/useWaitForState'; -import { getUrlWithLanguage } from 'src/utils/urls/urlHelper'; -import type { SchemaLookupTool } from 'src/features/datamodel/DataModelSchemaProvider'; +import { doPatchMultipleFormData } from 'src/queries/queries'; +import { getMultiPatchUrl } from 'src/utils/urls/appUrlHelper'; +import type { SchemaLookupTool } from 'src/features/datamodel/useDataModelSchemaQuery'; import type { IRuleConnections } from 'src/features/form/dynamics'; import type { FormDataWriteProxies } from 'src/features/formData/FormDataWriteProxies'; -import type { FDSaveFinished, FDSaveResult, FormDataContext } from 'src/features/formData/FormDataWriteStateMachine'; -import type { BackendValidationIssueGroups } from 'src/features/validation'; +import type { + DataModelState, + FDActionResult, + FDSaveFinished, + FormDataContext, + UpdatedDataModel, +} from 'src/features/formData/FormDataWriteStateMachine'; import type { FormDataRowsSelector, FormDataSelector } from 'src/layout'; -import type { IMapping } from 'src/layout/common.generated'; +import type { IDataModelReference, IMapping } from 'src/layout/common.generated'; import type { IDataModelBindings } from 'src/layout/layout'; import type { BaseRow } from 'src/utils/layout/types'; @@ -33,12 +43,11 @@ export type FDLeafValue = string | number | boolean | null | undefined | string[ export type FDValue = FDLeafValue | object | FDValue[]; interface FormDataContextInitialProps { - url: string; - initialData: object; + initialDataModels: { [dataType: string]: DataModelState }; autoSaving: boolean; proxies: FormDataWriteProxies; ruleConnections: IRuleConnections | null; - schemaLookup: SchemaLookupTool; + schemaLookup: { [dataType: string]: SchemaLookupTool }; } const { @@ -56,42 +65,66 @@ const { name: 'FormDataWrite', required: true, initialCreateStore: ({ - url, - initialData, + initialDataModels, autoSaving, proxies, ruleConnections, schemaLookup, }: FormDataContextInitialProps) => - createFormDataWriteStore(url, initialData, autoSaving, proxies, ruleConnections, schemaLookup), + createFormDataWriteStore(initialDataModels, autoSaving, proxies, ruleConnections, schemaLookup), }); function useFormDataSaveMutation() { const { doPatchFormData, doPostStatelessFormData } = useAppMutations(); - const dataModelUrl = useSelector((s) => s.controlState.saveUrl); - const currentLanguageRef = useAsRef(useCurrentLanguage()); + const getDataModelUrl = useGetDataModelUrl(); + const instanceId = useLaxInstance()?.instanceId; + const multiPatchUrl = instanceId ? getMultiPatchUrl(instanceId) : undefined; + const dataModelsRef = useAsRef(useSelector((state) => state.dataModels)); const saveFinished = useSelector((s) => s.saveFinished); const cancelSave = useSelector((s) => s.cancelSave); const isStateless = useApplicationMetadata().isStatelessApp; const debounce = useSelector((s) => s.debounce); - const waitFor = useWaitForState<{ prev: object; next: object }, FormDataContext>(useStore()); + const waitFor = useWaitForState< + { prev: { [dataType: string]: object }; next: { [dataType: string]: object } }, + FormDataContext + >(useStore()); const useIsSavingRef = useAsRef(useIsSaving()); const onSaveFinishedRef = useSelectorAsRef((s) => s.onSaveFinished); + const queryClient = useQueryClient(); - return useMutation({ - mutationKey: ['saveFormData', dataModelUrl], - mutationFn: async (): Promise => { - if (useIsSavingRef.current) { - return; + // This updates the query cache with the new data models every time a save has finished. This means we won't have to + // refetch the data from the backend if the providers suddenly change (i.e. when navigating back and forth between + // the main form and a subform). + function updateQueryCache(result: FDSaveFinished) { + for (const { dataType, data, dataElementId } of result.newDataModels) { + const url = getDataModelUrl({ dataType, dataElementId, includeRowIds: true }); + if (!url) { + continue; } + const queryKey = getFormDataQueryKey(url); + queryClient.setQueryData(queryKey, data); + } + } + const mutation = useMutation({ + mutationKey: ['saveFormData'], + mutationFn: async (): Promise => { // While we could get the next model from a ref, we want to make sure we get the latest model after debounce // at the moment we're saving. This is especially important when automatically saving (and debouncing) when // navigating away from the form context. debounce(); const { next, prev } = await waitFor((state, setReturnValue) => { - if (state.debouncedCurrentData === state.currentData) { - setReturnValue({ next: state.debouncedCurrentData, prev: state.lastSavedData }); + if (!hasUnDebouncedCurrentChanges(state)) { + setReturnValue({ + next: Object.entries(state.dataModels).reduce((next, [dataType, { debouncedCurrentData }]) => { + next[dataType] = debouncedCurrentData; + return next; + }, {}), + prev: Object.entries(state.dataModels).reduce((prev, [dataType, { lastSavedData }]) => { + prev[dataType] = lastSavedData; + return prev; + }, {}), + }); return true; } return false; @@ -101,63 +134,168 @@ function useFormDataSaveMutation() { return; } - // Add current language as a query parameter - const urlWithLanguage = getUrlWithLanguage(dataModelUrl, currentLanguageRef.current); - if (isStateless) { - const newDataModel = await doPostStatelessFormData(urlWithLanguage, next); - onSaveFinishedRef.current?.(); - return { newDataModel, savedData: next, validationIssues: undefined }; - } else { - const patch = createPatch({ prev, next }); - if (patch.length === 0) { + // Stateless does not support multi patch, so we need to save each model independently + const newDataModels: Promise[] = []; + + for (const dataType of Object.keys(dataModelsRef.current)) { + if (next[dataType] === prev[dataType]) { + continue; + } + const url = getDataModelUrl({ dataType }); + if (!url) { + throw new Error(`Cannot post data, url for dataType '${dataType}' could not be determined`); + } + newDataModels.push( + doPostStatelessFormData(url, next[dataType]).then((newDataModel) => ({ + dataType, + data: newDataModel, + dataElementId: undefined, + })), + ); + } + + if (newDataModels.length === 0) { return; } - const result = await doPatchFormData(urlWithLanguage, { - patch, - ignoredValidators: [], - }); onSaveFinishedRef.current?.(); - return { ...result, patch, savedData: next }; + return { newDataModels: await Promise.all(newDataModels), savedData: next, validationIssues: undefined }; + } else { + // Stateful needs to use either old patch or multi patch + + const dataTypes = Object.keys(dataModelsRef.current); + const shouldUseMultiPatch = dataTypes.length > 1; + if (shouldUseMultiPatch) { + if (!multiPatchUrl) { + throw new Error(`Cannot patch data, multipatch url could not be determined`); + } + + const patches = dataTypes.reduce((patches, dataType) => { + const { dataElementId, debouncedCurrentData, lastSavedData } = dataModelsRef.current[dataType]; + if (dataElementId && debouncedCurrentData !== lastSavedData) { + const patch = createPatch({ prev: prev[dataType], next: next[dataType] }); + if (patch.length > 0) { + patches[dataElementId] = patch; + } + } + return patches; + }, {}); + + if (Object.keys(patches).length === 0) { + return; + } + + const { newDataModels, validationIssues } = await doPatchMultipleFormData(multiPatchUrl, { + patches, + // Ignore validations that require layout parsing in the backend which will slow down requests significantly + ignoredValidators: IgnoredValidators, + }); + + const dataModelChanges: UpdatedDataModel[] = []; + for (const dataElementId of Object.keys(newDataModels)) { + const dataType = Object.keys(dataModelsRef.current).find( + (dataType) => dataModelsRef.current[dataType].dataElementId === dataElementId, + ); + if (dataType) { + dataModelChanges.push({ dataType, data: newDataModels[dataElementId], dataElementId }); + } + } + + onSaveFinishedRef.current?.(); + return { newDataModels: dataModelChanges, validationIssues, savedData: next }; + } else { + const dataType = dataTypes[0]; + const patch = createPatch({ prev: prev[dataType], next: next[dataType] }); + if (patch.length === 0) { + return; + } + + const dataElementId = dataModelsRef.current[dataType].dataElementId; + if (!dataElementId) { + throw new Error(`Cannot patch data, dataElementId for dataType '${dataType}' could not be determined`); + } + const url = getDataModelUrl({ dataElementId }); + if (!url) { + throw new Error(`Cannot patch data, url for dataType '${dataType}' could not be determined`); + } + const { newDataModel, validationIssues } = await doPatchFormData(url, { + patch, + // Ignore validations that require layout parsing in the backend which will slow down requests significantly + ignoredValidators: IgnoredValidators, + }); + onSaveFinishedRef.current?.(); + return { + newDataModels: [{ dataType, data: newDataModel, dataElementId }], + validationIssues, + savedData: next, + }; + } } }, onError: () => { cancelSave(); }, onSuccess: (result) => { + result && updateQueryCache(result); result && saveFinished(result); !result && cancelSave(); }, }); + + // Check if save has already started before calling mutate + const _mutate = mutation.mutate; + const mutate: typeof mutation.mutate = useCallback( + (...args) => !useIsSavingRef.current && _mutate(...args), + [useIsSavingRef, _mutate], + ); + + return { + ...mutation, + mutate, + }; } function useIsSaving() { - const dataModelUrl = useLaxSelector((s) => s.controlState.saveUrl); return ( useIsMutating({ - mutationKey: ['saveFormData', dataModelUrl === ContextNotProvided ? '__never__' : dataModelUrl], + mutationKey: ['saveFormData'], }) > 0 ); } -interface FormDataWriterProps extends PropsWithChildren { - url: string; - initialData: object; - autoSaving: boolean; -} - -export function FormDataWriteProvider({ url, initialData, autoSaving, children }: FormDataWriterProps) { +export function FormDataWriteProvider({ children }: PropsWithChildren) { const proxies = useFormDataWriteProxies(); const ruleConnections = useRuleConnections(); - const schemaLookup = useCurrentDataModelSchemaLookup(); + const { allDataTypes, writableDataTypes, defaultDataType, initialData, schemaLookup, dataElementIds } = + DataModels.useFullState(); + const autoSaveBehaviour = usePageSettings().autoSaveBehavior; + + if (!writableDataTypes || !allDataTypes) { + throw new Error('FormDataWriteProvider failed because data types have not been loaded, see DataModelsProvider.'); + } + + const initialDataModels = allDataTypes.reduce((dm, dt) => { + const emptyInvalidData = {}; + dm[dt] = { + currentData: initialData[dt], + invalidCurrentData: emptyInvalidData, + debouncedCurrentData: initialData[dt], + invalidDebouncedCurrentData: emptyInvalidData, + lastSavedData: initialData[dt], + hasUnsavedChanges: false, + dataElementId: dataElementIds[dt], + readonly: !writableDataTypes.includes(dt), + isDefault: dt === defaultDataType, + }; + return dm; + }, {}); return ( @@ -168,20 +306,13 @@ export function FormDataWriteProvider({ url, initialData, autoSaving, children } } function FormDataEffects() { - const state = useSelector((s) => s); - const { - currentData, - debouncedCurrentData, - lastSavedData, - controlState, - invalidCurrentData, - invalidDebouncedCurrentData, - } = state; - const { debounceTimeout, autoSaving, manualSaveRequested, lockedBy } = controlState; + const { autoSaving, lockedBy, debounceTimeout, manualSaveRequested } = useSelector((s) => s); + const hasUnsavedChanges = useHasUnsavedChanges(); + const setUnsavedAttrTimeout = useRef | undefined>(undefined); + const { mutate: performSave, error } = useFormDataSaveMutation(); const isSaving = useIsSaving(); const debounce = useDebounceImmediately(); - const hasUnsavedChanges = useHasUnsavedChanges(); const hasUnsavedChangesNow = useHasUnsavedChangesNow(); // If errors occur, we want to throw them so that the user can see them, and they @@ -190,28 +321,6 @@ function FormDataEffects() { throw error; } - // Debounce the data model when the user stops typing. This has the effect of triggering the useEffect below, - // saving the data model to the backend. Freezing can also be triggered manually, when a manual save is requested. - useEffect(() => { - const timer = setTimeout(() => { - if (currentData !== debouncedCurrentData || invalidCurrentData !== invalidDebouncedCurrentData) { - debounce(); - } - }, debounceTimeout); - - return () => clearTimeout(timer); - }, [debounce, currentData, debouncedCurrentData, debounceTimeout, invalidCurrentData, invalidDebouncedCurrentData]); - - // Save the data model when the data has been frozen/debounced, and we're ready - const needsToSave = lastSavedData !== debouncedCurrentData; - const canSaveNow = !isSaving && !lockedBy; - const shouldSave = (needsToSave && canSaveNow && autoSaving) || manualSaveRequested; - const setUnsavedAttrTimeout = useRef | undefined>(undefined); - - useEffect(() => { - shouldSave && performSave(); - }, [performSave, shouldSave]); - // Marking the document as having unsaved changes. The data attribute is used in tests, while the beforeunload // event is used to warn the user when they try to navigate away from the page with unsaved changes. useEffect(() => { @@ -232,6 +341,28 @@ function FormDataEffects() { }; }, [hasUnsavedChanges]); + // Debounce the data model when the user stops typing. This has the effect of triggering the useEffect below, + // saving the data model to the backend. Freezing can also be triggered manually, when a manual save is requested. + const shouldDebounce = useSelector(hasUnDebouncedChanges); + useEffect(() => { + const timer = shouldDebounce.hasChanges + ? setTimeout(() => { + debounce(); + }, debounceTimeout) + : undefined; + + return () => clearTimeout(timer); + }, [debounce, debounceTimeout, shouldDebounce]); + + // Save the data model when the data has been frozen/debounced, and we're ready + const needsToSave = useSelector(hasDebouncedUnsavedChanges); + const canSaveNow = !isSaving && !lockedBy; + const shouldSave = (needsToSave && canSaveNow && autoSaving) || manualSaveRequested; + + useEffect(() => { + shouldSave && performSave(); + }, [performSave, shouldSave]); + // Always save unsaved changes when the user navigates away from the page and this component is unmounted. // We cannot put the current and last saved data in the dependency array, because that would cause the effect // to trigger when the user is typing, which is not what we want. @@ -245,11 +376,16 @@ function FormDataEffects() { ); // Sets the debounced data in the window object, so that Cypress tests can access it. - useEffect(() => { + useSelector((state) => { if (window.Cypress) { - window.CypressState = { ...window.CypressState, formData: debouncedCurrentData }; + const formData: { [key: string]: unknown } = {}; + for (const [dataType, { debouncedCurrentData }] of Object.entries(state.dataModels)) { + formData[dataType] = debouncedCurrentData; + } + + window.CypressState = { ...window.CypressState, formData }; } - }, [debouncedCurrentData]); + }); return null; } @@ -275,11 +411,37 @@ const useDebounceImmediately = () => { }, [debounce]); }; +function hasDebouncedUnsavedChanges(state: FormDataContext) { + return Object.values(state.dataModels).some( + ({ debouncedCurrentData, lastSavedData }) => debouncedCurrentData !== lastSavedData, + ); +} + +/** + * Checks if we need to debounce. This returns a new object so that the useEffect where it is used gets rerun whenever FormDataEffects renders. + * If it returned the boolean directly, it would not extend the timeout beyond the first time which causes the debounce timeout not to work as intendend. + * This may not be an optimal solution, it would ideally cause a rerender whenever any of the items it checks changes with some sort of selector. + */ +function hasUnDebouncedChanges(state: FormDataContext) { + return { + hasChanges: Object.values(state.dataModels).some( + ({ currentData, debouncedCurrentData, invalidCurrentData, invalidDebouncedCurrentData }) => + currentData !== debouncedCurrentData || invalidCurrentData !== invalidDebouncedCurrentData, + ), + }; +} + +function hasUnDebouncedCurrentChanges(state: FormDataContext) { + return Object.values(state.dataModels).some( + ({ currentData, debouncedCurrentData }) => currentData !== debouncedCurrentData, + ); +} + function hasUnsavedChanges(state: FormDataContext) { - if (state.currentData !== state.lastSavedData) { - return true; - } - return state.debouncedCurrentData !== state.lastSavedData; + return Object.values(state.dataModels).some( + ({ currentData, lastSavedData, debouncedCurrentData }) => + currentData !== lastSavedData || debouncedCurrentData !== lastSavedData, + ); } const useHasUnsavedChanges = () => { @@ -305,22 +467,21 @@ const useHasUnsavedChangesNow = () => { }; const useIsSavingNow = () => { - const dataModelUrl = useLaxSelector((s) => s.controlState.saveUrl); const queryClient = useQueryClient(); return useCallback(() => { const numRequests = queryClient.getMutationCache().findAll({ status: 'pending', - mutationKey: ['saveFormData', dataModelUrl === ContextNotProvided ? '__never__' : dataModelUrl], + mutationKey: ['saveFormData'], }).length; return numRequests > 0; - }, [queryClient, dataModelUrl]); + }, [queryClient]); }; const useWaitForSave = () => { const requestSave = useRequestManualSave(); - const url = useLaxSelector((s) => s.controlState.saveUrl); + const dataTypes = useLaxMemoSelector((s) => Object.keys(s.dataModels)); const waitFor = useWaitForState< BackendValidationIssueGroups | undefined, FormDataContext | typeof ContextNotProvided @@ -328,7 +489,7 @@ const useWaitForSave = () => { return useCallback( async (requestManualSave = false): Promise => { - if (url === ContextNotProvided) { + if (dataTypes === ContextNotProvided) { return Promise.resolve(undefined); } @@ -350,13 +511,18 @@ const useWaitForSave = () => { return true; }); }, - [requestSave, url, waitFor], + [requestSave, dataTypes, waitFor], ); }; const emptyObject = {}; const emptyArray = []; +const debouncedSelector = (reference: IDataModelReference) => (state: FormDataContext) => + dot.pick(reference.field, state.dataModels[reference.dataType].debouncedCurrentData); +const invalidDebouncedSelector = (reference: IDataModelReference) => (state: FormDataContext) => + dot.pick(reference.field, state.dataModels[reference.dataType].invalidDebouncedCurrentData); + export const FD = { /** * Gives you a selector function that can be used to look up paths in the data model. This is similar to @@ -367,7 +533,7 @@ export const FD = { useDebouncedSelector(): FormDataSelector { return useDelayedSelector({ mode: 'simple', - selector: (path: string) => (state) => dot.pick(path, state.debouncedCurrentData), + selector: debouncedSelector, }); }, @@ -379,8 +545,8 @@ export const FD = { useDebouncedRowsSelector(): FormDataRowsSelector { return useDelayedSelector({ mode: 'simple', - selector: (path: string) => (state) => { - const rawRows = dot.pick(path, state.debouncedCurrentData); + selector: (reference: IDataModelReference) => (state) => { + const rawRows = dot.pick(reference.field, state.dataModels[reference.dataType].debouncedCurrentData); if (!Array.isArray(rawRows) || !rawRows.length) { return emptyArray; } @@ -397,7 +563,7 @@ export const FD = { useInvalidDebouncedSelector(): FormDataSelector { return useDelayedSelector({ mode: 'simple', - selector: (path: string) => (state) => dot.pick(path, state.invalidDebouncedCurrentData), + selector: invalidDebouncedSelector, }); }, @@ -405,8 +571,8 @@ export const FD = { * This will return the form data as a deep object, just like the server sends it to us (and the way we send it back). * This will always give you the debounced data, which may or may not be saved to the backend yet. */ - useDebounced(): object { - return useSelector((v) => v.debouncedCurrentData); + useDebounced(dataType: string): object { + return useSelector((v) => v.dataModels[dataType].debouncedCurrentData); }, /** @@ -416,7 +582,7 @@ export const FD = { useLaxDebouncedSelector(): FormDataSelector | typeof ContextNotProvided { return useLaxDelayedSelector({ mode: 'simple', - selector: (path: string) => (state) => dot.pick(path, state.debouncedCurrentData), + selector: debouncedSelector, }); }, @@ -425,8 +591,8 @@ export const FD = { * the value will be returned as-is. If the value is not found, undefined is returned. Null may also be returned if * the value is explicitly set to null. */ - useDebouncedPick(path: string): FDValue { - return useSelector((v) => dot.pick(path, v.debouncedCurrentData)); + useDebouncedPick(reference: IDataModelReference): FDValue { + return useSelector((v) => dot.pick(reference.field, v.dataModels[reference.dataType].debouncedCurrentData)); }, /** @@ -446,13 +612,15 @@ export const FD = { // eslint-disable-next-line @typescript-eslint/no-explicit-any const out: any = {}; for (const key of Object.keys(bindings)) { - const invalidValue = dot.pick(bindings[key], s.invalidCurrentData); + const field = bindings[key].field; + const dataType = bindings[key].dataType; + const invalidValue = dot.pick(field, s.dataModels[dataType].invalidCurrentData); if (invalidValue !== undefined) { out[key] = invalidValue; continue; } - const value = dot.pick(bindings[key], s.currentData); + const value = dot.pick(field, s.dataModels[dataType].currentData); if (dataAs === 'raw') { out[key] = value; } else if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') { @@ -483,7 +651,9 @@ export const FD = { // eslint-disable-next-line @typescript-eslint/no-explicit-any const out: any = {}; for (const key of Object.keys(bindings)) { - out[key] = dot.pick(bindings[key], s.invalidCurrentData) === undefined; + const field = bindings[key].field; + const dataType = bindings[key].dataType; + out[key] = dot.pick(field, s.dataModels[dataType].invalidCurrentData) === undefined; } return out; }), @@ -494,8 +664,8 @@ export const FD = { * later, such as `-5`). As this is the debounced data, it will only be updated when the user stops typing for a * while, so that this model can be used for i.e. validation messages. */ - useInvalidDebounced(): object { - return useSelector((v) => v.invalidDebouncedCurrentData); + useInvalidDebounced(dataType: string): object { + return useSelector((v) => v.dataModels[dataType].invalidDebouncedCurrentData); }, /** @@ -509,15 +679,16 @@ export const FD = { useMapping: ( mapping: IMapping | undefined, dataAs?: D, - ): D extends 'raw' ? { [key: string]: FDValue } : { [key: string]: string } => - useMemoSelector((s) => { + ): D extends 'raw' ? { [key: string]: FDValue } : { [key: string]: string } => { + const currentDataType = useCurrentDataModelName(); + return useMemoSelector((s) => { const realDataAs = dataAs || 'string'; // eslint-disable-next-line @typescript-eslint/no-explicit-any const out: any = {}; - if (mapping) { + if (mapping && currentDataType) { for (const key of Object.keys(mapping)) { const outputKey = mapping[key]; - const value = dot.pick(key, s.debouncedCurrentData); + const value = dot.pick(key, s.dataModels[currentDataType].debouncedCurrentData); if (realDataAs === 'raw') { out[outputKey] = value; @@ -531,7 +702,8 @@ export const FD = { } } return out; - }), + }); + }, /** * This returns the raw method for setting a value in the form data. This is useful if you want to @@ -558,8 +730,8 @@ export const FD = { const rawLock = useSelector((s) => s.lock); const rawUnlock = useSelector((s) => s.unlock); - const lockedBy = useSelector((s) => s.controlState.lockedBy); - const lockedByRef = useSelectorAsRef((s) => s.controlState.lockedBy); + const lockedBy = useSelector((s) => s.lockedBy); + const lockedByRef = useSelectorAsRef((s) => s.lockedBy); const isLocked = lockedBy !== undefined; const isLockedRef = useAsRef(isLocked); const isLockedByMe = lockedBy === lockId; @@ -587,7 +759,7 @@ export const FD = { }, [hasUnsavedChangesNow, isLockedByMeRef, isLockedRef, lockId, lockedByRef, rawLock, waitForSave]); const unlock = useCallback( - (saveResult?: FDSaveResult) => { + (actionResult?: FDActionResult) => { if (!isLockedRef.current) { window.logWarn(`Form data is not locked, cannot unlock it (requested by ${lockId})`); } @@ -598,7 +770,7 @@ export const FD = { return false; } - rawUnlock(saveResult); + rawUnlock(actionResult); return true; }, [isLockedByMeRef, isLockedRef, lockId, lockedByRef, rawUnlock], @@ -614,13 +786,13 @@ export const FD = { * Returns a list of rows, given a binding/path that points to a repeating-group-like structure (i.e. an array of * objects). This will always be 'fresh', meaning it will update immediately when a new row is added/removed. */ - useFreshRows: (binding: string | undefined): BaseRow[] => + useFreshRows: (reference: IDataModelReference | undefined): BaseRow[] => useMemoSelector((s) => { - if (!binding) { + if (!reference) { return emptyArray; } - const rawRows = dot.pick(binding, s.currentData); + const rawRows = dot.pick(reference.field, s.dataModels[reference.dataType].currentData); if (!Array.isArray(rawRows) || !rawRows.length) { return emptyArray; } @@ -685,6 +857,18 @@ export const FD = { */ useLastSaveValidationIssues: () => useSelector((s) => s.validationIssues), + useGetDataTypeForElementId: () => { + const map: Record = useMemoSelector((s) => + Object.fromEntries( + Object.entries(s.dataModels) + .filter(([_, dataModel]) => dataModel.dataElementId) + .map(([dataType, dataModel]) => [dataModel.dataElementId, dataType]), + ), + ); + + return useCallback((dataElementId: string) => map[dataElementId], [map]); + }, + /** * This lets you set to a function that will be called as soon as the saving operation finishes. * Beware that this is not a subscription service, so you can easily overwrite an existing callback here. This diff --git a/src/features/formData/FormDataWriteStateMachine.tsx b/src/features/formData/FormDataWriteStateMachine.tsx index f4f74a4c5a..12e5614092 100644 --- a/src/features/formData/FormDataWriteStateMachine.tsx +++ b/src/features/formData/FormDataWriteStateMachine.tsx @@ -8,14 +8,14 @@ import { convertData } from 'src/features/formData/convertData'; import { createPatch } from 'src/features/formData/jsonPatch/createPatch'; import { runLegacyRules } from 'src/features/formData/LegacyRules'; import { DEFAULT_DEBOUNCE_TIMEOUT } from 'src/features/formData/types'; -import type { SchemaLookupTool } from 'src/features/datamodel/DataModelSchemaProvider'; +import type { SchemaLookupTool } from 'src/features/datamodel/useDataModelSchemaQuery'; import type { IRuleConnections } from 'src/features/form/dynamics'; import type { FDLeafValue } from 'src/features/formData/FormDataWrite'; import type { FormDataWriteProxies, Proxy } from 'src/features/formData/FormDataWriteProxies'; -import type { JsonPatch } from 'src/features/formData/jsonPatch/types'; import type { BackendValidationIssueGroups } from 'src/features/validation'; +import type { IDataModelReference } from 'src/layout/common.generated'; -export interface FormDataState { +export interface DataModelState { // These values contain the current data model, with the values immediately available whenever the user is typing. // Use these values to render the form, and for other cases where you need the current data model immediately. currentData: object; @@ -45,6 +45,36 @@ export interface FormDataState { // model when saving. You probably don't need to use these values directly unless you know what you're doing. lastSavedData: object; + // This identifies the specific data element in storage. This is needed for identifying the correct model when receiving updates from the server. + // For stateless apps, this will be null. + dataElementId: string | null; + + // Whether this data model can be written to or not + readonly: boolean; + + // Whether this data model is the default data model (from layout sets) + isDefault: boolean; +} + +type FormDataState = { + // Data model state + dataModels: { [dataType: string]: DataModelState }; + + // Auto-saving is turned on by default, and will automatically save the data model to the server whenever the + // debouncedCurrentData model changes. This can be turned off when, for example, you want to save the data model + // only when the user navigates to another page. + autoSaving: boolean; + + // The time in milliseconds to debounce the currentData model. This is used to determine how long to wait after the + // user has stopped typing before updating that data into the debouncedCurrentData model. Usually this will follow + // the default value, it can also be changed at any time by each component that uses the FormDataWriter. + debounceTimeout: number; + + // This is used to track whether the user has requested a manual save. When auto-saving is turned off, this is + // the way we track when to save the data model to the server. It can also be used to trigger a manual save + // as a way to immediately save the data model to the server, for example before locking the data model. + manualSaveRequested: boolean; + // This contains the validation issues we receive from the server last time we saved the data model. validationIssues: BackendValidationIssueGroups | undefined; @@ -53,35 +83,13 @@ export interface FormDataState { onSaveFinished: (() => void) | undefined; setOnSaveFinished: (callback: () => void) => void; - // Control state is used to control the behavior of form data. - controlState: { - // The time in milliseconds to debounce the currentData model. This is used to determine how long to wait after the - // user has stopped typing before updating that data into the debouncedCurrentData model. Usually this will follow - // the default value, it can also be changed at any time by each component that uses the FormDataWriter. - debounceTimeout: number; - - // Auto-saving is turned on by default, and will automatically save the data model to the server whenever the - // debouncedCurrentData model changes. This can be turned off when, for example, you want to save the data model - // only when the user navigates to another page. - autoSaving: boolean; - - // This is used to track whether the user has requested a manual save. When auto-saving is turned off, this is - // the way we track when to save the data model to the server. It can also be used to trigger a manual save - // as a way to immediately save the data model to the server, for example before locking the data model. - manualSaveRequested: boolean; - - // This is used to track which component is currently blocking the auto-saving feature. If this is set to a string - // value, auto-saving will be disabled, even if the autoSaving flag is set to true. This is useful when you want - // to temporarily disable auto-saving, for example when clicking a CustomButton and waiting for the server to - // respond. The server might read the data model, change it, and return changes back to the client, which could - // cause data loss if we were to auto-save the data model while the server is still processing the request. - lockedBy: string | undefined; - - // This is the url to use when saving the data model to the server. This can also be used to uniquely identify - // the data model, so that we can save multiple data models to the server at the same time. - saveUrl: string; - }; -} + // This is used to track which component is currently blocking the auto-saving feature. If this is set to a string + // value, auto-saving will be disabled, even if the autoSaving flag is set to true. This is useful when you want + // to temporarily disable auto-saving, for example when clicking a CustomButton and waiting for the server to + // respond. The server might read the data model, change it, and return changes back to the client, which could + // cause data loss if we were to auto-save the data model while the server is still processing the request. + lockedBy: string | undefined; +}; export interface FDChange { // Overrides the timeout before the change is applied to the debounced data model. If not set, the default @@ -91,7 +99,7 @@ export interface FDChange { } export interface FDNewValue extends FDChange { - path: string; + reference: IDataModelReference; newValue: FDLeafValue; } @@ -100,43 +108,64 @@ export interface FDNewValues extends FDChange { } export interface FDAppendToListUnique { - path: string; + reference: IDataModelReference; // eslint-disable-next-line @typescript-eslint/no-explicit-any newValue: any; } export interface FDAppendToList { - path: string; + reference: IDataModelReference; // eslint-disable-next-line @typescript-eslint/no-explicit-any newValue: any; } export interface FDRemoveIndexFromList { - path: string; + reference: IDataModelReference; index: number; } export interface FDRemoveValueFromList { - path: string; + reference: IDataModelReference; // eslint-disable-next-line @typescript-eslint/no-explicit-any value: any; } export interface FDRemoveFromListCallback { - path: string; + reference: IDataModelReference; startAtIndex?: number; // eslint-disable-next-line @typescript-eslint/no-explicit-any callback: (value: any) => boolean; } +export interface UpdatedDataModel { + data: unknown; + dataType: string; + dataElementId: string | undefined; // Can be undefined in stateless apps +} + export interface FDSaveResult { - newDataModel: object; + newDataModels: UpdatedDataModel[]; validationIssues: BackendValidationIssueGroups | undefined; } +export interface FDActionResult { + updatedDataModels: + | { + [dataElementId: string]: object; + } + | undefined; + updatedValidationIssues: BackendValidationIssueGroups | undefined; +} + export interface FDSaveFinished extends FDSaveResult { - patch?: JsonPatch; - savedData: object; + savedData: { + [dataType: string]: object; + }; +} + +interface ToProcess { + savedData: FDSaveFinished['savedData']; + newDataModels: UpdatedDataModel[]; } export interface FormDataMethods { @@ -156,7 +185,7 @@ export interface FormDataMethods { saveFinished: (props: FDSaveFinished) => void; requestManualSave: (setTo?: boolean) => void; lock: (lockName: string) => void; - unlock: (saveResult?: FDSaveResult) => void; + unlock: (saveResult?: FDActionResult) => void; } export type FormDataContext = FormDataState & FormDataMethods; @@ -164,10 +193,10 @@ export type FormDataContext = FormDataState & FormDataMethods; function makeActions( set: (fn: (state: FormDataContext) => void) => void, ruleConnections: IRuleConnections | null, - schemaLookup: SchemaLookupTool, + schemaLookup: { [dataType: string]: SchemaLookupTool }, ): FormDataMethods { function setDebounceTimeout(state: FormDataContext, change: FDChange) { - state.controlState.debounceTimeout = change.debounceTimeout ?? DEFAULT_DEBOUNCE_TIMEOUT; + state.debounceTimeout = change.debounceTimeout ?? DEFAULT_DEBOUNCE_TIMEOUT; } /** @@ -177,89 +206,113 @@ function makeActions( * to work properly. */ function deduplicateModels(state: FormDataContext) { - const models = [ - { key: 'currentData', model: state.currentData }, - { key: 'debouncedCurrentData', model: state.debouncedCurrentData }, - { key: 'lastSavedData', model: state.lastSavedData }, - ]; - - const currentIsDebounced = state.currentData === state.debouncedCurrentData; - const currentIsSaved = state.currentData === state.lastSavedData; - const debouncedIsSaved = state.debouncedCurrentData === state.lastSavedData; - if (currentIsDebounced && currentIsSaved && debouncedIsSaved) { - return; - } + for (const [dataType, { currentData, debouncedCurrentData, lastSavedData }] of Object.entries(state.dataModels)) { + const models = [ + { key: 'currentData', model: currentData }, + { key: 'debouncedCurrentData', model: debouncedCurrentData }, + { key: 'lastSavedData', model: lastSavedData }, + ]; + + const currentIsDebounced = currentData === debouncedCurrentData; + const currentIsSaved = currentData === lastSavedData; + const debouncedIsSaved = debouncedCurrentData === lastSavedData; + if (currentIsDebounced && currentIsSaved && debouncedIsSaved) { + return; + } - for (const modelA of models) { - for (const modelB of models) { - if (modelA.model === modelB.model) { - continue; - } - if (deepEqual(modelA.model, modelB.model)) { - state[modelB.key] = modelA.model; - modelB.model = modelA.model; + for (const modelA of models) { + for (const modelB of models) { + if (modelA.model === modelB.model) { + continue; + } + if (deepEqual(modelA.model, modelB.model)) { + state.dataModels[dataType][modelB.key] = modelA.model; + modelB.model = modelA.model; + } } } } } - function processChanges( - state: FormDataContext, - { newDataModel, savedData }: Pick, - ) { - state.controlState.manualSaveRequested = false; - if (newDataModel) { - const backendChangesPatch = createPatch({ prev: savedData, next: newDataModel, current: state.currentData }); - applyPatch(state.currentData, backendChangesPatch); - state.lastSavedData = newDataModel; - - // Run rules again, against current data. Now that we have updates from the backend, some rules may - // have caused data to change. - const ruleResults = runLegacyRules(ruleConnections, savedData, state.currentData); - for (const { path, newValue } of ruleResults) { - dot.str(path, newValue, state.currentData); + function processChanges(state: FormDataContext, { newDataModels, savedData }: ToProcess) { + state.manualSaveRequested = false; + for (const [dataType, { dataElementId, isDefault }] of Object.entries(state.dataModels)) { + const next = dataElementId + ? newDataModels.find((m) => m.dataElementId === dataElementId)?.data // Stateful apps + : newDataModels.find((m) => m.dataType === dataType)?.data; // Stateless apps + if (next) { + const backendChangesPatch = createPatch({ + prev: savedData[dataType], + next, + current: state.dataModels[dataType].currentData, + }); + applyPatch(state.dataModels[dataType].currentData, backendChangesPatch); + state.dataModels[dataType].lastSavedData = next; + + // Run rules again, against current data. Now that we have updates from the backend, some rules may + // have caused data to change. + if (isDefault) { + const ruleResults = runLegacyRules( + ruleConnections, + savedData[dataType], + state.dataModels[dataType].currentData, + dataType, + ); + for (const { reference, newValue } of ruleResults) { + dot.str(reference.field, newValue, state.dataModels[dataType].currentData); + } + } + } else { + state.dataModels[dataType].lastSavedData = savedData[dataType]; } - } else { - state.lastSavedData = savedData; } deduplicateModels(state); } function debounce(state: FormDataContext) { - state.invalidDebouncedCurrentData = state.invalidCurrentData; - if (deepEqual(state.debouncedCurrentData, state.currentData)) { - state.debouncedCurrentData = state.currentData; - return; - } + for (const [dataType, { isDefault }] of Object.entries(state.dataModels)) { + state.dataModels[dataType].invalidDebouncedCurrentData = state.dataModels[dataType].invalidCurrentData; + if (deepEqual(state.dataModels[dataType].debouncedCurrentData, state.dataModels[dataType].currentData)) { + state.dataModels[dataType].debouncedCurrentData = state.dataModels[dataType].currentData; + continue; + } - const ruleChanges = runLegacyRules(ruleConnections, state.debouncedCurrentData, state.currentData); - for (const { path, newValue } of ruleChanges) { - dot.str(path, newValue, state.currentData); - } + if (isDefault) { + const ruleChanges = runLegacyRules( + ruleConnections, + state.dataModels[dataType].debouncedCurrentData, + state.dataModels[dataType].currentData, + dataType, + ); + for (const { reference, newValue } of ruleChanges) { + dot.str(reference.field, newValue, state.dataModels[dataType].currentData); + } + } - state.debouncedCurrentData = state.currentData; + state.dataModels[dataType].debouncedCurrentData = state.dataModels[dataType].currentData; + } } - function setValue(props: { path: string; newValue: FDLeafValue; state: FormDataState & FormDataMethods }) { - const { path, newValue, state } = props; + function setValue(props: { reference: IDataModelReference; newValue: FDLeafValue; state: FormDataContext }) { + const { reference, newValue, state } = props; if (newValue === '' || newValue === null || newValue === undefined) { - const prevValue = dot.pick(path, state.currentData); + const prevValue = dot.pick(reference.field, state.dataModels[reference.dataType].currentData); // We conflate null and undefined, so no need to set to null or undefined if the value is // already null or undefined if (prevValue !== null && prevValue !== undefined) { - dot.delete(path, state.currentData); - dot.delete(path, state.invalidCurrentData); + dot.delete(reference.field, state.dataModels[reference.dataType].currentData); + dot.delete(reference.field, state.dataModels[reference.dataType].invalidCurrentData); } } else { - const schema = schemaLookup.getSchemaForPath(path)[0]; + const schema = schemaLookup[reference.dataType].getSchemaForPath(reference.field)[0]; const { newValue: convertedValue, error } = convertData(newValue, schema); if (error) { - dot.delete(path, state.currentData); - dot.str(path, newValue, state.invalidCurrentData); + dot.delete(reference.field, state.dataModels[reference.dataType].currentData); + dot.str(reference.field, newValue, state.dataModels[reference.dataType].invalidCurrentData); } else { - dot.delete(path, state.invalidCurrentData); - dot.str(path, convertedValue, state.currentData); + dot.delete(reference.field, state.dataModels[reference.dataType].invalidCurrentData); + dot.str(reference.field, convertedValue, state.dataModels[reference.dataType].currentData); } } } @@ -271,7 +324,7 @@ function makeActions( }), cancelSave: () => set((state) => { - state.controlState.manualSaveRequested = false; + state.manualSaveRequested = false; deduplicateModels(state); }), saveFinished: (props) => @@ -280,22 +333,30 @@ function makeActions( state.validationIssues = validationIssues; processChanges(state, props); }), - setLeafValue: ({ path, newValue, ...rest }) => + setLeafValue: ({ reference, newValue, ...rest }) => set((state) => { - const existingValue = dot.pick(path, state.currentData); + if (state.dataModels[reference.dataType].readonly) { + window.logError(`Tried to write to readOnly dataType "${reference.dataType}"`); + return; + } + const existingValue = dot.pick(reference.field, state.dataModels[reference.dataType].currentData); if (existingValue === newValue) { return; } setDebounceTimeout(state, rest); - setValue({ newValue, path, state }); + setValue({ newValue, reference, state }); }), // All the list methods perform their work immediately, without debouncing, so that UI updates for new/removed // list items are immediate. - appendToListUnique: ({ path, newValue }) => + appendToListUnique: ({ reference, newValue }) => set((state) => { - const existingValue = dot.pick(path, state.currentData); + if (state.dataModels[reference.dataType].readonly) { + window.logError(`Tried to write to readOnly dataType "${reference.dataType}"`); + return; + } + const existingValue = dot.pick(reference.field, state.dataModels[reference.dataType].currentData); if (Array.isArray(existingValue) && existingValue.includes(newValue)) { return; } @@ -303,40 +364,56 @@ function makeActions( if (Array.isArray(existingValue)) { existingValue.push(newValue); } else { - dot.str(path, [newValue], state.currentData); + dot.str(reference.field, [newValue], state.dataModels[reference.dataType].currentData); } }), - appendToList: ({ path, newValue }) => + appendToList: ({ reference, newValue }) => set((state) => { - const existingValue = dot.pick(path, state.currentData); + if (state.dataModels[reference.dataType].readonly) { + window.logError(`Tried to write to readOnly dataType "${reference.dataType}"`); + return; + } + const existingValue = dot.pick(reference.field, state.dataModels[reference.dataType].currentData); if (Array.isArray(existingValue)) { existingValue.push(newValue); } else { - dot.str(path, [newValue], state.currentData); + dot.str(reference.field, [newValue], state.dataModels[reference.dataType].currentData); } }), - removeIndexFromList: ({ path, index }) => + removeIndexFromList: ({ reference, index }) => set((state) => { - const existingValue = dot.pick(path, state.currentData); + if (state.dataModels[reference.dataType].readonly) { + window.logError(`Tried to write to readOnly dataType "${reference.dataType}"`); + return; + } + const existingValue = dot.pick(reference.field, state.dataModels[reference.dataType].currentData); if (index >= existingValue.length) { return; } existingValue.splice(index, 1); }), - removeValueFromList: ({ path, value }) => + removeValueFromList: ({ reference, value }) => set((state) => { - const existingValue = dot.pick(path, state.currentData); + if (state.dataModels[reference.dataType].readonly) { + window.logError(`Tried to write to readOnly dataType "${reference.dataType}"`); + return; + } + const existingValue = dot.pick(reference.field, state.dataModels[reference.dataType].currentData); if (!existingValue.includes(value)) { return; } existingValue.splice(existingValue.indexOf(value), 1); }), - removeFromListCallback: ({ path, startAtIndex, callback }) => + removeFromListCallback: ({ reference, startAtIndex, callback }) => set((state) => { - const existingValue = dot.pick(path, state.currentData); + if (state.dataModels[reference.dataType].readonly) { + window.logError(`Tried to write to readOnly dataType "${reference.dataType}"`); + return; + } + const existingValue = dot.pick(reference.field, state.dataModels[reference.dataType].currentData); if (!Array.isArray(existingValue)) { return; } @@ -364,47 +441,68 @@ function makeActions( setMultiLeafValues: ({ changes, ...rest }) => set((state) => { - let changesFound = false; - for (const { path, newValue } of changes) { - const existingValue = dot.pick(path, state.currentData); + const changedTypes = new Set(); + for (const { reference, newValue } of changes) { + if (state.dataModels[reference.dataType].readonly) { + window.logError(`Tried to write to readOnly dataType "${reference.dataType}"`); + continue; + } + + const existingValue = dot.pick(reference.field, state.dataModels[reference.dataType].currentData); if (existingValue === newValue) { continue; } - setValue({ newValue, path, state }); - changesFound = true; - } - if (changesFound) { - setDebounceTimeout(state, rest); + setValue({ newValue, reference, state }); + changedTypes.add(reference.dataType); } + setDebounceTimeout(state, rest); }), requestManualSave: (setTo = true) => set((state) => { - state.controlState.manualSaveRequested = setTo; + state.manualSaveRequested = setTo; }), lock: (lockName) => set((state) => { - state.controlState.lockedBy = lockName; + state.lockedBy = lockName; }), - unlock: (saveResult) => + unlock: (actionResult) => set((state) => { - state.controlState.lockedBy = undefined; - if (saveResult?.newDataModel) { - processChanges(state, { newDataModel: saveResult.newDataModel, savedData: state.lastSavedData }); + state.lockedBy = undefined; + // Update form data + if (actionResult?.updatedDataModels) { + const newDataModels: UpdatedDataModel[] = []; + for (const dataElementId of Object.keys(actionResult.updatedDataModels)) { + const dataType = Object.keys(state.dataModels).find( + (dt) => state.dataModels[dt].dataElementId === dataElementId, + ); + if (dataType) { + const data = actionResult.updatedDataModels[dataElementId]; + newDataModels.push({ data, dataType, dataElementId }); + } + } + + processChanges(state, { + newDataModels, + savedData: Object.entries(state.dataModels).reduce((savedData, [dataType, { lastSavedData }]) => { + savedData[dataType] = lastSavedData; + return savedData; + }, {}), + }); } - if (saveResult?.validationIssues) { - state.validationIssues = saveResult.validationIssues; + // Update validation issues + if (actionResult?.updatedValidationIssues) { + state.validationIssues = actionResult.updatedValidationIssues; } }), }; } export const createFormDataWriteStore = ( - url: string, - initialData: object, + initialDataModels: { [dataType: string]: DataModelState }, autoSaving: boolean, proxies: FormDataWriteProxies, ruleConnections: IRuleConnections | null, - schemaLookup: SchemaLookupTool, + schemaLookup: { [dataType: string]: SchemaLookupTool }, ) => createStore()( immer((set) => { @@ -421,27 +519,18 @@ export const createFormDataWriteStore = ( }; } - const emptyInvalidData = {}; return { - currentData: initialData, - invalidCurrentData: emptyInvalidData, - debouncedCurrentData: initialData, - invalidDebouncedCurrentData: emptyInvalidData, - lastSavedData: initialData, - hasUnsavedChanges: false, + dataModels: initialDataModels, + autoSaving, + lockedBy: undefined, + debounceTimeout: DEFAULT_DEBOUNCE_TIMEOUT, + manualSaveRequested: false, validationIssues: undefined, onSaveFinished: undefined, setOnSaveFinished: (callback) => set((state) => { state.onSaveFinished = callback; }), - controlState: { - autoSaving, - manualSaveRequested: false, - lockedBy: undefined, - debounceTimeout: DEFAULT_DEBOUNCE_TIMEOUT, - saveUrl: url, - }, ...actions, }; }), diff --git a/src/features/formData/InitialFormData.tsx b/src/features/formData/InitialFormData.tsx deleted file mode 100644 index e41ccdfdfc..0000000000 --- a/src/features/formData/InitialFormData.tsx +++ /dev/null @@ -1,51 +0,0 @@ -import React from 'react'; -import type { PropsWithChildren } from 'react'; - -import { DisplayError } from 'src/core/errorHandling/DisplayError'; -import { Loader } from 'src/core/loading/Loader'; -import { useCurrentDataModelUrl } from 'src/features/datamodel/useBindingSchema'; -import { usePageSettings } from 'src/features/form/layoutSettings/LayoutSettingsContext'; -import { FormDataWriteProvider } from 'src/features/formData/FormDataWrite'; -import { useFormDataQuery } from 'src/features/formData/useFormDataQuery'; -import { MissingRolesError } from 'src/features/instantiate/containers/MissingRolesError'; -import { useCurrentLanguage } from 'src/features/language/LanguageProvider'; -import { isAxiosError } from 'src/utils/isAxiosError'; -import { HttpStatusCodes } from 'src/utils/network/networking'; -import { getUrlWithLanguage } from 'src/utils/urls/urlHelper'; - -/** - * This provider loads the initial form data for a data task, and then provides a FormDataWriteProvider with that - * initial data. When this is provided, you'll have the tools needed to read/write form data. - */ -export function InitialFormDataProvider({ children }: PropsWithChildren) { - const url = useCurrentDataModelUrl(true); - const { error, isFetching, data } = useFormDataQuery(getUrlWithLanguage(url, useCurrentLanguage())); - const autoSaveBehaviour = usePageSettings().autoSaveBehavior; - - if (!url) { - throw new Error('InitialFormDataProvider cannot be provided without a url'); - } - - if (error) { - // Error trying to fetch data, if missing rights we display relevant page - if (isAxiosError(error) && error.response?.status === HttpStatusCodes.Forbidden) { - return ; - } - - return ; - } - - if (isFetching) { - return ; - } - - return ( - - {children} - - ); -} diff --git a/src/features/formData/LegacyRules.ts b/src/features/formData/LegacyRules.ts index 364be0aa8c..7945e2f8d3 100644 --- a/src/features/formData/LegacyRules.ts +++ b/src/features/formData/LegacyRules.ts @@ -8,7 +8,12 @@ import type { FDNewValue } from 'src/features/formData/FormDataWriteStateMachine * This function has been copied from checkIfRuleShouldRun() and modified to work with the new formData feature. * It runs the legacy rules after a field has been updated. */ -export function runLegacyRules(ruleConnections: IRuleConnections | null, oldFormData: object, newFormData: object) { +export function runLegacyRules( + ruleConnections: IRuleConnections | null, + oldFormData: object, + newFormData: object, + dataType: string, +) { const changes: FDNewValue[] = []; if (!ruleConnections) { return changes; @@ -63,7 +68,7 @@ export function runLegacyRules(ruleConnections: IRuleConnections | null, oldForm if (updatedDataBinding) { changes.push({ - path: updatedDataBinding, + reference: { field: updatedDataBinding, dataType }, newValue: result, }); } diff --git a/src/features/formData/types.ts b/src/features/formData/types.ts index 02352b0bae..1438365f41 100644 --- a/src/features/formData/types.ts +++ b/src/features/formData/types.ts @@ -29,3 +29,13 @@ export interface IDataModelPatchResponse { validationIssues: BackendValidationIssueGroups; newDataModel: object; } + +export interface IDataModelMultiPatchRequest { + patches: { [dataElementId: string]: JsonPatch }; + ignoredValidators: BuiltInValidationIssueSources[]; +} + +export interface IDataModelMultiPatchResponse { + validationIssues: BackendValidationIssueGroups; + newDataModels: { [dataElementId: string]: object }; +} diff --git a/src/features/formData/useDataModelBindings.test.tsx b/src/features/formData/useDataModelBindings.test.tsx index ed23d88e15..7340001afa 100644 --- a/src/features/formData/useDataModelBindings.test.tsx +++ b/src/features/formData/useDataModelBindings.test.tsx @@ -4,6 +4,8 @@ import { afterAll, beforeAll, jest } from '@jest/globals'; import { screen, waitFor } from '@testing-library/react'; import { userEvent } from '@testing-library/user-event'; +import { defaultDataTypeMock } from 'src/__mocks__/getLayoutSetsMock'; +import { FD } from 'src/features/formData/FormDataWrite'; import { useDataModelBindings } from 'src/features/formData/useDataModelBindings'; import { renderWithInstanceAndLayout } from 'src/test/renderWithProviders'; import type { IDataModelPatchResponse } from 'src/features/formData/types'; @@ -21,11 +23,12 @@ describe('useDataModelBindings', () => { const renderCount = useRef(0); renderCount.current++; - const { formData, setValue, setValues, isValid, debounce } = useDataModelBindings({ - stringy: 'stringyField', - decimal: 'decimalField', - integer: 'integerField', - boolean: 'booleanField', + const debounce = FD.useDebounceImmediately(); + const { formData, setValue, setValues, isValid } = useDataModelBindings({ + stringy: { field: 'stringyField', dataType: defaultDataTypeMock }, + decimal: { field: 'decimalField', dataType: defaultDataTypeMock }, + integer: { field: 'integerField', dataType: defaultDataTypeMock }, + boolean: { field: 'booleanField', dataType: defaultDataTypeMock }, }); return ( @@ -130,7 +133,7 @@ describe('useDataModelBindings', () => { expect(screen.getByTestId('isValid-stringy')).toHaveTextContent('yes'); expect(formDataMethods.setLeafValue).toHaveBeenCalledWith({ - path: 'stringyField', + reference: { field: 'stringyField', dataType: defaultDataTypeMock }, newValue: fooBar, }); expect(formDataMethods.setLeafValue).toHaveBeenCalledTimes(fooBar.length); @@ -146,7 +149,7 @@ describe('useDataModelBindings', () => { expect(formDataMethods.setLeafValue).toHaveBeenCalledTimes(1); expect(formDataMethods.setLeafValue).toHaveBeenCalledWith({ - path: 'decimalField', + reference: { field: 'decimalField', dataType: defaultDataTypeMock }, newValue: '-', }); @@ -176,7 +179,7 @@ describe('useDataModelBindings', () => { expect(screen.getByTestId('isValid-decimal')).toHaveTextContent('yes'); expect(formDataMethods.setLeafValue).toHaveBeenCalledWith({ - path: 'decimalField', + reference: { field: 'decimalField', dataType: defaultDataTypeMock }, newValue: '-1.53', // Inputs are passed as strings }); expect(formDataMethods.setLeafValue).toHaveBeenCalledTimes(fullDecimal.length); @@ -193,7 +196,7 @@ describe('useDataModelBindings', () => { expect(formDataMethods.setLeafValue).toHaveBeenCalledTimes(1); expect(formDataMethods.setLeafValue).toHaveBeenCalledWith({ - path: 'integerField', + reference: { field: 'integerField', dataType: defaultDataTypeMock }, newValue: '-', }); @@ -223,7 +226,7 @@ describe('useDataModelBindings', () => { expect(screen.getByTestId('isValid-integer')).toHaveTextContent('yes'); expect(formDataMethods.setLeafValue).toHaveBeenCalledWith({ - path: 'integerField', + reference: { field: 'integerField', dataType: defaultDataTypeMock }, newValue: '-153', // Inputs are passed as strings }); @@ -246,7 +249,7 @@ describe('useDataModelBindings', () => { expect(screen.getByTestId('isValid-boolean')).toHaveTextContent('yes'); expect(formDataMethods.setLeafValue).toHaveBeenCalledWith({ - path: 'booleanField', + reference: { field: 'booleanField', dataType: defaultDataTypeMock }, newValue: 'true', // Inputs are passed as strings }); expect(formDataMethods.setLeafValue).toHaveBeenCalledTimes(4); diff --git a/src/features/formData/useDataModelBindings.ts b/src/features/formData/useDataModelBindings.ts index 98edee6a62..ebdb67b94e 100644 --- a/src/features/formData/useDataModelBindings.ts +++ b/src/features/formData/useDataModelBindings.ts @@ -5,7 +5,7 @@ import { DEFAULT_DEBOUNCE_TIMEOUT } from 'src/features/formData/types'; import { useMemoDeepEqual } from 'src/hooks/useStateDeepEqual'; import type { FDLeafValue } from 'src/features/formData/FormDataWrite'; import type { FDNewValue } from 'src/features/formData/FormDataWriteStateMachine'; -import type { SaveWhileTyping } from 'src/layout/common.generated'; +import type { IDataModelReference, SaveWhileTyping } from 'src/layout/common.generated'; import type { IDataModelBindings } from 'src/layout/layout'; // Describes how you want the data to be returned from the useDataModelBindings hook. Usually, if you're @@ -18,13 +18,12 @@ type DataAs = 'raw' | 'string'; type DataType = DA extends 'raw' ? unknown : string; interface Output { formData: B extends undefined ? Record : { [key in keyof B]: DataType }; - debounce: () => void; setValue: (key: keyof Exclude, value: FDLeafValue) => void; setValues: (values: Partial<{ [key in keyof B]: FDLeafValue }>) => void; isValid: { [key in keyof B]: boolean }; } -type SaveOptions = Omit; +type SaveOptions = Omit; const defaultBindings = {}; @@ -42,7 +41,6 @@ export function useDataModelBindings setLeafValue({ - path: bindings[key] as string, + reference: bindings[key] as IDataModelReference, newValue, ...saveOptions, }), @@ -71,7 +69,7 @@ export function useDataModelBindings { newValues.push({ - path: bindings[key as keyof B] as string, + reference: bindings[key as keyof B] as IDataModelReference, newValue: value as FDLeafValue, }); }); @@ -84,7 +82,7 @@ export function useDataModelBindings ({ formData: formData as Output['formData'], debounce, setValue, setValues, isValid }), - [debounce, formData, isValid, setValue, setValues], + () => ({ formData: formData as Output['formData'], setValue, setValues, isValid }), + [formData, isValid, setValue, setValues], ); } diff --git a/src/features/formData/useFormDataQuery.tsx b/src/features/formData/useFormDataQuery.tsx index e64ea54f91..4e8a555f45 100644 --- a/src/features/formData/useFormDataQuery.tsx +++ b/src/features/formData/useFormDataQuery.tsx @@ -6,29 +6,31 @@ import type { AxiosRequestConfig } from 'axios'; import { useAppQueries } from 'src/core/contexts/AppQueriesProvider'; import { type QueryDefinition } from 'src/core/queries/usePrefetchQuery'; import { useApplicationMetadata } from 'src/features/applicationMetadata/ApplicationMetadataProvider'; -import { useLaxProcessData } from 'src/features/instance/ProcessContext'; import { useCurrentParty } from 'src/features/party/PartiesProvider'; +import { useMemoDeepEqual } from 'src/hooks/useStateDeepEqual'; import { isAxiosError } from 'src/utils/isAxiosError'; import { maybeAuthenticationRedirect } from 'src/utils/maybeAuthenticationRedirect'; -export function useFormDataQueryDef( - cacheKeyUrl?: string, - currentTaskId?: string, - url?: string, - options?: AxiosRequestConfig, - // eslint-disable-next-line @typescript-eslint/no-explicit-any -): QueryDefinition { +export function useFormDataQueryDef(url: string | undefined): QueryDefinition { const { fetchFormData } = useAppQueries(); + const queryKey = useFormDataQueryKey(url); + const options = useFormDataQueryOptions(); return { - queryKey: ['fetchFormData', cacheKeyUrl, currentTaskId], + queryKey, queryFn: url ? () => fetchFormData(url, options) : skipToken, enabled: !!url, - gcTime: 0, - staleTime: 0, refetchInterval: false, }; } +export function useFormDataQueryKey(url: string | undefined) { + return useMemoDeepEqual(() => getFormDataQueryKey(url), [url]); +} + +export function getFormDataQueryKey(url: string | undefined) { + return ['fetchFormData', getFormDataCacheKeyUrl(url)]; +} + export function useFormDataQueryOptions() { const currentPartyId = useCurrentParty()?.partyId; const isStateless = useApplicationMetadata().isStatelessApp; @@ -41,27 +43,21 @@ export function useFormDataQueryOptions() { return options; } -// We dont want to include the current language in the cacheKey url +// We dont want to include the current language in the cacheKey url, but for stateless we still need to keep +// the 'dataType' query parameter in the cacheKey url to avoid caching issues. export function getFormDataCacheKeyUrl(url: string | undefined) { - return url ? new URL(url).pathname : undefined; + if (!url) { + return undefined; + } + const urlObj = new URL(url); + const searchParams = new URLSearchParams(urlObj.search); + searchParams.delete('language'); + return `${urlObj.pathname}?${searchParams.toString()}`; } export function useFormDataQuery(url: string | undefined) { - // We also add the current task id to the query key, so that the query is re-fetched when the task changes. This - // is needed because we provide this query two different places: - // 1. In the to fetch the initial form data for a task. At that point forwards, the - // form data is managed by the , which will maintain an updated copy of the form data. - // 2. In the to fetch the form data used in text resource variable lookups on-demand. This - // reads the data model, assumes it doesn't really change, and caches it indefinitely. So, if you start at Task_1 - // and then navigate to Task_2, the form data fetched during Task_1 may still be used in Task_2 unless evicted - // from the cache by using a different query key. - const options = useFormDataQueryOptions(); - const currentTaskId = useLaxProcessData()?.currentTask?.elementId; - const cacheKeyUrl = getFormDataCacheKeyUrl(url); - - // We dont want to refetch if only the language changes - // const utils = useQuery({ - const utils = useQuery(useFormDataQueryDef(cacheKeyUrl, currentTaskId, url, options)); + const def = useFormDataQueryDef(url); + const utils = useQuery(def); useEffect(() => { if (utils.error && isAxiosError(utils.error)) { diff --git a/src/features/instance/ProcessNavigationContext.tsx b/src/features/instance/ProcessNavigationContext.tsx index e3d0e7e522..8ce456465a 100644 --- a/src/features/instance/ProcessNavigationContext.tsx +++ b/src/features/instance/ProcessNavigationContext.tsx @@ -9,7 +9,7 @@ import { useHasPendingAttachments } from 'src/features/attachments/hooks'; import { useLaxInstance, useStrictInstance } from 'src/features/instance/InstanceContext'; import { useLaxProcessData, useSetProcessData } from 'src/features/instance/ProcessContext'; import { useCurrentLanguage } from 'src/features/language/LanguageProvider'; -import { mapValidationIssueToFieldValidation } from 'src/features/validation/backendValidation/backendValidationUtils'; +import { mapBackendIssuesToTaskValidations } from 'src/features/validation/backendValidation/backendValidationUtils'; import { useOnFormSubmitValidation } from 'src/features/validation/callbacks/onFormSubmitValidation'; import { Validation } from 'src/features/validation/validationContext'; import { useNavigatePage } from 'src/hooks/useNavigatePage'; @@ -65,7 +65,7 @@ function useProcessNext() { setProcessData?.({ ...processData, processTasks: currentProcessData?.processTasks }); navigateToTask(processData?.currentTask?.elementId); } else if (validationIssues && updateTaskValidations !== ContextNotProvided) { - updateTaskValidations(validationIssues.map(mapValidationIssueToFieldValidation)); + updateTaskValidations(mapBackendIssuesToTaskValidations(validationIssues)); } }, onError: (error: HttpClientError) => { diff --git a/src/features/instantiate/InstantiationContext.tsx b/src/features/instantiate/InstantiationContext.tsx index bc90890134..58821c2259 100644 --- a/src/features/instantiate/InstantiationContext.tsx +++ b/src/features/instantiate/InstantiationContext.tsx @@ -38,6 +38,7 @@ const { Provider, useCtx } = createContext({ name: 'Instan function useInstantiateMutation() { const { doInstantiate } = useAppMutations(); + const navigate = useNavigate(); const queryClient = useQueryClient(); return useMutation({ @@ -45,7 +46,8 @@ function useInstantiateMutation() { onError: (error: HttpClientError) => { window.logError('Instantiation failed:\n', error); }, - onSuccess: () => { + onSuccess: (data) => { + navigate(`/instance/${data.id}`); queryClient.invalidateQueries({ queryKey: ['fetchApplicationMetadata'] }); }, }); @@ -53,6 +55,7 @@ function useInstantiateMutation() { function useInstantiateWithPrefillMutation() { const { doInstantiateWithPrefill } = useAppMutations(); + const navigate = useNavigate(); const queryClient = useQueryClient(); return useMutation({ @@ -60,7 +63,8 @@ function useInstantiateWithPrefillMutation() { onError: (error: HttpClientError) => { window.logError('Instantiation with prefill failed:\n', error); }, - onSuccess: () => { + onSuccess: (data) => { + navigate(`/instance/${data.id}`); queryClient.invalidateQueries({ queryKey: ['fetchApplicationMetadata'] }); }, }); @@ -71,21 +75,18 @@ export function InstantiationProvider({ children }: React.PropsWithChildren) { const instantiateWithPrefill = useInstantiateWithPrefillMutation(); const [busyWithId, setBusyWithId] = useState(undefined); const isInstantiatingRef = useRef(false); - const navigate = useNavigate(); // Redirect to the instance page when instantiation completes useEffect(() => { if (instantiate.data?.id) { - navigate(`/instance/${instantiate.data.id}`); setBusyWithId(undefined); isInstantiatingRef.current = false; } if (instantiateWithPrefill.data?.id) { - navigate(`/instance/${instantiateWithPrefill.data.id}`); setBusyWithId(undefined); isInstantiatingRef.current = false; } - }, [instantiate.data?.id, instantiateWithPrefill.data?.id, navigate]); + }, [instantiate.data?.id, instantiateWithPrefill.data?.id]); return ( ; export interface LangDataSources extends LimitedTextResourceVariablesDataSources { textResources: TextResourceMap; diff --git a/src/features/language/useLanguage.ts b/src/features/language/useLanguage.ts index 9d5aa9da59..15f6a2aced 100644 --- a/src/features/language/useLanguage.ts +++ b/src/features/language/useLanguage.ts @@ -2,8 +2,7 @@ import { Children, isValidElement, useCallback, useMemo } from 'react'; import type { JSX, ReactNode } from 'react'; import { ContextNotProvided } from 'src/core/contexts/context'; -import { useDataTypeByLayoutSetId } from 'src/features/applicationMetadata/appMetadataUtils'; -import { useCurrentLayoutSetId } from 'src/features/form/layoutSets/useCurrentLayoutSetId'; +import { DataModels } from 'src/features/datamodel/DataModelsProvider'; import { DataModelReaders } from 'src/features/formData/FormDataReaders'; import { FD } from 'src/features/formData/FormDataWrite'; import { Lang } from 'src/features/language/Lang'; @@ -15,7 +14,7 @@ import { getKeyWithoutIndexIndicators } from 'src/utils/databindings'; import { transposeDataBinding } from 'src/utils/databindings/DataBinding'; import { smartLowerCaseFirst } from 'src/utils/formComponentUtils'; import { useDataModelBindingTranspose } from 'src/utils/layout/useDataModelBindingTranspose'; -import type { useDataModelReaders } from 'src/features/formData/FormDataReaders'; +import type { DataModelReader, useDataModelReaders } from 'src/features/formData/FormDataReaders'; import type { LangDataSources, LimitedTextResourceVariablesDataSources, @@ -23,6 +22,7 @@ import type { import type { TextResourceMap } from 'src/features/language/textResources'; import type { FixedLanguageList, NestedTexts } from 'src/language/languages'; import type { FormDataSelector } from 'src/layout'; +import type { IDataModelReference } from 'src/layout/common.generated'; import type { IApplicationSettings, IInstanceDataSources, ILanguage, IVariable } from 'src/types/shared'; import type { LayoutNode } from 'src/utils/layout/LayoutNode'; import type { DataModelTransposeSelector } from 'src/utils/layout/useDataModelBindingTranspose'; @@ -44,13 +44,13 @@ export interface IUseLanguage { langAsString(key: ValidLanguageKey | string | undefined, params?: ValidLangParam[], makeLowerCase?: boolean): string; langAsStringUsingPathInDataModel( key: ValidLanguageKey | string | undefined, - dataModelPath: string, + dataModelPath: IDataModelReference, params?: ValidLangParam[], ): string; langAsNonProcessedString(key: ValidLanguageKey | string | undefined, params?: ValidLangParam[]): string; langAsNonProcessedStringUsingPathInDataModel( key: ValidLanguageKey | string | undefined, - dataModelPath: string, + dataModelPath: IDataModelReference, params?: ValidLangParam[], ): string; elementAsString(element: ReactNode): string; @@ -60,10 +60,11 @@ export interface TextResourceVariablesDataSources { node: LayoutNode | undefined; applicationSettings: IApplicationSettings | null; instanceDataSources: IInstanceDataSources | null; - dataModelPath?: string; + dataModelPath?: IDataModelReference; dataModels: ReturnType; - currentDataModelName: string | undefined; - currentDataModel: FormDataSelector | typeof ContextNotProvided; + defaultDataType: string | undefined | typeof ContextNotProvided; + formDataTypes: string[] | typeof ContextNotProvided; + formDataSelector: FormDataSelector | typeof ContextNotProvided; transposeSelector: DataModelTransposeSelector; } @@ -102,9 +103,9 @@ export function useLanguage(node?: LayoutNode) { export function useLanguageWithForcedNode(node: LayoutNode | undefined) { const sources = useLangToolsDataSources(); - const layoutSetId = useCurrentLayoutSetId(); - const currentDataModelName = useDataTypeByLayoutSetId(layoutSetId); - const currentDataModel = FD.useLaxDebouncedSelector(); + const defaultDataType = DataModels.useLaxDefaultDataType(); + const formDataTypes = DataModels.useLaxReadableDataTypes(); + const formDataSelector = FD.useLaxDebouncedSelector(); const transposeSelector = useDataModelBindingTranspose(); return useMemo(() => { @@ -116,19 +117,20 @@ export function useLanguageWithForcedNode(node: LayoutNode | undefined) { return staticUseLanguage(textResources, language, selectedLanguage, { ...(dataSources as LimitedTextResourceVariablesDataSources), node, - currentDataModel, - currentDataModelName, + defaultDataType, + formDataTypes, + formDataSelector, transposeSelector, }); - }, [currentDataModel, currentDataModelName, node, transposeSelector, sources]); + }, [sources, node, defaultDataType, formDataTypes, formDataSelector, transposeSelector]); } // Exactly the same as above, but returns a function accepting a node export function useLanguageWithForcedNodeSelector() { const sources = useLangToolsDataSources(); - const layoutSetId = useCurrentLayoutSetId(); - const currentDataModelName = useDataTypeByLayoutSetId(layoutSetId); - const currentDataModel = FD.useLaxDebouncedSelector(); + const defaultDataType = DataModels.useLaxDefaultDataType(); + const formDataTypes = DataModels.useLaxReadableDataTypes(); + const formDataSelector = FD.useLaxDebouncedSelector(); const transposeSelector = useDataModelBindingTranspose(); return useCallback( @@ -141,12 +143,13 @@ export function useLanguageWithForcedNodeSelector() { return staticUseLanguage(textResources, language, selectedLanguage, { ...dataSources, node, - currentDataModel, - currentDataModelName, + defaultDataType, + formDataTypes, + formDataSelector, transposeSelector, }); }, - [currentDataModel, currentDataModelName, sources, transposeSelector], + [defaultDataType, formDataSelector, formDataTypes, sources, transposeSelector], ); } @@ -315,8 +318,9 @@ function replaceVariables(text: string, variables: IVariable[], dataSources: Tex instanceDataSources, applicationSettings, dataModelPath, - currentDataModelName, - currentDataModel, + defaultDataType, + formDataTypes, + formDataSelector, transposeSelector, } = dataSources; let out = text; @@ -327,42 +331,59 @@ function replaceVariables(text: string, variables: IVariable[], dataSources: Tex if (variable.dataSource.startsWith('dataModel')) { const dataModelName = variable.dataSource.split('.')[1]; const cleanPath = getKeyWithoutIndexIndicators(value); - const transposedPath = dataModelPath - ? transposeDataBinding({ subject: cleanPath, currentLocation: dataModelPath }) - : node - ? transposeSelector(node, cleanPath) - : value; - if (transposedPath) { - // If the data model is the current one, look up there - const modelReader = - dataModelName === 'default' || dataModelName === currentDataModelName - ? undefined - : dataModels.getReader(dataModelName); - const readValue = modelReader - ? modelReader.getAsString(transposedPath) - : currentDataModel === ContextNotProvided - ? undefined - : currentDataModel(transposedPath); - const stringValue = - typeof readValue === 'string' || typeof readValue === 'number' || typeof readValue === 'boolean' - ? readValue.toString() - : undefined; - const hasDefaultValue = variable.defaultValue !== undefined && variable.defaultValue !== null; - - if (stringValue !== undefined) { - value = stringValue; - } else if (modelReader && modelReader.isLoading()) { - value = '...'; // TODO: Use a loading indicator, or at least let this value be configurable - } else if (dataModelName === 'default' && !hasDefaultValue) { - window.logWarnOnce( - `A text resource variable with key '${variable.key}' did not exist in the default data model. ` + - `You should provide a specific data model name instead, and/or set a defaultValue.`, - ); - } else if (modelReader && modelReader.hasError() && !hasDefaultValue) { - window.logWarnOnce( - `A text resource variable with key '${variable.key}' did not exist in the data model '${dataModelName}'. ` + - `You may want to set a defaultValue to prevent the full key from being presented to the user.`, - ); + + const dataTypeToRead = + dataModelName === 'default' + ? typeof defaultDataType === 'string' + ? defaultDataType + : undefined + : dataModelName; + + if (dataTypeToRead) { + const rawReference: IDataModelReference = { + dataType: dataTypeToRead, + field: cleanPath, + }; + + const transposed = dataModelPath + ? transposeDataBinding({ subject: rawReference, currentLocation: dataModelPath }) + : node + ? transposeSelector(node, rawReference) + : { dataType: dataTypeToRead, field: value }; + if (transposed) { + let readValue: unknown = undefined; + let modelReader: DataModelReader | undefined = undefined; + + const dataFromDataModel = tryReadFromDataModel(transposed, formDataTypes, formDataSelector); + + if (dataFromDataModel !== dataModelNotReadable) { + readValue = dataFromDataModel; + } else { + modelReader = dataModels.getReader(dataModelName); + readValue = modelReader.getAsString(transposed); + } + + const stringValue = + typeof readValue === 'string' || typeof readValue === 'number' || typeof readValue === 'boolean' + ? readValue.toString() + : undefined; + const hasDefaultValue = variable.defaultValue !== undefined && variable.defaultValue !== null; + + if (stringValue !== undefined) { + value = stringValue; + } else if (modelReader && modelReader.isLoading()) { + value = '...'; // TODO: Use a loading indicator, or at least let this value be configurable + } else if (dataModelName === 'default' && !hasDefaultValue) { + window.logWarnOnce( + `A text resource variable with key '${variable.key}' did not exist in the default data model. ` + + `You should provide a specific data model name instead, and/or set a defaultValue.`, + ); + } else if (modelReader && modelReader.hasError() && !hasDefaultValue) { + window.logWarnOnce( + `A text resource variable with key '${variable.key}' did not exist in the data model '${dataModelName}'. ` + + `You may want to set a defaultValue to prevent the full key from being presented to the user.`, + ); + } } } } else if (variable.dataSource === 'instanceContext') { @@ -389,6 +410,24 @@ function replaceVariables(text: string, variables: IVariable[], dataSources: Tex return out; } + +const dataModelNotReadable = Symbol('dataModelNotReadable'); +function tryReadFromDataModel( + reference: IDataModelReference, + formDataTypes: string[] | typeof ContextNotProvided, + formDataSelector: FormDataSelector | typeof ContextNotProvided, +): unknown | typeof dataModelNotReadable { + const { dataType: dataModelName, field: path } = reference; + if ( + formDataSelector === ContextNotProvided || + formDataTypes === ContextNotProvided || + !formDataTypes.includes(dataModelName) + ) { + return dataModelNotReadable; + } + return formDataSelector({ dataType: dataModelName, field: path }); +} + const replaceParameters = (nameString: string, params: SimpleLangParam[]) => { if (nameString === undefined) { return nameString; @@ -440,8 +479,9 @@ export function staticUseLanguageForTests({ instanceOwnerPartyType: 'person', }, dataModels: new DataModelReaders({}), - currentDataModelName: undefined, - currentDataModel: () => null, + defaultDataType: undefined, + formDataTypes: [], + formDataSelector: () => null, applicationSettings: {}, node: undefined, transposeSelector: (_node, path) => path, diff --git a/src/features/options/evalQueryParameters.ts b/src/features/options/evalQueryParameters.ts new file mode 100644 index 0000000000..b84c251f18 --- /dev/null +++ b/src/features/options/evalQueryParameters.ts @@ -0,0 +1,39 @@ +import { evalExpr } from 'src/features/expressions'; +import { type ExprResolved, ExprVal } from 'src/features/expressions/types'; +import type { IQueryParameters } from 'src/layout/common.generated'; +import type { CompWithBehavior } from 'src/layout/layout'; +import type { ExprResolver } from 'src/layout/LayoutComponent'; +import type { LayoutNode } from 'src/utils/layout/LayoutNode'; +import type { ExpressionDataSources } from 'src/utils/layout/useExpressionDataSources'; + +export function evalQueryParameters(props: ExprResolver<'List'>) { + if (!props.item.queryParameters) { + return undefined; + } + + const { evalStr } = props; + const out = { ...props.item.queryParameters } as ExprResolved; + for (const [key, value] of Object.entries(out)) { + out[key] = evalStr(value, ''); + } + return out; +} + +export function resolveQueryParameters( + queryParameters: IQueryParameters | undefined, + node: LayoutNode>, + dataSources: ExpressionDataSources, +) { + return queryParameters + ? Object.entries(queryParameters).reduce((obj, [key, expr]) => { + obj[key] = evalExpr(expr, node, dataSources, { + config: { + returnType: ExprVal.String, + defaultValue: '', + }, + errorIntroText: `Invalid expression for component '${node.baseId}'`, + }); + return obj; + }, {}) + : undefined; +} diff --git a/src/features/options/useGetOptions.test.tsx b/src/features/options/useGetOptions.test.tsx index b62fba974a..378270c8a3 100644 --- a/src/features/options/useGetOptions.test.tsx +++ b/src/features/options/useGetOptions.test.tsx @@ -5,6 +5,7 @@ import { screen } from '@testing-library/react'; import { userEvent } from '@testing-library/user-event'; import type { AxiosResponse } from 'axios'; +import { defaultDataTypeMock } from 'src/__mocks__/getLayoutSetsMock'; import { ALTINN_ROW_ID } from 'src/features/formData/types'; import { useGetOptions } from 'src/features/options/useGetOptions'; import { renderWithNode } from 'src/test/renderWithProviders'; @@ -66,7 +67,7 @@ async function render(props: RenderProps) { type: props.type === 'single' ? 'Dropdown' : 'MultipleSelect', id: 'myComponent', dataModelBindings: { - simpleBinding: 'result', + simpleBinding: { dataType: defaultDataTypeMock, field: 'result' }, }, textResourceBindings: { title: 'mockTitle', @@ -147,7 +148,7 @@ describe('useGetOptions', () => { for (const option of options) { await userEvent.click(screen.getByRole('button', { name: `Choose ${option.label} option` })); expect(formDataMethods.setLeafValue).toHaveBeenCalledWith({ - path: 'result', + reference: { field: 'result', dataType: defaultDataTypeMock }, newValue: option.value.toString(), }); (formDataMethods.setLeafValue as jest.Mock).mockClear(); diff --git a/src/features/options/useGetOptions.ts b/src/features/options/useGetOptions.ts index b513ac418b..03d7d4e756 100644 --- a/src/features/options/useGetOptions.ts +++ b/src/features/options/useGetOptions.ts @@ -4,10 +4,12 @@ import { useDataModelBindings } from 'src/features/formData/useDataModelBindings import { useCurrentLanguage } from 'src/features/language/LanguageProvider'; import { useLanguage } from 'src/features/language/useLanguage'; import { castOptionsToStrings } from 'src/features/options/castOptionsToStrings'; +import { resolveQueryParameters } from 'src/features/options/evalQueryParameters'; import { useGetOptionsQuery } from 'src/features/options/useGetOptionsQuery'; import { useNodeOptions } from 'src/features/options/useNodeOptions'; import { useSourceOptions } from 'src/hooks/useSourceOptions'; import { Hidden, NodesInternal } from 'src/utils/layout/NodesContext'; +import { useExpressionDataSources } from 'src/utils/layout/useExpressionDataSources'; import { useNodeItem } from 'src/utils/layout/useNodeItem'; import { filterDuplicateOptions, verifyOptions } from 'src/utils/options'; import type { IUseLanguage } from 'src/features/language/useLanguage'; @@ -205,6 +207,10 @@ function useRemoveStaleValues(props: EffectProps) { export function useFetchOptions({ node, valueType, item }: FetchOptionsProps): GetOptionsResult { const { options, optionsId, secure, source, mapping, queryParameters, sortOrder, dataModelBindings } = item; + + const dataSources = useExpressionDataSources(); + const resolvedQueryParameters = resolveQueryParameters(queryParameters, node, dataSources); + const preselectedOptionIndex = 'preselectedOptionIndex' in item ? item.preselectedOptionIndex : undefined; const { langAsString } = useLanguage(); const selectedLanguage = useCurrentLanguage(); @@ -213,7 +219,11 @@ export function useFetchOptions({ node, valueType, item }: FetchOptionsProps): G const sourceOptions = useSourceOptions({ source, node }); const staticOptions = useMemo(() => (optionsId ? undefined : castOptionsToStrings(options)), [options, optionsId]); - const { data: fetchedOptions, isFetching, isError } = useGetOptionsQuery(optionsId, mapping, queryParameters, secure); + const { + data: fetchedOptions, + isFetching, + isError, + } = useGetOptionsQuery(optionsId, mapping, resolvedQueryParameters, secure); const isNodeHidden = Hidden.useIsHidden(node); const isNodesReady = NodesInternal.useIsReady(); diff --git a/src/features/pdf/usePdfFormatQuery.ts b/src/features/pdf/usePdfFormatQuery.ts index 478c09217c..a5ab8d35f4 100644 --- a/src/features/pdf/usePdfFormatQuery.ts +++ b/src/features/pdf/usePdfFormatQuery.ts @@ -24,6 +24,13 @@ export function usePdfFormatQueryDef( }; } +/** + * This exists to suport the legacy IPdfFormatter interface which was used with the old PDF generator to make it easier to migrate from the old one. + * The IPdfFormatter interface is marked as obsolete in app-lib v8+ and can therefore be considered to be deprecated in frontend v4 as well. + * For some reason, the API requires the dataGuid of the data element for the current task instead of the task id. This therefore uses the default data model (from layout-sets), + * and does not care about any additional data models. + * @deprecated should be removed in the next major version + */ export const usePdfFormatQuery = (enabled: boolean): UseQueryResult => { const instanceId = useLaxInstance()?.instanceId; const dataGuid = useCurrentDataModelGuid(); diff --git a/src/features/validation/backendValidation/BackendValidation.tsx b/src/features/validation/backendValidation/BackendValidation.tsx new file mode 100644 index 0000000000..dbc4e2e1db --- /dev/null +++ b/src/features/validation/backendValidation/BackendValidation.tsx @@ -0,0 +1,77 @@ +import { useEffect, useMemo, useRef } from 'react'; + +import deepEqual from 'fast-deep-equal'; + +import { DataModels } from 'src/features/datamodel/DataModelsProvider'; +import { FD } from 'src/features/formData/FormDataWrite'; +import { + type BackendFieldValidatorGroups, + type BuiltInValidationIssueSources, + IgnoredValidators, +} from 'src/features/validation'; +import { + mapBackendIssuesToFieldValdiations, + mapValidatorGroupsToDataModelValidations, +} from 'src/features/validation/backendValidation/backendValidationUtils'; +import { Validation } from 'src/features/validation/validationContext'; + +export function BackendValidation({ dataTypes }: { dataTypes: string[] }) { + const updateBackendValidations = Validation.useUpdateBackendValidations(); + const getDataTypeForElementId = DataModels.useGetDataTypeForDataElementId(); + const lastSaveValidations = FD.useLastSaveValidationIssues(); + + // Map initial validations + const initialValidations = DataModels.useInitialValidations(); + const initialValidatorGroups: BackendFieldValidatorGroups = useMemo(() => { + if (!initialValidations) { + return {}; + } + // Note that we completely ignore task validations (validations not related to form data) on initial validations, + // this is because validations like minimum number of attachments in application metadata is not really useful to show initially + const fieldValidations = mapBackendIssuesToFieldValdiations(initialValidations, getDataTypeForElementId); + const validatorGroups: BackendFieldValidatorGroups = {}; + for (const validation of fieldValidations) { + // Do not include ignored ignored validators in initial validations + if (IgnoredValidators.includes(validation.source as BuiltInValidationIssueSources)) { + continue; + } + + if (!validatorGroups[validation.source]) { + validatorGroups[validation.source] = []; + } + validatorGroups[validation.source].push(validation); + } + return validatorGroups; + }, [getDataTypeForElementId, initialValidations]); + + // Initial validation + useEffect(() => { + const backendValidations = mapValidatorGroupsToDataModelValidations(initialValidatorGroups, dataTypes); + updateBackendValidations(backendValidations, initialValidatorGroups); + }, [dataTypes, initialValidatorGroups, updateBackendValidations]); + + const validatorGroups = useRef(initialValidatorGroups); + + // Incremental validation: Update validators and propagate changes to validationcontext + useEffect(() => { + if (lastSaveValidations) { + const newValidatorGroups = structuredClone(validatorGroups.current); + + for (const [group, validationIssues] of Object.entries(lastSaveValidations)) { + newValidatorGroups[group] = mapBackendIssuesToFieldValdiations(validationIssues, getDataTypeForElementId); + } + + if (deepEqual(validatorGroups.current, newValidatorGroups)) { + // Dont update any validations, only set last saved validations + updateBackendValidations(undefined, lastSaveValidations); + return; + } + + validatorGroups.current = newValidatorGroups; + const backendValidations = mapValidatorGroupsToDataModelValidations(validatorGroups.current, dataTypes); + updateBackendValidations(backendValidations, lastSaveValidations); + } + }, [dataTypes, getDataTypeForElementId, lastSaveValidations, updateBackendValidations]); + + return null; +} diff --git a/src/features/validation/backendValidation/backendValidationQuery.ts b/src/features/validation/backendValidation/backendValidationQuery.ts new file mode 100644 index 0000000000..8c449830e0 --- /dev/null +++ b/src/features/validation/backendValidation/backendValidationQuery.ts @@ -0,0 +1,44 @@ +import { useEffect } from 'react'; + +import { useQuery } from '@tanstack/react-query'; + +import type { BackendValidationIssue } from '..'; + +import { useAppQueries } from 'src/core/contexts/AppQueriesProvider'; +import { useLaxInstance } from 'src/features/instance/InstanceContext'; +import { useLaxProcessData } from 'src/features/instance/ProcessContext'; +import { useCurrentLanguage } from 'src/features/language/LanguageProvider'; +import type { QueryDefinition } from 'src/core/queries/usePrefetchQuery'; + +// Also used for prefetching @see formPrefetcher.ts +export function useBackendValidationQueryDef( + enabled: boolean, + currentLanguage: string, + instanceId?: string, + currentTaskId?: string, +): QueryDefinition { + const { fetchBackendValidations } = useAppQueries(); + return { + queryKey: ['validation', instanceId, currentTaskId, enabled], + queryFn: instanceId ? () => fetchBackendValidations(instanceId, currentLanguage) : () => [], + enabled, + gcTime: 0, + }; +} + +export function useBackendValidationQuery(enabled: boolean) { + const currentLanguage = useCurrentLanguage(); + const instance = useLaxInstance(); + const instanceId = instance?.instanceId; + const currentProcessTaskId = useLaxProcessData()?.currentTask?.elementId; + + const utils = useQuery({ + ...useBackendValidationQueryDef(enabled, currentLanguage, instanceId, currentProcessTaskId), + }); + + useEffect(() => { + utils.error && window.logError('Fetching initial validations failed:\n', utils.error); + }, [utils.error]); + + return utils; +} diff --git a/src/features/validation/backendValidation/backendValidationUtils.ts b/src/features/validation/backendValidation/backendValidationUtils.ts index 6aa80b4567..cb48bb2baf 100644 --- a/src/features/validation/backendValidation/backendValidationUtils.ts +++ b/src/features/validation/backendValidation/backendValidationUtils.ts @@ -1,5 +1,5 @@ -import { useCurrentDataModelGuid } from 'src/features/datamodel/useBindingSchema'; -import { useLaxInstance } from 'src/features/instance/InstanceContext'; +import { useApplicationMetadata } from 'src/features/applicationMetadata/ApplicationMetadataProvider'; +import { DataModels } from 'src/features/datamodel/DataModelsProvider'; import { useProcessTaskId } from 'src/features/instance/useProcessTaskId'; import { BackendValidationSeverity, BuiltInValidationIssueSources, ValidationMask } from 'src/features/validation'; import { validationTexts } from 'src/features/validation/backendValidation/validationTexts'; @@ -7,8 +7,10 @@ import { useIsPdf } from 'src/hooks/useIsPdf'; import { TaskKeys } from 'src/hooks/useNavigatePage'; import type { TextReference } from 'src/features/language/useLanguage'; import type { + BackendFieldValidatorGroups, BackendValidationIssue, BaseValidation, + DataModelValidations, FieldValidation, ValidationSeverity, } from 'src/features/validation'; @@ -26,46 +28,85 @@ const severityMap: { [s in BackendValidationSeverity]: ValidationSeverity } = { export function useShouldValidateInitial(): boolean { const isCustomReceipt = useProcessTaskId() === TaskKeys.CustomReceipt; const isPDF = useIsPdf(); - const shouldLoadValidations = !isCustomReceipt && !isPDF; - - const instance = useLaxInstance(); - const currentDataElementId = useCurrentDataModelGuid(); - const isDataElementLocked = instance?.data?.data.find((el) => el.id === currentDataElementId)?.locked; - - return shouldLoadValidations && !isDataElementLocked; + const isStateless = useApplicationMetadata().isStatelessApp; + const writableDataTypes = DataModels.useWritableDataTypes(); + return !isCustomReceipt && !isPDF && !isStateless && !!writableDataTypes?.length; } export function getValidationIssueSeverity(issue: BackendValidationIssue): ValidationSeverity { return severityMap[issue.severity]; } -export function mapValidationIssueToFieldValidation(issue: BackendValidationIssue): BaseValidation | FieldValidation { - const { field, source } = issue; - const severity = getValidationIssueSeverity(issue); - const message = getValidationIssueMessage(issue); - - /** - * Identify category (visibility mask) - * Standard validation sources should use the Backend mask - * Custom backend validations should use the CustomBackend mask - */ - let category: number = ValidationMask.Backend; - if (!Object.values(BuiltInValidationIssueSources).includes(source)) { - if (issue.showImmediately) { - category = 0; - } else if (issue.actLikeRequired) { - category = ValidationMask.Required; - } else { - category = ValidationMask.CustomBackend; +/** + * Checks the source field of a validation issue to determine if it is a standard backend validation + * which is already covered by frontend validation and should therefore not be shown as it would be a duplicate + */ +function isStandardBackend(rawSource: string): boolean { + const source = rawSource.includes('+') ? rawSource.split('+')[0] : rawSource; + return Object.values(BuiltInValidationIssueSources).includes(source); +} + +/** + * Extracts field validations from a list of validation issues and assigns the correct data type based on the dataElementId + * Will skip over any validations that are missing a field and/or dataElementId + */ +export function mapBackendIssuesToFieldValdiations( + issues: BackendValidationIssue[], + getDataTypeForElementId: ReturnType, +): FieldValidation[] { + const fieldValidations: FieldValidation[] = []; + for (const issue of issues) { + const { field, source, dataElementId } = issue; + + if (!field) { + continue; + } + + const dataType = getDataTypeForElementId(dataElementId); + + if (!dataType) { + continue; + } + + const severity = getValidationIssueSeverity(issue); + const message = getValidationIssueMessage(issue); + + /** + * Identify category (visibility mask) + * Standard validation sources should use the Backend mask + * Custom backend validations should use the CustomBackend mask + */ + let category: number = ValidationMask.Backend; + if (!isStandardBackend(issue.source)) { + if (issue.showImmediately) { + category = 0; + } else if (issue.actLikeRequired) { + category = ValidationMask.Required; + } else { + category = ValidationMask.CustomBackend; + } } - } - if (!field) { - // Unmapped error (task validation) - return { severity, message, category: 0, source }; + fieldValidations.push({ field, dataType, severity, message, category, source }); } - return { field, severity, message, category, source }; + return fieldValidations; +} + +export function mapBackendIssuesToTaskValidations(issues: BackendValidationIssue[]): BaseValidation[] { + const taskValidations: BaseValidation[] = []; + for (const issue of issues) { + const { field, source } = issue; + if (field) { + continue; + } + + const severity = getValidationIssueSeverity(issue); + const message = getValidationIssueMessage(issue); + + taskValidations.push({ severity, message, category: 0, source }); + } + return taskValidations; } /** @@ -95,3 +136,33 @@ export function getValidationIssueMessage(issue: BackendValidationIssue): TextRe return { key: issue.source ? `${issue.source}.${issue.code}` : issue.code }; } + +export function mapValidatorGroupsToDataModelValidations( + validators: BackendFieldValidatorGroups, + dataTypes: string[], +): DataModelValidations { + const backendValidations: DataModelValidations = {}; + + // We need to clear all data types regardless if there are any validations or not + // Otherwise it would not update if there are no validations for a data type any more + for (const dataType of dataTypes) { + backendValidations[dataType] = {}; + } + + // Map validator groups to validations per data type and field + for (const group of Object.values(validators)) { + for (const validation of group) { + if (!backendValidations[validation.dataType]) { + backendValidations[validation.dataType] = {}; + } + + if (!backendValidations[validation.dataType][validation.field]) { + backendValidations[validation.dataType][validation.field] = []; + } + + backendValidations[validation.dataType][validation.field].push(validation); + } + } + + return backendValidations; +} diff --git a/src/features/validation/backendValidation/useBackendValidation.ts b/src/features/validation/backendValidation/useBackendValidation.ts deleted file mode 100644 index 16d86d16d1..0000000000 --- a/src/features/validation/backendValidation/useBackendValidation.ts +++ /dev/null @@ -1,135 +0,0 @@ -import { useEffect, useMemo, useState } from 'react'; - -import { useQuery } from '@tanstack/react-query'; -import { useImmer } from 'use-immer'; - -import type { - BackendValidationIssue, - BackendValidationIssueGroups, - BackendValidatorGroups, - FieldValidations, -} from '..'; - -import { useAppQueries } from 'src/core/contexts/AppQueriesProvider'; -import { type QueryDefinition } from 'src/core/queries/usePrefetchQuery'; -import { useCurrentDataModelGuid } from 'src/features/datamodel/useBindingSchema'; -import { FD } from 'src/features/formData/FormDataWrite'; -import { useLaxInstance } from 'src/features/instance/InstanceContext'; -import { useLaxProcessData } from 'src/features/instance/ProcessContext'; -import { useCurrentLanguage } from 'src/features/language/LanguageProvider'; -import { - mapValidationIssueToFieldValidation, - useShouldValidateInitial, -} from 'src/features/validation/backendValidation/backendValidationUtils'; - -interface RetVal { - validations: FieldValidations; - processedLast: BackendValidationIssueGroups | undefined; - initialValidationDone: boolean; -} - -// Also used for prefetching @see formPrefetcher.ts -export function useBackendValidationQueryDef( - enabled: boolean, - currentLanguage: string, - instanceId?: string, - currentDataElementId?: string, - currentTaskId?: string, -): QueryDefinition { - const { fetchBackendValidations } = useAppQueries(); - return { - queryKey: ['validation', instanceId, currentDataElementId, currentTaskId, enabled], - queryFn: - instanceId && currentDataElementId - ? () => fetchBackendValidations(instanceId, currentDataElementId, currentLanguage) - : () => [], - enabled, - gcTime: 0, - }; -} - -export function useBackendValidation(): RetVal { - const lastSaveValidations = FD.useLastSaveValidationIssues(); - const [validatorGroups, setValidatorGroups] = useImmer({}); - const [initialValidationDone, setInitialValidationDone] = useState(false); - const [processedLast, setProcessedLast] = useState(undefined); - - /** - * Run full validation initially for each step - */ - const instanceId = useLaxInstance()?.instanceId; - const currentDataElementId = useCurrentDataModelGuid(); - const currentProcessTaskId = useLaxProcessData()?.currentTask?.elementId; - const currentLanguage = useCurrentLanguage(); - const enabled = useShouldValidateInitial(); - - const { data: initialValidations } = useQuery( - useBackendValidationQueryDef(enabled, currentLanguage, instanceId, currentDataElementId, currentProcessTaskId), - ); - - /** - * Overwrite validation groups with initial validation - */ - useEffect(() => { - if (initialValidations !== undefined && initialValidations.length > 0) { - setValidatorGroups( - initialValidations.map(mapValidationIssueToFieldValidation).reduce((validatorGroups, validation) => { - if (!validatorGroups[validation.source]) { - validatorGroups[validation.source] = []; - } - validatorGroups[validation.source].push(validation); - return validatorGroups; - }, {}) ?? {}, - ); - } - - setInitialValidationDone(initialValidations !== undefined); - }, [initialValidations, setValidatorGroups]); - - /** - * Add incremental validation - */ - useEffect(() => { - if (lastSaveValidations !== undefined && Object.keys(lastSaveValidations).length > 0) { - setValidatorGroups((groups) => { - for (const [group, validationIssues] of Object.entries(lastSaveValidations)) { - groups[group] = validationIssues.map(mapValidationIssueToFieldValidation); - } - }); - } - - setProcessedLast(lastSaveValidations); - }, [lastSaveValidations, setValidatorGroups]); - - /** - * Map validator groups to validations per field - */ - const validations = useMemo(() => { - const validations: FieldValidations = {}; - - for (const [key, group] of Object.entries(validatorGroups)) { - for (const validation of group) { - if ('field' in validation) { - if (!validations[validation.field]) { - validations[validation.field] = []; - } - validations[validation.field].push(validation); - } else { - // Unmapped error (task validation) - window.logWarn( - `When validating the datamodel, validator ${key} returned a validation error without a field\n`, - validation, - ); - } - } - } - - return validations; - }, [validatorGroups]); - - return { - validations, - processedLast, - initialValidationDone, - }; -} diff --git a/src/features/validation/callbacks/onFormSubmitValidation.ts b/src/features/validation/callbacks/onFormSubmitValidation.ts index a5fd6f5158..851c2a94e4 100644 --- a/src/features/validation/callbacks/onFormSubmitValidation.ts +++ b/src/features/validation/callbacks/onFormSubmitValidation.ts @@ -65,7 +65,7 @@ export function useOnFormSubmitValidation() { ); if (nodesWithAnyError !== ContextNotProvided && nodesWithAnyError.length > 0) { - setNodeVisibility(nodesWithAnyError, ValidationMask.All); + setNodeVisibility(nodesWithAnyError, ValidationMask.AllIncludingBackend); return true; } @@ -75,7 +75,9 @@ export function useOnFormSubmitValidation() { */ const backendMask = getVisibilityMask(['Backend', 'CustomBackend']); const hasFieldErrors = - Object.values(state.fields).flatMap((field) => selectValidations(field, backendMask, 'error')).length > 0; + Object.values(state.dataModels) + .flatMap((fields) => Object.values(fields)) + .flatMap((field) => selectValidations(field, backendMask, 'error')).length > 0; if (hasFieldErrors) { setShowAllErrors(true); diff --git a/src/features/validation/expressionValidation/useExpressionValidation.test.tsx b/src/features/validation/expressionValidation/ExpressionValidation.test.tsx similarity index 55% rename from src/features/validation/expressionValidation/useExpressionValidation.test.tsx rename to src/features/validation/expressionValidation/ExpressionValidation.test.tsx index 0d4844a92f..5d866dfb5c 100644 --- a/src/features/validation/expressionValidation/useExpressionValidation.test.tsx +++ b/src/features/validation/expressionValidation/ExpressionValidation.test.tsx @@ -1,18 +1,20 @@ import React from 'react'; -import { screen } from '@testing-library/react'; +import { jest } from '@jest/globals'; import fs from 'node:fs'; -import * as CustomValidationContext from 'src/features/customValidation/CustomValidationContext'; +import { defaultDataTypeMock } from 'src/__mocks__/getLayoutSetsMock'; +import { DataModels } from 'src/features/datamodel/DataModelsProvider'; import { FD } from 'src/features/formData/FormDataWrite'; -import { useExpressionValidation } from 'src/features/validation/expressionValidation/useExpressionValidation'; +import { ExpressionValidation } from 'src/features/validation/expressionValidation/ExpressionValidation'; +import { Validation } from 'src/features/validation/validationContext'; import { renderWithInstanceAndLayout } from 'src/test/renderWithProviders'; import * as NodesContext from 'src/utils/layout/NodesContext'; -import type { IExpressionValidationConfig } from 'src/features/validation'; +import type { FieldValidations, IExpressionValidationConfig } from 'src/features/validation'; import type { ILayoutCollection } from 'src/layout/layout'; interface SimpleValidation { - message: string | undefined; + message: string; severity: string; field: string; } @@ -50,22 +52,6 @@ function sortValidations(validations: SimpleValidation[]) { }); } -function ExprValidationTester() { - const result = useExpressionValidation(); - - // Format results in a way that makes it easier to compare - const validationsArray: SimpleValidation[] = Object.entries(result).flatMap(([field, V]) => - V.map(({ message, severity }) => ({ - message: message.key, - severity, - field, - })), - ); - const validations = JSON.stringify(sortValidations(validationsArray), null, 2); - - return
{validations}
; -} - function getSharedTests() { const fullPath = `${__dirname}/shared-expression-validation-tests`; const out: ExpressionValidationTest[] = []; @@ -84,14 +70,22 @@ function getSharedTests() { describe('Expression validation shared tests', () => { beforeEach(() => { jest.spyOn(FD, 'useDebounced').mockRestore(); - jest.spyOn(CustomValidationContext, 'useCustomValidationConfig').mockRestore(); + jest.spyOn(DataModels, 'useExpressionValidationConfig').mockRestore(); jest.spyOn(NodesContext, 'useNodes').mockRestore(); + jest.spyOn(Validation, 'useUpdateDataModelValidations').mockRestore(); }); const sharedTests = getSharedTests(); it.each(sharedTests)('$name', async ({ name: _, expects, validationConfig, formData, layouts }) => { + // Mock updateDataModelValidations + let result: FieldValidations = {}; + const updateDataModelValidations = jest.fn((_key, _dataType, validations: FieldValidations) => { + result = validations; + }); + jest.spyOn(Validation, 'useUpdateDataModelValidations').mockImplementation(() => updateDataModelValidations); + await renderWithInstanceAndLayout({ - renderer: () => , + renderer: () => , queries: { fetchLayouts: async () => layouts, fetchCustomValidationConfig: async () => validationConfig, @@ -99,10 +93,33 @@ describe('Expression validation shared tests', () => { }, }); - const expectedValidations = sortValidations( - expects.map(({ message, severity, field }) => ({ message, severity, field })), + expect(updateDataModelValidations).toHaveBeenCalledWith( + 'expression', + defaultDataTypeMock, + expect.objectContaining({}), ); - const validations = JSON.parse(screen.getByTestId('validations').textContent!); + + // Format results in a way that makes it easier to compare + const validations = JSON.stringify( + sortValidations( + Object.entries(result).flatMap(([field, V]) => + V.map(({ message, severity }) => ({ + message: message.key!, + severity, + field, + })), + ) satisfies SimpleValidation[], + ), + null, + 2, + ); + + const expectedValidations = JSON.stringify( + sortValidations(expects.map(({ message, severity, field }) => ({ message, severity, field }))), + null, + 2, + ); + expect(validations).toEqual(expectedValidations); }); }); diff --git a/src/features/validation/expressionValidation/ExpressionValidation.tsx b/src/features/validation/expressionValidation/ExpressionValidation.tsx new file mode 100644 index 0000000000..8feb26df2b --- /dev/null +++ b/src/features/validation/expressionValidation/ExpressionValidation.tsx @@ -0,0 +1,117 @@ +import React, { useEffect } from 'react'; + +import { FrontendValidationSource, ValidationMask } from '..'; + +import { DataModels } from 'src/features/datamodel/DataModelsProvider'; +import { evalExpr } from 'src/features/expressions'; +import { FD } from 'src/features/formData/FormDataWrite'; +import { Validation } from 'src/features/validation/validationContext'; +import { getKeyWithoutIndex } from 'src/utils/databindings'; +import { NodesInternal } from 'src/utils/layout/NodesContext'; +import { useExpressionDataSources } from 'src/utils/layout/useExpressionDataSources'; +import { useNodeTraversalSilent } from 'src/utils/layout/useNodeTraversal'; +import type { Expression } from 'src/features/expressions/types'; +import type { IDataModelReference, ILayoutSet } from 'src/layout/common.generated'; +import type { ExpressionDataSources } from 'src/utils/layout/useExpressionDataSources'; + +export function ExpressionValidation() { + const writableDataTypes = DataModels.useWritableDataTypes(); + + return ( + <> + {writableDataTypes.map((dataType) => ( + + ))} + + ); +} + +function IndividualExpressionValidation({ dataType }: { dataType: string }) { + const updateDataModelValidations = Validation.useUpdateDataModelValidations(); + const formData = FD.useDebounced(dataType); + const expressionValidationConfig = DataModels.useExpressionValidationConfig(dataType); + const dataSources = useExpressionDataSources(); + const allNodes = useNodeTraversalSilent((t) => t.allNodes()); + const nodeDataSelector = NodesInternal.useNodeDataSelector(); + + useEffect(() => { + if (expressionValidationConfig && Object.keys(expressionValidationConfig).length > 0 && formData && allNodes) { + const validations = {}; + + for (const node of allNodes) { + const dmb = nodeDataSelector((picker) => picker(node)?.layout.dataModelBindings, [node]); + if (!dmb) { + continue; + } + + // Modify the hierarchy data sources to make the current dataModel the default one when running expression validations + const currentLayoutSet = dataSources.currentLayoutSet; + const modifiedCurrentLayoutSet: ILayoutSet | null = currentLayoutSet + ? { + ...currentLayoutSet, + dataType, + } + : null; + const modifiedDataSources: ExpressionDataSources = { + ...dataSources, + currentLayoutSet: modifiedCurrentLayoutSet, + }; + + for (const reference of Object.values(dmb as Record)) { + 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 isInvalid = evalExpr(validationDef.condition as Expression, node, modifiedDataSources, { + positionalArguments: [field], + }); + if (isInvalid) { + if (!validations[field]) { + validations[field] = []; + } + + validations[field].push({ + field, + source: FrontendValidationSource.Expression, + message: { key: validationDef.message }, + severity: validationDef.severity, + category: validationDef.showImmediately ? 0 : ValidationMask.Expression, + }); + } + } + } + } + + updateDataModelValidations('expression', dataType, validations); + } + }, [ + expressionValidationConfig, + formData, + dataType, + updateDataModelValidations, + allNodes, + nodeDataSelector, + dataSources, + ]); + + return null; +} diff --git a/src/features/validation/expressionValidation/useExpressionValidation.ts b/src/features/validation/expressionValidation/useExpressionValidation.ts deleted file mode 100644 index 56ab7b78c8..0000000000 --- a/src/features/validation/expressionValidation/useExpressionValidation.ts +++ /dev/null @@ -1,73 +0,0 @@ -import { useMemo } from 'react'; - -import { useCustomValidationConfig } from 'src/features/customValidation/CustomValidationContext'; -import { evalExpr } from 'src/features/expressions'; -import { FD } from 'src/features/formData/FormDataWrite'; -import { type FieldValidations, FrontendValidationSource, ValidationMask } from 'src/features/validation'; -import { getKeyWithoutIndex } from 'src/utils/databindings'; -import { NodesInternal } from 'src/utils/layout/NodesContext'; -import { useExpressionDataSources } from 'src/utils/layout/useExpressionDataSources'; -import { useNodeTraversalSilent } from 'src/utils/layout/useNodeTraversal'; -import type { Expression } from 'src/features/expressions/types'; - -const __default__ = {}; - -export function useExpressionValidation(): FieldValidations { - const formData = FD.useDebounced(); - const customValidationConfig = useCustomValidationConfig(); - const dataSources = useExpressionDataSources(); - const allNodes = useNodeTraversalSilent((t) => t.allNodes()); - const nodeDataSelector = NodesInternal.useNodeDataSelector(); - - /** - * Should only update when form data changes - */ - return useMemo(() => { - if (!customValidationConfig || Object.keys(customValidationConfig).length === 0 || !formData || !allNodes) { - return __default__; - } - - return allNodes.reduce((validations, node) => { - const dmb = nodeDataSelector((picker) => picker(node)?.layout.dataModelBindings, [node]); - if (!dmb) { - return validations; - } - - for (const field of Object.values(dmb)) { - /** - * Should not run validations on the same field multiple times - */ - if (validations[field]) { - continue; - } - - const baseField = getKeyWithoutIndex(field); - const validationDefs = customValidationConfig[baseField]; - if (!validationDefs) { - continue; - } - - for (const validationDef of validationDefs) { - const isInvalid = evalExpr(validationDef.condition as Expression, node, dataSources, { - positionalArguments: [field], - }); - if (isInvalid) { - if (!validations[field]) { - validations[field] = []; - } - - validations[field].push({ - field, - source: FrontendValidationSource.Expression, - message: { key: validationDef.message }, - severity: validationDef.severity, - category: validationDef.showImmediately ? 0 : ValidationMask.Expression, - }); - } - } - } - - return validations; - }, {}); - }, [customValidationConfig, formData, allNodes, nodeDataSelector, dataSources]); -} diff --git a/src/features/validation/index.ts b/src/features/validation/index.ts index f53dd4b7f5..1381af62bb 100644 --- a/src/features/validation/index.ts +++ b/src/features/validation/index.ts @@ -22,6 +22,10 @@ export enum BuiltInValidationIssueSources { Expression = 'Expression', } +// TODO(Datamodels): Ignore unecessary validations +// This requires some changes to how we check validations before submit, and how we show validations after submit if it fails with validation messages +export const IgnoredValidators: BuiltInValidationIssueSources[] = []; // [BuiltInValidationIssueSources.Required, BuiltInValidationIssueSources.Expression]; + export enum BackendValidationSeverity { Error = 1, Warning = 2, @@ -70,7 +74,11 @@ export type ValidationContext = { export type ValidationState = { task: BaseValidation[]; - fields: FieldValidations; + dataModels: DataModelValidations; +}; + +export type DataModelValidations = { + [dataType: string]: FieldValidations; }; export type FieldValidations = { @@ -91,6 +99,10 @@ export type BackendValidatorGroups = { [validator: string]: (BaseValidation | FieldValidation)[]; }; +export type BackendFieldValidatorGroups = { + [validator: string]: FieldValidation[]; +}; + export type BaseValidation = { message: TextReference; severity: Severity; @@ -104,6 +116,7 @@ export type BaseValidation = BaseValidation & { field: string; + dataType: string; }; /** diff --git a/src/features/validation/invalidDataValidation/InvalidDataValidation.tsx b/src/features/validation/invalidDataValidation/InvalidDataValidation.tsx new file mode 100644 index 0000000000..c40af3b3f7 --- /dev/null +++ b/src/features/validation/invalidDataValidation/InvalidDataValidation.tsx @@ -0,0 +1,44 @@ +import { useEffect } from 'react'; + +import dot from 'dot-object'; + +import { FrontendValidationSource, ValidationMask } from '..'; + +import { FD } from 'src/features/formData/FormDataWrite'; +import { Validation } from 'src/features/validation/validationContext'; + +function isScalar(value: unknown): value is string | number | boolean { + return typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean'; +} + +export function InvalidDataValidation({ dataType }: { dataType: string }) { + const updateDataModelValidations = Validation.useUpdateDataModelValidations(); + const invalidData = FD.useInvalidDebounced(dataType); + + useEffect(() => { + let validations = {}; + + if (Object.keys(invalidData).length > 0) { + validations = Object.entries(dot.dot(invalidData)) + .filter(([_, value]) => isScalar(value)) + .reduce((validations, [field, _]) => { + if (!validations[field]) { + validations[field] = []; + } + + validations[field].push({ + field, + source: FrontendValidationSource.InvalidData, + message: { key: 'validation_errors.pattern' }, + severity: 'error', + category: ValidationMask.Schema, // Use same visibility as schema validations + }); + + return validations; + }, {}); + } + updateDataModelValidations('invalidData', dataType, validations); + }, [dataType, invalidData, updateDataModelValidations]); + + return null; +} diff --git a/src/features/validation/invalidDataValidation/useInvalidDataValidation.ts b/src/features/validation/invalidDataValidation/useInvalidDataValidation.ts deleted file mode 100644 index 7c7b3a5baa..0000000000 --- a/src/features/validation/invalidDataValidation/useInvalidDataValidation.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { useMemo } from 'react'; - -import dot from 'dot-object'; - -import { FD } from 'src/features/formData/FormDataWrite'; -import { FrontendValidationSource, ValidationMask } from 'src/features/validation'; -import type { FieldValidations } from 'src/features/validation'; - -const __default__ = {}; - -function isScalar(value: unknown): value is string | number | boolean { - return typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean'; -} - -export function useInvalidDataValidation(): FieldValidations { - const invalidData = FD.useInvalidDebounced(); - - return useMemo(() => { - if (Object.keys(invalidData).length === 0) { - return __default__; - } - - return Object.entries(dot.dot(invalidData)) - .filter(([_, value]) => isScalar(value)) - .reduce((validations, [field, _]) => { - if (!validations[field]) { - validations[field] = []; - } - - validations[field].push({ - field, - source: FrontendValidationSource.InvalidData, - message: { key: 'validation_errors.pattern' }, - severity: 'error', - category: ValidationMask.Schema, // Use same visibility as schema validations - }); - - return validations; - }, {}); - }, [invalidData]); -} diff --git a/src/features/validation/nodeValidation/useNodeValidation.ts b/src/features/validation/nodeValidation/useNodeValidation.ts index 3479424168..039efeb291 100644 --- a/src/features/validation/nodeValidation/useNodeValidation.ts +++ b/src/features/validation/nodeValidation/useNodeValidation.ts @@ -8,6 +8,7 @@ import { implementsValidateComponent, implementsValidateEmptyField } from 'src/l import { NodesInternal } from 'src/utils/layout/NodesContext'; import type { AnyValidation, BaseValidation, ValidationDataSources } from 'src/features/validation'; import type { CompDef, ValidationFilter } from 'src/layout'; +import type { IDataModelReference } from 'src/layout/common.generated'; import type { LayoutNode } from 'src/utils/layout/LayoutNode'; import type { NodeDataSelector } from 'src/utils/layout/NodesContext'; @@ -16,7 +17,7 @@ import type { NodeDataSelector } from 'src/utils/layout/NodesContext'; * validations for a node and return them. */ export function useNodeValidation(node: LayoutNode, shouldValidate: boolean): AnyValidation[] { - const fieldSelector = Validation.useFieldSelector(); + const dataModelSelector = Validation.useDataModelSelector(); const validationDataSources = useValidationDataSources(); const nodeDataSelector = NodesInternal.useNodeDataSelector(); @@ -40,16 +41,17 @@ export function useNodeValidation(node: LayoutNode, shouldValidate: boolean): An (picker) => picker(node)?.layout.dataModelBindings, [node], ); - for (const [bindingKey, _field] of Object.entries(dataModelBindings || {})) { - const field = _field as string; - const fieldValidations = fieldSelector((fields) => fields[field], [field]); + for (const [bindingKey, { dataType, field }] of Object.entries( + (dataModelBindings ?? {}) as Record, + )) { + const fieldValidations = dataModelSelector((dataModels) => dataModels[dataType]?.[field], [dataType, field]); if (fieldValidations) { validations.push(...fieldValidations.map((v) => ({ ...v, node, bindingKey }))); } } return filter(validations, node, nodeDataSelector); - }, [node, fieldSelector, shouldValidate, validationDataSources, nodeDataSelector]); + }, [node, dataModelSelector, shouldValidate, validationDataSources, nodeDataSelector]); } /** diff --git a/src/features/validation/schemaValidation/SchemaValidation.test.tsx b/src/features/validation/schemaValidation/SchemaValidation.test.tsx new file mode 100644 index 0000000000..6bbcbb0c82 --- /dev/null +++ b/src/features/validation/schemaValidation/SchemaValidation.test.tsx @@ -0,0 +1,281 @@ +import React from 'react'; + +import { render } from '@testing-library/react'; +import type { JSONSchema7 } from 'json-schema'; + +import { DataModels } from 'src/features/datamodel/DataModelsProvider'; +import * as UseBindingSchema from 'src/features/datamodel/useBindingSchema'; +import { FD } from 'src/features/formData/FormDataWrite'; +import { SchemaValidation } from 'src/features/validation/schemaValidation/SchemaValidation'; +import { Validation } from 'src/features/validation/validationContext'; +import type { IDataType } from 'src/types/shared'; + +describe('SchemaValidation', () => { + describe('format validation', () => { + beforeEach(() => { + jest.spyOn(FD, 'useDebounced').mockRestore(); + jest.spyOn(DataModels, 'useDataModelSchema').mockRestore(); + jest.spyOn(UseBindingSchema, 'useDataModelType').mockRestore(); + jest.spyOn(Validation, 'useUpdateDataModelValidations').mockRestore(); + }); + + const formatTests = [ + { + format: 'date', + tests: [ + { value: '2020-01-01', valid: true }, + { value: '1985-04-12T23:20:50.52Z', valid: false }, + { value: 'asdfasdf', valid: false }, + { value: '', valid: true }, + { value: null, valid: true }, + { value: undefined, valid: true }, + ], + }, + { + format: 'date-time', + tests: [ + { value: '2020-01-01', valid: false }, + { value: '1985-04-12T23:20:50.52Z', valid: true }, + { value: 'asdfasdf', valid: false }, + { value: '', valid: true }, + { value: null, valid: true }, + { value: undefined, valid: true }, + ], + }, + { + format: 'time', + tests: [ + { value: '23:20:50.52Z', valid: true }, + { value: '2020-01-01', valid: false }, + { value: '1985-04-12T23:20:50.52Z', valid: false }, + { value: 'asdfasdf', valid: false }, + { value: '', valid: true }, + { value: null, valid: true }, + { value: undefined, valid: true }, + ], + }, + { + format: 'duration', + tests: [ + { value: 'P3Y6M4DT12H30M5S', valid: true }, + { value: 'P23DT23H', valid: true }, + { value: 'P3Y6M4DT12H30M5', valid: false }, + { value: 'asdfasdf', valid: false }, + { value: '', valid: true }, + { value: null, valid: true }, + { value: undefined, valid: true }, + ], + }, + { + format: 'email', + tests: [ + { value: 'test@gmail.com', valid: true }, + { value: 'æøå@gmail.com', valid: false }, + { value: 'asdfasdf', valid: false }, + { value: '', valid: true }, + { value: null, valid: true }, + { value: undefined, valid: true }, + ], + }, + { + format: 'idn-email', + tests: [ + { value: 'test@gmail.com', valid: true }, + { value: 'æøå@gmail.com', valid: true }, + { value: 'asdfasdf', valid: false }, + { value: '', valid: true }, + { value: null, valid: true }, + { value: undefined, valid: true }, + ], + }, + { + format: 'hostname', + tests: [ + { value: 'altinn.no', valid: true }, + { value: 'altinnæøå.no.', valid: false }, + { value: 'altinn/studio', valid: false }, + { value: '', valid: true }, + { value: null, valid: true }, + { value: undefined, valid: true }, + ], + }, + { + format: 'idn-hostname', + tests: [ + { value: 'altinn.no', valid: true }, + { value: 'altinnæøå.no.', valid: true }, + { value: 'altinn/studio', valid: false }, + { value: '', valid: true }, + { value: null, valid: true }, + { value: undefined, valid: true }, + ], + }, + { + format: 'ipv4', + tests: [ + { value: '192.168.10.101', valid: true }, + { value: '192.168.10.999', valid: false }, + { value: 'asdfasdf', valid: false }, + { value: '', valid: true }, + { value: null, valid: true }, + { value: undefined, valid: true }, + ], + }, + { + format: 'ipv6', + tests: [ + { value: '2001:0db8:85a3:0000:0000:8a2e:0370:7334', valid: true }, + { value: '2001:0db8:85a3::8a2e:0370:7334', valid: true }, + { value: '2001:0db8:85a3::8a2e:0370:733m', valid: false }, + { value: 'asdfasdf', valid: false }, + { value: '', valid: true }, + { value: null, valid: true }, + { value: undefined, valid: true }, + ], + }, + { + format: 'uuid', + tests: [ + { value: '123e4567-e89b-12d3-a456-426614174000', valid: true }, + { value: '123e4567-e89b-12d3-a456-42661417400g', valid: false }, + { value: 'asdfasdf', valid: false }, + { value: '', valid: true }, + { value: null, valid: true }, + { value: undefined, valid: true }, + ], + }, + { + format: 'uri', + tests: [ + { value: 'http://altinn.no', valid: true }, + { value: 'http://altinn.no/æøå', valid: false }, + { value: '#/hei', valid: false }, + { value: 'asdfasdf', valid: false }, + { value: '', valid: true }, + { value: null, valid: true }, + { value: undefined, valid: true }, + ], + }, + { + format: 'uri-reference', + tests: [ + { value: 'http://altinn.no', valid: true }, + { value: '#/hei', valid: true }, + { value: '%%', valid: false }, + { value: '#/æøå', valid: false }, + { value: '', valid: true }, + { value: null, valid: true }, + { value: undefined, valid: true }, + ], + }, + { + format: 'iri', + tests: [ + { value: 'http://altinn.no/æøå', valid: true }, + { value: 'asdfasdf', valid: false }, + { value: '', valid: true }, + { value: null, valid: true }, + { value: undefined, valid: true }, + ], + }, + { + format: 'iri-reference', + tests: [ + { value: 'http://altinn.no/æøå', valid: true }, + { value: '#/æøå', valid: true }, + { value: 'javascript:;', valid: false }, // It was hard to find an invalid case, not sure why this is invalid + { value: '', valid: true }, + { value: null, valid: true }, + { value: undefined, valid: true }, + ], + }, + { + format: 'uri-template', + tests: [ + { value: 'http://{org}.apps.altinn.no/{org}/{app}', valid: true }, + { value: 'http://altinn.no/', valid: true }, + { value: 'htt%p://altinn.no', valid: false }, + { value: '', valid: true }, + { value: null, valid: true }, + { value: undefined, valid: true }, + ], + }, + { + format: 'json-pointer', + tests: [ + { value: '/foo/bar', valid: true }, + { value: '0', valid: false }, + { value: '1/a~1b', valid: false }, + { value: '', valid: true }, + { value: null, valid: true }, + { value: undefined, valid: true }, + ], + }, + { + format: 'relative-json-pointer', + tests: [ + { value: '/foo/bar', valid: false }, + { value: '0', valid: true }, + { value: '1/a~1b', valid: true }, + { value: '', valid: true }, + { value: null, valid: true }, + { value: undefined, valid: true }, + ], + }, + { + format: 'regex', + tests: [ + { value: '^\\d{4}-\\d{2}-\\d{2}$', valid: true }, + { value: '^\\d{4}-\\d{2}-(\\d{2}$', valid: false }, + { value: '', valid: true }, + { value: null, valid: true }, + { value: undefined, valid: true }, + ], + }, + ]; + + formatTests.forEach(({ format, tests }) => { + describe(format, () => { + tests.forEach(({ value, valid }) => { + it(`${value} should ${valid ? 'be valid' : 'not be valid'}`, async () => { + const formData = { + field: value, + }; + + const schema: JSONSchema7 = { + $schema: 'https://json-schema.org/draft/2020-12/schema', + type: 'object', + properties: { + field: { + type: 'string', + format, + }, + }, + }; + + jest.spyOn(FD, 'useDebounced').mockReturnValue(formData); + jest.spyOn(DataModels, 'useDataModelSchema').mockReturnValue(schema); + jest.spyOn(UseBindingSchema, 'useDataModelType').mockReturnValue({} as IDataType); + + const updateDataModelValidations = jest.fn(); + jest + .spyOn(Validation, 'useUpdateDataModelValidations') + .mockImplementation(() => updateDataModelValidations); + + render(); + + // If valid, expect empty validations object + // If not valid, expect an object containing at least field and severity + const expectedValidations = valid + ? {} + : expect.objectContaining({ + field: expect.arrayContaining([expect.objectContaining({ field: 'field', severity: 'error' })]), + }); + + expect(updateDataModelValidations).toHaveBeenCalledWith('schema', 'mockDataType', expectedValidations); + }); + }); + }); + }); + }); +}); diff --git a/src/features/validation/schemaValidation/SchemaValidation.tsx b/src/features/validation/schemaValidation/SchemaValidation.tsx new file mode 100644 index 0000000000..9ab88a5558 --- /dev/null +++ b/src/features/validation/schemaValidation/SchemaValidation.tsx @@ -0,0 +1,123 @@ +import { useEffect, useMemo } from 'react'; + +import { FrontendValidationSource } from '..'; +import type { FieldValidations } from '..'; + +import { DataModels } from 'src/features/datamodel/DataModelsProvider'; +import { useDataModelType } from 'src/features/datamodel/useBindingSchema'; +import { FD } from 'src/features/formData/FormDataWrite'; +import { + createValidator, + getErrorCategory, + getErrorParams, + getErrorTextKey, +} from 'src/features/validation/schemaValidation/schemaValidationUtils'; +import { Validation } from 'src/features/validation/validationContext'; +import { + getRootElementPath, + getSchemaPart, + getSchemaPartOldGenerator, + processInstancePath, +} from 'src/utils/schemaUtils'; +import type { TextReference } from 'src/features/language/useLanguage'; + +export function SchemaValidation({ dataType }: { dataType: string }) { + const updateDataModelValidations = Validation.useUpdateDataModelValidations(); + + const formData = FD.useDebounced(dataType); + const schema = DataModels.useDataModelSchema(dataType); + const dataTypeDef = useDataModelType(dataType); + + /** + * Create a validator for the current schema and data type. + */ + const [validator, rootElementPath] = useMemo(() => { + if (!schema || !dataTypeDef) { + return [undefined, undefined] as const; + } + + return [createValidator(schema), getRootElementPath(schema, dataTypeDef)] as const; + }, [schema, dataTypeDef]); + + /** + * Perform validation using AJV schema validation. + */ + useEffect(() => { + if (validator && rootElementPath !== undefined && schema) { + const valid = validator.validate(`schema${rootElementPath}`, structuredClone(formData)); + const validations: FieldValidations = {}; + if (!valid) { + for (const error of validator.errors || []) { + /** + * Skip schema validation for empty fields and ignore required errors. + * JSON schema required does not work too well for our use case. The expectation that a missing field should give an error is not necessarily true, + * since it will not work in nested objects if the parent is also missing. + * Check if AVJ validation error is a oneOf error ("must match exactly one schema in oneOf"). + * We don't currently support oneOf validation. + * These can be ignored, as there will be other, specific validation errors that actually + * from the specified sub-schemas that will trigger validation errors where relevant. + */ + if ( + error.data == null || + error.data === '' || + error.keyword === 'required' || + error.keyword === 'oneOf' || + error.params?.type === 'null' + ) { + continue; + } + + /** + * Get schema for the field that failed validation. + * Backward compatible if we are validating against a sub scheme. + */ + const fieldSchema = rootElementPath + ? getSchemaPartOldGenerator(error.schemaPath, schema, rootElementPath) + : getSchemaPart(error.schemaPath, schema); + + /** + * Get TextReference for error message. + * Either a standardized language key or a custom error message from the schema. + */ + const message: TextReference = fieldSchema?.errorMessage + ? { key: fieldSchema.errorMessage } + : { + key: getErrorTextKey(error), + }; + + const category = getErrorCategory(error); + + /** + * Extract error parameters and add to message if available. + */ + const errorParams = getErrorParams(error); + if (errorParams !== null) { + message['params'] = [errorParams]; + } + + /** + * Extract data model field from the error's instancePath + */ + const field = processInstancePath(error.instancePath); + + if (!validations[field]) { + validations[field] = []; + } + + validations[field].push({ + message, + field, + dataType, + source: FrontendValidationSource.Schema, + category, + severity: 'error', + }); + } + } + + updateDataModelValidations('schema', dataType, validations); + } + }, [dataType, formData, rootElementPath, schema, updateDataModelValidations, validator]); + + return null; +} diff --git a/src/features/validation/schemaValidation/useSchemaValidation.test.tsx b/src/features/validation/schemaValidation/useSchemaValidation.test.tsx deleted file mode 100644 index b6a3b9537f..0000000000 --- a/src/features/validation/schemaValidation/useSchemaValidation.test.tsx +++ /dev/null @@ -1,265 +0,0 @@ -import { jest } from '@jest/globals'; -import { renderHook } from '@testing-library/react'; -import type { JSONSchema7 } from 'json-schema'; - -import * as DataModelSchemaProvider from 'src/features/datamodel/DataModelSchemaProvider'; -import * as UseBindingSchema from 'src/features/datamodel/useBindingSchema'; -import { FD } from 'src/features/formData/FormDataWrite'; -import { useSchemaValidation } from 'src/features/validation/schemaValidation/useSchemaValidation'; -import type { IDataType } from 'src/types/shared'; - -describe('useSchemaValidation', () => { - describe('format validation', () => { - beforeEach(() => { - jest.spyOn(FD, 'useDebounced').mockRestore(); - jest.spyOn(DataModelSchemaProvider, 'useCurrentDataModelSchema').mockRestore(); - jest.spyOn(UseBindingSchema, 'useCurrentDataModelType').mockRestore(); - }); - - const formatTests = [ - { - format: 'date', - tests: [ - { value: '2020-01-01', expected: true }, - { value: '1985-04-12T23:20:50.52Z', expected: false }, - { value: 'asdfasdf', expected: false }, - { value: '', expected: true }, - { value: null, expected: true }, - { value: undefined, expected: true }, - ], - }, - { - format: 'date-time', - tests: [ - { value: '2020-01-01', expected: false }, - { value: '1985-04-12T23:20:50.52Z', expected: true }, - { value: 'asdfasdf', expected: false }, - { value: '', expected: true }, - { value: null, expected: true }, - { value: undefined, expected: true }, - ], - }, - { - format: 'time', - tests: [ - { value: '23:20:50.52Z', expected: true }, - { value: '2020-01-01', expected: false }, - { value: '1985-04-12T23:20:50.52Z', expected: false }, - { value: 'asdfasdf', expected: false }, - { value: '', expected: true }, - { value: null, expected: true }, - { value: undefined, expected: true }, - ], - }, - { - format: 'duration', - tests: [ - { value: 'P3Y6M4DT12H30M5S', expected: true }, - { value: 'P23DT23H', expected: true }, - { value: 'P3Y6M4DT12H30M5', expected: false }, - { value: 'asdfasdf', expected: false }, - { value: '', expected: true }, - { value: null, expected: true }, - { value: undefined, expected: true }, - ], - }, - { - format: 'email', - tests: [ - { value: 'test@gmail.com', expected: true }, - { value: 'æøå@gmail.com', expected: false }, - { value: 'asdfasdf', expected: false }, - { value: '', expected: true }, - { value: null, expected: true }, - { value: undefined, expected: true }, - ], - }, - { - format: 'idn-email', - tests: [ - { value: 'test@gmail.com', expected: true }, - { value: 'æøå@gmail.com', expected: true }, - { value: 'asdfasdf', expected: false }, - { value: '', expected: true }, - { value: null, expected: true }, - { value: undefined, expected: true }, - ], - }, - { - format: 'hostname', - tests: [ - { value: 'altinn.no', expected: true }, - { value: 'altinnæøå.no.', expected: false }, - { value: 'altinn/studio', expected: false }, - { value: '', expected: true }, - { value: null, expected: true }, - { value: undefined, expected: true }, - ], - }, - { - format: 'idn-hostname', - tests: [ - { value: 'altinn.no', expected: true }, - { value: 'altinnæøå.no.', expected: true }, - { value: 'altinn/studio', expected: false }, - { value: '', expected: true }, - { value: null, expected: true }, - { value: undefined, expected: true }, - ], - }, - { - format: 'ipv4', - tests: [ - { value: '192.168.10.101', expected: true }, - { value: '192.168.10.999', expected: false }, - { value: 'asdfasdf', expected: false }, - { value: '', expected: true }, - { value: null, expected: true }, - { value: undefined, expected: true }, - ], - }, - { - format: 'ipv6', - tests: [ - { value: '2001:0db8:85a3:0000:0000:8a2e:0370:7334', expected: true }, - { value: '2001:0db8:85a3::8a2e:0370:7334', expected: true }, - { value: '2001:0db8:85a3::8a2e:0370:733m', expected: false }, - { value: 'asdfasdf', expected: false }, - { value: '', expected: true }, - { value: null, expected: true }, - { value: undefined, expected: true }, - ], - }, - { - format: 'uuid', - tests: [ - { value: '123e4567-e89b-12d3-a456-426614174000', expected: true }, - { value: '123e4567-e89b-12d3-a456-42661417400g', expected: false }, - { value: 'asdfasdf', expected: false }, - { value: '', expected: true }, - { value: null, expected: true }, - { value: undefined, expected: true }, - ], - }, - { - format: 'uri', - tests: [ - { value: 'http://altinn.no', expected: true }, - { value: 'http://altinn.no/æøå', expected: false }, - { value: '#/hei', expected: false }, - { value: 'asdfasdf', expected: false }, - { value: '', expected: true }, - { value: null, expected: true }, - { value: undefined, expected: true }, - ], - }, - { - format: 'uri-reference', - tests: [ - { value: 'http://altinn.no', expected: true }, - { value: '#/hei', expected: true }, - { value: '%%', expected: false }, - { value: '#/æøå', expected: false }, - { value: '', expected: true }, - { value: null, expected: true }, - { value: undefined, expected: true }, - ], - }, - { - format: 'iri', - tests: [ - { value: 'http://altinn.no/æøå', expected: true }, - { value: 'asdfasdf', expected: false }, - { value: '', expected: true }, - { value: null, expected: true }, - { value: undefined, expected: true }, - ], - }, - { - format: 'iri-reference', - tests: [ - { value: 'http://altinn.no/æøå', expected: true }, - { value: '#/æøå', expected: true }, - { value: 'javascript:;', expected: false }, // It was hard to find an invalid case, not sure why this is invalid - { value: '', expected: true }, - { value: null, expected: true }, - { value: undefined, expected: true }, - ], - }, - { - format: 'uri-template', - tests: [ - { value: 'http://{org}.apps.altinn.no/{org}/{app}', expected: true }, - { value: 'http://altinn.no/', expected: true }, - { value: 'htt%p://altinn.no', expected: false }, - { value: '', expected: true }, - { value: null, expected: true }, - { value: undefined, expected: true }, - ], - }, - { - format: 'json-pointer', - tests: [ - { value: '/foo/bar', expected: true }, - { value: '0', expected: false }, - { value: '1/a~1b', expected: false }, - { value: '', expected: true }, - { value: null, expected: true }, - { value: undefined, expected: true }, - ], - }, - { - format: 'relative-json-pointer', - tests: [ - { value: '/foo/bar', expected: false }, - { value: '0', expected: true }, - { value: '1/a~1b', expected: true }, - { value: '', expected: true }, - { value: null, expected: true }, - { value: undefined, expected: true }, - ], - }, - { - format: 'regex', - tests: [ - { value: '^\\d{4}-\\d{2}-\\d{2}$', expected: true }, - { value: '^\\d{4}-\\d{2}-(\\d{2}$', expected: false }, - { value: '', expected: true }, - { value: null, expected: true }, - { value: undefined, expected: true }, - ], - }, - ]; - - formatTests.forEach(({ format, tests }) => { - describe(format, () => { - tests.forEach(({ value, expected }) => { - it(`${value} should ${expected ? 'be valid' : 'not be valid'}`, async () => { - const formData = { - field: value, - }; - - const schema: JSONSchema7 = { - $schema: 'https://json-schema.org/draft/2020-12/schema', - type: 'object', - properties: { - field: { - type: 'string', - format, - }, - }, - }; - - jest.spyOn(FD, 'useDebounced').mockReturnValue(formData); - jest.spyOn(DataModelSchemaProvider, 'useCurrentDataModelSchema').mockReturnValue(schema); - jest.spyOn(UseBindingSchema, 'useCurrentDataModelType').mockReturnValue({} as IDataType); - - const { result } = renderHook(() => useSchemaValidation()); - - expect(Object.keys(result.current)).toHaveLength(expected ? 0 : 1); - }); - }); - }); - }); - }); -}); diff --git a/src/features/validation/schemaValidation/useSchemaValidation.ts b/src/features/validation/schemaValidation/useSchemaValidation.ts deleted file mode 100644 index dd663db1d4..0000000000 --- a/src/features/validation/schemaValidation/useSchemaValidation.ts +++ /dev/null @@ -1,122 +0,0 @@ -import { useMemo } from 'react'; - -import { useCurrentDataModelSchema } from 'src/features/datamodel/DataModelSchemaProvider'; -import { useCurrentDataModelType } from 'src/features/datamodel/useBindingSchema'; -import { FD } from 'src/features/formData/FormDataWrite'; -import { type FieldValidations, FrontendValidationSource } from 'src/features/validation'; -import { - createValidator, - getErrorCategory, - getErrorParams, - getErrorTextKey, -} from 'src/features/validation/schemaValidation/schemaValidationUtils'; -import { - getRootElementPath, - getSchemaPart, - getSchemaPartOldGenerator, - processInstancePath, -} from 'src/utils/schemaUtils'; -import type { TextReference } from 'src/features/language/useLanguage'; - -const __default__ = {}; - -export function useSchemaValidation(): FieldValidations { - const formData = FD.useDebounced(); - const schema = useCurrentDataModelSchema(); - const dataType = useCurrentDataModelType(); - - /** - * Create a validator for the current schema and data type. - */ - const [validator, rootElementPath] = useMemo(() => { - if (!schema || !dataType) { - return [undefined, undefined] as const; - } - - return [createValidator(schema), getRootElementPath(schema, dataType)] as const; - }, [schema, dataType]); - - /** - * Perform validation using AJV schema validation. - */ - return useMemo(() => { - if (!validator || rootElementPath === undefined || !schema) { - return __default__; - } - - const valid = validator.validate(`schema${rootElementPath}`, structuredClone(formData)); - if (valid) { - return __default__; - } - - const fieldValidations: FieldValidations = {}; - - for (const error of validator.errors || []) { - /** - * Skip schema validation for empty fields and ignore required errors. - * JSON schema required does not work too well for our use case. The expectation that a missing field should give an error is not necessarily true, - * since it will not work in nested objects if the parent is also missing. - * Check if AVJ validation error is a oneOf error ("must match exactly one schema in oneOf"). - * We don't currently support oneOf validation. - * These can be ignored, as there will be other, specific validation errors that actually - * from the specified sub-schemas that will trigger validation errors where relevant. - */ - if ( - error.data == null || - error.data === '' || - error.keyword === 'required' || - error.keyword === 'oneOf' || - error.params?.type === 'null' - ) { - continue; - } - - /** - * Get schema for the field that failed validation. - * Backward compatible if we are validating against a sub scheme. - */ - const fieldSchema = rootElementPath - ? getSchemaPartOldGenerator(error.schemaPath, schema, rootElementPath) - : getSchemaPart(error.schemaPath, schema); - - /** - * Get TextReference for error message. - * Either a standardized language key or a custom error message from the schema. - */ - const message: TextReference = fieldSchema?.errorMessage - ? { key: fieldSchema.errorMessage } - : { - key: getErrorTextKey(error), - }; - - const category = getErrorCategory(error); - - /** - * Extract error parameters and add to message if available. - */ - const errorParams = getErrorParams(error); - if (errorParams !== null) { - message['params'] = [errorParams]; - } - - /** - * Extract data model field from the error's instancePath - */ - const field = processInstancePath(error.instancePath); - - if (!fieldValidations[field]) { - fieldValidations[field] = []; - } - - fieldValidations[field].push({ - message, - field, - source: FrontendValidationSource.Schema, - category, - severity: 'error', - }); - } - - return fieldValidations; - }, [formData, rootElementPath, schema, validator]); -} diff --git a/src/features/validation/selectors/taskErrors.ts b/src/features/validation/selectors/taskErrors.ts index 4688737eb5..5fc348b78a 100644 --- a/src/features/validation/selectors/taskErrors.ts +++ b/src/features/validation/selectors/taskErrors.ts @@ -40,11 +40,16 @@ export function useTaskErrors(): { const taskErrors: BaseValidation<'error'>[] = []; const taskValidations = selector((state) => state.state.task, []); - const allShown = selector((state) => (state.showAllErrors ? { fields: state.state.fields } : undefined), []); + const allShown = selector( + (state) => (state.showAllErrors ? { dataModels: state.state.dataModels } : undefined), + [], + ); if (allShown) { const backendMask = getVisibilityMask(['Backend', 'CustomBackend']); - for (const field of Object.values(allShown.fields)) { - taskErrors.push(...(selectValidations(field, backendMask, 'error') as BaseValidation<'error'>[])); + for (const fields of Object.values(allShown.dataModels)) { + for (const field of Object.values(fields)) { + taskErrors.push(...(selectValidations(field, backendMask, 'error') as BaseValidation<'error'>[])); + } } } diff --git a/src/features/validation/utils.ts b/src/features/validation/utils.ts index 82fba41a5c..d17cc2d574 100644 --- a/src/features/validation/utils.ts +++ b/src/features/validation/utils.ts @@ -8,18 +8,21 @@ import type { } from 'src/features/validation'; import type { AllowedValidationMasks } from 'src/layout/common.generated'; -export function mergeFieldValidations(...X: FieldValidations[]): FieldValidations { +export function mergeFieldValidations(...X: (FieldValidations | undefined)[]): FieldValidations { if (X.length === 0) { return {}; } if (X.length === 1) { - return X[0]; + return X[0] ?? {}; } const [X1, ...XRest] = X; - const out = structuredClone(X1); + const out = X1 ? structuredClone(X1) : {}; for (const Xn of XRest) { + if (!Xn) { + continue; + } for (const [field, validations] of Object.entries(structuredClone(Xn))) { if (!out[field]) { out[field] = []; diff --git a/src/features/validation/validationContext.tsx b/src/features/validation/validationContext.tsx index df879b11d1..1ba9744b62 100644 --- a/src/features/validation/validationContext.tsx +++ b/src/features/validation/validationContext.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useEffect } from 'react'; +import React, { Fragment, useCallback, useEffect } from 'react'; import type { PropsWithChildren } from 'react'; import { createStore } from 'zustand'; @@ -7,12 +7,11 @@ import { immer } from 'zustand/middleware/immer'; import { createZustandContext } from 'src/core/contexts/zustandContext'; import { Loader } from 'src/core/loading/Loader'; import { useHasPendingAttachments } from 'src/features/attachments/hooks'; +import { DataModels } from 'src/features/datamodel/DataModelsProvider'; import { FD } from 'src/features/formData/FormDataWrite'; -import { useShouldValidateInitial } from 'src/features/validation/backendValidation/backendValidationUtils'; -import { useBackendValidation } from 'src/features/validation/backendValidation/useBackendValidation'; -import { useExpressionValidation } from 'src/features/validation/expressionValidation/useExpressionValidation'; -import { useInvalidDataValidation } from 'src/features/validation/invalidDataValidation/useInvalidDataValidation'; -import { useSchemaValidation } from 'src/features/validation/schemaValidation/useSchemaValidation'; +import { BackendValidation } from 'src/features/validation/backendValidation/BackendValidation'; +import { InvalidDataValidation } from 'src/features/validation/invalidDataValidation/InvalidDataValidation'; +import { SchemaValidation } from 'src/features/validation/schemaValidation/SchemaValidation'; import { getVisibilityMask, hasValidationErrors, @@ -20,31 +19,41 @@ import { selectValidations, } from 'src/features/validation/utils'; import { useAsRef } from 'src/hooks/useAsRef'; +import { useIsPdf } from 'src/hooks/useIsPdf'; import { useWaitForState } from 'src/hooks/useWaitForState'; import { NodesInternal } from 'src/utils/layout/NodesContext'; import type { + BackendFieldValidatorGroups, BackendValidationIssueGroups, BaseValidation, + DataModelValidations, FieldValidations, ValidationContext, WaitForValidation, } from 'src/features/validation'; interface Internals { - isLoading: boolean; individualValidations: { - backend: FieldValidations; - expression: FieldValidations; - schema: FieldValidations; - invalidData: FieldValidations; + backend: DataModelValidations; + expression: DataModelValidations; + schema: DataModelValidations; + invalidData: DataModelValidations; }; - issueGroupsProcessedLast: BackendValidationIssueGroups | undefined; - updateValidations: ( - key: K, - value: Internals['individualValidations'][K], - issueGroups?: BackendValidationIssueGroups, - ) => void; + issueGroupsProcessedLast: BackendValidationIssueGroups | BackendFieldValidatorGroups | undefined; // This should only be used to check if we have finished processing the last validations from backend so that we know if the validation state is up to date updateTaskValidations: (validations: BaseValidation[]) => void; + /** + * updateDataModelValidations + * if validations is undefined, nothing will be changed + */ + updateDataModelValidations: ( + key: Exclude, + dataType: string, + validations?: FieldValidations, + ) => void; + updateBackendValidations: ( + backendValidations: { [dataType: string]: FieldValidations } | undefined, + processedLast?: BackendValidationIssueGroups | BackendFieldValidatorGroups, + ) => void; updateValidating: (validating: WaitForValidation) => void; } @@ -55,7 +64,7 @@ function initialCreateStore() { // Publicly exposed state state: { task: [], - fields: {}, + dataModels: {}, }, setShowAllErrors: (newValue) => set((state) => { @@ -66,31 +75,45 @@ function initialCreateStore() { // ======= // Internal state - isLoading: true, individualValidations: { backend: {}, expression: {}, schema: {}, invalidData: {}, }, - issueGroupsProcessedLast: undefined, - updateValidations: (key, validations, issueGroups) => + issueGroupsProcessedLast: {}, + updateTaskValidations: (validations) => set((state) => { - if (key === 'backend') { - state.isLoading = false; - state.issueGroupsProcessedLast = issueGroups; + state.state.task = validations; + }), + updateDataModelValidations: (key, dataType, validations) => + set((state) => { + if (validations) { + state.individualValidations[key][dataType] = validations; + state.state.dataModels[dataType] = mergeFieldValidations( + state.individualValidations.backend[dataType], + state.individualValidations.invalidData[dataType], + state.individualValidations.schema[dataType], + state.individualValidations.expression[dataType], + ); } - state.individualValidations[key] = validations; - state.state.fields = mergeFieldValidations( - state.individualValidations.backend, - state.individualValidations.invalidData, - state.individualValidations.schema, - state.individualValidations.expression, - ); }), - updateTaskValidations: (validations) => + updateBackendValidations: (backendValidations, processedLast) => set((state) => { - state.state.task = validations; + if (processedLast) { + state.issueGroupsProcessedLast = processedLast; + } + if (backendValidations) { + state.individualValidations.backend = backendValidations; + for (const dataType of Object.keys(backendValidations)) { + state.state.dataModels[dataType] = mergeFieldValidations( + state.individualValidations.backend[dataType], + state.individualValidations.invalidData[dataType], + state.individualValidations.schema[dataType], + state.individualValidations.expression[dataType], + ); + } + } }), updateValidating: (newValidating) => set((state) => { @@ -108,11 +131,18 @@ const { Provider, useSelector, useLaxSelector, useSelectorAsRef, useStore, useLa }); export function ValidationProvider({ children }: PropsWithChildren) { + const writableDataTypes = DataModels.useWritableDataTypes(); return ( - + {writableDataTypes.map((dataType) => ( + + + + + ))} + - {children} + {children} ); } @@ -127,17 +157,29 @@ function useWaitForValidation(): WaitForValidation { const pendingAttachmentsRef = useAsRef(hasPendingAttachments); const waitForAttachments = useWaitForState(pendingAttachmentsRef); + const hasWritableDataTypes = !!DataModels.useWritableDataTypes()?.length; + const isPDF = useIsPdf(); + return useCallback( async (forceSave = true) => { + if (isPDF || !hasWritableDataTypes) { + return; + } + await waitForAttachments((state) => !state); // Wait until we've saved changed to backend, and we've processed the backend validations we got from that save await waitForNodesReady(); const validationsFromSave = await waitForSave(forceSave); await waitForNodesReady(); - await waitForState((state) => state.issueGroupsProcessedLast === validationsFromSave); + // If validationsFromSave is not defined, we check if initial validations are done processing + await waitForState( + (state) => + (!!validationsFromSave && state.issueGroupsProcessedLast === validationsFromSave) || + !!state.issueGroupsProcessedLast, + ); }, - [waitForAttachments, waitForSave, waitForState, waitForNodesReady], + [isPDF, hasWritableDataTypes, waitForAttachments, waitForNodesReady, waitForSave, waitForState], ); } @@ -154,61 +196,13 @@ export function ProvideWaitForValidation() { export function LoadingBlockerWaitForValidation({ children }: PropsWithChildren) { const validating = useSelector((state) => state.validating); - const shouldValidateInitial = useShouldValidateInitial(); - if (!validating && shouldValidateInitial) { + if (!validating) { return ; } return <>{children}; } -function LoadingBlocker({ children }: PropsWithChildren) { - const isLoading = useSelector((state) => state.isLoading); - const shouldValidateInitial = useShouldValidateInitial(); - - if (isLoading && shouldValidateInitial) { - return ; - } - - return <>{children}; -} - -function UpdateValidations() { - const updateValidations = useSelector((state) => state.updateValidations); - const backendValidation = useBackendValidation(); - - useEffect(() => { - const { validations: backendValidations, processedLast, initialValidationDone } = backendValidation; - if (initialValidationDone) { - updateValidations('backend', backendValidations, processedLast); - } - }, [backendValidation, updateValidations]); - - const schemaValidations = useSchemaValidation(); - const invalidDataValidations = useInvalidDataValidation(); - - useEffect(() => { - updateValidations('schema', schemaValidations); - }, [schemaValidations, updateValidations]); - - useEffect(() => { - updateValidations('invalidData', invalidDataValidations); - }, [invalidDataValidations, updateValidations]); - - return null; -} - -export function UpdateExpressionValidation() { - const updateValidations = useSelector((state) => state.updateValidations); - const expressionValidations = useExpressionValidation(); - - useEffect(() => { - updateValidations('expression', expressionValidations); - }, [expressionValidations, updateValidations]); - - return null; -} - function ManageShowAllErrors() { const showAllErrors = useSelector((state) => state.showAllErrors); return showAllErrors ? : null; @@ -216,7 +210,7 @@ function ManageShowAllErrors() { function UpdateShowAllErrors() { const taskValidations = useSelector((state) => state.state.task); - const fieldValidations = useSelector((state) => state.state.fields); + const dataModelValidations = useSelector((state) => state.state.dataModels); const setShowAllErrors = useSelector((state) => state.setShowAllErrors); /** @@ -225,12 +219,14 @@ function UpdateShowAllErrors() { useEffect(() => { const backendMask = getVisibilityMask(['Backend', 'CustomBackend']); const hasFieldErrors = - Object.values(fieldValidations).flatMap((field) => selectValidations(field, backendMask, 'error')).length > 0; + Object.values(dataModelValidations) + .flatMap((fields) => Object.values(fields)) + .flatMap((field) => selectValidations(field, backendMask, 'error')).length > 0; if (!hasFieldErrors && !hasValidationErrors(taskValidations)) { setShowAllErrors(false); } - }, [fieldValidations, setShowAllErrors, taskValidations]); + }, [dataModelValidations, setShowAllErrors, taskValidations]); return null; } @@ -247,18 +243,20 @@ function useDS(outerSelector: (state: ValidationContext) => U) { } export type ValidationSelector = ReturnType; -export type ValidationFieldSelector = ReturnType; +export type ValidationDataModelSelector = ReturnType; export const Validation = { useFullStateRef: () => useSelectorAsRef((state) => state.state), // Selectors. These are memoized, so they won't cause a re-render unless the selected fields change. useSelector: () => useDS((state) => state), - useFieldSelector: () => useDS((state) => state.state.fields), + useDataModelSelector: () => useDS((state) => state.state.dataModels), useSetShowAllErrors: () => useSelector((state) => state.setShowAllErrors), useValidating: () => useSelector((state) => state.validating!), useUpdateTaskValidations: () => useLaxSelector((state) => state.updateTaskValidations), + useUpdateDataModelValidations: () => useSelector((state) => state.updateDataModelValidations), + useUpdateBackendValidations: () => useSelector((state) => state.updateBackendValidations), useRef: () => useSelectorAsRef((state) => state), useLaxRef: () => useLaxSelectorAsRef((state) => state), diff --git a/src/global.ts b/src/global.ts index 0bf474ba20..9a4a6632af 100644 --- a/src/global.ts +++ b/src/global.ts @@ -26,7 +26,7 @@ declare global { Cypress?: any; // Can be used to test if we are running in Cypress CypressState?: { attachments?: IAttachmentsMap; - formData?: object; + formData?: { [key: string]: unknown }; nodesStore?: NodesContextStore; }; diff --git a/src/hooks/useSourceOptions.ts b/src/hooks/useSourceOptions.ts index 48111cef67..3ad468ba35 100644 --- a/src/hooks/useSourceOptions.ts +++ b/src/hooks/useSourceOptions.ts @@ -6,7 +6,7 @@ import { transposeDataBinding } from 'src/utils/databindings/DataBinding'; import { useExpressionDataSources } from 'src/utils/layout/useExpressionDataSources'; import type { ExprVal, ExprValToActualOrExpr } from 'src/features/expressions/types'; import type { IOptionInternal } from 'src/features/options/castOptionsToStrings'; -import type { IOptionSource } from 'src/layout/common.generated'; +import type { IDataModelReference, IOptionSource } from 'src/layout/common.generated'; import type { LayoutNode } from 'src/utils/layout/LayoutNode'; import type { ExpressionDataSources } from 'src/utils/layout/useExpressionDataSources'; @@ -24,24 +24,34 @@ export const useSourceOptions = ({ source, node }: IUseSourceOptionsArgs): IOpti } const { formDataRowsSelector, formDataSelector, langToolsSelector } = dataSources; + const output: IOptionInternal[] = []; const langTools = langToolsSelector(node); - const { group, value, label, helpText, description } = source; + const { group, value, label, helpText, description, dataType } = source; const cleanValue = getKeyWithoutIndexIndicators(value); const cleanGroup = getKeyWithoutIndexIndicators(group); - const groupPath = dataSources.transposeSelector(node, cleanGroup) || group; - const output: IOptionInternal[] = []; - - if (!groupPath) { + const groupDataType = dataType ?? dataSources.currentLayoutSet?.dataType; + if (!groupDataType) { return output; } - const groupRows = formDataRowsSelector(groupPath); + const rawReference: IDataModelReference = { dataType: groupDataType, field: cleanGroup }; + const groupReference = dataSources.transposeSelector(node, rawReference); + if (!groupReference) { + return output; + } + + const valueReference: IDataModelReference = { dataType: groupDataType, field: cleanValue }; + const groupRows = formDataRowsSelector(groupReference); if (!groupRows.length) { return output; } for (const idx in groupRows) { - const path = `${groupPath}[${idx}]`; - const valuePath = transposeDataBinding({ subject: cleanValue, currentLocation: path }); + const path = `${groupReference.field}[${idx}]`; + const nonTransposed = { dataType: groupDataType, field: path }; + const transposed = transposeDataBinding({ + subject: valueReference, + currentLocation: nonTransposed, + }); /** * Running evalExpression is all that is needed to support dynamic expressions in @@ -58,16 +68,17 @@ export const useSourceOptions = ({ source, node }: IUseSourceOptionsArgs): IOpti ...dataSources, langToolsSelector: () => ({ ...langTools, - langAsString: (key: string) => langTools.langAsStringUsingPathInDataModel(key, path), - langAsNonProcessedString: (key: string) => langTools.langAsNonProcessedStringUsingPathInDataModel(key, path), + langAsString: (key: string) => langTools.langAsStringUsingPathInDataModel(key, nonTransposed), + langAsNonProcessedString: (key: string) => + langTools.langAsNonProcessedStringUsingPathInDataModel(key, nonTransposed), }), }; output.push({ - value: String(formDataSelector(valuePath)), - label: resolveText(label, node, modifiedDataSources, path) as string, - description: resolveText(description, node, modifiedDataSources, path), - helpText: resolveText(helpText, node, modifiedDataSources, path), + value: String(formDataSelector(transposed)), + label: resolveText(label, node, modifiedDataSources, nonTransposed) as string, + description: resolveText(description, node, modifiedDataSources, nonTransposed), + helpText: resolveText(helpText, node, modifiedDataSources, nonTransposed), }); } @@ -79,13 +90,13 @@ function resolveText( text: ExprValToActualOrExpr | undefined, node: LayoutNode, dataSources: ExpressionDataSources, - path: string, + reference: IDataModelReference, ): string | undefined { if (text && ExprValidation.isValid(text)) { return evalExpr(text, node, dataSources); } if (text) { - return dataSources.langToolsSelector(node).langAsStringUsingPathInDataModel(text as string, path); + return dataSources.langToolsSelector(node).langAsStringUsingPathInDataModel(text as string, reference); } return undefined; } diff --git a/src/layout/Address/AddressComponent.test.tsx b/src/layout/Address/AddressComponent.test.tsx index b5f7a8a442..39890c3a92 100644 --- a/src/layout/Address/AddressComponent.test.tsx +++ b/src/layout/Address/AddressComponent.test.tsx @@ -3,6 +3,7 @@ import React from 'react'; import { screen, waitFor } from '@testing-library/react'; import { userEvent } from '@testing-library/user-event'; +import { defaultDataTypeMock } from 'src/__mocks__/getLayoutSetsMock'; import { AddressComponent } from 'src/layout/Address/AddressComponent'; import { renderGenericComponentTest } from 'src/test/renderWithProviders'; import type { RenderGenericComponentTestProps } from 'src/test/renderWithProviders'; @@ -17,11 +18,11 @@ const render = async ({ component, ...rest }: Partial { await userEvent.tab(); expect(formDataMethods.setLeafValue).toHaveBeenCalledWith({ - path: 'address', + reference: { field: 'address', dataType: defaultDataTypeMock }, newValue: 'Slottsplassen 1', }); }); @@ -141,7 +142,7 @@ describe('AddressComponent', () => { await screen.findByDisplayValue('OSLO'); expect(formDataMethods.setLeafValue).toHaveBeenCalledWith({ - path: 'postPlace', + reference: { field: 'postPlace', dataType: defaultDataTypeMock }, newValue: 'OSLO', }); }); @@ -160,7 +161,7 @@ describe('AddressComponent', () => { await userEvent.tab(); expect(formDataMethods.setLeafValue).toHaveBeenCalledWith({ - path: 'zipCode', + reference: { field: 'zipCode', dataType: defaultDataTypeMock }, newValue: '0001', }); }); @@ -179,11 +180,11 @@ describe('AddressComponent', () => { await userEvent.tab(); expect(formDataMethods.setLeafValue).toHaveBeenCalledWith({ - path: 'zipCode', + reference: { field: 'zipCode', dataType: defaultDataTypeMock }, newValue: '', }); expect(formDataMethods.setLeafValue).toHaveBeenCalledWith({ - path: 'postPlace', + reference: { field: 'postPlace', dataType: defaultDataTypeMock }, newValue: '', }); diff --git a/src/layout/Address/AddressComponent.tsx b/src/layout/Address/AddressComponent.tsx index 740b2c1c11..9c09d3c86c 100644 --- a/src/layout/Address/AddressComponent.tsx +++ b/src/layout/Address/AddressComponent.tsx @@ -19,7 +19,7 @@ import type { IDataModelBindingsForAddress } from 'src/layout/Address/config.gen export type IAddressProps = PropsFromGenericComponent<'Address'>; -const bindingKeys: IDataModelBindingsForAddress = { +const bindingKeys: { [k in keyof IDataModelBindingsForAddress]: k } = { address: 'address', postPlace: 'postPlace', zipCode: 'zipCode', @@ -33,7 +33,8 @@ export function AddressComponent({ node }: IAddressProps) { const bindingValidations = useBindingValidationsForNode(node); const componentValidations = useComponentValidationsForNode(node); - const { formData, setValue, debounce } = useDataModelBindings(dataModelBindings, saveWhileTyping); + const { formData, setValue } = useDataModelBindings(dataModelBindings, saveWhileTyping); + const debounce = FD.useDebounceImmediately(); const { address, careOf, postPlace, zipCode, houseNumber } = formData; const updatePostPlace = useEffectEvent((newPostPlace) => { diff --git a/src/layout/Address/config.ts b/src/layout/Address/config.ts index 792f318834..c8c6f54ddb 100644 --- a/src/layout/Address/config.ts +++ b/src/layout/Address/config.ts @@ -53,11 +53,38 @@ export const Config = new CG.component({ ) .addDataModelBinding( new CG.obj( - new CG.prop('address', new CG.str()), - new CG.prop('zipCode', new CG.str()), - new CG.prop('postPlace', new CG.str()), - new CG.prop('careOf', new CG.str().optional()), - new CG.prop('houseNumber', new CG.str().optional()), + new CG.prop( + 'address', + new CG.dataModelBinding() + .setTitle('Data model binding for address') + .setDescription('Describes the location in the data model where the component should store the address.'), + ), + new CG.prop( + 'zipCode', + new CG.dataModelBinding() + .setTitle('Data model binding for zip code') + .setDescription('Describes the location in the data model where the component should store the zip code.'), + ), + new CG.prop( + 'postPlace', + new CG.dataModelBinding() + .setTitle('Data model binding for post place') + .setDescription('Describes the location in the data model where the component should store the post place.'), + ), + new CG.prop( + 'careOf', + new CG.dataModelBinding() + .setTitle('Data model binding for care of') + .setDescription('Describes the location in the data model where the component should store care of.') + .optional(), + ), + new CG.prop( + 'houseNumber', + new CG.dataModelBinding() + .setTitle('Data model binding for house number') + .setDescription('Describes the location in the data model where the component should store the house number.') + .optional(), + ), ).exportAs('IDataModelBindingsForAddress'), ) .addProperty(new CG.prop('saveWhileTyping', CG.common('SaveWhileTyping').optional({ default: true }))) diff --git a/src/layout/Checkboxes/CheckboxesContainerComponent.test.tsx b/src/layout/Checkboxes/CheckboxesContainerComponent.test.tsx index 6edad82472..2f2ffb8cc7 100644 --- a/src/layout/Checkboxes/CheckboxesContainerComponent.test.tsx +++ b/src/layout/Checkboxes/CheckboxesContainerComponent.test.tsx @@ -5,6 +5,7 @@ import { userEvent } from '@testing-library/user-event'; import type { AxiosResponse } from 'axios'; import { getFormDataMockForRepGroup } from 'src/__mocks__/getFormDataMockForRepGroup'; +import { defaultDataTypeMock } from 'src/__mocks__/getLayoutSetsMock'; import { CheckboxContainerComponent } from 'src/layout/Checkboxes/CheckboxesContainerComponent'; import { LayoutStyle } from 'src/layout/common.generated'; import { renderGenericComponentTest } from 'src/test/renderWithProviders'; @@ -43,7 +44,7 @@ const render = async ({ component, options, formData, groupData = getFormDataMoc component: { optionsId: 'countries', dataModelBindings: { - simpleBinding: 'selectedValues', + simpleBinding: { dataType: defaultDataTypeMock, field: 'selectedValues' }, }, ...component, }, @@ -74,7 +75,7 @@ describe('CheckboxesContainerComponent', () => { await waitFor(() => { expect(formDataMethods.setLeafValue).toHaveBeenCalledWith({ - path: 'selectedValues', + reference: { field: 'selectedValues', dataType: defaultDataTypeMock }, newValue: 'sweden', }); }); @@ -132,7 +133,7 @@ describe('CheckboxesContainerComponent', () => { await waitFor(() => { expect(formDataMethods.setLeafValue).toHaveBeenCalledWith({ - path: 'selectedValues', + reference: { field: 'selectedValues', dataType: defaultDataTypeMock }, newValue: 'norway,denmark', }); }); @@ -153,7 +154,10 @@ describe('CheckboxesContainerComponent', () => { await userEvent.click(getCheckbox({ name: 'Denmark', isChecked: true })); await waitFor(() => { - expect(formDataMethods.setLeafValue).toHaveBeenCalledWith({ path: 'selectedValues', newValue: 'norway' }); + expect(formDataMethods.setLeafValue).toHaveBeenCalledWith({ + reference: { field: 'selectedValues', dataType: defaultDataTypeMock }, + newValue: 'norway', + }); }); }); @@ -184,7 +188,10 @@ describe('CheckboxesContainerComponent', () => { await userEvent.click(getCheckbox({ name: 'Denmark' })); await waitFor(() => { - expect(formDataMethods.setLeafValue).toHaveBeenCalledWith({ path: 'selectedValues', newValue: 'denmark' }); + expect(formDataMethods.setLeafValue).toHaveBeenCalledWith({ + reference: { field: 'selectedValues', dataType: defaultDataTypeMock }, + newValue: 'denmark', + }); }); }); @@ -274,7 +281,7 @@ describe('CheckboxesContainerComponent', () => { await waitFor(() => { expect(formDataMethods.setLeafValue).toHaveBeenCalledWith({ - path: 'selectedValues', + reference: { field: 'selectedValues', dataType: defaultDataTypeMock }, newValue: 'Value for second', }); }); diff --git a/src/layout/Custom/config.ts b/src/layout/Custom/config.ts index d7c5170957..8184569432 100644 --- a/src/layout/Custom/config.ts +++ b/src/layout/Custom/config.ts @@ -17,7 +17,7 @@ export const Config = new CG.component({ }, }) .addDataModelBinding( - new CG.obj().optional().additionalProperties(new CG.str()).exportAs('IDataModelBindingsForCustom'), + new CG.obj().optional().additionalProperties(new CG.dataModelBinding()).exportAs('IDataModelBindingsForCustom'), ) .addTextResource( new CG.trb({ diff --git a/src/layout/CustomButton/CustomButtonComponent.tsx b/src/layout/CustomButton/CustomButtonComponent.tsx index cd55bcba17..7bdc4acec2 100644 --- a/src/layout/CustomButton/CustomButtonComponent.tsx +++ b/src/layout/CustomButton/CustomButtonComponent.tsx @@ -5,7 +5,6 @@ import { Button } from '@digdir/designsystemet-react'; import { useMutation } from '@tanstack/react-query'; import { useAppMutations } from 'src/core/contexts/AppQueriesProvider'; -import { useCurrentDataModelGuid } from 'src/features/datamodel/useBindingSchema'; import { FD } from 'src/features/formData/FormDataWrite'; import { useLaxProcessData } from 'src/features/instance/ProcessContext'; import { Lang } from 'src/features/language/Lang'; @@ -28,6 +27,12 @@ type UpdatedDataModels = { [dataModelGuid: string]: object; }; +/** + * This is the format we get from app-lib, it turns out mapping BackendValidationIssueGroups on a per-dataelement basis is unecessary, + * and so this mapping is simply un-done after receiving it. To avoid breaking changes which would require handling multiple + * formats in app-frontend, we decided to leave it as is for now, as it does not have any practical consequences. In a future + * major/breaking release which would require a specific backend version, this could be changed to simply return a single BackendValidationIssueGroups object. + */ type UpdatedValidationIssues = { [dataModelGuid: string]: BackendValidationIssueGroups; }; @@ -56,7 +61,6 @@ const isClientAction = (action: CBTypes.CustomAction): action is CBTypes.ClientA const isServerAction = (action: CBTypes.CustomAction): action is CBTypes.ServerAction => action.type === 'ServerAction'; function useHandleClientActions(): UseHandleClientActions { - const currentDataModelGuid = useCurrentDataModelGuid(); const { navigateToPage, navigateToNextPage, navigateToPreviousPage } = useNavigatePage(); const frontendActions: ClientActionHandlers = useMemo( @@ -93,23 +97,28 @@ function useHandleClientActions(): UseHandleClientActions { const handleDataModelUpdate: UseHandleClientActions['handleDataModelUpdate'] = useCallback( async (lockTools, result) => { - const newDataModel = - currentDataModelGuid && result.updatedDataModels ? result.updatedDataModels[currentDataModelGuid] : undefined; - const validationIssues = - currentDataModelGuid && result.updatedValidationIssues - ? result.updatedValidationIssues[currentDataModelGuid] - : undefined; - - if (newDataModel && validationIssues) { - lockTools.unlock({ - newDataModel, - validationIssues, - }); - } else { - lockTools.unlock(); - } + const updatedDataModels = result.updatedDataModels; + const _updatedValidationIssues = result.updatedValidationIssues; + + // Undo data element mapping from backend by combining sources into a single BackendValidationIssueGroups object + const updatedValidationIssues = _updatedValidationIssues + ? Object.values(_updatedValidationIssues).reduce((issueGroups, currentGroups) => { + for (const [source, group] of Object.entries(currentGroups)) { + if (!issueGroups[source]) { + issueGroups[source] = []; + } + issueGroups[source].push(...group); + return issueGroups; + } + }, {}) + : undefined; + + lockTools.unlock({ + updatedDataModels, + updatedValidationIssues, + }); }, - [currentDataModelGuid], + [], ); return { handleClientActions, handleDataModelUpdate }; diff --git a/src/layout/Datepicker/DatepickerComponent.test.tsx b/src/layout/Datepicker/DatepickerComponent.test.tsx index c104aa25a4..95d8d7e2df 100644 --- a/src/layout/Datepicker/DatepickerComponent.test.tsx +++ b/src/layout/Datepicker/DatepickerComponent.test.tsx @@ -4,6 +4,7 @@ import { jest } from '@jest/globals'; import { screen } from '@testing-library/react'; import { userEvent } from '@testing-library/user-event'; +import { defaultDataTypeMock } from 'src/__mocks__/getLayoutSetsMock'; import { DatepickerComponent } from 'src/layout/Datepicker/DatepickerComponent'; import { mockMediaQuery } from 'src/test/mockMediaQuery'; import { renderGenericComponentTest } from 'src/test/renderWithProviders'; @@ -25,7 +26,7 @@ const render = async ({ component, ...rest }: Partial { // Ignore TZ part of timestamp to avoid test failing when this changes // Calendar opens up on current year/month by default, so we need to cater for this in the expected output expect(formDataMethods.setLeafValue).toHaveBeenCalledWith({ - path: 'myDate', + reference: { field: 'myDate', dataType: defaultDataTypeMock }, newValue: expect.stringContaining(`${currentYearNumeric}-${currentMonthNumeric}-15T12:00:00.000+`), }); }); @@ -120,7 +121,10 @@ describe('DatepickerComponent', () => { await userEvent.clear(screen.getByRole('textbox')); - expect(formDataMethods.setLeafValue).toHaveBeenCalledWith({ path: 'myDate', newValue: '' }); + expect(formDataMethods.setLeafValue).toHaveBeenCalledWith({ + reference: { field: 'myDate', dataType: defaultDataTypeMock }, + newValue: '', + }); }); it('should call setLeafValue with formatted value (timestamp=true) if date is valid', async () => { @@ -129,7 +133,7 @@ describe('DatepickerComponent', () => { await userEvent.type(screen.getByRole('textbox'), '31122022'); expect(formDataMethods.setLeafValue).toHaveBeenCalledWith({ - path: 'myDate', + reference: { field: 'myDate', dataType: defaultDataTypeMock }, newValue: expect.stringContaining('2022-12-31T12:00:00.000+'), }); }); @@ -139,7 +143,10 @@ describe('DatepickerComponent', () => { await userEvent.type(screen.getByRole('textbox'), '31122022'); - expect(formDataMethods.setLeafValue).toHaveBeenCalledWith({ path: 'myDate', newValue: '2022-12-31' }); + expect(formDataMethods.setLeafValue).toHaveBeenCalledWith({ + reference: { field: 'myDate', dataType: defaultDataTypeMock }, + newValue: '2022-12-31', + }); }); it('should call setLeafValue with formatted value (timestamp=undefined) if date is valid', async () => { @@ -148,7 +155,7 @@ describe('DatepickerComponent', () => { await userEvent.type(screen.getByRole('textbox'), '31122022'); expect(formDataMethods.setLeafValue).toHaveBeenCalledWith({ - path: 'myDate', + reference: { field: 'myDate', dataType: defaultDataTypeMock }, newValue: expect.stringContaining('2022-12-31T12:00:00.000+'), }); }); @@ -158,7 +165,10 @@ describe('DatepickerComponent', () => { await userEvent.type(screen.getByRole('textbox'), '12345678'); - expect(formDataMethods.setLeafValue).toHaveBeenCalledWith({ path: 'myDate', newValue: '12.34.5678' }); + expect(formDataMethods.setLeafValue).toHaveBeenCalledWith({ + reference: { field: 'myDate', dataType: defaultDataTypeMock }, + newValue: '12.34.5678', + }); }); it('should call setLeafValue if not finished filling out the date', async () => { @@ -166,7 +176,10 @@ describe('DatepickerComponent', () => { await userEvent.type(screen.getByRole('textbox'), `1234`); - expect(formDataMethods.setLeafValue).toHaveBeenCalledWith({ path: 'myDate', newValue: '12.34.____' }); + expect(formDataMethods.setLeafValue).toHaveBeenCalledWith({ + reference: { field: 'myDate', dataType: defaultDataTypeMock }, + newValue: '12.34.____', + }); }); it('should have aria-describedby if textResourceBindings.description is present', async () => { diff --git a/src/layout/Datepicker/DatepickerComponent.tsx b/src/layout/Datepicker/DatepickerComponent.tsx index b79775f9f9..003e0c32d9 100644 --- a/src/layout/Datepicker/DatepickerComponent.tsx +++ b/src/layout/Datepicker/DatepickerComponent.tsx @@ -7,6 +7,7 @@ import { CalendarIcon } from '@navikt/aksel-icons'; import moment from 'moment'; import type { MaterialUiPickersDate } from '@material-ui/pickers/typings/date'; +import { FD } from 'src/features/formData/FormDataWrite'; import { useDataModelBindings } from 'src/features/formData/useDataModelBindings'; import { useCurrentLanguage } from 'src/features/language/LanguageProvider'; import { useLanguage } from 'src/features/language/useLanguage'; @@ -129,7 +130,8 @@ export function DatepickerComponent({ node, overrideDisplay }: IDatepickerProps) const calculatedFormat = getDateFormat(format, languageLocale); const isMobile = useIsMobile(); - const { setValue, debounce, formData } = useDataModelBindings(dataModelBindings); + const { setValue, formData } = useDataModelBindings(dataModelBindings); + const debounce = FD.useDebounceImmediately(); const value = formData.simpleBinding; const dateValue = moment(formData.simpleBinding, moment.ISO_8601); const [date, input] = dateValue.isValid() ? [dateValue, undefined] : [null, value ?? '']; diff --git a/src/layout/Dropdown/DropdownComponent.test.tsx b/src/layout/Dropdown/DropdownComponent.test.tsx index b803b404ee..5ae53dc61a 100644 --- a/src/layout/Dropdown/DropdownComponent.test.tsx +++ b/src/layout/Dropdown/DropdownComponent.test.tsx @@ -6,6 +6,7 @@ import { userEvent } from '@testing-library/user-event'; import type { AxiosResponse } from 'axios'; import { getFormDataMockForRepGroup } from 'src/__mocks__/getFormDataMockForRepGroup'; +import { defaultDataTypeMock } from 'src/__mocks__/getLayoutSetsMock'; import { useDataModelBindings } from 'src/features/formData/useDataModelBindings'; import { DropdownComponent } from 'src/layout/Dropdown/DropdownComponent'; import { queryPromiseMock, renderGenericComponentTest } from 'src/test/renderWithProviders'; @@ -32,7 +33,9 @@ interface Props extends Partial } function MySuperSimpleInput() { - const { setValue, formData } = useDataModelBindings({ simpleBinding: 'myInput' }); + const { setValue, formData } = useDataModelBindings({ + simpleBinding: { field: 'myInput', dataType: defaultDataTypeMock }, + }); return ( { optionsId: 'countries', readOnly: false, dataModelBindings: { - simpleBinding: 'myDropdown', + simpleBinding: { dataType: defaultDataTypeMock, field: 'myDropdown' }, }, ...component, }, @@ -91,7 +94,10 @@ describe('DropdownComponent', () => { await userEvent.click(screen.getByRole('option', { name: /sweden/i })); await waitFor(() => - expect(formDataMethods.setLeafValue).toHaveBeenCalledWith({ path: 'myDropdown', newValue: 'sweden' }), + expect(formDataMethods.setLeafValue).toHaveBeenCalledWith({ + reference: { field: 'myDropdown', dataType: defaultDataTypeMock }, + newValue: 'sweden', + }), ); }); @@ -128,7 +134,10 @@ describe('DropdownComponent', () => { }); await waitFor(() => - expect(formDataMethods.setLeafValue).toHaveBeenCalledWith({ path: 'myDropdown', newValue: 'denmark' }), + expect(formDataMethods.setLeafValue).toHaveBeenCalledWith({ + reference: { field: 'myDropdown', dataType: defaultDataTypeMock }, + newValue: 'denmark', + }), ); }); @@ -194,7 +203,10 @@ describe('DropdownComponent', () => { await waitFor(() => expect(formDataMethods.setLeafValue).toHaveBeenCalledTimes(1)); await waitFor(() => - expect(formDataMethods.setLeafValue).toHaveBeenCalledWith({ path: 'myDropdown', newValue: 'Value for first' }), + expect(formDataMethods.setLeafValue).toHaveBeenCalledWith({ + reference: { field: 'myDropdown', dataType: defaultDataTypeMock }, + newValue: 'Value for first', + }), ); await userEvent.click(screen.getByRole('combobox')); @@ -202,7 +214,10 @@ describe('DropdownComponent', () => { await waitFor(() => expect(formDataMethods.setLeafValue).toHaveBeenCalledTimes(2)); await waitFor(() => - expect(formDataMethods.setLeafValue).toHaveBeenCalledWith({ path: 'myDropdown', newValue: 'Value for second' }), + expect(formDataMethods.setLeafValue).toHaveBeenCalledWith({ + reference: { field: 'myDropdown', dataType: defaultDataTypeMock }, + newValue: 'Value for second', + }), ); }); diff --git a/src/layout/Dropdown/DropdownComponent.tsx b/src/layout/Dropdown/DropdownComponent.tsx index 8988808a88..2fa453ac9b 100644 --- a/src/layout/Dropdown/DropdownComponent.tsx +++ b/src/layout/Dropdown/DropdownComponent.tsx @@ -24,9 +24,8 @@ export function DropdownComponent({ node, overrideDisplay }: IDropdownProps) { const { id, readOnly, textResourceBindings, alertOnChange } = item; const { langAsString, lang } = useLanguage(node); - const debounce = FD.useDebounceImmediately(); - const { options, isFetching, selectedValues, setData, key } = useGetOptions(node, 'single'); + const debounce = FD.useDebounceImmediately(); const changeMessageGenerator = useCallback( (values: string[]) => { diff --git a/src/layout/Group/SummaryGroupComponent.test.tsx b/src/layout/Group/SummaryGroupComponent.test.tsx index e8adf8c38b..280f75450f 100644 --- a/src/layout/Group/SummaryGroupComponent.test.tsx +++ b/src/layout/Group/SummaryGroupComponent.test.tsx @@ -2,6 +2,7 @@ import React from 'react'; import { jest } from '@jest/globals'; +import { defaultDataTypeMock } from 'src/__mocks__/getLayoutSetsMock'; import { ALTINN_ROW_ID } from 'src/features/formData/types'; import { SummaryGroupComponent } from 'src/layout/Group/SummaryGroupComponent'; import { renderWithNode } from 'src/test/renderWithProviders'; @@ -60,7 +61,7 @@ describe('SummaryGroupComponent', () => { type: 'RepeatingGroup', id: 'groupComponent', dataModelBindings: { - group: 'mockGroup', + group: { dataType: defaultDataTypeMock, field: 'mockGroup' }, }, textResourceBindings: { title: 'mockGroupTitle', @@ -75,7 +76,7 @@ describe('SummaryGroupComponent', () => { type: 'Input', id: 'mockId1', dataModelBindings: { - simpleBinding: 'mockGroup.mockDataBinding1', + simpleBinding: { dataType: defaultDataTypeMock, field: 'mockGroup.mockDataBinding1' }, }, readOnly: false, required: false, @@ -87,7 +88,7 @@ describe('SummaryGroupComponent', () => { type: 'Input', id: 'mockId2', dataModelBindings: { - simpleBinding: 'mockGroup.mockDataBinding2', + simpleBinding: { dataType: defaultDataTypeMock, field: 'mockGroup.mockDataBinding2' }, }, readOnly: false, required: false, diff --git a/src/layout/Input/InputComponent.test.tsx b/src/layout/Input/InputComponent.test.tsx index f1de3359ff..e64059f988 100644 --- a/src/layout/Input/InputComponent.test.tsx +++ b/src/layout/Input/InputComponent.test.tsx @@ -3,6 +3,7 @@ import React from 'react'; import { screen } from '@testing-library/react'; import { userEvent } from '@testing-library/user-event'; +import { defaultDataTypeMock } from 'src/__mocks__/getLayoutSetsMock'; import { InputComponent } from 'src/layout/Input/InputComponent'; import { renderGenericComponentTest } from 'src/test/renderWithProviders'; import type { RenderGenericComponentTestProps } from 'src/test/renderWithProviders'; @@ -44,7 +45,10 @@ describe('InputComponent', () => { await userEvent.type(inputComponent, typedValue); expect(inputComponent).toHaveValue(typedValue); - expect(formDataMethods.setLeafValue).toHaveBeenCalledWith({ path: 'some.field', newValue: typedValue }); + expect(formDataMethods.setLeafValue).toHaveBeenCalledWith({ + reference: { field: 'some.field', dataType: defaultDataTypeMock }, + newValue: typedValue, + }); expect(inputComponent).toHaveValue(typedValue); }); @@ -74,7 +78,10 @@ describe('InputComponent', () => { await userEvent.tab(); expect(inputComponent).toHaveValue(finalValueFormatted); - expect(formDataMethods.setLeafValue).toHaveBeenCalledWith({ path: 'some.field', newValue: finalValuePlainText }); + expect(formDataMethods.setLeafValue).toHaveBeenCalledWith({ + reference: { field: 'some.field', dataType: defaultDataTypeMock }, + newValue: finalValuePlainText, + }); }); it('should show aria-describedby if textResourceBindings.description is present', async () => { @@ -112,7 +119,10 @@ describe('InputComponent', () => { const inputComponent = screen.getByRole('textbox'); await userEvent.type(inputComponent, typedValue); expect(inputComponent).toHaveValue(formattedValue); - expect(formDataMethods.setLeafValue).toHaveBeenCalledWith({ path: 'some.field', newValue: typedValue }); + expect(formDataMethods.setLeafValue).toHaveBeenCalledWith({ + reference: { field: 'some.field', dataType: defaultDataTypeMock }, + newValue: typedValue, + }); expect(inputComponent).toHaveValue(formattedValue); }); @@ -132,7 +142,10 @@ describe('InputComponent', () => { const inputComponent = screen.getByRole('textbox'); await userEvent.type(inputComponent, typedValue); expect(inputComponent).toHaveValue(formattedValue); - expect(formDataMethods.setLeafValue).toHaveBeenCalledWith({ path: 'some.field', newValue: typedValue }); + expect(formDataMethods.setLeafValue).toHaveBeenCalledWith({ + reference: { field: 'some.field', dataType: defaultDataTypeMock }, + newValue: typedValue, + }); expect(inputComponent).toHaveValue(formattedValue); }); @@ -145,7 +158,7 @@ describe('InputComponent', () => { readOnly: false, required: false, dataModelBindings: { - simpleBinding: 'some.field', + simpleBinding: { dataType: defaultDataTypeMock, field: 'some.field' }, }, ...component, }, diff --git a/src/layout/Input/InputComponent.tsx b/src/layout/Input/InputComponent.tsx index 8043250958..17ce88a43b 100644 --- a/src/layout/Input/InputComponent.tsx +++ b/src/layout/Input/InputComponent.tsx @@ -17,6 +17,7 @@ export type IInputProps = PropsFromGenericComponent<'Input'>; import type { TextfieldProps } from '@digdir/designsystemet-react/dist/types/components/form/Textfield/Textfield'; +import { FD } from 'src/features/formData/FormDataWrite'; import { useIsValid } from 'src/features/validation/selectors/isValid'; import { ComponentStructureWrapper } from 'src/layout/ComponentStructureWrapper'; @@ -85,8 +86,8 @@ export const InputComponent: React.FunctionComponent = ({ node, ove const { formData: { simpleBinding: formValue }, setValue, - debounce, } = useDataModelBindings(dataModelBindings, saveWhileTyping); + const debounce = FD.useDebounceImmediately(); const { langAsString } = useLanguage(); diff --git a/src/layout/InstantiationButton/InstantiationButton.tsx b/src/layout/InstantiationButton/InstantiationButton.tsx index 607983dab0..6dcc65900c 100644 --- a/src/layout/InstantiationButton/InstantiationButton.tsx +++ b/src/layout/InstantiationButton/InstantiationButton.tsx @@ -8,6 +8,7 @@ import type { IInstantiationButtonComponentProvidedProps } from 'src/layout/Inst type Props = Omit, 'text'>; +// TODO(Datamodels): This uses mapping and therefore only supports the "default" data model export const InstantiationButton = ({ children, ...props }: Props) => { const { instantiateWithPrefill, error, isLoading } = useInstantiation(); const prefill = FD.useMapping(props.mapping); diff --git a/src/layout/LayoutComponent.tsx b/src/layout/LayoutComponent.tsx index 7aa8ca99f1..9d36768ac5 100644 --- a/src/layout/LayoutComponent.tsx +++ b/src/layout/LayoutComponent.tsx @@ -19,7 +19,12 @@ import type { DisplayData, DisplayDataProps } from 'src/features/displayData'; import type { SimpleEval } from 'src/features/expressions'; import type { ExprResolved, ExprVal } from 'src/features/expressions/types'; import type { ComponentValidation, ValidationDataSources } from 'src/features/validation'; -import type { ComponentBase, FormComponentProps, SummarizableComponentProps } from 'src/layout/common.generated'; +import type { + ComponentBase, + FormComponentProps, + IDataModelReference, + SummarizableComponentProps, +} from 'src/layout/common.generated'; import type { FormDataSelector, PropsFromGenericComponent, ValidateEmptyField } from 'src/layout/index'; import type { CompExternalExact, @@ -310,7 +315,7 @@ abstract class _FormComponent extends AnyComponent name = key, ): [string[], undefined] | [undefined, JSONSchema7] { const { item, lookupBinding } = ctx; - const value = (item.dataModelBindings ?? {})[key] ?? ''; + const value: IDataModelReference = (item.dataModelBindings ?? {})[key] ?? undefined; if (!value) { if (isRequired) { @@ -415,8 +420,8 @@ export abstract class FormComponent const validations: ComponentValidation[] = []; - for (const [bindingKey, field] of Object.entries(dataModelBindings) as [string, string][]) { - const data = formDataSelector(field) ?? invalidDataSelector(field); + for (const [bindingKey, reference] of Object.entries(dataModelBindings as Record)) { + const data = formDataSelector(reference) ?? invalidDataSelector(reference); const asString = typeof data === 'string' || typeof data === 'number' || typeof data === 'boolean' ? String(data) : ''; const trb = nodeDataSelector((picker) => picker(node)?.item?.textResourceBindings, [node]); diff --git a/src/layout/Likert/Generator/LikertGeneratorChildren.tsx b/src/layout/Likert/Generator/LikertGeneratorChildren.tsx index 9f0f7d5a08..d5ae9702e5 100644 --- a/src/layout/Likert/Generator/LikertGeneratorChildren.tsx +++ b/src/layout/Likert/Generator/LikertGeneratorChildren.tsx @@ -16,6 +16,7 @@ import { mutateMapping, } from 'src/utils/layout/generator/NodeRepeatingChildren'; import { NodesInternal } from 'src/utils/layout/NodesContext'; +import type { IDataModelReference } from 'src/layout/common.generated'; import type { CompExternalExact, CompIntermediate } from 'src/layout/layout'; import type { ChildClaims } from 'src/utils/layout/generator/GeneratorContext'; import type { LayoutNode } from 'src/utils/layout/LayoutNode'; @@ -59,7 +60,7 @@ function PerformWork() { interface GenerateRowProps { rowIndex: number; rowUuid: string; - questionsBinding: string; + questionsBinding: IDataModelReference; } function _GenerateRow({ rowIndex, rowUuid, questionsBinding }: GenerateRowProps) { diff --git a/src/layout/Likert/LikertTestUtils.tsx b/src/layout/Likert/LikertTestUtils.tsx index 0210d98df9..8e7b38b86c 100644 --- a/src/layout/Likert/LikertTestUtils.tsx +++ b/src/layout/Likert/LikertTestUtils.tsx @@ -5,6 +5,8 @@ import { screen, within } from '@testing-library/react'; import { v4 as uuidv4 } from 'uuid'; import type { AxiosResponse } from 'axios'; +import { defaultMockDataElementId } from 'src/__mocks__/getInstanceDataMock'; +import { defaultDataTypeMock } from 'src/__mocks__/getLayoutSetsMock'; import { ALTINN_ROW_ID } from 'src/features/formData/types'; import { type BackendValidationIssue, BackendValidationSeverity } from 'src/features/validation'; import { LikertComponent } from 'src/layout/Likert/LikertComponent'; @@ -41,6 +43,7 @@ export const generateValidations = (validations: { index: number; message: strin ({ customTextKey: message, field: `${groupBinding}[${index}].${answerBinding}`, + dataElementId: defaultMockDataElementId, severity: BackendValidationSeverity.Error, source: 'custom', showImmediately: true, @@ -79,8 +82,8 @@ const createLikertLayout = (props: Partial | undefined): Com questions: 'likert-questions', }, dataModelBindings: { - answer: `${groupBinding}.${answerBinding}`, - questions: groupBinding, + answer: { dataType: defaultDataTypeMock, field: `${groupBinding}.${answerBinding}` }, + questions: { dataType: defaultDataTypeMock, field: groupBinding }, }, optionsId: 'option-test', readOnly: false, @@ -89,7 +92,10 @@ const createLikertLayout = (props: Partial | undefined): Com }); export const createFormDataUpdateProp = (index: number, optionValue: string): FDNewValue => ({ - path: `Questions[${index}].Answer`, + reference: { + dataType: defaultDataTypeMock, + field: `Questions[${index}].Answer`, + }, newValue: optionValue, }); diff --git a/src/layout/LikertItem/index.tsx b/src/layout/LikertItem/index.tsx index 76c761ecfe..aa6834b9a4 100644 --- a/src/layout/LikertItem/index.tsx +++ b/src/layout/LikertItem/index.tsx @@ -48,26 +48,20 @@ export class LikertItem extends LikertItemDef { } validateDataModelBindings(ctx: LayoutValidationCtx<'LikertItem'>): string[] { - const [answerErr, answer] = this.validateDataModelBindingsAny(ctx, 'simpleBinding', [ - 'string', - 'number', - 'boolean', - ]); - const errors: string[] = [...(answerErr || [])]; + const [answerErr] = this.validateDataModelBindingsAny(ctx, 'simpleBinding', ['string', 'number', 'boolean']); + const errors: string[] = [...(answerErr ?? [])]; const parentBindings = ctx.nodeDataSelector( (picker) => picker(ctx.node.parent as LayoutNode<'Likert'>)?.layout?.dataModelBindings, [ctx.node.parent], ); const bindings = ctx.item.dataModelBindings; - if ( - answer && - bindings && - bindings.simpleBinding && - parentBindings && - parentBindings.questions && - bindings.simpleBinding.startsWith(`${parentBindings.questions}.`) - ) { + + if (parentBindings?.questions.dataType && bindings.simpleBinding.dataType !== parentBindings.questions.dataType) { + errors.push('answer-datamodellbindingen må peke på samme datatype som questions-datamodellbindingen'); + } + + if (parentBindings?.questions && !bindings.simpleBinding.field.startsWith(`${parentBindings.questions.field}[`)) { errors.push(`answer-datamodellbindingen må peke på en egenskap inne i questions-datamodellbindingen`); } diff --git a/src/layout/List/ListComponent.test.tsx b/src/layout/List/ListComponent.test.tsx index 9d704902b9..1df4e7b42c 100644 --- a/src/layout/List/ListComponent.test.tsx +++ b/src/layout/List/ListComponent.test.tsx @@ -4,6 +4,7 @@ import { jest } from '@jest/globals'; import { act, screen, waitFor } from '@testing-library/react'; import { userEvent } from '@testing-library/user-event'; +import { defaultDataTypeMock } from 'src/__mocks__/getLayoutSetsMock'; import { useDataModelBindings } from 'src/features/formData/useDataModelBindings'; import { ListComponent } from 'src/layout/List/ListComponent'; import { renderGenericComponentTest } from 'src/test/renderWithProviders'; @@ -84,9 +85,9 @@ const render = async ({ component, ...rest }: Partial { expect(formDataMethods.setMultiLeafValues).toHaveBeenCalledWith({ debounceTimeout: undefined, changes: [ - { path: 'CountryName', newValue: 'Sweden' }, - { path: 'CountryPopulation', newValue: 10 }, - { path: 'CountryHighestMountain', newValue: 1738 }, + { reference: { field: 'CountryName', dataType: defaultDataTypeMock }, newValue: 'Sweden' }, + { reference: { field: 'CountryPopulation', dataType: defaultDataTypeMock }, newValue: 10 }, + { reference: { field: 'CountryHighestMountain', dataType: defaultDataTypeMock }, newValue: 1738 }, ], }); expect(screen.getByTestId('render-count')).toHaveTextContent('2'); @@ -168,9 +169,9 @@ describe('ListComponent', () => { expect(formDataMethods.setMultiLeafValues).toHaveBeenCalledWith({ debounceTimeout: undefined, changes: [ - { path: 'CountryName', newValue: 'Denmark' }, - { path: 'CountryPopulation', newValue: 6 }, - { path: 'CountryHighestMountain', newValue: 170 }, + { reference: { field: 'CountryName', dataType: defaultDataTypeMock }, newValue: 'Denmark' }, + { reference: { field: 'CountryPopulation', dataType: defaultDataTypeMock }, newValue: 6 }, + { reference: { field: 'CountryHighestMountain', dataType: defaultDataTypeMock }, newValue: 170 }, ], }); expect(screen.getByTestId('render-count')).toHaveTextContent('3'); diff --git a/src/layout/List/ListComponent.tsx b/src/layout/List/ListComponent.tsx index 444af578a8..bde9062830 100644 --- a/src/layout/List/ListComponent.tsx +++ b/src/layout/List/ListComponent.tsx @@ -26,7 +26,16 @@ const defaultBindings: IDataModelBindingsForList = {}; export const ListComponent = ({ node }: IListProps) => { const item = useNodeItem(node); - const { tableHeaders, pagination, sortableColumns, tableHeadersMobile, mapping, secure, dataListId } = item; + const { + tableHeaders, + pagination, + sortableColumns, + tableHeadersMobile, + mapping, + queryParameters, + secure, + dataListId, + } = item; const { langAsString, language, lang } = useLanguage(); const [pageSize, setPageSize] = useState(pagination?.default || 0); const [pageNumber, setPageNumber] = useState(0); @@ -42,8 +51,7 @@ export const ListComponent = ({ node }: IListProps) => { }) as Filter, [pageNumber, pageSize, sortColumn, sortDirection], ); - const { data } = useDataListQuery(filter, dataListId, secure, mapping); - + const { data } = useDataListQuery(filter, dataListId, secure, mapping, queryParameters); const calculatedDataList = (data && data.listItems) || defaultDataList; const bindings = item.dataModelBindings || defaultBindings; diff --git a/src/layout/List/config.ts b/src/layout/List/config.ts index 4020afe2d2..c18244f567 100644 --- a/src/layout/List/config.ts +++ b/src/layout/List/config.ts @@ -20,12 +20,14 @@ export const Config = new CG.component({ renderInTabs: true, }, functionality: { - customExpressions: false, + customExpressions: true, }, }) .extends(CG.common('LabeledComponentProps')) .extendTextResources(CG.common('TRBLabel')) - .addDataModelBinding(new CG.obj().optional().additionalProperties(new CG.str()).exportAs('IDataModelBindingsForList')) + .addDataModelBinding( + new CG.obj().optional().additionalProperties(new CG.dataModelBinding()).exportAs('IDataModelBindingsForList'), + ) .addProperty( new CG.prop( 'tableHeaders', @@ -95,16 +97,36 @@ export const Config = new CG.component({ .setDescription('Boolean value indicating if the options should be instance aware. Defaults to false.'), ), ) - .addProperty(new CG.prop('mapping', CG.common('IMapping').optional())) + .addProperty( + new CG.prop( + 'mapping', + CG.common('IMapping') + .optional() + .setDeprecated('Will be removed in the next major version. Use `queryParameters` with expressions instead.'), + ), + ) + .addProperty(new CG.prop('queryParameters', CG.common('IQueryParameters').optional())) + .addProperty( + new CG.prop( + 'summaryBinding', + new CG.str() + .optional() + .setTitle('Data model binding to show in summary') + .setDescription( + 'Specify one of the keys in the `dataModelBindings` object to show in the summary component for the list.', + ), + ), + ) .addProperty( new CG.prop( 'bindingToShowInSummary', new CG.str() .optional() .setTitle('Binding to show in summary') + .setDeprecated('This property will be removed in the next major version, use `summaryBinding` instead.') .setDescription( - 'The value of this binding will be shown in the summary component for the list. This binding must be one ' + - 'of the specified bindings under dataModelBindings.', + 'The value of this binding will be shown in the summary component for the list. ' + + 'It expects a path in the datamodel. The binding must be one of the specified bindings under dataModelBindings.', ), ), ) diff --git a/src/layout/List/index.tsx b/src/layout/List/index.tsx index 063508ff08..4a16b77094 100644 --- a/src/layout/List/index.tsx +++ b/src/layout/List/index.tsx @@ -1,6 +1,7 @@ import React, { forwardRef } from 'react'; import type { JSX } from 'react'; +import { evalQueryParameters } from 'src/features/options/evalQueryParameters'; import { FrontendValidationSource, ValidationMask } from 'src/features/validation'; import { ListDef } from 'src/layout/List/config.def.generated'; import { ListComponent } from 'src/layout/List/ListComponent'; @@ -11,7 +12,7 @@ import type { LayoutValidationCtx } from 'src/features/devtools/layoutValidation import type { DisplayDataProps } from 'src/features/displayData'; import type { ComponentValidation, ValidationDataSources } from 'src/features/validation'; import type { PropsFromGenericComponent } from 'src/layout'; -import type { SummaryRendererProps } from 'src/layout/LayoutComponent'; +import type { ExprResolver, SummaryRendererProps } from 'src/layout/LayoutComponent'; import type { Summary2Props } from 'src/layout/Summary2/SummaryComponent2/types'; import type { LayoutNode } from 'src/utils/layout/LayoutNode'; @@ -25,10 +26,16 @@ export class List extends ListDef { getDisplayData(node: LayoutNode<'List'>, { nodeFormDataSelector, nodeDataSelector }: DisplayDataProps): string { const formData = nodeFormDataSelector(node); const dmBindings = nodeDataSelector((picker) => picker(node)?.layout.dataModelBindings, [node]); - const dmBindingForSummary = nodeDataSelector((picker) => picker(node)?.item?.bindingToShowInSummary, [node]); - for (const [key, binding] of Object.entries(dmBindings || {})) { - if (binding == dmBindingForSummary) { - return formData[key] || ''; + const summaryBinding = nodeDataSelector((picker) => picker(node)?.item?.summaryBinding, [node]); + const legacySummaryBinding = nodeDataSelector((picker) => picker(node)?.item?.bindingToShowInSummary, [node]); + + if (summaryBinding && dmBindings) { + return formData[summaryBinding] ?? ''; + } else if (legacySummaryBinding && dmBindings) { + for (const [key, binding] of Object.entries(dmBindings)) { + if (binding.field === legacySummaryBinding) { + return formData[key] ?? ''; + } } } @@ -66,13 +73,13 @@ export class List extends ListDef { return []; } - const fields = Object.values(dataModelBindings); + const references = Object.values(dataModelBindings); const validations: ComponentValidation[] = []; const textResourceBindings = nodeDataSelector((picker) => picker(node)?.item?.textResourceBindings, [node]); let listHasErrors = false; - for (const field of fields) { - const data = formDataSelector(field) ?? invalidDataSelector(field); + for (const reference of references) { + const data = formDataSelector(reference) ?? invalidDataSelector(reference); const dataAsString = typeof data === 'string' || typeof data === 'number' || typeof data === 'boolean' ? String(data) : undefined; @@ -104,10 +111,10 @@ export class List extends ListDef { } validateDataModelBindings(ctx: LayoutValidationCtx<'List'>): string[] { - const possibleBindings = Object.keys(ctx.item.tableHeaders || {}); + const possibleBindings = Object.keys(ctx.item.tableHeaders ?? {}); const errors: string[] = []; - for (const binding of possibleBindings) { + for (const binding of Object.keys(ctx.item.dataModelBindings ?? {})) { if (possibleBindings.includes(binding)) { const [newErrors] = this.validateDataModelBindingsAny( ctx, @@ -125,4 +132,11 @@ export class List extends ListDef { return errors; } + + evalExpressions(props: ExprResolver<'List'>) { + return { + ...this.evalDefaultExpressions(props), + queryParameters: evalQueryParameters(props), + }; + } } diff --git a/src/layout/Map/MapComponent.test.tsx b/src/layout/Map/MapComponent.test.tsx index 031c99d5b7..871ef00f32 100644 --- a/src/layout/Map/MapComponent.test.tsx +++ b/src/layout/Map/MapComponent.test.tsx @@ -3,8 +3,10 @@ import React from 'react'; import { screen } from '@testing-library/react'; import { userEvent } from '@testing-library/user-event'; +import { defaultDataTypeMock } from 'src/__mocks__/getLayoutSetsMock'; import { MapComponent } from 'src/layout/Map/MapComponent'; import { renderGenericComponentTest } from 'src/test/renderWithProviders'; +import type { FDNewValue } from 'src/features/formData/FormDataWriteStateMachine'; import type { RenderGenericComponentTestProps } from 'src/test/renderWithProviders'; const user = userEvent.setup(); @@ -18,7 +20,7 @@ const render = async ({ component, ...rest }: Partial { await clickMap(container); expect(formDataMethods.setLeafValue).toHaveBeenCalledWith({ - path: 'myCoords', + reference: { dataType: defaultDataTypeMock, field: 'myCoords' }, newValue: '64.886265,12.832031', - }); + } satisfies FDNewValue); }); it('should call onClick with longitude between 180 and -180 even when map is wrapped', async () => { @@ -94,9 +96,9 @@ describe('MapComponent', () => { await clickMap(container, 2500, 0); // Click so that the world is wrapped expect(formDataMethods.setLeafValue).toHaveBeenCalledWith({ - path: 'myCoords', + reference: { dataType: defaultDataTypeMock, field: 'myCoords' }, newValue: '64.886265,-127.441406', - }); + } satisfies FDNewValue); }); it('should not call onClick when readOnly is true and map is clicked', async () => { @@ -113,17 +115,17 @@ describe('MapComponent', () => { await clickMap(container); expect(formDataMethods.setLeafValue).toHaveBeenCalledTimes(1); expect(formDataMethods.setLeafValue).toHaveBeenCalledWith({ - path: 'myCoords', + reference: { dataType: defaultDataTypeMock, field: 'myCoords' }, newValue: '64.886265,12.832031', - }); + } satisfies FDNewValue); // Second click at different location await clickMap(container, 50, 50); expect(formDataMethods.setLeafValue).toHaveBeenCalledTimes(2); expect(formDataMethods.setLeafValue).toHaveBeenLastCalledWith({ - path: 'myCoords', + reference: { dataType: defaultDataTypeMock, field: 'myCoords' }, newValue: '64.885810,12.833104', - }); + } satisfies FDNewValue); }); it('should display attribution link', async () => { diff --git a/src/layout/Map/config.ts b/src/layout/Map/config.ts index 6e7e88b488..96d25ede57 100644 --- a/src/layout/Map/config.ts +++ b/src/layout/Map/config.ts @@ -18,10 +18,10 @@ export const Config = new CG.component({ }) .addDataModelBinding( new CG.obj( - new CG.prop('simpleBinding', new CG.str().optional()), + new CG.prop('simpleBinding', new CG.dataModelBinding().optional()), new CG.prop( 'geometries', - new CG.str() + new CG.dataModelBinding() .optional() .setDescription('Should point to an array of objects like {data: string, label: string}'), ), diff --git a/src/layout/MultipleSelect/MultipleSelectComponent.test.tsx b/src/layout/MultipleSelect/MultipleSelectComponent.test.tsx index f97b5bddc2..79fbca7241 100644 --- a/src/layout/MultipleSelect/MultipleSelectComponent.test.tsx +++ b/src/layout/MultipleSelect/MultipleSelectComponent.test.tsx @@ -3,6 +3,7 @@ import React from 'react'; import { screen, waitFor } from '@testing-library/react'; import { userEvent } from '@testing-library/user-event'; +import { defaultDataTypeMock } from 'src/__mocks__/getLayoutSetsMock'; import { MultipleSelectComponent } from 'src/layout/MultipleSelect/MultipleSelectComponent'; import { renderGenericComponentTest } from 'src/test/renderWithProviders'; import type { RenderGenericComponentTestProps } from 'src/test/renderWithProviders'; @@ -19,7 +20,7 @@ const render = async ({ component, ...rest }: Partial ), component: { - dataModelBindings: { simpleBinding: 'someField' }, + dataModelBindings: { simpleBinding: { dataType: defaultDataTypeMock, field: 'someField' } }, options: [ { value: 'value1', label: 'label1' }, { value: 'value2', label: 'label2' }, @@ -55,7 +56,10 @@ describe('MultipleSelect', () => { await userEvent.click(screen.getByRole('button', { name: /Slett label2/i })); await waitFor(() => - expect(formDataMethods.setLeafValue).toHaveBeenCalledWith({ path: 'someField', newValue: 'value1,value3' }), + expect(formDataMethods.setLeafValue).toHaveBeenCalledWith({ + reference: { field: 'someField', dataType: defaultDataTypeMock }, + newValue: 'value1,value3', + }), ); }); }); diff --git a/src/layout/MultipleSelect/MultipleSelectComponent.tsx b/src/layout/MultipleSelect/MultipleSelectComponent.tsx index 05f6482db1..68668d3078 100644 --- a/src/layout/MultipleSelect/MultipleSelectComponent.tsx +++ b/src/layout/MultipleSelect/MultipleSelectComponent.tsx @@ -21,8 +21,8 @@ export function MultipleSelectComponent({ node, overrideDisplay }: IMultipleSele const item = useNodeItem(node); const isValid = useIsValid(node); const { id, readOnly, textResourceBindings, alertOnChange } = item; - const debounce = FD.useDebounceImmediately(); const { options, isFetching, selectedValues, setData } = useGetOptions(node, 'multi'); + const debounce = FD.useDebounceImmediately(); const { langAsString, lang } = useLanguage(node); const changeMessageGenerator = useCallback( diff --git a/src/layout/NavigationBar/NavigationBarComponent.test.tsx b/src/layout/NavigationBar/NavigationBarComponent.test.tsx index aa03fd9ba1..c3f3383942 100644 --- a/src/layout/NavigationBar/NavigationBarComponent.test.tsx +++ b/src/layout/NavigationBar/NavigationBarComponent.test.tsx @@ -3,6 +3,7 @@ import React from 'react'; import { screen } from '@testing-library/react'; import { userEvent } from '@testing-library/user-event'; +import { defaultDataTypeMock } from 'src/__mocks__/getLayoutSetsMock'; import { NavigationBarComponent } from 'src/layout/NavigationBar/NavigationBarComponent'; import { mockMediaQuery } from 'src/test/mockMediaQuery'; import { renderGenericComponentTest } from 'src/test/renderWithProviders'; @@ -34,7 +35,7 @@ const render = async () => title: 'page1', }, dataModelBindings: { - simpleBinding: 'InternInformasjon.periodeFritekst', + simpleBinding: { dataType: defaultDataTypeMock, field: 'InternInformasjon.periodeFritekst' }, }, required: true, readOnly: false, @@ -56,7 +57,7 @@ const render = async () => title: 'page2', }, dataModelBindings: { - simpleBinding: 'InternInformasjon.raNummer', + simpleBinding: { dataType: defaultDataTypeMock, field: 'InternInformasjon.raNummer' }, }, required: true, readOnly: false, @@ -78,7 +79,10 @@ const render = async () => title: 'page3', }, dataModelBindings: { - simpleBinding: 'InternInformasjon.sendtFraSluttbrukersystem', + simpleBinding: { + dataType: defaultDataTypeMock, + field: 'InternInformasjon.sendtFraSluttbrukersystem', + }, }, required: true, readOnly: false, diff --git a/src/layout/NavigationButtons/NavigationButtonsComponent.test.tsx b/src/layout/NavigationButtons/NavigationButtonsComponent.test.tsx index b88604e024..684b15c728 100644 --- a/src/layout/NavigationButtons/NavigationButtonsComponent.test.tsx +++ b/src/layout/NavigationButtons/NavigationButtonsComponent.test.tsx @@ -2,6 +2,7 @@ import React from 'react'; import { screen } from '@testing-library/react'; +import { defaultDataTypeMock } from 'src/__mocks__/getLayoutSetsMock'; import { NavigationButtonsComponent } from 'src/layout/NavigationButtons/NavigationButtonsComponent'; import { renderGenericComponentTest } from 'src/test/renderWithProviders'; import type { CompNavigationButtonsExternal } from 'src/layout/NavigationButtons/config.generated'; @@ -41,7 +42,7 @@ describe('NavigationButtons', () => { type: 'Input', id: 'mockId1', dataModelBindings: { - simpleBinding: 'mockDataBinding1', + simpleBinding: { dataType: defaultDataTypeMock, field: 'mockDataBinding1' }, }, readOnly: false, required: false, @@ -58,7 +59,7 @@ describe('NavigationButtons', () => { type: 'Input', id: 'mockId2', dataModelBindings: { - simpleBinding: 'mockDataBinding2', + simpleBinding: { dataType: defaultDataTypeMock, field: 'mockDataBinding2' }, }, readOnly: false, required: false, diff --git a/src/layout/RadioButtons/ControlledRadioGroup.test.tsx b/src/layout/RadioButtons/ControlledRadioGroup.test.tsx index af3ba9700b..0ccfe4781b 100644 --- a/src/layout/RadioButtons/ControlledRadioGroup.test.tsx +++ b/src/layout/RadioButtons/ControlledRadioGroup.test.tsx @@ -5,6 +5,7 @@ import { userEvent } from '@testing-library/user-event'; import type { AxiosResponse } from 'axios'; import { getFormDataMockForRepGroup } from 'src/__mocks__/getFormDataMockForRepGroup'; +import { defaultDataTypeMock } from 'src/__mocks__/getLayoutSetsMock'; import { ControlledRadioGroup } from 'src/layout/RadioButtons/ControlledRadioGroup'; import { renderGenericComponentTest } from 'src/test/renderWithProviders'; import type { IRawOption } from 'src/layout/common.generated'; @@ -39,7 +40,7 @@ const render = async ({ component, options, formData, groupData = getFormDataMoc component: { optionsId: 'countries', preselectedOptionIndex: undefined, - dataModelBindings: { simpleBinding: 'myRadio' }, + dataModelBindings: { simpleBinding: { dataType: defaultDataTypeMock, field: 'myRadio' } }, ...component, }, queries: { @@ -74,7 +75,10 @@ describe('RadioButtonsContainerComponent', () => { }); await waitFor(() => - expect(formDataMethods.setLeafValue).toHaveBeenCalledWith({ path: 'myRadio', newValue: 'sweden' }), + expect(formDataMethods.setLeafValue).toHaveBeenCalledWith({ + reference: { field: 'myRadio', dataType: defaultDataTypeMock }, + newValue: 'sweden', + }), ); }); @@ -125,7 +129,10 @@ describe('RadioButtonsContainerComponent', () => { await userEvent.click(denmark); await waitFor(() => - expect(formDataMethods.setLeafValue).toHaveBeenCalledWith({ path: 'myRadio', newValue: 'denmark' }), + expect(formDataMethods.setLeafValue).toHaveBeenCalledWith({ + reference: { field: 'myRadio', dataType: defaultDataTypeMock }, + newValue: 'denmark', + }), ); }); @@ -179,7 +186,10 @@ describe('RadioButtonsContainerComponent', () => { expect(formDataMethods.setLeafValue).not.toHaveBeenCalled(); await userEvent.click(getRadio({ name: /The value from the group is: Label for first/ })); - expect(formDataMethods.setLeafValue).toHaveBeenCalledWith({ path: 'myRadio', newValue: 'Value for first' }); + expect(formDataMethods.setLeafValue).toHaveBeenCalledWith({ + reference: { field: 'myRadio', dataType: defaultDataTypeMock }, + newValue: 'Value for first', + }); }); it('should present the options list in the order it is provided when sortOrder is not specified', async () => { diff --git a/src/layout/RepeatingGroup/Container/RepeatingGroupContainer.test.tsx b/src/layout/RepeatingGroup/Container/RepeatingGroupContainer.test.tsx index 2d626c77a4..2d62764d36 100644 --- a/src/layout/RepeatingGroup/Container/RepeatingGroupContainer.test.tsx +++ b/src/layout/RepeatingGroup/Container/RepeatingGroupContainer.test.tsx @@ -6,6 +6,8 @@ import { userEvent } from '@testing-library/user-event'; import { v4 as uuidv4 } from 'uuid'; import { getFormLayoutRepeatingGroupMock } from 'src/__mocks__/getFormLayoutGroupMock'; +import { defaultMockDataElementId } from 'src/__mocks__/getInstanceDataMock'; +import { defaultDataTypeMock } from 'src/__mocks__/getLayoutSetsMock'; import { ALTINN_ROW_ID } from 'src/features/formData/types'; import { type BackendValidationIssue, BackendValidationSeverity } from 'src/features/validation'; import { RepeatingGroupContainer } from 'src/layout/RepeatingGroup/Container/RepeatingGroupContainer'; @@ -37,7 +39,7 @@ async function render({ container, numRows = 3, validationIssues = [] }: IRender id: 'field1', type: 'Input', dataModelBindings: { - simpleBinding: 'Group.prop1', + simpleBinding: { dataType: defaultDataTypeMock, field: 'Group.prop1' }, }, showValidations: [], textResourceBindings: { @@ -50,7 +52,7 @@ async function render({ container, numRows = 3, validationIssues = [] }: IRender id: 'field2', type: 'Input', dataModelBindings: { - simpleBinding: 'Group.prop2', + simpleBinding: { dataType: defaultDataTypeMock, field: 'Group.prop2' }, }, showValidations: [], textResourceBindings: { @@ -63,7 +65,7 @@ async function render({ container, numRows = 3, validationIssues = [] }: IRender id: 'field3', type: 'Input', dataModelBindings: { - simpleBinding: 'Group.prop3', + simpleBinding: { dataType: defaultDataTypeMock, field: 'Group.prop3' }, }, showValidations: [], textResourceBindings: { @@ -76,7 +78,7 @@ async function render({ container, numRows = 3, validationIssues = [] }: IRender id: 'field4', type: 'Checkboxes', dataModelBindings: { - simpleBinding: 'Group.checkboxBinding', + simpleBinding: { dataType: defaultDataTypeMock, field: 'Group.checkboxBinding' }, }, showValidations: [], textResourceBindings: { @@ -92,7 +94,7 @@ async function render({ container, numRows = 3, validationIssues = [] }: IRender ...mockContainer, ...container, dataModelBindings: { - group: 'Group', + group: { dataType: defaultDataTypeMock, field: 'Group' }, }, }); @@ -218,6 +220,7 @@ describe('RepeatingGroupContainer', () => { { customTextKey: 'Feltet er feil', field: 'Group[0].prop1', + dataElementId: defaultMockDataElementId, severity: BackendValidationSeverity.Error, source: 'custom', } as BackendValidationIssue, @@ -245,6 +248,7 @@ describe('RepeatingGroupContainer', () => { { customTextKey: 'Feltet er feil', field: 'Group[0].prop1', + dataElementId: defaultMockDataElementId, severity: BackendValidationSeverity.Error, source: 'custom', } as BackendValidationIssue, diff --git a/src/layout/RepeatingGroup/EditContainer/RepeatingGroupEditContainer.test.tsx b/src/layout/RepeatingGroup/EditContainer/RepeatingGroupEditContainer.test.tsx index bc0843bf93..c4cd9f2fba 100644 --- a/src/layout/RepeatingGroup/EditContainer/RepeatingGroupEditContainer.test.tsx +++ b/src/layout/RepeatingGroup/EditContainer/RepeatingGroupEditContainer.test.tsx @@ -3,6 +3,7 @@ import React from 'react'; import { screen } from '@testing-library/react'; import { userEvent } from '@testing-library/user-event'; +import { defaultDataTypeMock } from 'src/__mocks__/getLayoutSetsMock'; import { getMultiPageGroupMock } from 'src/__mocks__/getMultiPageGroupMock'; import { ALTINN_ROW_ID } from 'src/features/formData/types'; import { RepeatingGroupsEditContainer } from 'src/layout/RepeatingGroup/EditContainer/RepeatingGroupsEditContainer'; @@ -25,7 +26,7 @@ describe('RepeatingGroupsEditContainer', () => { id: 'field1', type: 'Input', dataModelBindings: { - simpleBinding: 'Group.prop1', + simpleBinding: { dataType: defaultDataTypeMock, field: 'Group.prop1' }, }, textResourceBindings: { title: 'Title1', @@ -37,7 +38,7 @@ describe('RepeatingGroupsEditContainer', () => { id: 'field2', type: 'Input', dataModelBindings: { - simpleBinding: 'Group.prop2', + simpleBinding: { dataType: defaultDataTypeMock, field: 'Group.prop2' }, }, textResourceBindings: { title: 'Title2', @@ -49,7 +50,7 @@ describe('RepeatingGroupsEditContainer', () => { id: 'field3', type: 'Input', dataModelBindings: { - simpleBinding: 'Group.prop3', + simpleBinding: { dataType: defaultDataTypeMock, field: 'Group.prop3' }, }, textResourceBindings: { title: 'Title3', @@ -61,7 +62,7 @@ describe('RepeatingGroupsEditContainer', () => { id: 'field4', type: 'Checkboxes', dataModelBindings: { - simpleBinding: 'some-group.checkboxBinding', + simpleBinding: { dataType: defaultDataTypeMock, field: 'some-group.checkboxBinding' }, }, textResourceBindings: { title: 'Title4', diff --git a/src/layout/RepeatingGroup/Providers/OpenByDefaultProvider.test.tsx b/src/layout/RepeatingGroup/Providers/OpenByDefaultProvider.test.tsx index 43b5d2cb8e..099a827129 100644 --- a/src/layout/RepeatingGroup/Providers/OpenByDefaultProvider.test.tsx +++ b/src/layout/RepeatingGroup/Providers/OpenByDefaultProvider.test.tsx @@ -4,6 +4,7 @@ import { afterAll, beforeAll, jest } from '@jest/globals'; import { screen, waitFor } from '@testing-library/react'; import { userEvent } from '@testing-library/user-event'; +import { defaultDataTypeMock } from 'src/__mocks__/getLayoutSetsMock'; import { FD } from 'src/features/formData/FormDataWrite'; import { ALTINN_ROW_ID } from 'src/features/formData/types'; import { @@ -38,7 +39,7 @@ describe('openByDefault', () => { const { deleteRow } = useRepeatingGroup(); const { visibleRows, hiddenRows } = useRepeatingGroupRowState(); - const data = FD.useDebouncedPick('MyGroup'); + const data = FD.useDebouncedPick({ field: 'MyGroup', dataType: defaultDataTypeMock }); return ( <>
@@ -78,7 +79,7 @@ describe('openByDefault', () => { id: 'myGroup', type: 'RepeatingGroup', dataModelBindings: { - group: 'MyGroup', + group: { dataType: defaultDataTypeMock, field: 'MyGroup' }, }, children: ['name'], edit: { @@ -90,7 +91,7 @@ describe('openByDefault', () => { id: 'name', type: 'Input', dataModelBindings: { - simpleBinding: 'MyGroup.name', + simpleBinding: { dataType: defaultDataTypeMock, field: 'MyGroup.name' }, }, showValidations: [], }, diff --git a/src/layout/RepeatingGroup/Providers/RepeatingGroupContext.tsx b/src/layout/RepeatingGroup/Providers/RepeatingGroupContext.tsx index 376951f773..c07c4b0cba 100644 --- a/src/layout/RepeatingGroup/Providers/RepeatingGroupContext.tsx +++ b/src/layout/RepeatingGroup/Providers/RepeatingGroupContext.tsx @@ -435,7 +435,7 @@ function useExtendedRepeatingGroupState(node: LayoutNode<'RepeatingGroup'>): Ext } const uuid = uuidv4(); appendToList({ - path: groupBinding, + reference: groupBinding, newValue: { [ALTINN_ROW_ID]: uuid }, }); startAddingRow(uuid); @@ -484,7 +484,7 @@ function useExtendedRepeatingGroupState(node: LayoutNode<'RepeatingGroup'>): Ext const attachmentDeletionSuccessful = await onBeforeRowDeletion(row.index); if (attachmentDeletionSuccessful && groupBinding) { removeFromList({ - path: groupBinding, + reference: groupBinding, startAtIndex: row.index, callback: (item) => item[ALTINN_ROW_ID] === row.uuid, }); diff --git a/src/layout/RepeatingGroup/Summary/SummaryRepeatingGroup.test.tsx b/src/layout/RepeatingGroup/Summary/SummaryRepeatingGroup.test.tsx index 50a962d816..a1a392d19a 100644 --- a/src/layout/RepeatingGroup/Summary/SummaryRepeatingGroup.test.tsx +++ b/src/layout/RepeatingGroup/Summary/SummaryRepeatingGroup.test.tsx @@ -2,6 +2,7 @@ import React from 'react'; import { jest } from '@jest/globals'; +import { defaultDataTypeMock } from 'src/__mocks__/getLayoutSetsMock'; import { ALTINN_ROW_ID } from 'src/features/formData/types'; import { SummaryRepeatingGroup } from 'src/layout/RepeatingGroup/Summary/SummaryRepeatingGroup'; import { renderWithNode } from 'src/test/renderWithProviders'; @@ -60,7 +61,7 @@ describe('SummaryGroupComponent', () => { type: 'RepeatingGroup', id: 'groupComponent', dataModelBindings: { - group: 'mockGroup', + group: { dataType: defaultDataTypeMock, field: 'mockGroup' }, }, textResourceBindings: { title: 'mockGroupTitle', @@ -75,7 +76,7 @@ describe('SummaryGroupComponent', () => { type: 'Input', id: 'mockId1', dataModelBindings: { - simpleBinding: 'mockGroup.mockDataBinding1', + simpleBinding: { dataType: defaultDataTypeMock, field: 'mockGroup.mockDataBinding1' }, }, readOnly: false, required: false, @@ -88,7 +89,7 @@ describe('SummaryGroupComponent', () => { type: 'Input', id: 'mockId2', dataModelBindings: { - simpleBinding: 'mockGroup.mockDataBinding2', + simpleBinding: { dataType: defaultDataTypeMock, field: 'mockGroup.mockDataBinding2' }, }, readOnly: false, required: false, diff --git a/src/layout/RepeatingGroup/Table/RepeatingGroupTable.test.tsx b/src/layout/RepeatingGroup/Table/RepeatingGroupTable.test.tsx index 31436e4e84..865e9e5687 100644 --- a/src/layout/RepeatingGroup/Table/RepeatingGroupTable.test.tsx +++ b/src/layout/RepeatingGroup/Table/RepeatingGroupTable.test.tsx @@ -6,6 +6,7 @@ import ResizeObserverModule from 'resize-observer-polyfill'; import { v4 as uuidv4 } from 'uuid'; import { getFormLayoutRepeatingGroupMock } from 'src/__mocks__/getFormLayoutGroupMock'; +import { defaultDataTypeMock } from 'src/__mocks__/getLayoutSetsMock'; import { ALTINN_ROW_ID } from 'src/features/formData/types'; import { RepeatingGroupProvider, @@ -41,7 +42,7 @@ describe('RepeatingGroupTable', () => { id: 'field1', type: 'Input', dataModelBindings: { - simpleBinding: 'some-group.prop1', + simpleBinding: { dataType: defaultDataTypeMock, field: 'some-group.prop1' }, }, showValidations: [], textResourceBindings: { @@ -54,7 +55,7 @@ describe('RepeatingGroupTable', () => { id: 'field2', type: 'Input', dataModelBindings: { - simpleBinding: 'some-group.prop2', + simpleBinding: { dataType: defaultDataTypeMock, field: 'some-group.prop2' }, }, showValidations: [], textResourceBindings: { @@ -67,7 +68,7 @@ describe('RepeatingGroupTable', () => { id: 'field3', type: 'Input', dataModelBindings: { - simpleBinding: 'some-group.prop3', + simpleBinding: { dataType: defaultDataTypeMock, field: 'some-group.prop3' }, }, showValidations: [], textResourceBindings: { @@ -80,7 +81,7 @@ describe('RepeatingGroupTable', () => { id: 'field4', type: 'Checkboxes', dataModelBindings: { - simpleBinding: 'some-group.checkboxBinding', + simpleBinding: { dataType: defaultDataTypeMock, field: 'some-group.checkboxBinding' }, }, showValidations: [], textResourceBindings: { @@ -131,7 +132,7 @@ describe('RepeatingGroupTable', () => { expect(formDataMethods.removeFromListCallback).toBeCalledTimes(1); expect(formDataMethods.removeFromListCallback).toBeCalledWith({ - path: 'some-group', + reference: { field: 'some-group', dataType: defaultDataTypeMock }, startAtIndex: 0, callback: expect.any(Function), }); diff --git a/src/layout/RepeatingGroup/config.ts b/src/layout/RepeatingGroup/config.ts index 131f65eacf..83d396d98a 100644 --- a/src/layout/RepeatingGroup/config.ts +++ b/src/layout/RepeatingGroup/config.ts @@ -129,7 +129,7 @@ export const Config = new CG.component({ new CG.obj( new CG.prop( 'group', - new CG.str() + new CG.dataModelBinding() .setTitle('Group') .setDescription( 'Dot notation location for a repeating group structure (array of objects), where the data is stored', diff --git a/src/layout/Summary/SummaryComponent.test.tsx b/src/layout/Summary/SummaryComponent.test.tsx index 6cbbf61721..e57b0777e9 100644 --- a/src/layout/Summary/SummaryComponent.test.tsx +++ b/src/layout/Summary/SummaryComponent.test.tsx @@ -2,6 +2,8 @@ import React from 'react'; import { fireEvent, screen } from '@testing-library/react'; +import { defaultMockDataElementId } from 'src/__mocks__/getInstanceDataMock'; +import { defaultDataTypeMock } from 'src/__mocks__/getLayoutSetsMock'; import { type BackendValidationIssue, BackendValidationSeverity } from 'src/features/validation'; import { SummaryComponent } from 'src/layout/Summary/SummaryComponent'; import { renderWithNode } from 'src/test/renderWithProviders'; @@ -19,7 +21,8 @@ describe('SummaryComponent', () => { ({ id: t, type: t, - dataModelBindings: t === 'Input' ? { simpleBinding: 'field' } : {}, + dataModelBindings: + t === 'Input' ? { simpleBinding: { dataType: defaultDataTypeMock, field: 'field' } } : {}, textResourceBindings: {}, children: [], maxCount: 10, @@ -57,6 +60,7 @@ describe('SummaryComponent', () => { { customTextKey: 'Error message', field: 'field', + dataElementId: defaultMockDataElementId, severity: BackendValidationSeverity.Error, source: 'custom', showImmediately: true, diff --git a/src/layout/Summary2/SummaryComponent2/SummaryComponent2.test.tsx b/src/layout/Summary2/SummaryComponent2/SummaryComponent2.test.tsx index 39ca149ef7..2ff25d4e46 100644 --- a/src/layout/Summary2/SummaryComponent2/SummaryComponent2.test.tsx +++ b/src/layout/Summary2/SummaryComponent2/SummaryComponent2.test.tsx @@ -2,6 +2,7 @@ import React from 'react'; import { screen } from '@testing-library/react'; +import { defaultDataTypeMock } from 'src/__mocks__/getLayoutSetsMock'; import { type BackendValidationIssue } from 'src/features/validation'; import { SummaryComponent2 } from 'src/layout/Summary2/SummaryComponent2/SummaryComponent2'; import { renderWithNode } from 'src/test/renderWithProviders'; @@ -21,7 +22,8 @@ describe('SummaryComponent', () => { ({ id: t, type: t, - dataModelBindings: t === 'Input' ? { simpleBinding: 'field' } : {}, + dataModelBindings: + t === 'Input' ? { simpleBinding: { dataType: defaultDataTypeMock, field: 'field' } } : {}, textResourceBindings: {}, children: [], maxCount: 10, @@ -87,7 +89,7 @@ describe('SummaryComponent', () => { { id: 'Input', type: 'Input', - dataModelBindings: { simpleBinding: 'field' }, + dataModelBindings: { simpleBinding: { dataType: defaultDataTypeMock, field: 'field' } }, required: true, }, ], @@ -116,7 +118,7 @@ describe('SummaryComponent', () => { { id: 'Input', type: 'Input', - dataModelBindings: { simpleBinding: 'field' }, + dataModelBindings: { simpleBinding: { dataType: defaultDataTypeMock, field: 'field' } }, forceShowInSummary: true, }, ], @@ -145,13 +147,13 @@ describe('SummaryComponent', () => { { id: 'Input', type: 'Input', - dataModelBindings: { simpleBinding: 'field' }, + dataModelBindings: { simpleBinding: { dataType: defaultDataTypeMock, field: 'field' } }, required: true, }, { id: 'Input2', type: 'Input', - dataModelBindings: { simpleBinding: 'field2' }, + dataModelBindings: { simpleBinding: { dataType: defaultDataTypeMock, field: 'field2' } }, required: true, }, ], @@ -180,13 +182,13 @@ describe('SummaryComponent', () => { { id: 'Input', type: 'Input', - dataModelBindings: { simpleBinding: 'field' }, + dataModelBindings: { simpleBinding: { dataType: defaultDataTypeMock, field: 'field' } }, required: false, }, { id: 'Input2', type: 'Input', - dataModelBindings: { simpleBinding: 'field2' }, + dataModelBindings: { simpleBinding: { dataType: defaultDataTypeMock, field: 'field2' } }, required: false, }, ], @@ -217,7 +219,7 @@ describe('SummaryComponent', () => { { id: 'Input', type: 'Input', - dataModelBindings: { simpleBinding: 'field' }, + dataModelBindings: { simpleBinding: { field: 'field', dataType: defaultDataTypeMock } }, required: false, }, ], @@ -252,7 +254,7 @@ describe('SummaryComponent', () => { { id: 'TextAreaId', type: 'TextArea', - dataModelBindings: { simpleBinding: 'field' }, + dataModelBindings: { simpleBinding: { field: 'field', dataType: defaultDataTypeMock } }, required: false, }, ], @@ -287,7 +289,7 @@ describe('SummaryComponent', () => { { id: 'RadioButtonsId', type: 'RadioButtons', - dataModelBindings: { simpleBinding: 'field' }, + dataModelBindings: { simpleBinding: { field: 'field', dataType: defaultDataTypeMock } }, required: false, }, ], @@ -322,7 +324,7 @@ describe('SummaryComponent', () => { { id: 'CheckboxesId', type: 'Checkboxes', - dataModelBindings: { simpleBinding: 'field' }, + dataModelBindings: { simpleBinding: { field: 'field', dataType: defaultDataTypeMock } }, required: false, }, ], @@ -357,7 +359,7 @@ describe('SummaryComponent', () => { { id: 'DropdownId', type: 'Dropdown', - dataModelBindings: { simpleBinding: 'field' }, + dataModelBindings: { simpleBinding: { field: 'field', dataType: defaultDataTypeMock } }, required: false, }, ], @@ -393,7 +395,7 @@ describe('SummaryComponent', () => { id: 'MultipleSelectPage', type: 'MultipleSelect', dataModelBindings: { - simpleBinding: 'multipleSelect', + simpleBinding: { field: 'multipleSelect', dataType: defaultDataTypeMock }, }, textResourceBindings: { title: 'MultipleSelectPage.MultipleSelect.title', diff --git a/src/layout/TextArea/TextAreaComponent.test.tsx b/src/layout/TextArea/TextAreaComponent.test.tsx index ba013c6a4d..009ce47b4c 100644 --- a/src/layout/TextArea/TextAreaComponent.test.tsx +++ b/src/layout/TextArea/TextAreaComponent.test.tsx @@ -3,6 +3,7 @@ import React from 'react'; import { screen } from '@testing-library/react'; import { userEvent } from '@testing-library/user-event'; +import { defaultDataTypeMock } from 'src/__mocks__/getLayoutSetsMock'; import { TextAreaComponent } from 'src/layout/TextArea/TextAreaComponent'; import { renderGenericComponentTest } from 'src/test/renderWithProviders'; import type { RenderGenericComponentTestProps } from 'src/test/renderWithProviders'; @@ -38,7 +39,7 @@ describe('TextAreaComponent', () => { await userEvent.type(textarea, addedText); expect(formDataMethods.setLeafValue).toHaveBeenCalledWith({ - path: 'myTextArea', + reference: { field: 'myTextArea', dataType: defaultDataTypeMock }, newValue: `${initialText}${addedText}`, }); }); @@ -102,7 +103,7 @@ const render = async ({ component, ...rest }: Partial , component: { dataModelBindings: { - simpleBinding: 'myTextArea', + simpleBinding: { dataType: defaultDataTypeMock, field: 'myTextArea' }, }, ...component, }, diff --git a/src/layout/TextArea/TextAreaComponent.tsx b/src/layout/TextArea/TextAreaComponent.tsx index ae16297445..c964337b8e 100644 --- a/src/layout/TextArea/TextAreaComponent.tsx +++ b/src/layout/TextArea/TextAreaComponent.tsx @@ -2,6 +2,7 @@ import React from 'react'; import { Textarea } from '@digdir/designsystemet-react'; +import { FD } from 'src/features/formData/FormDataWrite'; import { useDataModelBindings } from 'src/features/formData/useDataModelBindings'; import { useLanguage } from 'src/features/language/useLanguage'; import { useIsValid } from 'src/features/validation/selectors/isValid'; @@ -23,8 +24,8 @@ export function TextAreaComponent({ node, overrideDisplay }: ITextAreaProps) { const { formData: { simpleBinding: value }, setValue, - debounce, } = useDataModelBindings(dataModelBindings, saveWhileTyping); + const debounce = FD.useDebounceImmediately(); return ( ValidationFilterFunction[]; } -export type FormDataSelector = (path: string) => unknown; -export type FormDataRowsSelector = (path: string) => BaseRow[]; +export type FormDataSelector = (reference: IDataModelReference) => unknown; +export type FormDataRowsSelector = (reference: IDataModelReference) => BaseRow[]; export function implementsDisplayData(def: Def): def is Def & DisplayData> { return 'getDisplayData' in def && 'useDisplayData' in def; diff --git a/src/queries/formPrefetcher.ts b/src/queries/formPrefetcher.ts index bef5218790..06aca9e89e 100644 --- a/src/queries/formPrefetcher.ts +++ b/src/queries/formPrefetcher.ts @@ -1,20 +1,14 @@ import { usePrefetchQuery } from 'src/core/queries/usePrefetchQuery'; -import { useCustomValidationConfigQueryDef } from 'src/features/customValidation/CustomValidationContext'; -import { useDataModelSchemaQueryDef } from 'src/features/datamodel/DataModelSchemaProvider'; import { useCurrentDataModelGuid, useCurrentDataModelName } from 'src/features/datamodel/useBindingSchema'; import { useDynamicsQueryDef } from 'src/features/form/dynamics/DynamicsContext'; import { useLayoutQueryDef, useLayoutSetId } from 'src/features/form/layout/LayoutsContext'; import { useLayoutSettingsQueryDef } from 'src/features/form/layoutSettings/LayoutSettingsContext'; import { useRulesQueryDef } from 'src/features/form/rules/RulesContext'; import { useLaxInstance } from 'src/features/instance/InstanceContext'; -import { useLaxProcessData } from 'src/features/instance/ProcessContext'; -import { useCurrentLanguage } from 'src/features/language/LanguageProvider'; import { useOrderDetailsQueryDef } from 'src/features/payment/OrderDetailsProvider'; import { usePaymentInformationQueryDef } from 'src/features/payment/PaymentInformationProvider'; import { useHasPayment, useIsPayment } from 'src/features/payment/utils'; import { usePdfFormatQueryDef } from 'src/features/pdf/usePdfFormatQuery'; -import { useShouldValidateInitial } from 'src/features/validation/backendValidation/backendValidationUtils'; -import { useBackendValidationQueryDef } from 'src/features/validation/backendValidation/useBackendValidation'; import { useIsPdf } from 'src/hooks/useIsPdf'; /** @@ -22,35 +16,26 @@ import { useIsPdf } from 'src/hooks/useIsPdf'; */ export function FormPrefetcher() { const layoutSetId = useLayoutSetId(); - const dataTypeId = useCurrentDataModelName(); + const isPDF = useIsPdf(); + const dataTypeId = useCurrentDataModelName() ?? 'unknown'; + const instance = useLaxInstance(); - usePrefetchQuery(useLayoutQueryDef(true, layoutSetId)); - usePrefetchQuery(useCustomValidationConfigQueryDef(dataTypeId)); - usePrefetchQuery(useLayoutSettingsQueryDef(layoutSetId)); - usePrefetchQuery(useDynamicsQueryDef(layoutSetId)); - usePrefetchQuery(useRulesQueryDef(layoutSetId)); - usePrefetchQuery(useDataModelSchemaQueryDef(dataTypeId)); + // Prefetch layouts + usePrefetchQuery(useLayoutQueryDef(true, dataTypeId, layoutSetId)); - const currentTaskId = useLaxProcessData()?.currentTask?.elementId; - const currentLanguage = useCurrentLanguage(); - const instanceId = useLaxInstance()?.instanceId; const dataGuid = useCurrentDataModelGuid(); - const shouldValidateInitial = useShouldValidateInitial(); - // Prefetch validations if applicable - usePrefetchQuery( - useBackendValidationQueryDef(true, currentLanguage, instanceId, dataGuid, currentTaskId), - shouldValidateInitial, - ); + // Prefetch other layout related files + usePrefetchQuery(useLayoutSettingsQueryDef(layoutSetId)); + usePrefetchQuery(useDynamicsQueryDef(layoutSetId)); + usePrefetchQuery(useRulesQueryDef(layoutSetId)); // Prefetch payment data if applicable - usePrefetchQuery(usePaymentInformationQueryDef(useIsPayment(), instanceId)); - usePrefetchQuery(useOrderDetailsQueryDef(useHasPayment(), instanceId)); - - const isPDF = useIsPdf(); + usePrefetchQuery(usePaymentInformationQueryDef(useIsPayment(), instance?.instanceId)); + usePrefetchQuery(useOrderDetailsQueryDef(useHasPayment(), instance?.instanceId)); // Prefetch PDF format only if we are in PDF mode - usePrefetchQuery(usePdfFormatQueryDef(true, instanceId, dataGuid), isPDF); + usePrefetchQuery(usePdfFormatQueryDef(true, instance?.instanceId, dataGuid), isPDF); return null; } diff --git a/src/queries/queries.ts b/src/queries/queries.ts index ec34dd4eb5..c91118905e 100644 --- a/src/queries/queries.ts +++ b/src/queries/queries.ts @@ -16,7 +16,6 @@ import { getCreateInstancesUrl, getCustomValidationConfigUrl, getDataElementUrl, - getDataValidationUrl, getFetchFormDynamicsUrl, getFileTagUrl, getFileUploadUrl, @@ -32,6 +31,7 @@ import { getProcessStateUrl, getRulehandlerUrl, getSetCurrentPartyUrl, + getValidationUrl, instancesControllerUrl, instantiateUrl, profileApiUrl, @@ -44,7 +44,12 @@ import type { IncomingApplicationMetadata } from 'src/features/applicationMetada import type { IDataList } from 'src/features/dataLists'; import type { IFooterLayout } from 'src/features/footer/types'; import type { IFormDynamics } from 'src/features/form/dynamics'; -import type { IDataModelPatchRequest, IDataModelPatchResponse } from 'src/features/formData/types'; +import type { + IDataModelMultiPatchRequest, + IDataModelMultiPatchResponse, + IDataModelPatchRequest, + IDataModelPatchResponse, +} from 'src/features/formData/types'; import type { Instantiation } from 'src/features/instantiate/InstantiationContext'; import type { ITextResourceResult } from 'src/features/language/textResources'; import type { OrderDetails, PaymentResponsePayload } from 'src/features/payment/types'; @@ -156,6 +161,10 @@ export const doAttachmentRemove = async (instanceId: string, dataGuid: string, l export const doPatchFormData = (url: string, data: IDataModelPatchRequest) => httpPatch(url, data); +// New multi-patch endpoint for stateful apps +export const doPatchMultipleFormData = (url: string, data: IDataModelMultiPatchRequest) => + httpPatch(url, data); + // When saving data for stateless apps export const doPostStatelessFormData = async (url: string, data: object): Promise => (await httpPost(url, undefined, data)).data; @@ -236,11 +245,8 @@ export const fetchPaymentInformation = (instanceId: string, language?: string): export const fetchOrderDetails = (instanceId: string, language?: string): Promise => httpGet(getOrderDetailsUrl(instanceId, language)); -export const fetchBackendValidations = ( - instanceId: string, - currentDataElementId: string, - language: string, -): Promise => httpGet(getDataValidationUrl(instanceId, currentDataElementId, language)); +export const fetchBackendValidations = (instanceId: string, language: string): Promise => + httpGet(getValidationUrl(instanceId, language)); export const fetchLayoutSchema = async (): Promise => { // Hacky (and only) way to get the correct CDN url diff --git a/src/queries/staticOptionsPrefetcher.tsx b/src/queries/staticOptionsPrefetcher.tsx index 558848d159..f1b66c618f 100644 --- a/src/queries/staticOptionsPrefetcher.tsx +++ b/src/queries/staticOptionsPrefetcher.tsx @@ -8,6 +8,7 @@ import { useGetOptionsQueryDef } from 'src/features/options/useGetOptionsQuery'; import { duplicateStringFilter } from 'src/utils/stringHelper'; import { getOptionsUrl } from 'src/utils/urls/appUrlHelper'; import type { ISelectionComponent } from 'src/layout/common.generated'; +import type { ParamValue } from 'src/utils/urls/appUrlHelper'; type O = ISelectionComponent; @@ -28,7 +29,7 @@ export function StaticOptionPrefetcher() { !(c as O).mapping && // Check that no mapping exists (not dynamic) (!(c as O).queryParameters || // Check that there are only static parameters (no expressions) Object.values((c as O).queryParameters!).every( - (v) => typeof v === 'string' || typeof v === 'number' || typeof v === 'boolean', + (v) => typeof v === 'string' || typeof v === 'number' || typeof v === 'boolean' || v === null, )), ) ?? [], ) @@ -37,7 +38,7 @@ export function StaticOptionPrefetcher() { instanceId, language, optionsId: (c as O).optionsId!, - queryParameters: (c as O).queryParameters, + queryParameters: (c as O).queryParameters as Record, secure: (c as O).secure, }), ) diff --git a/src/test/allApps.ts b/src/test/allApps.ts index cd0e42123b..564a3db84a 100644 --- a/src/test/allApps.ts +++ b/src/test/allApps.ts @@ -8,6 +8,7 @@ import type { JSONSchema7 } from 'json-schema'; import { getInstanceDataMock } from 'src/__mocks__/getInstanceDataMock'; import { getProcessDataMock } from 'src/__mocks__/getProcessDataMock'; import { MINIMUM_APPLICATION_VERSION } from 'src/features/applicationMetadata/minVersion'; +import { cleanLayout } from 'src/features/form/layout/cleanLayout'; import { ALTINN_ROW_ID } from 'src/features/formData/types'; import type { IncomingApplicationMetadata } from 'src/features/applicationMetadata/types'; import type { ITextResourceResult } from 'src/features/language/textResources'; @@ -168,6 +169,7 @@ export class ExternalApp { if (!this.dirExists(layoutsDir)) { throw new Error(`Layout set '${setId}' folder not found`); } + const set = this.getRawLayoutSets().sets.find((s) => s.id === setId); const collection: ILayoutCollection = {}; for (const file of this.readDir(layoutsDir)) { @@ -175,7 +177,11 @@ export class ExternalApp { continue; } - collection[file.replace('.json', '')] = this.readJson(`${layoutsDir}/${file}`); + const pageKey = file.replace('.json', ''); + collection[pageKey] = this.readJson(`${layoutsDir}/${file}`); + + const cleaned = cleanLayout(collection[pageKey].data.layout, set?.dataType ?? 'unknown'); + collection[pageKey].data.layout = cleaned; } return collection; @@ -323,10 +329,10 @@ export class ExternalAppDataModel { for (const page of Object.keys(layouts)) { for (const comp of layouts[page].data.layout) { if (comp.type === 'RepeatingGroup' && comp.dataModelBindings?.group) { - groupsNeeded.push(comp.dataModelBindings.group); + groupsNeeded.push(comp.dataModelBindings.group.field); } if (comp.type === 'Likert' && comp.dataModelBindings?.questions) { - groupsNeeded.push(comp.dataModelBindings.questions); + groupsNeeded.push(comp.dataModelBindings.questions.field); } } } diff --git a/src/test/renderWithProviders.tsx b/src/test/renderWithProviders.tsx index 5b30cbfd6b..ce8abaac16 100644 --- a/src/test/renderWithProviders.tsx +++ b/src/test/renderWithProviders.tsx @@ -116,6 +116,7 @@ export const makeMutationMocks = any>( doAttachmentRemoveTag: makeMock('doAttachmentRemoveTag'), doAttachmentUpload: makeMock('doAttachmentUpload'), doPatchFormData: makeMock('doPatchFormData'), + doPatchMultipleFormData: makeMock('doPatchMultipleFormData'), doPostStatelessFormData: makeMock('doPostStatelessFormData'), doSetCurrentParty: makeMock('doSetCurrentParty'), doInstantiate: makeMock('doInstantiate'), diff --git a/src/utils/databindings.ts b/src/utils/databindings.ts index 48fc3f44b9..b0a850c22c 100644 --- a/src/utils/databindings.ts +++ b/src/utils/databindings.ts @@ -1,3 +1,5 @@ +import type { IDataModelReference } from 'src/layout/common.generated'; + export const GLOBAL_INDEX_KEY_INDICATOR_REGEX = /\[{\d+}]/g; export function getKeyWithoutIndex(keyWithIndex: string): string { @@ -26,3 +28,15 @@ export function getKeyIndex(keyWithIndex: string): number[] { const match = keyWithIndex.match(/\[\d+]/g) || []; return match.map((n) => parseInt(n.replace('[', '').replace(']', ''), 10)); } + +export function isDataModelReference(binding: unknown): binding is IDataModelReference { + return ( + typeof binding === 'object' && + binding != null && + !Array.isArray(binding) && + 'field' in binding && + typeof binding.field === 'string' && + 'dataType' in binding && + typeof binding.dataType === 'string' + ); +} diff --git a/src/utils/databindings/DataBinding.ts b/src/utils/databindings/DataBinding.ts index b51582e51b..f0f4557b68 100644 --- a/src/utils/databindings/DataBinding.ts +++ b/src/utils/databindings/DataBinding.ts @@ -1,3 +1,5 @@ +import type { IDataModelReference } from 'src/layout/common.generated'; + /** * Simple class to let you work with (and mutate) a data model binding (possibly including array index accessors) * It breaks the data binding into DataBindingPart classes. @@ -5,16 +7,19 @@ export class DataBinding { public readonly parts: DataBindingPart[]; - public constructor(public readonly binding: string) { - this.parts = binding.split('.').map((part, index) => new DataBindingPart(this, index, part)); + public constructor(public readonly binding: IDataModelReference) { + this.parts = binding.field.split('.').map((part, index) => new DataBindingPart(this, index, part)); } public at(index: number): DataBindingPart | undefined { return this.parts[index]; } - public toString(): string { - return this.parts.map((part) => part.toString()).join('.'); + public export(): IDataModelReference { + return { + dataType: this.binding.dataType, + field: this.parts.map((part) => part.toString()).join('.'), + }; } } @@ -50,8 +55,8 @@ export class DataBindingPart { } interface TransposeDataBindingParams { - subject: string; - currentLocation: string; + subject: IDataModelReference; + currentLocation: IDataModelReference; rowIndex?: number; currentLocationIsRepGroup?: boolean; } @@ -61,7 +66,11 @@ export function transposeDataBinding({ currentLocation, rowIndex, currentLocationIsRepGroup, -}: TransposeDataBindingParams): string { +}: TransposeDataBindingParams): IDataModelReference { + if (currentLocation.dataType !== subject.dataType) { + return subject; + } + const ourBinding = new DataBinding(currentLocation); const theirBinding = new DataBinding(subject); const lastIdx = ourBinding.parts.length - 1; @@ -88,5 +97,5 @@ export function transposeDataBinding({ theirs.arrayIndex = arrayIndex; } - return theirBinding.toString(); + return theirBinding.export(); } diff --git a/src/utils/layout/NodesContext.tsx b/src/utils/layout/NodesContext.tsx index 2cd7efeef1..d361a84fc2 100644 --- a/src/utils/layout/NodesContext.tsx +++ b/src/utils/layout/NodesContext.tsx @@ -18,11 +18,8 @@ import { useLayouts } from 'src/features/form/layout/LayoutsContext'; import { useLaxLayoutSettings, useLayoutSettings } from 'src/features/form/layoutSettings/LayoutSettingsContext'; import { FD } from 'src/features/formData/FormDataWrite'; import { OptionsStorePlugin } from 'src/features/options/OptionsStorePlugin'; -import { - LoadingBlockerWaitForValidation, - ProvideWaitForValidation, - UpdateExpressionValidation, -} from 'src/features/validation/validationContext'; +import { ExpressionValidation } from 'src/features/validation/expressionValidation/ExpressionValidation'; +import { LoadingBlockerWaitForValidation, ProvideWaitForValidation } from 'src/features/validation/validationContext'; import { ValidationStorePlugin } from 'src/features/validation/ValidationStorePlugin'; import { SelectorStrictness, useDelayedSelector } from 'src/hooks/delayedSelectors'; import { useCurrentView } from 'src/hooks/useNavigatePage'; @@ -455,7 +452,7 @@ function ResettableStore({ counter, children }: PropsWithChildren<{ counter: num - + {children} diff --git a/src/utils/layout/all.test.tsx b/src/utils/layout/all.test.tsx index 7873e3974c..fd91dad3c1 100644 --- a/src/utils/layout/all.test.tsx +++ b/src/utils/layout/all.test.tsx @@ -93,11 +93,6 @@ describe('All known layout sets should evaluate as a hierarchy', () => { .filter((set) => set.isValid()) .map((set) => ({ appName: set.app.getName(), setName: set.getName(), set })); - const appsToSkip = ['multiple-datamodels-test']; - const filteredSets = allSets.filter( - ({ set }) => !appsToSkip.map((app) => set.app.getName().includes(app)).some((x) => x), - ); - async function testSet(set: ExternalAppLayoutSet) { window.location.hash = set.simulateValidUrlHash(); const [org, app] = set.app.getOrgApp(); @@ -149,12 +144,12 @@ describe('All known layout sets should evaluate as a hierarchy', () => { expect(alwaysFail).toBe(false); } - it.each(filteredSets)('$appName/$setName', async ({ set }) => testSet(set)); + it.each(allSets)('$appName/$setName', async ({ set }) => testSet(set)); if (env.parsed?.ALTINN_ALL_APPS_TEST_FOR_LAST_QUIRK === 'true') { it(`last quirk`, async () => { const lastQuirk = Object.keys(quirks).at(-1); - const found = filteredSets.find(({ set }) => { + const found = allSets.find(({ set }) => { const [org, app] = set.app.getOrgApp(); return `${org}/${app}/${set.getName()}` === lastQuirk; }); diff --git a/src/utils/layout/generator/NodeRepeatingChildren.tsx b/src/utils/layout/generator/NodeRepeatingChildren.tsx index ff3dddc1cf..d31fc3d196 100644 --- a/src/utils/layout/generator/NodeRepeatingChildren.tsx +++ b/src/utils/layout/generator/NodeRepeatingChildren.tsx @@ -18,6 +18,7 @@ import { useDef, useExpressionResolverProps } from 'src/utils/layout/generator/N import { NodesInternal } from 'src/utils/layout/NodesContext'; import { useNodeDirectChildren } from 'src/utils/layout/useNodeItem'; import type { CompDef } from 'src/layout'; +import type { IDataModelReference } from 'src/layout/common.generated'; import type { CompExternal } from 'src/layout/layout'; import type { ChildClaims, ChildMutator } from 'src/utils/layout/generator/GeneratorContext'; import type { LayoutNode } from 'src/utils/layout/LayoutNode'; @@ -76,7 +77,7 @@ interface GenerateRowProps { rowIndex: number; rowUuid: string; claims: ChildClaims; - groupBinding: string | undefined; + groupBinding: IDataModelReference | undefined; multiPageMapping: MultiPageMapping | undefined; internalProp: string; pluginKey: string; @@ -191,13 +192,18 @@ export function mutateComponentId(row: BaseRow): ChildMutator { }; } -export function mutateDataModelBindings(row: BaseRow, groupBinding: string | undefined): ChildMutator { +export function mutateDataModelBindings(row: BaseRow, groupBinding: IDataModelReference | undefined): ChildMutator { return (item) => { const bindings = item.dataModelBindings || {}; for (const key of Object.keys(bindings)) { - if (groupBinding && bindings[key]) { - bindings[key] = bindings[key].replace(groupBinding, `${groupBinding}[${row.index}]`); + const binding = bindings[key] as IDataModelReference | undefined; + if (!binding || !groupBinding || groupBinding.dataType !== binding.dataType) { + continue; } + bindings[key] = { + dataType: binding.dataType, + field: binding.field.replace(groupBinding.field, `${groupBinding.field}[${row.index}]`), + }; } }; } diff --git a/src/utils/layout/generator/validation/NodePropertiesValidation.tsx b/src/utils/layout/generator/validation/NodePropertiesValidation.tsx index 96c4d75e3e..b93a4d9610 100644 --- a/src/utils/layout/generator/validation/NodePropertiesValidation.tsx +++ b/src/utils/layout/generator/validation/NodePropertiesValidation.tsx @@ -1,7 +1,7 @@ import React, { useEffect, useMemo } from 'react'; import type { FC } from 'react'; -import { useCurrentDataModelSchemaLookup } from 'src/features/datamodel/DataModelSchemaProvider'; +import { DataModels } from 'src/features/datamodel/DataModelsProvider'; import { formatLayoutSchemaValidationError } from 'src/features/devtools/utils/layoutSchemaValidation'; import { getNodeDef } from 'src/layout'; import { GeneratorStages } from 'src/utils/layout/generator/GeneratorStages'; @@ -30,11 +30,11 @@ export function NodePropertiesValidation(props: NodeValidat function DataModelValidation({ node, intermediateItem }: NodeValidationProps) { const addError = NodesInternal.useAddError(); - const schemaLookup = useCurrentDataModelSchemaLookup(); + const lookupBinding = DataModels.useLookupBinding(); const nodeDataSelector = NodesInternal.useNodeDataSelector(); const errors = useMemo(() => { - if (window.forceNodePropertiesValidation === 'off') { + if (!lookupBinding || window.forceNodePropertiesValidation === 'off') { return []; } @@ -46,14 +46,14 @@ function DataModelValidation({ node, intermediateItem }: No // eslint-disable-next-line @typescript-eslint/no-explicit-any item: intermediateItem as CompIntermediate, nodeDataSelector, - lookupBinding: (binding: string) => schemaLookup.getSchemaForPath(binding), + lookupBinding, }; // eslint-disable-next-line @typescript-eslint/no-explicit-any return node.def.validateDataModelBindings(ctx as any); } return []; - }, [intermediateItem, node, schemaLookup, nodeDataSelector]); + }, [intermediateItem, node, lookupBinding, nodeDataSelector]); // Must run after nodes have been added for the errors to actually be added GeneratorStages.MarkHidden.useEffect(() => { diff --git a/src/utils/layout/index.tsx b/src/utils/layout/index.tsx index c1e9c0c4c8..49291559a8 100644 --- a/src/utils/layout/index.tsx +++ b/src/utils/layout/index.tsx @@ -6,13 +6,12 @@ export function getLayoutSetForDataElement( datatype: string | undefined, layoutSets: ILayoutSets, ) { - const foundLayout = layoutSets.sets.find((layoutSet: ILayoutSet) => { + return layoutSets.sets.find((layoutSet: ILayoutSet) => { if (layoutSet.dataType !== datatype) { return false; } return layoutSet.tasks?.find((taskId: string) => taskId === currentTaskId); }); - return foundLayout?.id; } export const shouldUseRowLayout = ({ layout, optionsCount }) => { diff --git a/src/utils/layout/useDataModelBindingTranspose.ts b/src/utils/layout/useDataModelBindingTranspose.ts index 2dec4eb36f..845d245249 100644 --- a/src/utils/layout/useDataModelBindingTranspose.ts +++ b/src/utils/layout/useDataModelBindingTranspose.ts @@ -4,6 +4,7 @@ import { ContextNotProvided } from 'src/core/contexts/context'; import { transposeDataBinding } from 'src/utils/databindings/DataBinding'; import { BaseLayoutNode } from 'src/utils/layout/LayoutNode'; import { NodesInternal } from 'src/utils/layout/NodesContext'; +import type { IDataModelReference } from 'src/layout/common.generated'; import type { LayoutNode } from 'src/utils/layout/LayoutNode'; import type { LaxNodeDataSelector } from 'src/utils/layout/NodesContext'; @@ -28,7 +29,7 @@ export function useDataModelBindingTranspose() { const nodeSelector = NodesInternal.useLaxNodeDataSelector(); return useCallback( - (node: LayoutNode, subject: string, _rowIndex?: number) => { + (node: LayoutNode, subject: IDataModelReference, _rowIndex?: number) => { const { currentLocation, currentLocationIsRepGroup, foundRowIndex } = firstDataModelBinding(node, nodeSelector); const rowIndex = _rowIndex ?? foundRowIndex; return currentLocation @@ -47,7 +48,11 @@ function firstDataModelBinding( node: LayoutNode, nodeSelector: LaxNodeDataSelector, rowIndex?: number, -): { currentLocation: string | undefined; currentLocationIsRepGroup: boolean; foundRowIndex: number | undefined } { +): { + currentLocation: IDataModelReference | undefined; + currentLocationIsRepGroup: boolean; + foundRowIndex: number | undefined; +} { const dataModelBindings = nodeSelector((picker) => picker(node)?.layout.dataModelBindings, [node]); if (dataModelBindings === ContextNotProvided) { return { diff --git a/src/utils/layout/useExpressionDataSources.ts b/src/utils/layout/useExpressionDataSources.ts index 0a420fb428..952ea787b6 100644 --- a/src/utils/layout/useExpressionDataSources.ts +++ b/src/utils/layout/useExpressionDataSources.ts @@ -3,7 +3,9 @@ import { useMemo } from 'react'; import { useApplicationMetadata } from 'src/features/applicationMetadata/ApplicationMetadataProvider'; import { useApplicationSettings } from 'src/features/applicationSettings/ApplicationSettingsProvider'; import { useAttachmentsSelector } from 'src/features/attachments/hooks'; +import { DataModels } from 'src/features/datamodel/DataModelsProvider'; import { useExternalApis } from 'src/features/externalApi/useExternalApi'; +import { useCurrentLayoutSet } from 'src/features/form/layoutSets/useCurrentLayoutSet'; import { FD } from 'src/features/formData/FormDataWrite'; import { useLaxInstanceDataSources } from 'src/features/instance/InstanceContext'; import { useLaxProcessData } from 'src/features/instance/ProcessContext'; @@ -19,6 +21,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 { IApplicationSettings, IInstanceDataSources, IProcess } from 'src/types/shared'; import type { LayoutNode } from 'src/utils/layout/LayoutNode'; import type { NodeDataSelector } from 'src/utils/layout/NodesContext'; @@ -30,12 +33,14 @@ export interface ExpressionDataSources { process?: IProcess; instanceDataSources: IInstanceDataSources | null; applicationSettings: IApplicationSettings | null; + dataModelNames: string[]; formDataSelector: FormDataSelector; formDataRowsSelector: FormDataRowsSelector; attachmentsSelector: AttachmentsSelector; optionsSelector: NodeOptionsSelector; langToolsSelector: (node: LayoutNode | undefined) => IUseLanguage; currentLanguage: string; + currentLayoutSet: ILayoutSet | null; isHiddenSelector: ReturnType; nodeFormDataSelector: NodeFormDataSelector; nodeDataSelector: NodeDataSelector; @@ -59,6 +64,8 @@ export function useExpressionDataSources(): ExpressionDataSources { const nodeDataSelector = NodesInternal.useNodeDataSelector(); const nodeTraversal = useNodeTraversalSelectorLax(); const transposeSelector = useDataModelBindingTranspose(); + const currentLayoutSet = useCurrentLayoutSet() ?? null; + const readableDataModels = DataModels.useReadableDataTypes(); const externalApiIds = useApplicationMetadata().externalApiIds ?? []; const externalApis = useExternalApis(externalApiIds); @@ -79,7 +86,9 @@ export function useExpressionDataSources(): ExpressionDataSources { nodeDataSelector, nodeTraversal, transposeSelector, + currentLayoutSet, externalApis, + dataModelNames: readableDataModels, }), [ formDataSelector, @@ -96,7 +105,9 @@ export function useExpressionDataSources(): ExpressionDataSources { nodeDataSelector, nodeTraversal, transposeSelector, + currentLayoutSet, externalApis, + readableDataModels, ], ); } diff --git a/src/utils/urls/appUrlHelper.test.ts b/src/utils/urls/appUrlHelper.test.ts index e86c82e87c..49b57c2dfb 100644 --- a/src/utils/urls/appUrlHelper.test.ts +++ b/src/utils/urls/appUrlHelper.test.ts @@ -43,8 +43,8 @@ describe('Frontend urlHelper.ts', () => { ); }); it('should return the expected url for getValidationUrl', () => { - expect(getValidationUrl('12345/instanceId-1234')).toBe( - 'https://local.altinn.cloud/ttd/test/instances/12345/instanceId-1234/validate', + expect(getValidationUrl('12345/instanceId-1234', 'nb')).toBe( + 'https://local.altinn.cloud/ttd/test/instances/12345/instanceId-1234/validate?language=nb', ); }); it('should return the expected url for getDataValidationUrl', () => { @@ -322,7 +322,7 @@ describe('Frontend urlHelper.ts', () => { it('should return correct url when formData/dataMapping is provided', () => { const result = getDataListsUrl({ dataListId: 'country', - mappedData: { + queryParameters: { selectedCountry: 'Norway', }, }); @@ -333,7 +333,7 @@ describe('Frontend urlHelper.ts', () => { it('should render correct url when formData/Mapping, language, pagination and sorting paramters are provided', () => { const result = getDataListsUrl({ dataListId: 'country', - mappedData: { + queryParameters: { selectedCountry: 'Norway', }, pageSize: '10', diff --git a/src/utils/urls/appUrlHelper.ts b/src/utils/urls/appUrlHelper.ts index 09cc0e0b4e..6fa167345b 100644 --- a/src/utils/urls/appUrlHelper.ts +++ b/src/utils/urls/appUrlHelper.ts @@ -45,8 +45,9 @@ export const getAnonymousStatelessDataModelUrl = (dataType: string, includeRowId `${appPath}/v1/data/anonymous?dataType=${dataType}&includeRowId=${includeRowIds.toString()}`; export const getStatelessDataModelUrl = (dataType: string, includeRowIds: boolean) => `${appPath}/v1/data?dataType=${dataType}&includeRowId=${includeRowIds.toString()}`; -export const getDataModelUrl = (instanceId: string, dataGuid: string, includeRowIds: boolean) => +export const getStatefulDataModelUrl = (instanceId: string, dataGuid: string, includeRowIds: boolean) => `${appPath}/instances/${instanceId}/data/${dataGuid}?includeRowId=${includeRowIds.toString()}`; +export const getMultiPatchUrl = (instanceId: string) => `${appPath}/instances/${instanceId}/data`; export const getDataElementUrl = (instanceId: string, dataGuid: string, language: string) => `${appPath}/instances/${instanceId}/data/${dataGuid}?language=${language}`; @@ -59,7 +60,10 @@ export const getActionsUrl = (partyId: string, instanceId: string, language?: st export const getCreateInstancesUrl = (partyId: number) => `${appPath}/instances?instanceOwnerPartyId=${partyId}`; -export const getValidationUrl = (instanceId: string) => `${appPath}/instances/${instanceId}/validate`; +export const getValidationUrl = (instanceId: string, language: string) => { + const queryString = getQueryStringFromObject({ language }); + return `${appPath}/instances/${instanceId}/validate${queryString}`; +}; export const getDataValidationUrl = (instanceId: string, dataGuid: string, language: string) => { const queryString = getQueryStringFromObject({ language }); @@ -154,9 +158,11 @@ export const getInstanceUiUrl = (instanceId: string) => `${appPath}#/instance/${ export const appFrontendCDNPath = 'https://altinncdn.no/toolkits/altinn-app-frontend'; export const frontendVersionsCDN = `${appFrontendCDNPath}/index.json`; +export type ParamValue = string | number | boolean | null; + export interface IGetOptionsUrlParams { optionsId: string; - queryParameters?: Record; + queryParameters?: Record; language?: string; secure?: boolean; instanceId?: string; @@ -170,17 +176,21 @@ export const getOptionsUrl = ({ optionsId, queryParameters, language, secure, in url = new URL(`${appPath}/api/options/${optionsId}`); } - const params: Record = {}; + const params: Record = {}; if (language) { params.language = language; } + queryParameters && Object.assign(params, queryParameters); - url.search = new URLSearchParams(params).toString(); + const stringParams = Object.fromEntries(Object.entries(params).map(([k, v]) => [k, String(v)])); + url.search = new URLSearchParams(stringParams).toString(); + return url.toString(); }; export interface IGetDataListsUrlParams { dataListId: string; + queryParameters?: Record; // eslint-disable-next-line @typescript-eslint/no-explicit-any mappedData?: Record; language?: string; @@ -194,7 +204,7 @@ export interface IGetDataListsUrlParams { export const getDataListsUrl = ({ dataListId, - mappedData, + queryParameters, language, pageSize, pageNumber, @@ -209,7 +219,7 @@ export const getDataListsUrl = ({ } else { url = new URL(`${appPath}/api/datalists/${dataListId}`); } - let params: Record = {}; + const params: Record = {}; if (language) { params.language = language; @@ -231,13 +241,11 @@ export const getDataListsUrl = ({ params.sortDirection = sortDirection; } - if (mappedData) { - params = { - ...params, - ...mappedData, - }; - } + queryParameters && Object.assign(params, queryParameters); + + // Cast all values to string + const stringParams = Object.fromEntries(Object.entries(params).map(([k, v]) => [k, String(v)])); + url.search = new URLSearchParams(stringParams).toString(); - url.search = new URLSearchParams(params).toString(); return url.toString(); }; diff --git a/test/README.md b/test/README.md index a7f44286e0..622f092018 100755 --- a/test/README.md +++ b/test/README.md @@ -34,7 +34,7 @@ npx cypress run --env environment=tt02 -s 'test/e2e/integration/*/*.ts' 1. Clone [app-localtest](https://github.com/Altinn/app-localtest) and follow the [instructions in the README](https://github.com/Altinn/app-localtest/blob/main/README.md) to start the local environment. -2. Clone one or more of the apps we've made automatic tests for: +1. Clone one or more of the apps we've made automatic tests for: - [ttd/frontend-test](https://dev.altinn.studio/repos/ttd/frontend-test) - [ttd/anonymous-stateless-app](https://dev.altinn.studio/repos/ttd/anonymous-stateless-app) @@ -43,6 +43,7 @@ npx cypress run --env environment=tt02 -s 'test/e2e/integration/*/*.ts' - [ttd/expression-validation-test](https://dev.altinn.studio/repos/ttd/expression-validation-test) - [ttd/payment-test](https://dev.altinn.studio/repos/ttd/payment-test) - [ttd/component-library](https://altinn.studio/repos/ttd/component-library.git) +- [ttd/multiple-datamodels-test](https://dev.altinn.studio/repos/ttd/multiple-datamodels-test) 3. Start the app you want to test: diff --git a/test/e2e/integration/frontend-test/attachments-in-group.ts b/test/e2e/integration/frontend-test/attachments-in-group.ts index 405264fe9a..223e349499 100644 --- a/test/e2e/integration/frontend-test/attachments-in-group.ts +++ b/test/e2e/integration/frontend-test/attachments-in-group.ts @@ -122,7 +122,7 @@ describe('Repeating group attachments', () => { cy.log('Waiting until formData equals', expected); return cy.waitUntil(() => cy.window().then((win) => { - const formData = win.CypressState?.formData || {}; + const formData = win.CypressState?.formData?.['nested-group'] || {}; const actual: [string, string][] = []; const idToNameMapping: { [attachmentId: string]: string } = {}; diff --git a/test/e2e/integration/frontend-test/dynamics.ts b/test/e2e/integration/frontend-test/dynamics.ts index 79c8337341..a24c3f884e 100644 --- a/test/e2e/integration/frontend-test/dynamics.ts +++ b/test/e2e/integration/frontend-test/dynamics.ts @@ -61,8 +61,7 @@ describe('Dynamics', () => { component.hidden = ['equals', 'hideFirstName', ['component', 'newLastName']]; } }); - cy.gotoAndComplete('changename'); - cy.gotoNavPage('form'); + cy.goto('changename'); cy.get(appFrontend.changeOfName.newFirstName).clear(); cy.findByRole('tab', { name: /nytt etternavn/i }).click(); cy.get(appFrontend.changeOfName.newLastName).clear(); @@ -70,6 +69,7 @@ describe('Dynamics', () => { cy.get(appFrontend.errorReport).should('contain.text', texts.testIsNotValidValue); cy.get(appFrontend.changeOfName.newLastName).type('hideFirstName'); cy.get(appFrontend.errorReport).should('not.exist'); + cy.get(appFrontend.changeOfName.newFirstName).should('not.exist'); cy.get(appFrontend.changeOfName.newLastName).clear(); cy.get(appFrontend.changeOfName.newFirstName).should('be.visible'); cy.get(appFrontend.errorReport).should('contain.text', texts.testIsNotValidValue); @@ -96,7 +96,12 @@ describe('Dynamics', () => { id: 'testInputOnSummary', type: 'Input', textResourceBindings: { title: 'Temporary field while testing' }, - dataModelBindings: { simpleBinding: 'Innledning-grp-9309.Kontaktinformasjon-grp-9311.MelderFultnavn.orid' }, + dataModelBindings: { + simpleBinding: { + field: 'Innledning-grp-9309.Kontaktinformasjon-grp-9311.MelderFultnavn.orid', + dataType: 'ServiceModel-test', + }, + }, }, lastButton, ]; diff --git a/test/e2e/integration/frontend-test/navigation.ts b/test/e2e/integration/frontend-test/navigation.ts index 9529d5b462..f5b2d02413 100644 --- a/test/e2e/integration/frontend-test/navigation.ts +++ b/test/e2e/integration/frontend-test/navigation.ts @@ -2,6 +2,14 @@ import type { IncomingApplicationMetadata } from 'src/features/applicationMetada describe('Navigation', () => { it('Should redirect to the current task and the first page of that task when navigating directly to the instance', () => { + /** + * This test has twice been able to reproduce a bug where stale form data is fetched from the tanstack query client + * when loading initial data to the form data provider, which causes us to try to PATCH form data later with an + * outdated initial model (causing a 409). This test is not made to reproduce the bug, but try to avoid + * changing the exact implementation so that the bug will be easier to reproduce if it happens again. + * @see updateQueryCache + */ + cy.intercept('PATCH', '**/data/**').as('saveFormData'); cy.goto('changename'); diff --git a/test/e2e/integration/frontend-test/summary.ts b/test/e2e/integration/frontend-test/summary.ts index fe1c61024b..4cbeeb69bb 100644 --- a/test/e2e/integration/frontend-test/summary.ts +++ b/test/e2e/integration/frontend-test/summary.ts @@ -35,6 +35,7 @@ describe('Summary', () => { cy.gotoNavPage('form'); cy.fillOut('changename'); cy.gotoNavPage('summary'); + cy.waitUntilSaved(); cy.get(appFrontend.backButton).should('be.visible'); // Summary displays change button for editable fields and does not for readonly fields @@ -654,7 +655,10 @@ function injectExtraPageAndSetTriggers(pageValidationConfig?: PageValidation | u title: 'Page3required', }, dataModelBindings: { - simpleBinding: 'etatid', + simpleBinding: { + field: 'etatid', + dataType: 'ServiceModel-test', + }, }, required: true, }, diff --git a/test/e2e/integration/frontend-test/validation.ts b/test/e2e/integration/frontend-test/validation.ts index 560a6eecb6..260fe7139d 100644 --- a/test/e2e/integration/frontend-test/validation.ts +++ b/test/e2e/integration/frontend-test/validation.ts @@ -378,6 +378,7 @@ describe('Validation', () => { // Validation message should now have changed, since we filled out currentValue and saved cy.get(appFrontend.errorReport).findByText('Du må fylle ut 2. endre verdi 123 til').should('be.visible'); cy.get(appFrontend.group.row(2).deleteBtn).click(); + cy.get(appFrontend.group.mainGroupTableBody).find('tr').should('have.length', 2); cy.waitUntilNodesReady(); // Check that nested group with multipage gets focus diff --git a/test/e2e/integration/multiple-datamodels-test/readonly.ts b/test/e2e/integration/multiple-datamodels-test/readonly.ts new file mode 100644 index 0000000000..eb72c1f39c --- /dev/null +++ b/test/e2e/integration/multiple-datamodels-test/readonly.ts @@ -0,0 +1,134 @@ +import { AppFrontend } from 'test/e2e/pageobjects/app-frontend'; + +const appFrontend = new AppFrontend(); + +describe('readonly data models', () => { + beforeEach(() => { + cy.startAppInstance(appFrontend.apps.multipleDatamodelsTest); + }); + + it('can show data models from previous tasks as read only', () => { + cy.findByRole('textbox', { name: /tekstfelt 1/i }).type('første'); + cy.findByRole('textbox', { name: /tekstfelt 2/i }).type('andre'); + + cy.gotoNavPage('Side2'); + cy.findAllByRole('checkbox').eq(2).click(); + cy.findAllByRole('checkbox').eq(3).click(); + cy.findAllByRole('checkbox').eq(4).click(); + + cy.gotoNavPage('Side3'); + + const today = new Date(); + const age1 = 36; + const y1 = today.getFullYear() - age1; + const m = today.getMonth().toString().padStart(2, '0'); + const d = today.getDate().toString().padStart(2, '0'); + const age2 = 25; + const y2 = today.getFullYear() - age2; + + cy.findByRole('button', { name: /legg til ny/i }).click(); + cy.findByRole('textbox', { name: /fornavn/i }).type('Per'); + cy.findByRole('textbox', { name: /etternavn/i }).type('Hansen'); + cy.findByRole('textbox', { name: /fødselsdato/i }).type(`${d}${m}${y1}`); + cy.findAllByRole('button', { name: /lagre og lukk/i }) + .first() + .click(); + + cy.findByRole('button', { name: /legg til ny/i }).click(); + cy.findByRole('textbox', { name: /fornavn/i }).type('Hanne'); + cy.findByRole('textbox', { name: /etternavn/i }).type('Persen'); + cy.findByRole('textbox', { name: /fødselsdato/i }).type(`${d}${m}${y2}`); + cy.findAllByRole('button', { name: /lagre og lukk/i }) + .first() + .click(); + + cy.gotoNavPage('Side6'); + cy.findByRole('radio', { name: /kåre/i }).dsCheck(); + cy.get(appFrontend.errorReport).should('not.exist'); + cy.findByRole('button', { name: /send inn/i }).click(); + + cy.findByRole('heading', { name: /fra forrige steg/i }).should('be.visible'); + cy.get(appFrontend.errorReport).should('not.exist'); + + cy.get(appFrontend.multipleDatamodelsTest.textField1Summary).should('contain.text', 'første'); + cy.get(appFrontend.multipleDatamodelsTest.textField2Summary).should('contain.text', 'andre'); + cy.get(appFrontend.multipleDatamodelsTest.sectorSummary).should('contain.text', 'Privat'); + cy.get(appFrontend.multipleDatamodelsTest.industrySummary).should( + 'contain.text', + 'Elektronikk og telekommunikasjon', + ); + cy.get(appFrontend.multipleDatamodelsTest.industrySummary).should('contain.text', 'Forskning og utvikling'); + cy.get(appFrontend.multipleDatamodelsTest.industrySummary).should('contain.text', 'IKT (data/IT)'); + cy.get(appFrontend.multipleDatamodelsTest.personsSummary).should('contain.text', 'Fornavn : Per'); + cy.get(appFrontend.multipleDatamodelsTest.personsSummary).should('contain.text', 'Etternavn : Hansen'); + cy.get(appFrontend.multipleDatamodelsTest.personsSummary).should('contain.text', 'Alder : 36 år'); + cy.get(appFrontend.multipleDatamodelsTest.personsSummary).should('contain.text', 'Fornavn : Hanne'); + cy.get(appFrontend.multipleDatamodelsTest.personsSummary).should('contain.text', 'Etternavn : Persen'); + cy.get(appFrontend.multipleDatamodelsTest.personsSummary).should('contain.text', 'Alder : 25 år'); + + const formDataRequests: string[] = []; + cy.intercept('PATCH', '**/data', (req) => { + formDataRequests.push(req.url); + }).as('saveFormData'); + + cy.findByRole('textbox', { name: /tekstfelt 3/i }).type('Litt mer informasjon'); + cy.waitUntilSaved(); + cy.then(() => expect(formDataRequests.length).to.be.eq(1)); + + cy.findByRole('button', { name: /legg til ny/i }).click(); + cy.waitUntilSaved(); + cy.then(() => expect(formDataRequests.length).to.be.eq(2)); + + cy.findByRole('textbox', { name: /e-post/i }).type('test@test.test'); + cy.waitUntilSaved(); + cy.then(() => expect(formDataRequests.length).to.be.eq(3)); + + cy.findByRole('textbox', { name: /mobilnummer/i }).type('98765432'); + cy.waitUntilSaved(); + cy.then(() => expect(formDataRequests.length).to.be.eq(4)); + + cy.findAllByRole('button', { name: /lagre og lukk/i }) + .first() + .click(); + cy.waitUntilSaved(); + cy.then(() => expect(formDataRequests.length).to.be.eq(4)); + + cy.get(appFrontend.errorReport).should('not.exist'); + + // Test with autoSaveBehavior onChangePage in order to test that requestManualSave works as expected + cy.interceptLayoutSetsUiSettings({ autoSaveBehavior: 'onChangePage' }); + cy.then(() => formDataRequests.splice(0, formDataRequests.length)); // Clear requests + cy.reloadAndWait(); + + cy.findByRole('textbox', { name: /tekstfelt 3/i }).clear(); + cy.findByRole('textbox', { name: /tekstfelt 3/i }).type('Noe annet denne gangen'); + cy.findByRole('button', { name: /legg til ny/i }).click(); + cy.findByRole('textbox', { name: /e-post/i }).type('test123@test.test'); + cy.findByRole('textbox', { name: /mobilnummer/i }).type('12345678'); + cy.findAllByRole('button', { name: /lagre og lukk/i }) + .first() + .click(); + + cy.waitForNetworkIdle(400); + + cy.then(() => expect(formDataRequests.length).to.be.eq(0)); + + cy.findByRole('button', { name: /neste/i }).click(); + + cy.findByRole('heading', { name: /tittel/i }).should('be.visible'); + cy.waitUntilSaved(); + + cy.then(() => expect(formDataRequests.length).to.be.eq(1)); + cy.get(appFrontend.errorReport).should('not.exist'); + + cy.findByRole('button', { name: /tilbake/i }).click(); + cy.findByRole('button', { name: /send inn/i }).click(); + + cy.findByRole('heading', { name: /kvittering/i }).should('be.visible'); + cy.get(appFrontend.multipleDatamodelsTest.textField1Summary).should('contain.text', 'første'); + cy.get(appFrontend.multipleDatamodelsTest.textField2Paragraph).should('contain.text', 'andre'); + cy.get(appFrontend.multipleDatamodelsTest.textField3Summary).should('contain.text', 'Noe annet denne gangen'); + + cy.get(appFrontend.errorReport).should('not.exist'); + }); +}); diff --git a/test/e2e/integration/multiple-datamodels-test/saving.ts b/test/e2e/integration/multiple-datamodels-test/saving.ts new file mode 100644 index 0000000000..9994e56611 --- /dev/null +++ b/test/e2e/integration/multiple-datamodels-test/saving.ts @@ -0,0 +1,242 @@ +import type { Interception } from 'cypress/types/net-stubbing'; + +import { AppFrontend } from 'test/e2e/pageobjects/app-frontend'; + +const appFrontend = new AppFrontend(); + +describe('saving multiple data models', () => { + beforeEach(() => { + cy.startAppInstance(appFrontend.apps.multipleDatamodelsTest); + }); + + it('Calls save on individual data models', () => { + cy.intercept('PATCH', '**/data').as('saveFormData'); + + cy.findByRole('textbox', { name: /tekstfelt 1/i }).type('første'); + cy.findByRole('textbox', { name: /tekstfelt 1/i }).clear(); + cy.findByRole('textbox', { name: /tekstfelt 1/i }).type('andre'); + cy.findByRole('textbox', { name: /tekstfelt 2/i }).type('tredje'); + cy.findByRole('textbox', { name: /tekstfelt 2/i }).clear(); + cy.findByRole('textbox', { name: /tekstfelt 2/i }).type('fjerde'); + + cy.get('@saveFormData.all').should('have.length', 2); // Check that a total of two saves happened + cy.get('@saveFormData.all').should(haveTheSameUrls); // And that they were to the same url (multipatch) + + cy.findByRole('textbox', { name: /adresse/i }).type('Brattørgata 3'); + cy.get('@saveFormData.all').should('have.length', 3); + + cy.findByRole('textbox', { name: /postnr/i }).type('7010'); + cy.findByRole('textbox', { name: /poststed/i }).should('have.value', 'TRONDHEIM'); + + cy.get('@saveFormData.all').should('have.length', 4); + cy.get('@saveFormData.all').should(haveTheSameUrls); + + cy.get(appFrontend.altinnError).should('not.exist'); + }); + + it('Text resources should be able to read from two writable data models', () => { + cy.get(appFrontend.multipleDatamodelsTest.variableParagraph).should( + 'contain.text', + 'I første felt står det ingenting, og i det andre feltet står det ikke noe.', + ); + + cy.findByRole('textbox', { name: /tekstfelt 1/i }).type('fin'); + + cy.get(appFrontend.multipleDatamodelsTest.variableParagraph).should( + 'contain.text', + 'I første felt står det fin, og i det andre feltet står det ikke noe.', + ); + + cy.findByRole('textbox', { name: /tekstfelt 2/i }).type('bil'); + + cy.get(appFrontend.multipleDatamodelsTest.variableParagraph).should( + 'contain.text', + 'I første felt står det fin, og i det andre feltet står det bil.', + ); + + cy.findByRole('textbox', { name: /tekstfelt 2/i }).clear(); + cy.findByRole('textbox', { name: /tekstfelt 2/i }).type('fin'); + + cy.get(appFrontend.multipleDatamodelsTest.variableParagraph).should('contain.text', 'Begge feltene er helt like!'); + + cy.gotoNavPage('Side3'); + + const today = new Date(); + const age1 = 36; + const y1 = today.getFullYear() - age1; + const m = today.getMonth().toString().padStart(2, '0'); + const d = today.getDate().toString().padStart(2, '0'); + const age2 = 25; + const y2 = today.getFullYear() - age2; + + cy.findByRole('button', { name: /legg til ny/i }).click(); + + cy.get(appFrontend.multipleDatamodelsTest.repeatingParagraph).should( + 'contain.text', + 'fornavn etternavn er født dato og er dermed alder år gammel', + ); + + cy.findByRole('textbox', { name: /fornavn/i }).type('Per'); + + cy.get(appFrontend.multipleDatamodelsTest.repeatingParagraph).should( + 'contain.text', + 'Per etternavn er født dato og er dermed alder år gammel', + ); + + cy.findByRole('textbox', { name: /etternavn/i }).type('Hansen'); + + cy.get(appFrontend.multipleDatamodelsTest.repeatingParagraph).should( + 'contain.text', + 'Per Hansen er født dato og er dermed alder år gammel', + ); + + cy.findByRole('textbox', { name: /fødselsdato/i }).type(`${d}${m}${y1}`); + + cy.get(appFrontend.multipleDatamodelsTest.repeatingParagraph).should( + 'contain.text', + `Per Hansen er født ${y1}-${m}-${d} og er dermed ${age1} år gammel`, + ); + + cy.findAllByRole('button', { name: /lagre og lukk/i }) + .first() + .click(); + cy.findByRole('button', { name: /legg til ny/i }).click(); + + cy.findByRole('textbox', { name: /fornavn/i }).type('Hanne'); + cy.findByRole('textbox', { name: /etternavn/i }).type('Persen'); + cy.findByRole('textbox', { name: /fødselsdato/i }).type(`${d}${m}${y2}`); + + cy.get(appFrontend.multipleDatamodelsTest.repeatingParagraph).should( + 'contain.text', + `Hanne Persen er født ${y2}-${m}-${d} og er dermed ${age2} år gammel`, + ); + + cy.findAllByRole('button', { name: /lagre og lukk/i }) + .first() + .click(); + cy.findAllByRole('button', { name: /slett/i }).first().click(); + cy.findByRole('button', { name: /rediger/i }).click(); + + cy.get(appFrontend.multipleDatamodelsTest.repeatingParagraph).should( + 'contain.text', + `Hanne Persen er født ${y2}-${m}-${d} og er dermed ${age2} år gammel`, + ); + + cy.findAllByRole('button', { name: /lagre og lukk/i }) + .first() + .click(); + cy.findByRole('button', { name: /legg til ny/i }).click(); + cy.get(appFrontend.multipleDatamodelsTest.repeatingParagraph).should( + 'contain.text', + 'fornavn etternavn er født dato og er dermed alder år gammel', + ); + }); + + it('Server action can update multiple data models', () => { + cy.gotoNavPage('Side5'); + + cy.findByRole('textbox', { name: /tilfeldig tall/i }) + .invoke('val') + .should('be.empty'); + cy.findByRole('textbox', { name: /tilfeldig bokstaver/i }) + .invoke('val') + .should('be.empty'); + + cy.findByRole('button', { name: /få tilfeldige verdier/i }).click(); + cy.get(appFrontend.toast).should('contain.text', 'Du må krysse av for vellykket'); + cy.get(appFrontend.toast).click(); + + cy.findByRole('textbox', { name: /tilfeldig tall/i }) + .invoke('val') + .should('be.empty'); + cy.findByRole('textbox', { name: /tilfeldig bokstaver/i }) + .invoke('val') + .should('be.empty'); + + cy.findByRole('checkbox', { name: /vellykket?/i }).click(); + cy.findByRole('button', { name: /få tilfeldige verdier/i }).click(); + + cy.findByRole('textbox', { name: /tilfeldig tall/i }) + .invoke('val') + .should('not.be.empty'); + cy.findByRole('textbox', { name: /tilfeldig bokstaver/i }) + .invoke('val') + .should('not.be.empty'); + }); + + it('List component with search', () => { + cy.gotoNavPage('Side6'); + cy.findByRole('textbox', { name: /søk/i }).type('Snekker'); + cy.findAllByRole('radio').should('have.length', 1); + cy.findByRole('textbox', { name: /søk/i }).clear(); + cy.findByRole('textbox', { name: /søk/i }).type('Utvikler'); + cy.findAllByRole('radio').should('have.length', 2); + cy.findByRole('radio', { name: /johanne/i }).dsCheck(); + cy.findByRole('radio', { name: /johanne/i }).should('be.checked'); + cy.findByRole('textbox', { name: /søk/i }).clear(); + cy.findAllByRole('radio').should('have.length', 5); + cy.findByRole('radio', { name: /johanne/i }).should('be.checked'); + }); + + it('Likert component', () => { + cy.intercept('PATCH', '**/data').as('saveFormData'); + cy.gotoNavPage('Side4'); + + // The 'choose-sector' radio component has a 'preselectedOptionIndex' which will auto-select an option before + // we get a chance to interact with it ourselves, thus an initial save will happen. + cy.get('@saveFormData.all').should('have.length', 1); + + cy.findAllByRole('radio', { name: /middels/i }) + .eq(0) + .click(); + cy.findAllByRole('radio', { name: /i liten grad/i }) + .eq(1) + .click(); + cy.findAllByRole('radio', { name: /i stor grad/i }) + .eq(2) + .click(); + + cy.get('@saveFormData.all').should('have.length', 1); + + cy.findAllByRole('radio', { name: /middels/i }) + .eq(0) + .should('be.checked'); + cy.findAllByRole('radio', { name: /i liten grad/i }) + .eq(1) + .should('be.checked'); + cy.findAllByRole('radio', { name: /i stor grad/i }) + .eq(2) + .should('be.checked'); + }); + + it('Dynamic options', () => { + cy.intercept('PATCH', '**/data').as('saveFormData'); + cy.gotoNavPage('Side2'); + + // The 'choose-sector' radio component has a 'preselectedOptionIndex' which will auto-select an option before + // we get a chance to interact with it ourselves, thus an initial save will happen. + cy.get('@saveFormData.all').should('have.length', 1); + + cy.findByRole('radio', { name: /offentlig sektor/i }).click(); + cy.get('@saveFormData.all').should('have.length', 2); + + cy.findByRole('checkbox', { name: /statlig/i }).click(); + cy.get('@saveFormData.all').should('have.length', 3); + cy.get('@saveFormData.all').should(haveTheSameUrls); + + cy.findByRole('radio', { name: /privat/i }).click(); + cy.findByRole('checkbox', { name: /petroleum og engineering/i }).should('exist'); + + cy.get('@saveFormData.all').should('have.length', 5); + cy.get('@saveFormData.all').should(haveTheSameUrls); + + cy.findByRole('checkbox', { name: /petroleum og engineering/i }).click(); + cy.findByRole('alert', { name: /olje er ikke bra for planeten/i }).should('be.visible'); + }); +}); + +function haveTheSameUrls(subject: unknown) { + const interceptions = subject as Interception[]; + const urls = new Set(interceptions.map((i) => i.request.url)); + expect(urls.size).to.be.eq(1); +} diff --git a/test/e2e/integration/multiple-datamodels-test/validation.ts b/test/e2e/integration/multiple-datamodels-test/validation.ts new file mode 100644 index 0000000000..9096ab9b45 --- /dev/null +++ b/test/e2e/integration/multiple-datamodels-test/validation.ts @@ -0,0 +1,165 @@ +import { AppFrontend } from 'test/e2e/pageobjects/app-frontend'; +import type { BackendValidationResult } from 'test/e2e/support/global'; + +const appFrontend = new AppFrontend(); + +describe('validating multiple data models', () => { + beforeEach(() => { + cy.startAppInstance(appFrontend.apps.multipleDatamodelsTest); + }); + + it('shows validations for multiple data models', () => { + cy.waitForLoad(); + + cy.get(appFrontend.errorReport).should('not.exist'); + cy.get(appFrontend.fieldValidation(appFrontend.multipleDatamodelsTest.textField1)).should('not.exist'); + cy.get(appFrontend.fieldValidation(appFrontend.multipleDatamodelsTest.textField2)).should('not.exist'); + + cy.findByRole('textbox', { name: /tekstfelt 1/i }).type('Dette er en litt for lang tekst'); + cy.findByRole('textbox', { name: /tekstfelt 2/i }).type('Dette er en annen veldig lang tekst'); + cy.get(appFrontend.fieldValidation(appFrontend.multipleDatamodelsTest.textField1)).should( + 'contain.text', + 'Bruk 10 eller færre tegn', + ); + cy.get(appFrontend.fieldValidation(appFrontend.multipleDatamodelsTest.textField2)).should( + 'contain.text', + 'Bruk 10 eller færre tegn', + ); + cy.get(appFrontend.errorReport).findAllByRole('listitem').should('have.length', 2); + + cy.findByRole('textbox', { name: /tekstfelt 1/i }).clear(); + cy.findByRole('textbox', { name: /tekstfelt 2/i }).clear(); + + cy.get(appFrontend.errorReport).should('not.exist'); + + cy.findByRole('textbox', { name: /postnr/i }).type('0000'); + cy.get(appFrontend.fieldValidation(appFrontend.multipleDatamodelsTest.addressField)).should( + 'contain.text', + 'Postnummer er ugyldig', + ); + cy.get(appFrontend.errorReport).findAllByRole('listitem').should('have.length', 1); + cy.findByRole('textbox', { name: /postnr/i }).clear(); + cy.get(appFrontend.errorReport).should('not.exist'); + + cy.gotoNavPage('Side6'); + cy.findByRole('button', { name: /send inn/i }).click(); + cy.get(appFrontend.errorReport).findAllByRole('listitem').should('have.length', 4); + + cy.gotoNavPage('Side1'); + cy.findByRole('textbox', { name: /tekstfelt 1/i }).type('Tekst'); + cy.findByRole('textbox', { name: /tekstfelt 2/i }).type('Tekst'); + cy.gotoNavPage('Side3'); + cy.findByRole('button', { name: /legg til ny/i }).click(); + cy.gotoNavPage('Side6'); + cy.findByRole('radio', { name: /kåre/i }).dsCheck(); + cy.get(appFrontend.errorReport).should('not.exist'); + cy.findByRole('button', { name: /send inn/i }).click(); + cy.findByRole('heading', { name: /fra forrige steg/i }).should('be.visible'); + }); + + it('expression validation for multiple datamodels', () => { + const validationResult: BackendValidationResult = { validations: null }; + cy.runAllBackendValidations(); + cy.waitForLoad(); + + cy.get(appFrontend.fieldValidation(appFrontend.multipleDatamodelsTest.textField1)).should('not.exist'); + cy.getNextPatchValidations(validationResult); + cy.findByRole('textbox', { name: /tekstfelt 1/i }).type('feil'); + cy.get(appFrontend.fieldValidation(appFrontend.multipleDatamodelsTest.textField1)).should( + 'contain.text', + 'Feil er feil', + ); + // TODO: Verify data element id + cy.expectValidationToExist( + validationResult, + 'Expression', + (v) => v.severity === 1 && v.customTextKey === 'Feil er feil' && v.field === 'tekstfelt', + ); + // cy.expectValidationNotToExist( + // validationResult, + // 'Required', + // (v) => v.severity === 1 && v.code === 'required' && v.field === 'tekstfelt', // TODO: Check the dataElementId somehow + // ); + + cy.get(appFrontend.errorReport).findAllByRole('listitem').should('have.length', 1); + cy.getNextPatchValidations(validationResult); + cy.findByRole('textbox', { name: /tekstfelt 1/i }).clear(); + cy.expectValidationToExist( + validationResult, + 'Required', + (v) => v.severity === 1 && v.code === 'required' && v.field === 'tekstfelt', // TODO: Check the dataElementId somehow + ); + + cy.get(appFrontend.fieldValidation(appFrontend.multipleDatamodelsTest.textField2)).should('not.exist'); + cy.getNextPatchValidations(validationResult); + cy.findByRole('textbox', { name: /tekstfelt 2/i }).type('feil'); + cy.get(appFrontend.fieldValidation(appFrontend.multipleDatamodelsTest.textField2)).should( + 'contain.text', + 'Feil er advarsel', + ); + // TODO: Verify data element id + cy.expectValidationToExist( + validationResult, + 'Expression', + (v) => v.severity === 2 && v.customTextKey === 'Feil er advarsel' && v.field === 'tekstfelt', + ); + cy.get(appFrontend.errorReport).should('not.exist'); + cy.findByRole('textbox', { name: /tekstfelt 2/i }).clear(); + + cy.gotoNavPage('Side2'); + cy.getNextPatchValidations(validationResult); + cy.findAllByRole('checkbox').eq(0).click(); + cy.findAllByRole('checkbox').eq(1).click(); + cy.findAllByRole('checkbox').eq(2).click(); + cy.findAllByRole('checkbox').eq(3).click(); + cy.findAllByRole('checkbox').eq(4).click(); + cy.findAllByRole('checkbox').eq(5).click(); + cy.findAllByRole('checkbox').eq(7).click(); + cy.findAllByRole('checkbox').eq(8).click(); + + cy.get(appFrontend.fieldValidation(appFrontend.multipleDatamodelsTest.chooseIndusty)).should( + 'contain.text', + 'Du kan ikke velge både IKT og Verkstedindustri', + ); + // TODO: Verify data element id + cy.expectValidationToExist( + validationResult, + 'Expression', + (v) => + v.severity === 1 && + v.customTextKey === 'Du kan ikke velge både IKT og Verkstedindustri' && + v.field === 'bransje', + ); + cy.get(appFrontend.errorReport).findAllByRole('listitem').should('have.length', 1); + + cy.findByRole('checkbox', { name: /verkstedindustri/i }).click(); + + cy.get(appFrontend.fieldValidation(appFrontend.multipleDatamodelsTest.chooseIndusty)).should('not.exist'); + cy.get(appFrontend.errorReport).should('not.exist'); + + cy.gotoNavPage('Side3'); + cy.findByRole('button', { name: /legg til ny/i }).click(); + cy.getNextPatchValidations(validationResult); + cy.findByRole('textbox', { name: /etternavn/i }).type('Helt Konge!'); + + cy.get(appFrontend.fieldValidation('person-etternavn-0')).should( + 'contain.text', + 'Etternavn kan ikke inneholde utropstegn!!!', + ); + // TODO: Verify data element id + cy.expectValidationToExist( + validationResult, + 'Expression', + (v) => + v.severity === 1 && + v.customTextKey === 'Etternavn kan ikke inneholde utropstegn!!!' && + v.field === 'personer[0].etternavn', + ); + + cy.get(appFrontend.errorReport).findAllByRole('listitem').should('have.length', 1); + + cy.findAllByRole('button', { name: /slett/i }).first().click(); + + cy.get(appFrontend.errorReport).should('not.exist'); + }); +}); diff --git a/test/e2e/pageobjects/app-frontend.ts b/test/e2e/pageobjects/app-frontend.ts index 8a74f6c092..061d3fea12 100644 --- a/test/e2e/pageobjects/app-frontend.ts +++ b/test/e2e/pageobjects/app-frontend.ts @@ -22,6 +22,9 @@ export class AppFrontend { /** @see https://altinn.studio/repos/ttd/component-library.git */ componentLibrary: 'component-library', + + /** @see https://dev.altinn.studio/repos/ttd/multiple-datamodels-test */ + multipleDatamodelsTest: 'multiple-datamodels-test', }; //Start app instance page @@ -341,6 +344,22 @@ export class AppFrontend { groupTag: 'input[id^=attachment-tag]', uploaders: '[id^=Vedlegg-]', }; + + public multipleDatamodelsTest = { + variableParagraph: '#variableParagraph', + repeatingParagraph: '[id^=repeatingParagraph]', + textField1: '#Input-bhWSyO', + textField2: '#Input-aWlSF3', + addressField: '#Address-xdZ7PE', + chooseIndusty: '#choose-industry', + textField1Summary: '[data-testid="summary-text1"]', + textField2Summary: '[data-testid="summary-text2"]', + textField3Summary: '[data-testid="summary-text3"]', + textField2Paragraph: '[data-testid="paragraph-component-text2"]', + sectorSummary: '[data-testid="summary-sector"]', + industrySummary: '[data-testid="summary-industry"]', + personsSummary: '[data-testid="summary-persons"]', + }; } type Type = 'tagged' | 'untagged'; diff --git a/test/e2e/support/custom.ts b/test/e2e/support/custom.ts index 557ec9ee64..386373c1fb 100644 --- a/test/e2e/support/custom.ts +++ b/test/e2e/support/custom.ts @@ -10,6 +10,7 @@ import { breakpoints } from 'src/hooks/useDeviceWidths'; import { getInstanceIdRegExp } from 'src/utils/instanceIdRegExp'; import type { LayoutContextValue } from 'src/features/form/layout/LayoutsContext'; import JQueryWithSelector = Cypress.JQueryWithSelector; +import type { BackendValidationIssue } from 'src/features/validation'; import type { ILayoutFile } from 'src/layout/common.generated'; const appFrontend = new AppFrontend(); @@ -620,6 +621,76 @@ Cypress.Commands.add( }), ); +Cypress.Commands.add('runAllBackendValidations', () => { + cy.intercept('PATCH', '**/data', (req) => { + req.body.ignoredValidators = []; + }).as('runBackendValidations'); +}); + +Cypress.Commands.add('getNextPatchValidations', (result) => { + // We don't want to accidentally intercept a request caused by a change before this method is called + cy.waitUntilSaved(); + + // Clear existing data first + cy.then(() => { + result.validations = null; + }); + cy.intercept({ method: 'PATCH', url: '**/data', times: 1 }, (req) => { + req.on('response', (res) => { + // Consider finding out what data element id corresponds to each type at the beginning of the test instead, for more explicit checking + result.validations = res.body.validationIssues; + }); + }).as('getNextValidations'); +}); + +Cypress.Commands.add('expectValidationToExist', (result, group, predicate) => { + cy.wrap(result, { log: false }).should(({ validations }) => { + const ready = Boolean(validations); + if (ready) { + expect(ready, 'Found validations from backend').to.be.true; + } else { + expect(ready, 'Did not find validations from backend').to.be.true; + } + + const validation = validations?.[group]?.find((v: BackendValidationIssue) => predicate(v)); + if (validation) { + expect( + validation, + `Backend validation with predicate ${predicate.toString().replaceAll('\n', ' ')} exists in validation group '${group}'`, + ).to.exist; + } else { + expect( + validation, + `Unable to find backend validation with predicate ${predicate.toString().replaceAll('\n', ' ')}} in validation group '${group}'. Validations: ${JSON.stringify(validations?.[group])}.`, + ).to.exist; + } + }); +}); + +Cypress.Commands.add('expectValidationNotToExist', (result, group, predicate) => { + cy.wrap(result, { log: false }).should(({ validations }) => { + const ready = Boolean(validations); + if (ready) { + expect(ready, 'Found validations from backend').to.be.true; + } else { + expect(ready, 'Did not find validations from backend').to.be.true; + } + + const validation = validations?.[group]?.find((v) => predicate(v)); + if (!validation) { + expect( + validation, + `Backend validation with predicate ${predicate.toString().replaceAll('\n', ' ')} does not exist in validation group '${group}'`, + ).not.to.exist; + } else { + expect( + validation, + `Expected backend validation with predicate ${predicate.toString().replaceAll('\n', ' ')}} not to exist in validation group '${group}'. Validations: ${JSON.stringify(validations?.[group])}.`, + ).not.to.exist; + } + }); +}); + Cypress.Commands.add('allowFailureOnEnd', function () { // eslint-disable-next-line @typescript-eslint/no-explicit-any (this.test as any).__allowFailureOnEnd = true; diff --git a/test/e2e/support/global.ts b/test/e2e/support/global.ts index 0352be6a30..a3091a83df 100644 --- a/test/e2e/support/global.ts +++ b/test/e2e/support/global.ts @@ -1,5 +1,6 @@ import type { CyUser } from 'test/e2e/support/auth'; +import type { BackendValidationIssue, BackendValidationIssueGroups } from 'src/features/validation'; import type { ILayoutSets } from 'src/layout/common.generated'; import type { CompExternal, ILayoutCollection, ILayouts } from 'src/layout/layout'; @@ -220,6 +221,34 @@ declare global { testPdf(snapshotName: string | false, callback: () => void, returnToForm?: boolean): Chainable; getCurrentPageId(): Chainable; + /** + * Will intercept patch requests to set ignoredValidators to an empty array, causing the backend to run all validations + */ + runAllBackendValidations(): Chainable; + + /** + * Returns a result containing the validation issues for the next patch request + */ + getNextPatchValidations(resultContainer: BackendValidationResult): Chainable; + + /** + * Convenient way to check for the presence of a validation in a resultContainer + */ + expectValidationToExist( + resultContainer: BackendValidationResult, + validatorGroup: string, + predicate: BackendValdiationPredicate, + ): Chainable; + + /** + * Convenient way to check for the absense of a validation in a resultContainer + */ + expectValidationNotToExist( + resultContainer: BackendValidationResult, + validatorGroup: string, + predicate: BackendValdiationPredicate, + ): Chainable; + /** * All tests will check to make sure things didn't fail horribly after the test is done. This is useful for * catching errors that might not be caught by the test itself. Some tests however will explicitly test failure @@ -229,3 +258,8 @@ declare global { } } } + +export type BackendValidationResult = { + validations: BackendValidationIssueGroups | null; +}; +export type BackendValdiationPredicate = (validationIssue: BackendValidationIssue) => boolean | null | undefined;