diff --git a/package.json b/package.json index ac273b79bf..ffd6bb663d 100644 --- a/package.json +++ b/package.json @@ -68,6 +68,7 @@ "cypress": "12.5.1", "cypress-axe": "1.3.0", "cypress-plugin-tab": "1.0.5", + "cypress-wait-until": "^1.7.2", "dotenv": "16.0.3", "eslint": "8.33.0", "eslint-config-prettier": "8.6.0", diff --git a/src/__mocks__/formDataStateMock.ts b/src/__mocks__/formDataStateMock.ts index 471d591edb..c0a9f13cab 100644 --- a/src/__mocks__/formDataStateMock.ts +++ b/src/__mocks__/formDataStateMock.ts @@ -12,11 +12,11 @@ export function getFormDataStateMock(customState?: Partial) { 'referencedGroup[1].inputField': 'Value from input field [1]', 'referencedGroup[2].inputField': 'Value from input field [2]', }, - hasSubmitted: false, + lastSavedFormData: {}, savingId: '', submittingId: '', - responseInstance: false, unsavedChanges: false, + saving: false, ignoreWarnings: true, }; diff --git a/src/features/form/data/formDataSlice.ts b/src/features/form/data/formDataSlice.ts index 2770692002..deaebe7bf0 100644 --- a/src/features/form/data/formDataSlice.ts +++ b/src/features/form/data/formDataSlice.ts @@ -3,13 +3,12 @@ import type { AnyAction } from 'redux'; import { fetchFormDataSaga, watchFetchFormDataInitialSaga } from 'src/features/form/data/fetch/fetchFormDataSagas'; import { autoSaveSaga, saveFormDataSaga, submitFormSaga } from 'src/features/form/data/submit/submitFormDataSagas'; import { deleteAttachmentReferenceSaga, updateFormDataSaga } from 'src/features/form/data/update/updateFormDataSagas'; -import { FormLayoutActions } from 'src/features/form/layout/formLayoutSlice'; import { checkIfRuleShouldRunSaga } from 'src/features/form/rules/check/checkRulesSagas'; import { checkIfDataListShouldRefetchSaga } from 'src/shared/resources/dataLists/fetchDataListsSaga'; import { checkIfOptionsShouldRefetchSaga } from 'src/shared/resources/options/fetch/fetchOptionsSagas'; import { ProcessActions } from 'src/shared/resources/process/processSlice'; import { createSagaSlice } from 'src/shared/resources/utils/sagaSlice'; -import type { IFormDataState } from 'src/features/form/data'; +import type { IFormData, IFormDataState } from 'src/features/form/data'; import type { IDeleteAttachmentReference, IFetchFormData, @@ -24,12 +23,12 @@ import type { MkActionType } from 'src/shared/resources/utils/sagaSlice'; export const initialState: IFormDataState = { formData: {}, - error: null, - responseInstance: null, + lastSavedFormData: {}, unsavedChanges: false, + saving: false, submittingId: '', savingId: '', - hasSubmitted: false, + error: null, ignoreWarnings: false, }; @@ -51,6 +50,7 @@ const formDataSlice = createSagaSlice((mkAction: MkActionType) = reducer: (state, action) => { const { formData } = action.payload; state.formData = formData; + state.lastSavedFormData = formData; }, }), fetchRejected: mkAction({ @@ -66,12 +66,22 @@ const formDataSlice = createSagaSlice((mkAction: MkActionType) = }, }), submit: mkAction({ - takeLatest: submitFormSaga, + takeEvery: submitFormSaga, reducer: (state, action) => { const { apiMode, componentId } = action.payload; state.savingId = apiMode !== 'Complete' ? componentId : state.savingId; state.submittingId = apiMode === 'Complete' ? componentId : state.submittingId; - state.hasSubmitted = apiMode === 'Complete'; + }, + }), + savingStarted: mkAction({ + reducer: (state) => { + state.saving = true; + }, + }), + savingEnded: mkAction<{ model: IFormData }>({ + reducer: (state, action) => { + state.saving = false; + state.lastSavedFormData = action.payload.model; }, }), submitFulfilled: mkAction({ @@ -92,7 +102,6 @@ const formDataSlice = createSagaSlice((mkAction: MkActionType) = update: mkAction({ takeEvery: updateFormDataSaga, reducer: (state) => { - state.hasSubmitted = false; state.ignoreWarnings = false; }, }), @@ -100,14 +109,16 @@ const formDataSlice = createSagaSlice((mkAction: MkActionType) = takeLatest: [checkIfRuleShouldRunSaga, autoSaveSaga], takeEvery: [checkIfOptionsShouldRefetchSaga, checkIfDataListShouldRefetchSaga], reducer: (state, action) => { - const { field, data } = action.payload; + const { field, data, skipAutoSave } = action.payload; // Remove if data is null, undefined or empty string if (data === undefined || data === null || data === '') { delete state.formData[field]; } else { state.formData[field] = data; } - state.unsavedChanges = true; + if (!skipAutoSave) { + state.unsavedChanges = true; + } }, }), updateRejected: mkAction({ @@ -117,7 +128,7 @@ const formDataSlice = createSagaSlice((mkAction: MkActionType) = }, }), save: mkAction({ - takeLatest: saveFormDataSaga, + takeEvery: saveFormDataSaga, }), deleteAttachmentReference: mkAction({ takeLatest: deleteAttachmentReferenceSaga, @@ -125,12 +136,6 @@ const formDataSlice = createSagaSlice((mkAction: MkActionType) = }, extraReducers: (builder) => { builder - .addCase(FormLayoutActions.updateCurrentView, (state) => { - state.hasSubmitted = true; - }) - .addCase(FormLayoutActions.updateCurrentViewFulfilled, (state) => { - state.hasSubmitted = false; - }) .addMatcher(isProcessAction, (state) => { state.submittingId = ''; }) diff --git a/src/features/form/data/index.d.ts b/src/features/form/data/index.d.ts index 8fb1390239..3eb0eb0941 100644 --- a/src/features/form/data/index.d.ts +++ b/src/features/form/data/index.d.ts @@ -1,11 +1,27 @@ export interface IFormDataState { + // This is the constantly mutated object containing the current form data/data model. In key-value form. formData: IFormData; - error: Error | null; - responseInstance: any; + + // Last saved form data. This one is a copy of the above, and will be copied from there after each save. This means + // we can remember everything that changed, along with previous values, and pass them to the backend when we save + // values. Do not change this unless you know what you're doing. + lastSavedFormData: IFormData; + + // These two control the state machine for saving data. We should only perform one save/PUT request at a time, because + // we might get back data that we have to merge into our data model (from `ProcessDataWrite`), and running multiple + // save requests at the same time may overwrite data or cause non-atomic saves the backend does not expect. If + // `unsavedChanges` is set it means we currently have data in `formData` that has not yet been PUT - and if `saving` + // is set it means we're currently sending a request (so the next one, if triggered, will wait until the last save + // has completed). At last, the saved `formData` is set into `lastSavedFormData` (after potential changes from + // `ProcessDataWrite` on the server). unsavedChanges: boolean; + saving: boolean; + + // The component IDs which triggered submitting/saving last time submittingId: string; savingId: string; - hasSubmitted: boolean; + + error: Error | null; ignoreWarnings: boolean; } diff --git a/src/features/form/data/submit/submitFormDataSagas.test.ts b/src/features/form/data/submit/submitFormDataSagas.test.ts deleted file mode 100644 index 0a96b0cc98..0000000000 --- a/src/features/form/data/submit/submitFormDataSagas.test.ts +++ /dev/null @@ -1,271 +0,0 @@ -import { call, select } from 'redux-saga/effects'; -import { expectSaga } from 'redux-saga-test-plan'; - -import { getFormDataStateMock, getInitialStateMock, getInstanceDataStateMock } from 'src/__mocks__/mocks'; -import { FormDataActions } from 'src/features/form/data/formDataSlice'; -import { putFormData, saveFormDataSaga, saveStatelessData } from 'src/features/form/data/submit/submitFormDataSagas'; -import { FormDynamicsActions } from 'src/features/form/dynamics/formDynamicsSlice'; -import { makeGetAllowAnonymousSelector } from 'src/selectors/getAllowAnonymous'; -import { getCurrentDataTypeForApplication, getCurrentTaskDataElementId } from 'src/utils/appMetadata'; -import { convertDataBindingToModel } from 'src/utils/databindings'; -import { post } from 'src/utils/network/networking'; -import { put } from 'src/utils/network/sharedNetworking'; -import { dataElementUrl, getStatelessFormDataUrl } from 'src/utils/urls/appUrlHelper'; -import type { IApplicationMetadata } from 'src/shared/resources/applicationMetadata'; -import type { IInstanceDataState } from 'src/shared/resources/instanceData'; -import type { IRuntimeState } from 'src/types'; -import type { IData, IInstance } from 'src/types/shared'; - -describe('submitFormDataSagas', () => { - let stateMock: IRuntimeState; - beforeEach(() => { - stateMock = getInitialStateMock(); - }); - - it('saveFormDataSaga', () => { - const instanceDataMock: IInstanceDataState = getInstanceDataStateMock(); - const dataElement: IData = { - id: 'test-data-element-1', - instanceGuid: instanceDataMock.instance?.id || '', - dataType: 'test-data-model', - filename: 'testData1.pdf', - contentType: 'application/pdf', - blobStoragePath: '', - size: 1234, - locked: false, - refs: [], - created: new Date('2021-01-01').toISOString(), - createdBy: 'testUser', - lastChanged: new Date('2021-01-01').toISOString(), - lastChangedBy: 'testUser', - }; - const mockInstanceData: IInstanceDataState = { - ...instanceDataMock, - instance: { - ...(instanceDataMock.instance as IInstance), - data: [dataElement], - }, - }; - const state: IRuntimeState = { - ...stateMock, - instanceData: mockInstanceData, - formData: getFormDataStateMock({ - formData: { - field1: 'value1', - field2: '123', - }, - }), - }; - - const model = convertDataBindingToModel(state.formData.formData); - const defaultDataElementGuid = getCurrentTaskDataElementId( - state.applicationMetadata.applicationMetadata, - state.instanceData.instance, - state.formLayout.layoutsets, - ) as string; - const field = 'someField'; - const componentId = 'someComponent'; - const data = 'someData'; - - return expectSaga(saveFormDataSaga, { - payload: { componentId, field, data }, - type: '', - }) - .provide([ - [select(), state], - [ - call(put, dataElementUrl(defaultDataElementGuid), model, { - headers: { - 'X-DataField': encodeURIComponent(field), - 'X-ComponentId': encodeURIComponent(componentId), - }, - }), - {}, - ], - ]) - .call(putFormData, { state, model, field, componentId }) - .put(FormDataActions.submitFulfilled()) - .run(); - }); - - it('saveFormDataSaga for stateless app', () => { - const formData = { - field1: 'value1', - field2: 'abc', - }; - const state: IRuntimeState = { - ...stateMock, - applicationMetadata: { - ...stateMock.applicationMetadata, - applicationMetadata: { - ...(stateMock.applicationMetadata.applicationMetadata as IApplicationMetadata), - onEntry: { show: 'stateless' }, - }, - }, - formData: { - ...stateMock.formData, - formData: formData, - }, - formLayout: { - ...stateMock.formLayout, - layoutsets: { - sets: [ - { - id: 'stateless', - dataType: 'test-data-model', - tasks: [], - }, - ], - }, - }, - }; - - const model = convertDataBindingToModel(state.formData.formData); - const currentDataType = getCurrentDataTypeForApplication({ - application: state.applicationMetadata.applicationMetadata, - instance: state.instanceData.instance, - layoutSets: state.formLayout.layoutsets, - }) as string; - - const field = 'someField'; - const componentId = 'someComponent'; - const data = 'someData'; - - return expectSaga(saveFormDataSaga, { - payload: { - field, - componentId, - data, - }, - type: '', - }) - .provide([ - [select(), state], - [select(makeGetAllowAnonymousSelector()), false], - [ - call( - post, - getStatelessFormDataUrl(currentDataType), - { - headers: { - party: `partyid:${stateMock.party.selectedParty?.partyId}`, - 'X-DataField': encodeURIComponent(field), - 'X-ComponentId': encodeURIComponent(componentId), - }, - }, - model, - ), - { - data: { - ...formData, - group: { - field1: 'value1', - }, - }, - }, - ], - ]) - .call(saveStatelessData, { state, model, field, componentId }) - .put( - FormDataActions.fetchFulfilled({ - formData: { - ...formData, - 'group.field1': 'value1', - }, - }), - ) - .put(FormDynamicsActions.checkIfConditionalRulesShouldRun({})) - .put(FormDataActions.submitFulfilled()) - .run(); - }); - - it('saveFormDataSaga for stateless app with allowAnonymous', () => { - const formData = { - field1: 'value1', - field2: 'abc', - }; - const state: IRuntimeState = { - ...stateMock, - applicationMetadata: { - ...stateMock.applicationMetadata, - applicationMetadata: { - ...(stateMock.applicationMetadata.applicationMetadata as IApplicationMetadata), - onEntry: { show: 'stateless' }, - }, - }, - formData: { - ...stateMock.formData, - formData: formData, - }, - formLayout: { - ...stateMock.formLayout, - layoutsets: { - sets: [ - { - id: 'stateless', - dataType: 'test-data-model', - tasks: [], - }, - ], - }, - }, - }; - - const model = convertDataBindingToModel(state.formData.formData); - const currentDataType = getCurrentDataTypeForApplication({ - application: state.applicationMetadata.applicationMetadata, - instance: state.instanceData.instance, - layoutSets: state.formLayout.layoutsets, - }) as string; - - const field = 'someField'; - const componentId = 'someComponent'; - const data = 'someData'; - - return expectSaga(saveFormDataSaga, { - payload: { - field, - componentId, - data, - }, - type: '', - }) - .provide([ - [select(), state], - [select(makeGetAllowAnonymousSelector()), true], - [ - call( - post, - getStatelessFormDataUrl(currentDataType, true), - { - headers: { - 'X-DataField': encodeURIComponent(field), - 'X-ComponentId': encodeURIComponent(componentId), - }, - }, - model, - ), - { - data: { - ...formData, - group: { - field1: 'value1', - }, - }, - }, - ], - ]) - .call(saveStatelessData, { state, model, field, componentId }) - .put( - FormDataActions.fetchFulfilled({ - formData: { - ...formData, - 'group.field1': 'value1', - }, - }), - ) - .put(FormDynamicsActions.checkIfConditionalRulesShouldRun({})) - .put(FormDataActions.submitFulfilled()) - .run(); - }); -}); diff --git a/src/features/form/data/submit/submitFormDataSagas.ts b/src/features/form/data/submit/submitFormDataSagas.ts index 8da584047c..ea39a55342 100644 --- a/src/features/form/data/submit/submitFormDataSagas.ts +++ b/src/features/form/data/submit/submitFormDataSagas.ts @@ -13,6 +13,7 @@ import { Severity } from 'src/types'; import { getCurrentDataTypeForApplication, getCurrentTaskDataElementId, isStatelessApp } from 'src/utils/appMetadata'; import { convertDataBindingToModel, convertModelToDataBinding, filterOutInvalidData } from 'src/utils/databindings'; import { post } from 'src/utils/network/networking'; +import { waitFor } from 'src/utils/sagas'; import { get, put } from 'src/utils/sharedUtils'; import { dataElementUrl, getStatelessFormDataUrl, getValidationUrl } from 'src/utils/urls/appUrlHelper'; import { @@ -22,6 +23,7 @@ import { mergeValidationObjects, runClientSideValidation, } from 'src/utils/validation'; +import type { IFormData } from 'src/features/form/data'; import type { ISubmitDataAction, IUpdateFormDataFulfilled } from 'src/features/form/data/formDataTypes'; import type { ILayoutState } from 'src/features/form/layout/formLayoutSlice'; import type { IRuntimeState, IRuntimeStore, IUiConfig, IValidationIssue } from 'src/types'; @@ -34,8 +36,7 @@ export function* submitFormSaga({ }: PayloadAction): SagaIterator { try { const state: IRuntimeState = yield select(); - const { model, validationResult, componentSpecificValidations, emptyFieldsValidations } = - runClientSideValidation(state); + const { validationResult, componentSpecificValidations, emptyFieldsValidations } = runClientSideValidation(state); validationResult.validations = mergeValidationObjects( validationResult.validations, @@ -48,7 +49,7 @@ export function* submitFormSaga({ return yield sagaPut(FormDataActions.submitRejected({ error: null })); } - yield call(putFormData, { state, model }); + yield call(putFormData, {}); if (apiMode === 'Complete') { yield call(submitComplete, state, stopWithWarnings); } @@ -91,59 +92,141 @@ function* submitComplete(state: IRuntimeState, stopWithWarnings: boolean | undef return yield sagaPut(ProcessActions.complete()); } -export function* putFormData({ state, model, field, componentId }: SaveDataParams) { - // updates the default data element - const defaultDataElementGuid = getCurrentTaskDataElementId( - state.applicationMetadata.applicationMetadata, - state.instanceData.instance, - state.formLayout.layoutsets, +function createFormDataRequest( + state: IRuntimeState, + model: any, + field: string | undefined, + componentId: string | undefined, +): { data: any; options?: AxiosRequestConfig } { + if (state.applicationMetadata.applicationMetadata?.features?.multiPartSave) { + const previous = diffModels(state.formData.formData, state.formData.lastSavedFormData); + const data = new FormData(); + data.append('dataModel', JSON.stringify(model)); + data.append('previousValues', JSON.stringify(previous)); + return { data }; + } + + const options: AxiosRequestConfig = { + headers: { + 'X-DataField': (field && encodeURIComponent(field)) || 'undefined', + 'X-ComponentId': (componentId && encodeURIComponent(componentId)) || 'undefined', + }, + }; + + return { data: model, options }; +} + +function diffModels(current: IFormData, prev: IFormData) { + const changes: { [key: string]: string | null } = {}; + for (const key of Object.keys(current)) { + if (current[key] !== prev[key]) { + changes[key] = prev[key]; + if (prev[key] === undefined) { + changes[key] = null; + } + } + } + for (const key of Object.keys(prev)) { + if (!(key in current)) { + changes[key] = prev[key]; + } + } + + return changes; +} + +function* waitForSaving() { + // We should only run one save request at a time. This function waits until we can perform + // a saving operations, and reserves a spot. + yield waitFor((state) => !state.formData.saving); + yield sagaPut(FormDataActions.savingStarted()); +} + +export function* putFormData({ field, componentId }: SaveDataParams) { + const defaultDataElementGuid: string | undefined = yield select((state) => + getCurrentTaskDataElementId( + state.applicationMetadata.applicationMetadata, + state.instanceData.instance, + state.formLayout.layoutsets, + ), ); + if (!defaultDataElementGuid) { + return; + } + + yield call(waitForSaving); + const state: IRuntimeState = yield select(); + const model = getModelToSave(state); + + const url = dataElementUrl(defaultDataElementGuid); + let lastSavedModel = state.formData.formData; try { - const options: AxiosRequestConfig = { - headers: { - 'X-DataField': (field && encodeURIComponent(field)) || 'undefined', - 'X-ComponentId': (componentId && encodeURIComponent(componentId)) || 'undefined', - }, - }; - if (defaultDataElementGuid) { - yield call(put, dataElementUrl(defaultDataElementGuid), model, options); - } + const { data, options } = createFormDataRequest(state, model, field, componentId); + const responseData = yield call(put, url, data, options); + lastSavedModel = yield call(handleChangedFields, responseData?.changedFields, state.formData.formData); } catch (error) { if (error.response && error.response.status === 303) { // 303 means that data has been changed by calculation on server. Try to update from response. + // Newer backends might not reply back with this special response code when there are changes, they + // will just respond with the 'changedFields' property instead (see code handling this above). if (error.response.data?.changedFields) { - yield call(handleCalculationUpdate, error.response.data?.changedFields); - yield sagaPut(FormLayoutActions.initRepeatingGroups()); - } else if (defaultDataElementGuid) { + lastSavedModel = yield call(handleChangedFields, error.response.data?.changedFields, state.formData.formData); + } else { // No changedFields property returned, try to fetch - yield sagaPut( - FormDataActions.fetch({ - url: dataElementUrl(defaultDataElementGuid), - }), - ); + yield sagaPut(FormDataActions.fetch({ url })); } } else { throw error; } } + + yield sagaPut(FormDataActions.savingEnded({ model: lastSavedModel })); } -function* handleCalculationUpdate(changedFields) { +/** + * When asked to save the data model, the server will execute ProcessDataWrite(), which may mutate the data model and + * add new data/remove data from it. If that happens, we need to inject those changes back into our data model. + */ +function* handleChangedFields(changedFields: IFormData | undefined, lastSavedFormData: IFormData) { if (!changedFields) { - return; + return lastSavedFormData; } yield all( - Object.keys(changedFields).map((fieldKey) => - sagaPut( + Object.keys(changedFields).map((field) => { + // Simulating the update on lastSavedFormData as well, because we need to pretend these changes were here all + // along in order to send the proper list of changed fields in the next save request. We can't simply read the + // current formData when the save is done (and use that for the lastSavedFormData state) because that may have + // changed since we started saving (another request may be in the queue to save the next piece of data). + const data = changedFields[field]?.toString(); + if (data === undefined || data === null || data === '') { + delete lastSavedFormData[field]; + } else { + lastSavedFormData[field] = data; + } + + return sagaPut( FormDataActions.update({ - data: changedFields[fieldKey]?.toString(), - field: fieldKey, + data, + field, skipValidation: true, skipAutoSave: true, }), - ), - ), + ); + }), + ); + + yield sagaPut(FormLayoutActions.initRepeatingGroups()); + + return lastSavedFormData; +} + +function getModelToSave(state: IRuntimeState) { + return convertDataBindingToModel( + filterOutInvalidData({ + data: state.formData.formData, + invalidKeys: state.formValidations.invalidDataTypes, + }), ); } @@ -154,18 +237,12 @@ export function* saveFormDataSaga({ const state: IRuntimeState = yield select(); // updates the default data element const application = state.applicationMetadata.applicationMetadata; - const model = convertDataBindingToModel( - filterOutInvalidData({ - data: state.formData.formData, - invalidKeys: state.formValidations.invalidDataTypes, - }), - ); if (isStatelessApp(application)) { - yield call(saveStatelessData, { state, model, field, componentId }); + yield call(saveStatelessData, { field, componentId }); } else { // app with instance - yield call(putFormData, { state, model, field, componentId }); + yield call(putFormData, { field, componentId }); } if (singleFieldValidation && componentId) { @@ -186,13 +263,15 @@ export function* saveFormDataSaga({ } interface SaveDataParams { - state: IRuntimeState; - model: any; field?: string; componentId?: string; } -export function* saveStatelessData({ state, model, field, componentId }: SaveDataParams) { +export function* saveStatelessData({ field, componentId }: SaveDataParams) { + yield call(waitForSaving); + + const state: IRuntimeState = yield select(); + const model = getModelToSave(state); const allowAnonymous = yield select(makeGetAllowAnonymousSelector()); let headers: AxiosRequestConfig['headers'] = { 'X-DataField': (field && encodeURIComponent(field)) || 'undefined', @@ -217,6 +296,8 @@ export function* saveStatelessData({ state, model, field, componentId }: SaveDat yield sagaPut(FormDataActions.fetchFulfilled({ formData })); yield sagaPut(FormDynamicsActions.checkIfConditionalRulesShouldRun({})); } + + yield sagaPut(FormDataActions.savingEnded({ model: state.formData.formData })); } export function* autoSaveSaga({ diff --git a/src/layout/Likert/GroupContainerLikertTestUtils.tsx b/src/layout/Likert/GroupContainerLikertTestUtils.tsx index 319f8ccaa2..fa419308d3 100644 --- a/src/layout/Likert/GroupContainerLikertTestUtils.tsx +++ b/src/layout/Likert/GroupContainerLikertTestUtils.tsx @@ -219,13 +219,13 @@ export const render = ({ const components: ComponentInGroup[] = [mockRadioButton]; const mockData: IFormDataState = { formData: generateMockFormData(mockQuestions), + lastSavedFormData: {}, error: null, - hasSubmitted: false, ignoreWarnings: false, submittingId: '', savingId: '', - responseInstance: null, unsavedChanges: false, + saving: false, }; const preloadedState = getInitialStateMock({ diff --git a/src/shared/resources/applicationMetadata/index.d.ts b/src/shared/resources/applicationMetadata/index.d.ts index 54d2025452..5e3323004c 100644 --- a/src/shared/resources/applicationMetadata/index.d.ts +++ b/src/shared/resources/applicationMetadata/index.d.ts @@ -12,6 +12,7 @@ export interface IApplicationMetadata { title: ITitle; autoDeleteOnProcessEnd: boolean; onEntry?: IOnEntry; + features?: Partial; } export interface IApplicationMetadataState { @@ -39,3 +40,7 @@ export interface IGetApplicationMetadataFulfilled { export interface IGetApplicationMetadataRejected { error: Error; } + +export interface IBackendFeaturesState { + multiPartSave: boolean; +} diff --git a/src/utils/urls/appUrlHelper.ts b/src/utils/urls/appUrlHelper.ts index a1ca5b0385..d7985e7f05 100644 --- a/src/utils/urls/appUrlHelper.ts +++ b/src/utils/urls/appUrlHelper.ts @@ -51,6 +51,8 @@ export const getProcessNextUrl = (taskId?: string | null) => { export const getRedirectUrl = (returnUrl: string) => `${appPath}/api/v1/redirect?url=${encodeURIComponent(returnUrl)}`; +export const getFeatureSetUrl = () => `${appPath}/api/v1/featureset`; + export const getUpgradeAuthLevelUrl = (reqAuthLevel: string) => { const redirect: string = `https://platform.${getHostname()}` + `/authentication/api/v1/authentication?goto=${appPath}`; diff --git a/src/utils/validation/runClientSideValidation.ts b/src/utils/validation/runClientSideValidation.ts index 805ca59991..5f595bf072 100644 --- a/src/utils/validation/runClientSideValidation.ts +++ b/src/utils/validation/runClientSideValidation.ts @@ -11,7 +11,6 @@ import { import type { IRuntimeState, IValidationResult, IValidations } from 'src/types'; interface ValidationResult { - model: any; validationResult: IValidationResult; componentSpecificValidations: IValidations; emptyFieldsValidations: IValidations; @@ -23,7 +22,6 @@ interface ValidationResult { */ export function runClientSideValidation(state: IRuntimeState): ValidationResult { const out: ValidationResult = { - model: {}, validationResult: { validations: {}, invalidDataTypes: false, @@ -41,7 +39,6 @@ export function runClientSideValidation(state: IRuntimeState): ValidationResult instance: state.instanceData.instance, layoutSets: state.formLayout.layoutsets, }); - out.model = convertDataBindingToModel(state.formData.formData); const validator = getValidator(currentDataTaskDataTypeId, state.formDataModel.schemas); const hiddenFields = new Set(state.formLayout.uiConfig.hiddenFields); @@ -51,9 +48,10 @@ export function runClientSideValidation(state: IRuntimeState): ValidationResult return out; } + const model = convertDataBindingToModel(state.formData.formData); const layouts = resolvedLayoutsFromState(state); out.validationResult = validateFormData( - out.model, + model, layouts, layoutOrder, validator, diff --git a/test/e2e/integration/app-frontend/multipart-save.ts b/test/e2e/integration/app-frontend/multipart-save.ts new file mode 100644 index 0000000000..df94077d05 --- /dev/null +++ b/test/e2e/integration/app-frontend/multipart-save.ts @@ -0,0 +1,251 @@ +import dot from 'dot-object'; +import deepEqual from 'fast-deep-equal'; + +import AppFrontend from 'test/e2e/pageobjects/app-frontend'; + +import type { IFormData } from 'src/features/form/data'; +import type { IBackendFeaturesState } from 'src/shared/resources/applicationMetadata'; + +const appFrontend = new AppFrontend(); + +interface MultipartReq { + dataModel: IFormData; + previousValues: IFormData; +} + +describe('Multipart save', () => { + const requests: (MultipartReq | undefined)[] = []; + + /** + * This is not supported by 'frontend-test' yet, so we'll simulate the functionality by intercepting the requests + * and rewriting them to something the backend currently supports. In the process, we can verify that the + * functionality works on the frontend. + */ + function simulateMultipartSave() { + cy.intercept('GET', '**/applicationmetadata', (req) => { + req.on('response', (res) => { + res.body.features = { + multiPartSave: true, + } as IBackendFeaturesState; + }); + }); + cy.intercept('PUT', '**/instances/**/data/*', (req) => { + const contentType = req.headers['content-type']?.toString(); + if (contentType.startsWith('multipart/form-data')) { + const { dataModel, previousValues } = dirtyMultiPartParser(contentType, req.body); + requests.push({ + dataModel: dot.dot(dataModel), + previousValues, + }); + req.body = JSON.stringify(dataModel); + req.headers['content-type'] = 'application/json'; + delete req.headers['content-length']; + } + req.continue(); + }).as('multipartSave'); + } + + function expectReq(cb: (req: MultipartReq) => boolean, customMessage: string, errorMsg: string) { + cy.waitUntil( + () => { + for (const idx in requests) { + const req = requests[idx]; + if (req && cb(req)) { + requests[idx] = undefined; + return true; + } + } + + return false; + }, + { + description: 'save', + customMessage, + errorMsg, + }, + ); + } + + function expectSave(key: string, newValue: any, prevValue: any) { + const newValueString = newValue === undefined ? 'undefined' : JSON.stringify(newValue); + const prevValueString = prevValue === null ? 'null' : JSON.stringify(prevValue); + const msg = `${key} => ${newValueString} (was ${prevValueString})`; + expectReq( + (req) => { + let val: any = req.dataModel[key]; + if (val !== newValue && val === undefined) { + return false; + } + + if (Array.isArray(newValue) && Array.isArray(prevValue)) { + // Crude workaround for checkboxes not supporting array storage. We'll split the value and sort the results + // in order to not rely on the order of saving these. + newValue.sort(); + prevValue.sort(); + val = val.split(',').sort(); + if (typeof req.previousValues[key] === 'string') { + req.previousValues[key] = req.previousValues[key].split(',').sort() as any; + } + } + + return deepEqual(val, newValue) && deepEqual(req.previousValues, { [key]: prevValue }); + }, + msg, + `Failed to assert that saving occurred with ${msg}`, + ); + } + + it('Multipart saving with groups', () => { + cy.goto('group'); + + // We need to reload the app for it to recognize the features changed. We don't expect the backend features to + // change while a user is working in the same session, so there is no automatic detection for this. + simulateMultipartSave(); + cy.reload(); + + cy.get(appFrontend.nextButton).click(); + + // Checking the checkbox should update with a 'null' previous value + const root = 'Endringsmelding-grp-9786'; + const showGroupKey = `${root}.Avgiver-grp-9787.KontaktpersonEPost-datadef-27688.value`; + cy.get(appFrontend.group.showGroupToContinue).find('input').check().blur(); + expectSave(showGroupKey, 'Ja', null); + + // And then unchecking it should do the inverse + cy.get(appFrontend.group.showGroupToContinue).find('input').uncheck().blur(); + expectSave(showGroupKey, undefined, 'Ja'); + + cy.get(appFrontend.group.showGroupToContinue).find('input').check().blur(); + expectSave(showGroupKey, 'Ja', null); + + const groupKey = `${root}.OversiktOverEndringene-grp-9788`; + const currentValueKey = 'SkattemeldingEndringEtterFristOpprinneligBelop-datadef-37131.value'; + const newValueKey = 'SkattemeldingEndringEtterFristNyttBelop-datadef-37132.value'; + const subGroupKey = 'nested-grp-1234'; + const commentKey = 'SkattemeldingEndringEtterFristKommentar-datadef-37133.value'; + + // Add a simple item to the group + cy.addItemToGroup(1, 2, 'first comment'); + expectSave(`${groupKey}[0].${currentValueKey}`, '1', null); + expectSave(`${groupKey}[0].${newValueKey}`, '2', null); + expectSave(`${groupKey}[0].${subGroupKey}[0].source`, 'altinn', null); + expectSave(`${groupKey}[0].${subGroupKey}[0].${commentKey}`, 'first comment', null); + + cy.addItemToGroup(1234, 5678, 'second comment'); + expectSave(`${groupKey}[1].${currentValueKey}`, '1234', null); + expectSave(`${groupKey}[1].${newValueKey}`, '5678', null); + expectSave(`${groupKey}[1].${subGroupKey}[0].source`, 'altinn', null); + expectSave(`${groupKey}[1].${subGroupKey}[0].${commentKey}`, 'second comment', null); + + cy.get(appFrontend.group.row(0).editBtn).click(); + cy.get(appFrontend.group.editContainer).find(appFrontend.group.next).click(); + cy.get(appFrontend.group.addNewItemSubGroup).click(); + expectSave(`${groupKey}[0].${subGroupKey}[1].source`, 'altinn', null); + + cy.get(appFrontend.group.comments).type('third comment in first row'); + expectSave(`${groupKey}[0].${subGroupKey}[1].${commentKey}`, 'third comment in first row', null); + + cy.get(appFrontend.group.row(0).nestedGroup.row(1).nestedDynamics).click(); + expectSave(`${groupKey}[0].${subGroupKey}[1].extraOptionsToggle`, 'Ja', null); + + cy.get(appFrontend.group.row(0).nestedGroup.row(1).nestedOptions[2]).check().blur(); + expectSave(`${groupKey}[0].${subGroupKey}[1].extraOptions`, 'o111', null); + + cy.get(appFrontend.group.row(0).nestedGroup.row(1).nestedOptions[1]).check().blur(); + expectSave(`${groupKey}[0].${subGroupKey}[1].extraOptions`, ['o111', 'o1'], ['o111']); + + cy.get(appFrontend.group.row(0).nestedGroup.row(1).nestedOptions[0]).check().blur(); + expectSave(`${groupKey}[0].${subGroupKey}[1].extraOptions`, ['o111', 'o1', 'o11'], ['o111', 'o1']); + + cy.get(appFrontend.group.row(0).nestedGroup.row(1).nestedOptions[2]).uncheck().blur(); + expectSave(`${groupKey}[0].${subGroupKey}[1].extraOptions`, ['o1', 'o11'], ['o111', 'o1', 'o11']); + + cy.get(appFrontend.group.row(0).nestedGroup.row(1).nestedOptions[1]).uncheck().blur(); + expectSave(`${groupKey}[0].${subGroupKey}[1].extraOptions`, ['o11'], ['o1', 'o11']); + + cy.get(appFrontend.group.saveSubGroup).click(); + cy.get(appFrontend.group.saveMainGroup).click(); + + cy.get(appFrontend.group.row(0).deleteBtn).click(); + expectReq( + (req) => { + const relevantEntries = Object.entries(req.dataModel).filter(([k]) => k.startsWith(groupKey)); + const expectedEntries = [ + // Group should now just have one row (we deleted the first one) + [`${groupKey}[0].${currentValueKey}`, '1234'], + [`${groupKey}[0].${newValueKey}`, '5678'], + [`${groupKey}[0].${subGroupKey}[0].source`, 'altinn'], + [`${groupKey}[0].${subGroupKey}[0].${commentKey}`, 'second comment'], + ]; + + const expectedPrevValues = { + [`${groupKey}[0].${currentValueKey}`]: '1', + [`${groupKey}[0].${newValueKey}`]: '2', + + // This following is not present, because it never really changed (previous value is the same as new value): + // [`${groupKey}[0].${subGroupKey}[0].source`]: 'altinn', + [`${groupKey}[0].${subGroupKey}[0].${commentKey}`]: 'first comment', + + [`${groupKey}[0].${subGroupKey}[1].source`]: 'altinn', + [`${groupKey}[0].${subGroupKey}[1].${commentKey}`]: 'third comment in first row', + [`${groupKey}[0].${subGroupKey}[1].extraOptionsToggle`]: 'Ja', + [`${groupKey}[0].${subGroupKey}[1].extraOptions`]: 'o11', + + [`${groupKey}[1].${currentValueKey}`]: '1234', + [`${groupKey}[1].${newValueKey}`]: '5678', + [`${groupKey}[1].${subGroupKey}[0].source`]: 'altinn', + [`${groupKey}[1].${subGroupKey}[0].${commentKey}`]: 'second comment', + }; + + return deepEqual(relevantEntries, expectedEntries) && deepEqual(req.previousValues, expectedPrevValues); + }, + 'first row deleted', + 'failed to assert first row deletion', + ); + + cy.get(appFrontend.group.row(0).deleteBtn).click(); + expectReq( + (req) => { + const relevantKeys = Object.keys(req.dataModel).filter((k) => k.startsWith(groupKey)); + const expectedPrevValues = { + [`${groupKey}[0].${currentValueKey}`]: '1234', + [`${groupKey}[0].${newValueKey}`]: '5678', + [`${groupKey}[0].${subGroupKey}[0].source`]: 'altinn', + [`${groupKey}[0].${subGroupKey}[0].${commentKey}`]: 'second comment', + }; + + return relevantKeys.length === 0 && deepEqual(req.previousValues, expectedPrevValues); + }, + 'second row deleted', + 'failed to assert second row deletion', + ); + + // Ensure there are no more save requests in the queue afterwards + cy.waitUntil(() => requests.filter((r) => !!r).length === 0); + }); +}); + +/** + * Cypress does not parse the multiPart content for us, so instead of pulling in a dependency just to do that, we'll + * just haphazardly parse it. We only care about the happy-path here anyway, and we'll let the test fail if our parsing + * fails. This is not running in production code, just our test suite. + */ +function dirtyMultiPartParser(contentType: string, body: string): { [key: string]: any } { + const boundaryHeader = contentType.split(';')[1]; + const boundary = boundaryHeader.split('boundary=')[1]; + const parts = body + .split(boundary) + .map((s) => s.trim()) + .filter((p) => p !== '--'); + + const out = {}; + for (const part of parts) { + const innerParts = part.split('\r\n\r\n', 2); + const nameMatch = innerParts[0].match(/name=["'](.*?)["']/); + if (nameMatch && nameMatch[1]) { + out[nameMatch[1]] = JSON.parse(innerParts[1].replace(/--$/, '')); + } + } + + return out; +} diff --git a/test/e2e/support/index.ts b/test/e2e/support/index.ts index ddee5b07a3..7b076dfc30 100644 --- a/test/e2e/support/index.ts +++ b/test/e2e/support/index.ts @@ -1,4 +1,5 @@ import '@testing-library/cypress/add-commands'; +import 'cypress-wait-until'; import 'cypress-axe'; import 'cypress-plugin-tab'; import 'test/e2e/support/app-frontend'; diff --git a/yarn.lock b/yarn.lock index 503b166eb9..744c4b4ed9 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5512,6 +5512,7 @@ __metadata: cypress: 12.5.1 cypress-axe: 1.3.0 cypress-plugin-tab: 1.0.5 + cypress-wait-until: ^1.7.2 dompurify: 2.4.3 dot-object: 2.1.4 dotenv: 16.0.3 @@ -7194,6 +7195,13 @@ __metadata: languageName: node linkType: hard +"cypress-wait-until@npm:^1.7.2": + version: 1.7.2 + resolution: "cypress-wait-until@npm:1.7.2" + checksum: e3fe3c35ef8cfda39fb8919ae63a238bd580a98f5c02120306b32d6502ddfa9bfc3afde733cd9b282035b9f67e8386bd6c58bd59ca5fd2ea65291e6d9bac1ed7 + languageName: node + linkType: hard + "cypress@npm:12.5.1": version: 12.5.1 resolution: "cypress@npm:12.5.1"