diff --git a/src/codegen/Common.ts b/src/codegen/Common.ts index 29e5430b79..e39d39b4b8 100644 --- a/src/codegen/Common.ts +++ b/src/codegen/Common.ts @@ -144,6 +144,18 @@ const common = { '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( + 'metadata', + new CG.str() + .optional() + .setDescription( + 'Describes the location where metadata for the option based component should be stored in the datamodel.', + ), + ), + ), IDataModelBindingsList: () => new CG.obj(new CG.prop('list', new CG.str())) .setTitle('Data model binding') diff --git a/src/codegen/ComponentConfig.ts b/src/codegen/ComponentConfig.ts index 53d534dc1e..2adca5063f 100644 --- a/src/codegen/ComponentConfig.ts +++ b/src/codegen/ComponentConfig.ts @@ -100,7 +100,9 @@ export class ComponentConfig extends GenerateComponentLike { } addDataModelBinding( - type: GenerateCommonImport<'IDataModelBindingsSimple' | 'IDataModelBindingsList'> | GenerateObject, + type: + | GenerateCommonImport<'IDataModelBindingsSimple' | 'IDataModelBindingsList' | 'IDataModelBindingsOptionsSimple'> + | GenerateObject, ): this { this.ensureNotOverridden(); return super.addDataModelBinding(type); diff --git a/src/codegen/dataTypes/GenerateComponentLike.ts b/src/codegen/dataTypes/GenerateComponentLike.ts index 9cc349cfc0..557370aa8c 100644 --- a/src/codegen/dataTypes/GenerateComponentLike.ts +++ b/src/codegen/dataTypes/GenerateComponentLike.ts @@ -57,7 +57,9 @@ export class GenerateComponentLike { * Adding multiple data model bindings to the component makes it a union */ public addDataModelBinding( - type: GenerateCommonImport<'IDataModelBindingsSimple' | 'IDataModelBindingsList'> | GenerateObject, + type: + | GenerateCommonImport<'IDataModelBindingsSimple' | 'IDataModelBindingsList' | 'IDataModelBindingsOptionsSimple'> + | GenerateObject, ): this { const name = 'dataModelBindings'; const existing = this.inner.getProperty(name)?.type; diff --git a/src/features/options/fetch/fetchOptionsSagas.test.ts b/src/features/options/fetch/fetchOptionsSagas.test.ts index af568094ca..c9d616279b 100644 --- a/src/features/options/fetch/fetchOptionsSagas.test.ts +++ b/src/features/options/fetch/fetchOptionsSagas.test.ts @@ -13,7 +13,7 @@ import { repeatingGroupsSelector, } from 'src/features/options/fetch/fetchOptionsSagas'; import { staticUseLanguageForTests, staticUseLanguageFromState } from 'src/hooks/useLanguage'; -import * as networking from 'src/utils/network/sharedNetworking'; +import * as networking from 'src/utils/network/networking'; import { selectNotNull } from 'src/utils/sagas'; import type { ILayouts } from 'src/layout/layout'; import type { IOptions, IRuntimeState } from 'src/types'; @@ -35,7 +35,7 @@ describe('fetchOptionsSagas', () => { }; it('should refetch a given option when an updated field is in a option mapping', () => { - jest.spyOn(networking, 'httpGet').mockResolvedValue([]); + jest.spyOn(networking, 'httpGetRaw').mockResolvedValue([]); const optionsWithField: IOptions = { someOption: { id: 'someOption', @@ -87,7 +87,7 @@ describe('fetchOptionsSagas', () => { describe('fetchOptionsSaga', () => { it('should spawn fetchSpecificOptionSaga for each unique optionsId', () => { - jest.spyOn(networking, 'httpGet').mockResolvedValue([]); + jest.spyOn(networking, 'httpGetRaw').mockResolvedValue([]); const formLayoutWithTwoSharedOptionIds: ILayouts = { formLayout: [ { @@ -143,6 +143,7 @@ describe('fetchOptionsSagas', () => { dataMapping: undefined, fixedQueryParameters: undefined, secure: undefined, + metadataBinding: undefined, }) .fork(fetchSpecificOptionSaga, { optionsId: 'kommune', @@ -151,12 +152,13 @@ describe('fetchOptionsSagas', () => { }, fixedQueryParameters: undefined, secure: undefined, + metadataBinding: undefined, }) .run(); }); it('should spawn multiple fetchSpecificOptionSaga if components have shared optionsId but different mapping', () => { - jest.spyOn(networking, 'httpGet').mockResolvedValue([]); + jest.spyOn(networking, 'httpGetRaw').mockResolvedValue([]); const formLayoutWithSameOptionIdButDifferentMapping: ILayouts = { formLayout: [ { @@ -205,6 +207,7 @@ describe('fetchOptionsSagas', () => { }, fixedQueryParameters: undefined, secure: undefined, + metadataBinding: undefined, }) .fork(fetchSpecificOptionSaga, { optionsId: 'kommune', @@ -213,6 +216,7 @@ describe('fetchOptionsSagas', () => { }, fixedQueryParameters: undefined, secure: undefined, + metadataBinding: undefined, }) .run(); }); @@ -220,7 +224,7 @@ describe('fetchOptionsSagas', () => { describe('Fixed query parameters', () => { it('should include static query parameters and mapping', () => { - jest.spyOn(networking, 'httpGet').mockResolvedValue([]); + jest.spyOn(networking, 'httpGetRaw').mockResolvedValue([]); const formLayout: ILayouts = { formLayout: [ { @@ -270,6 +274,7 @@ describe('fetchOptionsSagas', () => { dataMapping: undefined, fixedQueryParameters: { level: '1' }, secure: undefined, + metadataBinding: undefined, }) .fork(fetchSpecificOptionSaga, { optionsId: 'kommune', @@ -278,12 +283,13 @@ describe('fetchOptionsSagas', () => { }, fixedQueryParameters: { level: '2' }, secure: undefined, + metadataBinding: undefined, }) .run(); }); it('should include static query parameters in url', () => { - jest.spyOn(networking, 'httpGet').mockResolvedValue([]); + jest.spyOn(networking, 'httpGetRaw').mockResolvedValue([]); const formData = { 'FlytteTil.Fylke': 'Oslo', @@ -294,18 +300,19 @@ describe('fetchOptionsSagas', () => { dataMapping: undefined, fixedQueryParameters: { level: '1' }, secure: undefined, + metadataBinding: undefined, }) .provide([ [select(formDataSelector), formData], [select(staticUseLanguageFromState), { selectedLanguage: 'nb' }], [select(instanceIdSelector), 'someId'], ]) - .call(networking.httpGet, 'https://local.altinn.cloud/ttd/test/api/options/kommune?language=nb&level=1') + .call(networking.httpGetRaw, 'https://local.altinn.cloud/ttd/test/api/options/kommune?language=nb&level=1') .run(); }); it('should include mapping in url', () => { - jest.spyOn(networking, 'httpGet').mockResolvedValue([]); + jest.spyOn(networking, 'httpGetRaw').mockResolvedValue([]); const formData = { 'FlytteTil.Fylke': 'Oslo', @@ -318,18 +325,19 @@ describe('fetchOptionsSagas', () => { }, fixedQueryParameters: undefined, secure: undefined, + metadataBinding: undefined, }) .provide([ [select(formDataSelector), formData], [select(staticUseLanguageFromState), { selectedLanguage: 'nb' }], [select(instanceIdSelector), 'someId'], ]) - .call(networking.httpGet, 'https://local.altinn.cloud/ttd/test/api/options/kommune?language=nb&fylke=Oslo') + .call(networking.httpGetRaw, 'https://local.altinn.cloud/ttd/test/api/options/kommune?language=nb&fylke=Oslo') .run(); }); it('should include static query parameters and mapping in request url', () => { - jest.spyOn(networking, 'httpGet').mockResolvedValue([]); + jest.spyOn(networking, 'httpGetRaw').mockResolvedValue([]); const formData = { 'FlytteTil.Fylke': 'Oslo', @@ -342,6 +350,7 @@ describe('fetchOptionsSagas', () => { }, fixedQueryParameters: { level: '1' }, secure: undefined, + metadataBinding: undefined, }) .provide([ [select(formDataSelector), formData], @@ -349,7 +358,7 @@ describe('fetchOptionsSagas', () => { [select(instanceIdSelector), 'someId'], ]) .call( - networking.httpGet, + networking.httpGetRaw, 'https://local.altinn.cloud/ttd/test/api/options/kommune?language=nb&level=1&fylke=Oslo', ) .run(); diff --git a/src/features/options/fetch/fetchOptionsSagas.ts b/src/features/options/fetch/fetchOptionsSagas.ts index bbfcd0d89f..1c4cf9e894 100644 --- a/src/features/options/fetch/fetchOptionsSagas.ts +++ b/src/features/options/fetch/fetchOptionsSagas.ts @@ -2,6 +2,7 @@ import { call, fork, put, race, select, take } from 'redux-saga/effects'; import type { PayloadAction } from '@reduxjs/toolkit'; import type { SagaIterator } from 'redux-saga'; +import { FormDataActions } from 'src/features/formData/formDataSlice'; import { OptionsActions } from 'src/features/options/optionsSlice'; import { staticUseLanguageFromState } from 'src/hooks/useLanguage'; import { @@ -10,14 +11,14 @@ import { getKeyWithoutIndexIndicators, replaceIndexIndicatorsWithIndexes, } from 'src/utils/databindings'; -import { httpGet } from 'src/utils/network/sharedNetworking'; +import { httpGetRaw } from 'src/utils/network/networking'; import { getOptionLookupKey, getOptionLookupKeys } from 'src/utils/options'; import { selectNotNull } from 'src/utils/sagas'; import { getOptionsUrl } from 'src/utils/urls/appUrlHelper'; import type { IFormData } from 'src/features/formData'; import type { IUpdateFormData } from 'src/features/formData/formDataTypes'; import type { IUseLanguage } from 'src/hooks/useLanguage'; -import type { IOption, ISelectionComponent } from 'src/layout/common.generated'; +import type { IDataModelBindingsOptionsSimple, IOption, ISelectionComponent } from 'src/layout/common.generated'; import type { ILayouts } from 'src/layout/layout'; import type { IFetchSpecificOptionSaga, IOptions, IOptionsMetaData, IRepeatingGroups, IRuntimeState } from 'src/types'; @@ -53,7 +54,11 @@ export function* fetchOptionsSaga(): SagaIterator { const optionsWithIndexIndicators: IOptionsMetaData[] = []; for (const layoutId of Object.keys(layouts)) { for (const element of layouts[layoutId] || []) { - const { optionsId, mapping, queryParameters, secure } = element as ISelectionComponent; + const { optionsId, mapping, queryParameters, secure, dataModelBindings } = element as ISelectionComponent & { + dataModelBindings?: IDataModelBindingsOptionsSimple; + }; + + const metadataBinding = dataModelBindings?.metadata; // if we have index indicators we get up the lookup keys for existing indexes const { keys, keyWithIndexIndicator } = @@ -84,6 +89,7 @@ export function* fetchOptionsSaga(): SagaIterator { dataMapping: mapping, fixedQueryParameters: queryParameters, secure, + metadataBinding, }); fetchedOptions.push(lookupKey); } @@ -105,6 +111,7 @@ export function* fetchSpecificOptionSaga({ dataMapping, fixedQueryParameters, secure, + metadataBinding, }: IFetchSpecificOptionSaga): SagaIterator { const key = getOptionLookupKey({ id: optionsId, mapping: dataMapping, fixedQueryParameters }); const instanceId = yield select(instanceIdSelector); @@ -129,7 +136,18 @@ export function* fetchSpecificOptionSaga({ instanceId, }); - const options: IOption[] = yield call(httpGet, url); + const optionsResponse = yield call(httpGetRaw, url); + const downstreamParameters: string = optionsResponse.headers['altinn-downstreamparameters']; + if (downstreamParameters && metadataBinding) { + yield put( + FormDataActions.update({ + field: metadataBinding, + data: downstreamParameters, + skipValidation: true, + }), + ); + } + const options = optionsResponse.data as IOption[]; yield put(OptionsActions.fetchFulfilled({ key, options })); } catch (error) { yield put(OptionsActions.fetchRejected({ key, error })); diff --git a/src/layout/Checkboxes/config.ts b/src/layout/Checkboxes/config.ts index 64919c6afd..a7dbbe7a32 100644 --- a/src/layout/Checkboxes/config.ts +++ b/src/layout/Checkboxes/config.ts @@ -12,7 +12,7 @@ export const Config = new CG.component({ }, }) .makeSelectionComponent() - .addDataModelBinding(CG.common('IDataModelBindingsSimple').optional({ onlyIn: Variant.Internal })) + .addDataModelBinding(CG.common('IDataModelBindingsOptionsSimple').optional({ onlyIn: Variant.Internal })) .addProperty(new CG.prop('layout', CG.common('LayoutStyle').optional())); // We don't render the label in GenericComponent, but we still need the diff --git a/src/layout/Dropdown/config.ts b/src/layout/Dropdown/config.ts index e7be9745d0..58f9bc67e5 100644 --- a/src/layout/Dropdown/config.ts +++ b/src/layout/Dropdown/config.ts @@ -12,4 +12,4 @@ export const Config = new CG.component({ }, }) .makeSelectionComponent() - .addDataModelBinding(CG.common('IDataModelBindingsSimple').optional({ onlyIn: Variant.Internal })); + .addDataModelBinding(CG.common('IDataModelBindingsOptionsSimple').optional({ onlyIn: Variant.Internal })); diff --git a/src/layout/Likert/config.ts b/src/layout/Likert/config.ts index 8cbb14ef44..18b64540b0 100644 --- a/src/layout/Likert/config.ts +++ b/src/layout/Likert/config.ts @@ -11,7 +11,7 @@ export const Config = new CG.component({ renderInAccordionGroup: false, }, }) - .addDataModelBinding(CG.common('IDataModelBindingsSimple').optional({ onlyIn: Variant.Internal })) + .addDataModelBinding(CG.common('IDataModelBindingsOptionsSimple').optional({ onlyIn: Variant.Internal })) .addTextResource( new CG.trb({ name: 'title', diff --git a/src/layout/MultipleSelect/config.ts b/src/layout/MultipleSelect/config.ts index de58623d12..a71332c7ba 100644 --- a/src/layout/MultipleSelect/config.ts +++ b/src/layout/MultipleSelect/config.ts @@ -11,5 +11,5 @@ export const Config = new CG.component({ renderInAccordionGroup: false, }, }) - .addDataModelBinding(CG.common('IDataModelBindingsSimple').optional({ onlyIn: Variant.Internal })) + .addDataModelBinding(CG.common('IDataModelBindingsOptionsSimple').optional({ onlyIn: Variant.Internal })) .makeSelectionComponent(); diff --git a/src/layout/RadioButtons/config.ts b/src/layout/RadioButtons/config.ts index 9c2a4dbac5..0fb7bc7b2b 100644 --- a/src/layout/RadioButtons/config.ts +++ b/src/layout/RadioButtons/config.ts @@ -11,7 +11,7 @@ export const Config = new CG.component({ renderInAccordionGroup: false, }, }) - .addDataModelBinding(CG.common('IDataModelBindingsSimple').optional({ onlyIn: Variant.Internal })) + .addDataModelBinding(CG.common('IDataModelBindingsOptionsSimple').optional({ onlyIn: Variant.Internal })) .makeSelectionComponent() .addProperty(new CG.prop('layout', CG.common('LayoutStyle').optional())) .addProperty( diff --git a/src/types/index.ts b/src/types/index.ts index 64c3e5a63c..cc4f2b81cc 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -216,6 +216,7 @@ export interface IFetchSpecificOptionSaga { fixedQueryParameters?: Record; secure?: boolean; instanceId?: string; + metadataBinding?: string; } /** diff --git a/src/utils/network/networking.ts b/src/utils/network/networking.ts index fe0c3eb7ef..d8cb44eaa4 100644 --- a/src/utils/network/networking.ts +++ b/src/utils/network/networking.ts @@ -16,6 +16,15 @@ export async function httpGet(url: string, options?: AxiosRequestConfig): Promis return response.data ? response.data : null; } +export async function httpGetRaw(url: string, options?: AxiosRequestConfig): Promise { + const headers = options?.headers as RawAxiosRequestHeaders | undefined; + const response: AxiosResponse = await axios.get(url, { + ...options, + headers: { ...headers, Pragma: 'no-cache' }, + }); + return response.data ? response : null; +} + export async function httpPost(url: string, options?: AxiosRequestConfig, data?: any): Promise { return await axios.post(url, data, options); } diff --git a/test/e2e/integration/frontend-test/options.ts b/test/e2e/integration/frontend-test/options.ts index ef46b8399d..8967eb5207 100644 --- a/test/e2e/integration/frontend-test/options.ts +++ b/test/e2e/integration/frontend-test/options.ts @@ -42,4 +42,22 @@ describe('Options', () => { cy.findByRole('option', { name: 'Endre fra: 1, Endre til: 2' }).click(); cy.get(appFrontend.group.options).should('have.value', 'Endre fra: 1, Endre til: 2'); }); + it('retrieves metadata from header when metadata is set in datamodelBindings', () => { + cy.intercept({ method: 'GET', url: '**/options/test-kommuner**' }, (req) => { + req.reply((res) => { + const headers = res.headers; + headers['altinn-downstreamparameters'] = 'language=nb,id=131,variant=,date=01/01/2021,level=,parentCode='; + res.send(res.body, headers); + }); + }).as('optionsMunicipality'); + + cy.goto('changename'); + cy.wait('@optionsMunicipality'); + + cy.get(appFrontend.changeOfName.municipality).dsSelect('Oslo'); + + cy.get(appFrontend.changeOfName.municipalityMetadata) + .should('have.prop', 'value') + .should('match', /language=nb,id=131,variant=,date=\d{1,2}\/\d{1,2}\/\d{4},level=,parentCode=/); + }); }); diff --git a/test/e2e/pageobjects/app-frontend.ts b/test/e2e/pageobjects/app-frontend.ts index 733f2e09bc..f1d7a2a0c6 100644 --- a/test/e2e/pageobjects/app-frontend.ts +++ b/test/e2e/pageobjects/app-frontend.ts @@ -144,6 +144,8 @@ export class AppFrontend { reference: '#reference', reference2: '#reference2', dateOfEffect: '#dateOfEffect', + municipalityMetadata: '#kommuner-metadata', + municipality: '#kommune', upload: '#fileUpload-changename', uploadWithTag: { uploadZone: '#fileUploadWithTags-changename',