From bc4fb432eab479657fdd7acb55dee667d2eca9c5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B8rnar=20=C3=98sttveit?= <47412359+bjosttveit@users.noreply.github.com> Date: Thu, 10 Aug 2023 09:34:05 +0200 Subject: [PATCH] Static query parameters for options (#1370) * possible to add fixed query parameters * updated existing unit tests * remove queryParameters from list schema * added unit tests * more unit tests & fix getOptionUrl * move logic to getOptionLookupKeys --- schemas/json/layout/layout.schema.v1.json | 25 +++ .../options/fetch/fetchOptionsSagas.test.ts | 143 ++++++++++++++++++ .../options/fetch/fetchOptionsSagas.ts | 25 ++- src/hooks/useGetOptions.ts | 6 +- .../CheckboxesContainerComponent.tsx | 3 +- src/layout/Dropdown/DropdownComponent.tsx | 5 +- .../Likert/RepeatingGroupsLikertContainer.tsx | 4 +- .../MultipleSelectComponent.tsx | 4 +- src/layout/RadioButtons/radioButtonsUtils.ts | 4 +- src/layout/layout.d.ts | 1 + src/types/index.ts | 2 + src/utils/options.ts | 21 ++- src/utils/urls/appUrlHelper.test.ts | 32 ++++ src/utils/urls/appUrlHelper.ts | 14 +- 14 files changed, 260 insertions(+), 29 deletions(-) diff --git a/schemas/json/layout/layout.schema.v1.json b/schemas/json/layout/layout.schema.v1.json index 6ee06fae90..4e2fc32ffc 100644 --- a/schemas/json/layout/layout.schema.v1.json +++ b/schemas/json/layout/layout.schema.v1.json @@ -295,6 +295,9 @@ "mapping": { "$ref": "#/definitions/mapping", "description": "Optionally used to map options" + }, + "queryParameters": { + "$ref": "#/definitions/queryParameters" } }, "required": ["optionsId"] @@ -387,6 +390,9 @@ "mapping": { "$ref": "#/definitions/mapping", "description": "Creates a new app instance with data collected from a stateless part of the app." + }, + "queryParameters": { + "$ref": "#/definitions/queryParameters" } } }, @@ -968,6 +974,9 @@ "$ref": "#/definitions/mapping", "description": "Optionally used to map options" }, + "queryParameters": { + "$ref": "#/definitions/queryParameters" + }, "autocomplete": { "$ref": "#/definitions/autocomplete" } @@ -1266,6 +1275,22 @@ "type": "string" } }, + "queryParameters": { + "type": "object", + "title": "Query parameters", + "description": "Fixed query parameters to add to the url.", + "patternProperties": { + "^.+$": { + "type": "string", + "title": "Query parameter value" + } + }, + "examples": [ + { + "level": "1" + } + ] + }, "iframeComponent": { "type": "object", "properties": { diff --git a/src/features/options/fetch/fetchOptionsSagas.test.ts b/src/features/options/fetch/fetchOptionsSagas.test.ts index 60cc60ebbe..af568094ca 100644 --- a/src/features/options/fetch/fetchOptionsSagas.test.ts +++ b/src/features/options/fetch/fetchOptionsSagas.test.ts @@ -58,6 +58,7 @@ describe('fetchOptionsSagas', () => { dataMapping: { some_field: 'some_url_parm', }, + fixedQueryParameters: undefined, secure: undefined, }) .run(); @@ -140,6 +141,7 @@ describe('fetchOptionsSagas', () => { .fork(fetchSpecificOptionSaga, { optionsId: 'fylke', dataMapping: undefined, + fixedQueryParameters: undefined, secure: undefined, }) .fork(fetchSpecificOptionSaga, { @@ -147,6 +149,7 @@ describe('fetchOptionsSagas', () => { dataMapping: { 'FlytteFra.Fylke': 'fylke', }, + fixedQueryParameters: undefined, secure: undefined, }) .run(); @@ -200,6 +203,7 @@ describe('fetchOptionsSagas', () => { dataMapping: { 'FlytteFra.Fylke': 'fylke', }, + fixedQueryParameters: undefined, secure: undefined, }) .fork(fetchSpecificOptionSaga, { @@ -207,12 +211,151 @@ describe('fetchOptionsSagas', () => { dataMapping: { 'FlytteTil.Fylke': 'fylke', }, + fixedQueryParameters: undefined, secure: undefined, }) .run(); }); }); + describe('Fixed query parameters', () => { + it('should include static query parameters and mapping', () => { + jest.spyOn(networking, 'httpGet').mockResolvedValue([]); + const formLayout: ILayouts = { + formLayout: [ + { + id: 'fylke', + type: 'Dropdown', + textResourceBindings: { + title: 'fylke', + }, + dataModelBindings: { + simpleBinding: 'FlytteFra.Fylke', + }, + optionsId: 'fylke', + required: true, + queryParameters: { + level: '1', + }, + }, + { + id: 'kommune', + type: 'Dropdown', + textResourceBindings: { + title: 'kommune', + }, + dataModelBindings: { + simpleBinding: 'FlytteTil.Kommune', + }, + optionsId: 'kommune', + required: true, + mapping: { + 'FlytteTil.Fylke': 'fylke', + }, + queryParameters: { + level: '2', + }, + }, + ], + }; + + return expectSaga(fetchOptionsSaga) + .provide([ + [selectNotNull(formLayoutSelector), formLayout], + [selectNotNull(repeatingGroupsSelector), {}], + [select(instanceIdSelector), 'someId'], + ]) + .fork(fetchSpecificOptionSaga, { + optionsId: 'fylke', + dataMapping: undefined, + fixedQueryParameters: { level: '1' }, + secure: undefined, + }) + .fork(fetchSpecificOptionSaga, { + optionsId: 'kommune', + dataMapping: { + 'FlytteTil.Fylke': 'fylke', + }, + fixedQueryParameters: { level: '2' }, + secure: undefined, + }) + .run(); + }); + + it('should include static query parameters in url', () => { + jest.spyOn(networking, 'httpGet').mockResolvedValue([]); + + const formData = { + 'FlytteTil.Fylke': 'Oslo', + }; + + return expectSaga(fetchSpecificOptionSaga, { + optionsId: 'kommune', + dataMapping: undefined, + fixedQueryParameters: { level: '1' }, + secure: 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') + .run(); + }); + + it('should include mapping in url', () => { + jest.spyOn(networking, 'httpGet').mockResolvedValue([]); + + const formData = { + 'FlytteTil.Fylke': 'Oslo', + }; + + return expectSaga(fetchSpecificOptionSaga, { + optionsId: 'kommune', + dataMapping: { + 'FlytteTil.Fylke': 'fylke', + }, + fixedQueryParameters: undefined, + secure: 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') + .run(); + }); + + it('should include static query parameters and mapping in request url', () => { + jest.spyOn(networking, 'httpGet').mockResolvedValue([]); + + const formData = { + 'FlytteTil.Fylke': 'Oslo', + }; + + return expectSaga(fetchSpecificOptionSaga, { + optionsId: 'kommune', + dataMapping: { + 'FlytteTil.Fylke': 'fylke', + }, + fixedQueryParameters: { level: '1' }, + secure: 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&fylke=Oslo', + ) + .run(); + }); + }); + describe('instanceIdSelector', () => { it('should return instance id if present', () => { const state = { diff --git a/src/features/options/fetch/fetchOptionsSagas.ts b/src/features/options/fetch/fetchOptionsSagas.ts index 0774f07778..a8cb73f23a 100644 --- a/src/features/options/fetch/fetchOptionsSagas.ts +++ b/src/features/options/fetch/fetchOptionsSagas.ts @@ -59,7 +59,7 @@ export function* fetchOptionsSaga(): SagaIterator { const optionsWithIndexIndicators: IOptionsMetaData[] = []; for (const layoutId of Object.keys(layouts)) { for (const element of layouts[layoutId] || []) { - const { optionsId, mapping, secure } = element as ISelectionComponentProps; + const { optionsId, mapping, queryParameters, secure } = element as ISelectionComponentProps; // if we have index indicators we get up the lookup keys for existing indexes const { keys, keyWithIndexIndicator } = @@ -67,6 +67,7 @@ export function* fetchOptionsSaga(): SagaIterator { getOptionLookupKeys({ id: optionsId, mapping, + fixedQueryParameters: queryParameters, secure, repeatingGroups, })) || @@ -81,12 +82,13 @@ export function* fetchOptionsSaga(): SagaIterator { } for (const optionObject of keys) { - const { id, mapping, secure } = optionObject; - const lookupKey = getOptionLookupKey({ id, mapping }); + const { id, mapping, fixedQueryParameters, secure } = optionObject; + const lookupKey = getOptionLookupKey({ id, mapping, fixedQueryParameters }); if (optionsId && !fetchedOptions.includes(lookupKey)) { yield fork(fetchSpecificOptionSaga, { optionsId, dataMapping: mapping, + fixedQueryParameters: queryParameters, secure, }); fetchedOptions.push(lookupKey); @@ -104,13 +106,19 @@ export function* fetchOptionsSaga(): SagaIterator { ); } -export function* fetchSpecificOptionSaga({ optionsId, dataMapping, secure }: IFetchSpecificOptionSaga): SagaIterator { - const key = getOptionLookupKey({ id: optionsId, mapping: dataMapping }); +export function* fetchSpecificOptionSaga({ + optionsId, + dataMapping, + fixedQueryParameters, + secure, +}: IFetchSpecificOptionSaga): SagaIterator { + const key = getOptionLookupKey({ id: optionsId, mapping: dataMapping, fixedQueryParameters }); const instanceId = yield select(instanceIdSelector); try { const metaData: IOptionsMetaData = { id: optionsId, mapping: dataMapping, + fixedQueryParameters, secure, }; yield put(OptionsActions.fetching({ key, metaData })); @@ -122,6 +130,7 @@ export function* fetchSpecificOptionSaga({ optionsId, dataMapping, secure }: IFe formData, language, dataMapping, + fixedQueryParameters, secure, instanceId, }); @@ -138,7 +147,7 @@ export function* checkIfOptionsShouldRefetchSaga({ payload: { field } }: Payload const optionsWithIndexIndicators = yield select(optionsWithIndexIndicatorsSelector); let foundInExistingOptions = false; for (const optionsKey of Object.keys(options)) { - const { mapping, id, secure } = options[optionsKey] || {}; + const { mapping, fixedQueryParameters, id, secure } = options[optionsKey] || {}; if (!id) { continue; } @@ -148,6 +157,7 @@ export function* checkIfOptionsShouldRefetchSaga({ payload: { field } }: Payload yield fork(fetchSpecificOptionSaga, { optionsId: id, dataMapping: mapping, + fixedQueryParameters, secure, }); } @@ -159,7 +169,7 @@ export function* checkIfOptionsShouldRefetchSaga({ payload: { field } }: Payload } for (const option of optionsWithIndexIndicators) { - const { mapping, id, secure } = option; + const { mapping, fixedQueryParameters, id, secure } = option; if ( mapping && Object.keys(mapping) @@ -175,6 +185,7 @@ export function* checkIfOptionsShouldRefetchSaga({ payload: { field } }: Payload yield fork(fetchSpecificOptionSaga, { optionsId: id, dataMapping: newDataMapping, + fixedQueryParameters, secure, }); } diff --git a/src/hooks/useGetOptions.ts b/src/hooks/useGetOptions.ts index 3f471379e1..4081ea4f01 100644 --- a/src/hooks/useGetOptions.ts +++ b/src/hooks/useGetOptions.ts @@ -10,6 +10,7 @@ import type { IDataSources } from 'src/types/shared'; interface IUseGetOptionsParams { optionsId: string | undefined; mapping?: IMapping; + queryParameters?: Record; source?: IOptionSource; } @@ -19,7 +20,7 @@ export interface IOptionResources { helpText?: ITextResource; } -export const useGetOptions = ({ optionsId, mapping, source }: IUseGetOptionsParams) => { +export const useGetOptions = ({ optionsId, mapping, queryParameters, source }: IUseGetOptionsParams) => { const relevantFormData = useAppSelector( (state) => (source && getRelevantFormDataForOptionSource(state.formData.formData, source)) || {}, shallowEqual, @@ -42,7 +43,7 @@ export const useGetOptions = ({ optionsId, mapping, source }: IUseGetOptionsPara useEffect(() => { if (optionsId) { - const key = getOptionLookupKey({ id: optionsId, mapping }); + const key = getOptionLookupKey({ id: optionsId, mapping, fixedQueryParameters: queryParameters }); setOptions(optionState[key]?.options); } @@ -83,6 +84,7 @@ export const useGetOptions = ({ optionsId, mapping, source }: IUseGetOptionsPara relevantTextResources.label, relevantTextResources.description, relevantTextResources.helpText, + queryParameters, ]); return options; diff --git a/src/layout/Checkboxes/CheckboxesContainerComponent.tsx b/src/layout/Checkboxes/CheckboxesContainerComponent.tsx index 4d5b6cd1f5..24d4961d46 100644 --- a/src/layout/Checkboxes/CheckboxesContainerComponent.tsx +++ b/src/layout/Checkboxes/CheckboxesContainerComponent.tsx @@ -35,12 +35,13 @@ export const CheckboxContainerComponent = ({ layout, readOnly, mapping, + queryParameters, source, textResourceBindings, required, labelSettings, } = node.item; - const apiOptions = useGetOptions({ optionsId, mapping, source }); + const apiOptions = useGetOptions({ optionsId, mapping, queryParameters, source }); const calculatedOptions = apiOptions || options || defaultOptions; const hasSelectedInitial = React.useRef(false); const optionsHasChanged = useHasChangedIgnoreUndefined(apiOptions); diff --git a/src/layout/Dropdown/DropdownComponent.tsx b/src/layout/Dropdown/DropdownComponent.tsx index 5433a68c33..3fed6f49f8 100644 --- a/src/layout/Dropdown/DropdownComponent.tsx +++ b/src/layout/Dropdown/DropdownComponent.tsx @@ -22,11 +22,14 @@ export function DropdownComponent({ node, formData, handleDataChange, isValid, o id, readOnly, mapping, + queryParameters, source, textResourceBindings, } = node.item; const { langAsString } = useLanguage(); - const options = (useGetOptions({ optionsId, mapping, source }) || staticOptions)?.filter(duplicateOptionFilter); + const options = (useGetOptions({ optionsId, mapping, queryParameters, source }) || staticOptions)?.filter( + duplicateOptionFilter, + ); const lookupKey = optionsId && getOptionLookupKey({ id: optionsId, mapping }); const fetchingOptions = useAppSelector((state) => lookupKey && state.optionState.options[lookupKey]?.loading); const hasSelectedInitial = React.useRef(false); diff --git a/src/layout/Likert/RepeatingGroupsLikertContainer.tsx b/src/layout/Likert/RepeatingGroupsLikertContainer.tsx index 0724e0284d..f257d6be6e 100644 --- a/src/layout/Likert/RepeatingGroupsLikertContainer.tsx +++ b/src/layout/Likert/RepeatingGroupsLikertContainer.tsx @@ -21,9 +21,9 @@ type RepeatingGroupsLikertContainerProps = { export const RepeatingGroupsLikertContainer = ({ node }: RepeatingGroupsLikertContainerProps) => { const firstLikertChild = node?.children((item) => item.type === 'Likert') as LayoutNodeFromType<'Likert'> | undefined; - const { optionsId, mapping, source, options } = firstLikertChild?.item || {}; + const { optionsId, mapping, queryParameters, source, options } = firstLikertChild?.item || {}; const mobileView = useIsMobileOrTablet(); - const apiOptions = useGetOptions({ optionsId, mapping, source }); + const apiOptions = useGetOptions({ optionsId, mapping, queryParameters, source }); const calculatedOptions = apiOptions || options || []; const lookupKey = optionsId && getOptionLookupKey({ id: optionsId, mapping }); const fetchingOptions = useAppSelector((state) => lookupKey && state.optionState.options[lookupKey]?.loading); diff --git a/src/layout/MultipleSelect/MultipleSelectComponent.tsx b/src/layout/MultipleSelect/MultipleSelectComponent.tsx index a7fd4432f1..97ddfdd6a4 100644 --- a/src/layout/MultipleSelect/MultipleSelectComponent.tsx +++ b/src/layout/MultipleSelect/MultipleSelectComponent.tsx @@ -19,8 +19,8 @@ export function MultipleSelectComponent({ isValid, overrideDisplay, }: IMultipleSelectProps) { - const { options, optionsId, mapping, source, id, readOnly, textResourceBindings } = node.item; - const apiOptions = useGetOptions({ optionsId, mapping, source }); + const { options, optionsId, mapping, queryParameters, source, id, readOnly, textResourceBindings } = node.item; + const apiOptions = useGetOptions({ optionsId, mapping, queryParameters, source }); const { value, setValue, saveValue } = useDelayedSavedState(handleDataChange, formData?.simpleBinding); const { langAsString } = useLanguage(); diff --git a/src/layout/RadioButtons/radioButtonsUtils.ts b/src/layout/RadioButtons/radioButtonsUtils.ts index 80d26a3707..2ab606b9dd 100644 --- a/src/layout/RadioButtons/radioButtonsUtils.ts +++ b/src/layout/RadioButtons/radioButtonsUtils.ts @@ -24,8 +24,8 @@ export const useRadioStyles = makeStyles(() => ({ })); export const useRadioButtons = ({ node, handleDataChange, formData }: IRadioButtonsContainerProps) => { - const { optionsId, options, preselectedOptionIndex, mapping, source } = node.item; - const apiOptions = useGetOptions({ optionsId, mapping, source }); + const { optionsId, options, preselectedOptionIndex, mapping, queryParameters, source } = node.item; + const apiOptions = useGetOptions({ optionsId, mapping, queryParameters, source }); const _calculatedOptions = useMemo(() => apiOptions || options, [apiOptions, options]); const calculatedOptions = _calculatedOptions || []; const optionsHasChanged = useHasChangedIgnoreUndefined(apiOptions); diff --git a/src/layout/layout.d.ts b/src/layout/layout.d.ts index a3acec1360..fd1f8b94c4 100644 --- a/src/layout/layout.d.ts +++ b/src/layout/layout.d.ts @@ -43,6 +43,7 @@ interface ISelectionComponent { options?: IOption[]; optionsId?: string; mapping?: IMapping; + queryParameters?: Record; secure?: boolean; source?: IOptionSource; preselectedOptionIndex?: number; diff --git a/src/types/index.ts b/src/types/index.ts index 5a888265b1..78de1fc57a 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -84,6 +84,7 @@ export interface IOptionsActualData { export interface IOptionsMetaData { id: string; mapping?: IMapping; + fixedQueryParameters?: Record; loading?: boolean; secure?: boolean; } @@ -271,6 +272,7 @@ export interface IFetchSpecificOptionSaga { formData?: IFormData; language?: string; dataMapping?: IMapping; + fixedQueryParameters?: Record; secure?: boolean; instanceId?: string; } diff --git a/src/utils/options.ts b/src/utils/options.ts index 1b6ba52bcf..f4e26e8a34 100644 --- a/src/utils/options.ts +++ b/src/utils/options.ts @@ -11,12 +11,20 @@ import type { IOptionResources } from 'src/hooks/useGetOptions'; import type { ILayout } from 'src/layout/layout'; import type { IMapping, IOption, IOptions, IOptionsMetaData, IOptionSource, IRepeatingGroups } from 'src/types'; import type { IDataSources } from 'src/types/shared'; -export function getOptionLookupKey({ id, mapping }: IOptionsMetaData) { - if (!mapping) { + +export function getOptionLookupKey({ id, mapping, fixedQueryParameters }: IOptionsMetaData) { + if (!mapping && !fixedQueryParameters) { return id; } - return JSON.stringify({ id, mapping }); + const keyObject: any = { id }; + if (mapping) { + keyObject.mapping = mapping; + } + if (fixedQueryParameters) { + keyObject.fixedQueryParameters = fixedQueryParameters; + } + return JSON.stringify(keyObject); } interface IGetOptionLookupKeysParam extends IOptionsMetaData { @@ -31,6 +39,7 @@ interface IOptionLookupKeys { export function getOptionLookupKeys({ id, mapping, + fixedQueryParameters, secure, repeatingGroups, }: IGetOptionLookupKeysParam): IOptionLookupKeys { @@ -50,17 +59,17 @@ export function getOptionLookupKeys({ }; delete newMapping[mappingKey]; newMapping[newMappingKey] = mapping[mappingKey]; - lookupKeys.push({ id, mapping: newMapping, secure }); + lookupKeys.push({ id, mapping: newMapping, fixedQueryParameters, secure }); } }); return { keys: lookupKeys, - keyWithIndexIndicator: { id, mapping, secure }, + keyWithIndexIndicator: { id, mapping, fixedQueryParameters, secure }, }; } - lookupKeys.push({ id, mapping, secure }); + lookupKeys.push({ id, mapping, fixedQueryParameters, secure }); return { keys: lookupKeys, }; diff --git a/src/utils/urls/appUrlHelper.test.ts b/src/utils/urls/appUrlHelper.test.ts index f986f34bfe..4b5a8a5607 100644 --- a/src/utils/urls/appUrlHelper.test.ts +++ b/src/utils/urls/appUrlHelper.test.ts @@ -252,6 +252,38 @@ describe('Frontend urlHelper.ts', () => { expect(result).toEqual('https://local.altinn.cloud/ttd/test/api/options/country?selectedCountry=Norway'); }); + it('should return correct url when fixed query parameters is provided', () => { + const result = getOptionsUrl({ + optionsId: 'country', + formData: { + country: 'Norway', + }, + dataMapping: undefined, + fixedQueryParameters: { + level: '1', + }, + }); + + expect(result).toEqual('https://local.altinn.cloud/ttd/test/api/options/country?level=1'); + }); + + it('should return correct url when fixed query parameters and dataMapping is provided', () => { + const result = getOptionsUrl({ + optionsId: 'country', + formData: { + country: 'Norway', + }, + dataMapping: { + country: 'selectedCountry', + }, + fixedQueryParameters: { + level: '1', + }, + }); + + expect(result).toEqual('https://local.altinn.cloud/ttd/test/api/options/country?level=1&selectedCountry=Norway'); + }); + it('should return correct url when both language is passed and formData/dataMapping is provided', () => { const result = getOptionsUrl({ optionsId: 'country', diff --git a/src/utils/urls/appUrlHelper.ts b/src/utils/urls/appUrlHelper.ts index f369df74ea..5e729fd4b7 100644 --- a/src/utils/urls/appUrlHelper.ts +++ b/src/utils/urls/appUrlHelper.ts @@ -171,6 +171,7 @@ export const frontendVersionsCDN = `${appFrontendCDNPath}/index.json`; export interface IGetOptionsUrlParams { optionsId: string; dataMapping?: IMapping; + fixedQueryParameters?: Record; formData?: IFormData; language?: string; secure?: boolean; @@ -180,6 +181,7 @@ export interface IGetOptionsUrlParams { export const getOptionsUrl = ({ optionsId, dataMapping, + fixedQueryParameters, formData, language, secure, @@ -191,18 +193,18 @@ export const getOptionsUrl = ({ } else { url = new URL(`${appPath}/api/options/${optionsId}`); } - let params: Record = {}; + + const params: Record = {}; if (language) { params.language = language; } + if (fixedQueryParameters) { + Object.assign(params, fixedQueryParameters); + } if (formData && dataMapping) { const mapped = mapFormData(formData, dataMapping); - - params = { - ...params, - ...mapped, - }; + Object.assign(params, mapped); } url.search = new URLSearchParams(params).toString();