Skip to content
27 changes: 27 additions & 0 deletions src/App/frontend/src/core/api-client/options.api.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import type { AxiosResponse } from 'axios';

import { axiosInstance } from 'src/core/axiosInstance';
import type { IDataList } from 'src/features/dataLists';
import type { IRawOption } from 'src/layout/common.generated';

type OptionsResponse = {
data: IRawOption[];
headers: AxiosResponse['headers'];
};

export interface OptionsApi {
getOptions(url: string): Promise<OptionsResponse>;
getDataList(url: string): Promise<IDataList>;
}

export const optionsApi: OptionsApi = {
async getOptions(url) {
const response = await axiosInstance.get<IRawOption[]>(url);
return { data: response.data, headers: response.headers };
},

async getDataList(url) {
const response = await axiosInstance.get<IDataList>(url);
return response.data;
},
};
6 changes: 5 additions & 1 deletion src/App/frontend/src/core/contexts/ApiProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import type { PropsWithChildren } from 'react';

import { type BackendValidationApi, backendValidationApi } from 'src/core/api-client/backendValidation.api';
import { type InstanceApi, instanceApi } from 'src/core/api-client/instance.api';
import { type OptionsApi, optionsApi } from 'src/core/api-client/options.api';
import { type PartyApi, partyApi } from 'src/core/api-client/party.api';
import { type TextResourcesApi, textResourcesApi } from 'src/core/api-client/textResources.api';
import { createContext } from 'src/core/contexts/context';
Expand All @@ -12,6 +13,7 @@ export interface ApiClients {
partyApi: PartyApi;
instanceApi: InstanceApi;
textResourcesApi: TextResourcesApi;
optionsApi: OptionsApi;
}

interface ApiProviderProps extends PropsWithChildren {
Expand All @@ -23,12 +25,13 @@ const defaultApis: ApiClients = {
partyApi,
instanceApi,
textResourcesApi,
optionsApi,
};

const { Provider, useCtx } = createContext<ApiClients>({
name: 'ApiProvider',
required: false,
default: { backendValidationApi, partyApi, instanceApi, textResourcesApi },
default: { backendValidationApi, partyApi, instanceApi, textResourcesApi, optionsApi },
});

export function ApiProvider({ children, apis }: ApiProviderProps) {
Expand All @@ -39,3 +42,4 @@ export const usePartyApi = () => useCtx().partyApi;
export const useTextResourcesApi = () => useCtx().textResourcesApi;
export const useInstanceApi = () => useCtx().instanceApi;
export const useBackendValidationApi = () => useCtx().backendValidationApi;
export const useOptionsApi = () => useCtx().optionsApi;
14 changes: 14 additions & 0 deletions src/App/frontend/src/core/queries/options/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { useQuery } from '@tanstack/react-query';

import { useOptionsApi } from 'src/core/contexts/ApiProvider';
import { dataListQuery, optionsQuery } from 'src/core/queries/options/options.queries';

export function useOptionsQuery(url: string | undefined) {
const optionsApi = useOptionsApi();
return useQuery(optionsQuery({ url, optionsApi }));
}

export function useDataListQuery(url: string | undefined) {
const optionsApi = useOptionsApi();
return useQuery(dataListQuery({ url, optionsApi }));
}
23 changes: 23 additions & 0 deletions src/App/frontend/src/core/queries/options/options.queries.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { queryOptions, skipToken } from '@tanstack/react-query';

import type { OptionsApi } from 'src/core/api-client/options.api';

export const optionsQueryKeys = {
all: () => ['options'] as const,
options: (url: string) => [...optionsQueryKeys.all(), 'options', url] as const,
dataList: (url: string) => [...optionsQueryKeys.all(), 'dataList', url] as const,
};

export function optionsQuery({ url, optionsApi }: { url: string | undefined; optionsApi: OptionsApi }) {
return queryOptions({
queryKey: optionsQueryKeys.options(url ?? ''),
queryFn: url ? () => optionsApi.getOptions(url) : skipToken,
});
}

export function dataListQuery({ url, optionsApi }: { url: string | undefined; optionsApi: OptionsApi }) {
return queryOptions({
queryKey: optionsQueryKeys.dataList(url ?? ''),
queryFn: url ? () => optionsApi.getDataList(url) : skipToken,
});
}
9 changes: 2 additions & 7 deletions src/App/frontend/src/features/dataLists/useDataListQuery.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,8 @@
import type { AriaAttributes } from 'react';

import { useQuery } from '@tanstack/react-query';
import type { UseQueryResult } from '@tanstack/react-query';

import { useAppQueries } from 'src/core/contexts/AppQueriesProvider';
import { useDataListQuery as useCoreDataListQuery } from 'src/core/queries/options';
import { FormStore } from 'src/features/form/FormContext';
import { FormBootstrap } from 'src/features/formBootstrap/FormBootstrap';
import { useLaxInstanceId } from 'src/features/instance/InstanceContext';
Expand All @@ -28,7 +27,6 @@ export const useDataListQuery = (
mapping?: IMapping,
queryParameters?: Record<string, string>,
): UseQueryResult<IDataList> => {
const { fetchDataList } = useAppQueries();
const selectedLanguage = useCurrentLanguage();
const instanceId = useLaxInstanceId();
const mappingResult = FormStore.data.useMapping(mapping, FormBootstrap.useDefaultDataType());
Expand All @@ -49,10 +47,7 @@ export const useDataListQuery = (
sortDirection: ariaSortToSortDirection(sortDirection),
});

return useQuery({
queryKey: ['fetchDataList', url],
queryFn: () => fetchDataList(url),
});
return useCoreDataListQuery(url);
};

function ariaSortToSortDirection(ariaSort: AriaAttributes['aria-sort']): SortDirection {
Expand Down
93 changes: 50 additions & 43 deletions src/App/frontend/src/features/options/useGetOptions.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ import React from 'react';
import { jest } from '@jest/globals';
import { screen, waitFor } from '@testing-library/react';
import { userEvent } from '@testing-library/user-event';
import type { AxiosResponse } from 'axios';

import { getFormBootstrapMock } from 'src/__mocks__/getFormBootstrapMock';
import { defaultDataTypeMock } from 'src/__mocks__/getUiConfigMock';
Expand All @@ -12,11 +11,13 @@ import { ALTINN_ROW_ID } from 'src/features/formData/types';
import { useGetOptions } from 'src/features/options/useGetOptions';
import { renderWithInstanceAndLayout } from 'src/test/renderWithProviders';
import { useExternalItem } from 'src/utils/layout/hooks';
import type { OptionsApi } from 'src/core/api-client/options.api';
import type { ExprVal, ExprValToActualOrExpr } from 'src/features/expressions/types';
import type { IOptionInternal } from 'src/features/options/castOptionsToStrings';
import type { IQueryParameters, IRawOption, ISelectionComponentFull } from 'src/layout/common.generated';
import type { ILayout } from 'src/layout/layout';
import type { fetchOptions } from 'src/queries/queries';

type GetOptions = OptionsApi['getOptions'];

interface RenderProps {
type: 'single' | 'multi';
Expand All @@ -27,7 +28,7 @@ interface RenderProps {
queryParameters?: IQueryParameters;
selected?: string;
preselectedOptionIndex?: number;
fetchOptions?: jest.Mock<typeof fetchOptions>;
getOptions?: jest.MockedFunction<GetOptions>;
extraLayout?: ILayout;
staticOptions?: Record<string, StaticOptionSet>;
}
Expand Down Expand Up @@ -119,18 +120,22 @@ async function render(props: RenderProps) {
};
obj.staticOptions = props.staticOptions ?? {};
}),
fetchOptions:
props.fetchOptions ??
(async () =>
({
data: props.options?.map((option) => ({
value: option.value,
label: option.label,
description: option.description,
helpText: option.helpText,
})),
},
apis: {
optionsApi: {
getOptions:
props.getOptions ??
(async () => ({
data:
props.options?.map((option) => ({
value: option.value,
label: option.label,
description: option.description,
helpText: option.helpText,
})) ?? [],
headers: {},
}) as AxiosResponse<IRawOption[]>),
})),
},
},
});
}
Expand Down Expand Up @@ -201,36 +206,34 @@ describe('useGetOptions', () => {
});

it('should include the mapping in the api request', async () => {
const fetchOptionsMock = jest.fn<typeof fetchOptions>().mockImplementation(
async (_url: string) =>
({
data: [] as IRawOption[],
headers: {},
}) as AxiosResponse<IRawOption[]>,
);
const getOptionsMock = jest.fn(async (_url: string) => ({
data: [] as IRawOption[],
headers: {},
})) as jest.MockedFunction<GetOptions>;

await render({
via: 'api',
type: 'single',
mapping: { someOther: 'someParam', result: 'someEmpty' },
fetchOptions: fetchOptionsMock,
getOptions: getOptionsMock,
});

expect(fetchOptionsMock).toHaveBeenCalledWith(
expect(getOptionsMock).toHaveBeenCalledWith(
expect.stringMatching(/^.+\/api\/options\/myOptions.+someParam=value&someEmpty=$/),
);
});

it('uses bootstrap static options and does not fetch options', async () => {
const fetchOptionsMock = jest.fn<typeof fetchOptions>().mockResolvedValue({
const getOptionsMock = jest.fn() as jest.MockedFunction<GetOptions>;
getOptionsMock.mockResolvedValue({
data: [] as IRawOption[],
headers: {},
} as AxiosResponse<IRawOption[]>);
});

await render({
via: 'api',
type: 'single',
fetchOptions: fetchOptionsMock,
getOptions: getOptionsMock,
staticOptions: {
myOptions: { options: [{ label: 'Bootstrap', value: 'bootstrap' }] },
},
Expand All @@ -239,20 +242,21 @@ describe('useGetOptions', () => {
expect(JSON.parse(screen.getByTestId('options').textContent ?? 'null')).toEqual([
{ label: 'Bootstrap', value: 'bootstrap' },
]);
expect(fetchOptionsMock).not.toHaveBeenCalled();
expect(getOptionsMock).not.toHaveBeenCalled();
});

it('fetches options from the API when mapping is configured', async () => {
const fetchOptionsMock = jest.fn<typeof fetchOptions>().mockResolvedValue({
const getOptionsMock = jest.fn() as jest.MockedFunction<GetOptions>;
getOptionsMock.mockResolvedValue({
data: [{ label: 'Fetched', value: 'fetched' }] as IRawOption[],
headers: {},
} as AxiosResponse<IRawOption[]>);
});

await render({
via: 'api',
type: 'single',
mapping: { someOther: 'someParam' },
fetchOptions: fetchOptionsMock,
getOptions: getOptionsMock,
staticOptions: {
myOptions: { options: [{ label: 'Bootstrap', value: 'bootstrap' }] },
},
Expand All @@ -261,20 +265,21 @@ describe('useGetOptions', () => {
expect(JSON.parse(screen.getByTestId('options').textContent ?? 'null')).toEqual([
{ label: 'Fetched', value: 'fetched' },
]);
expect(fetchOptionsMock).toHaveBeenCalledTimes(1);
expect(getOptionsMock).toHaveBeenCalledTimes(1);
});

it('fetches options from the API when query parameters are dynamic', async () => {
const fetchOptionsMock = jest.fn<typeof fetchOptions>().mockResolvedValue({
const getOptionsMock = jest.fn() as jest.MockedFunction<GetOptions>;
getOptionsMock.mockResolvedValue({
data: [{ label: 'Fetched', value: 'fetched' }] as IRawOption[],
headers: {},
} as AxiosResponse<IRawOption[]>);
});

await render({
via: 'api',
type: 'single',
queryParameters: { someParam: ['dataModel', 'someOther'] },
fetchOptions: fetchOptionsMock,
getOptions: getOptionsMock,
staticOptions: {
myOptions: { options: [{ label: 'Bootstrap', value: 'bootstrap' }] },
},
Expand All @@ -283,20 +288,21 @@ describe('useGetOptions', () => {
expect(JSON.parse(screen.getByTestId('options').textContent ?? 'null')).toEqual([
{ label: 'Fetched', value: 'fetched' },
]);
expect(fetchOptionsMock).toHaveBeenCalledTimes(1);
expect(getOptionsMock).toHaveBeenCalledTimes(1);
});

it('fetches options from the API when query parameters are static but non-empty', async () => {
const fetchOptionsMock = jest.fn<typeof fetchOptions>().mockResolvedValue({
const getOptionsMock = jest.fn() as jest.MockedFunction<GetOptions>;
getOptionsMock.mockResolvedValue({
data: [{ label: 'Fetched', value: 'fetched' }] as IRawOption[],
headers: {},
} as AxiosResponse<IRawOption[]>);
});

await render({
via: 'api',
type: 'single',
queryParameters: { region: 'asia' },
fetchOptions: fetchOptionsMock,
getOptions: getOptionsMock,
staticOptions: {
myOptions: { options: [{ label: 'Static list', value: 'static' }] },
},
Expand All @@ -305,21 +311,22 @@ describe('useGetOptions', () => {
expect(JSON.parse(screen.getByTestId('options').textContent ?? 'null')).toEqual([
{ label: 'Fetched', value: 'fetched' },
]);
expect(fetchOptionsMock).toHaveBeenCalledTimes(1);
expect(getOptionsMock).toHaveBeenCalledTimes(1);
});

it('uses bootstrap static options when mapping and query parameters are empty', async () => {
const fetchOptionsMock = jest.fn<typeof fetchOptions>().mockResolvedValue({
const getOptionsMock = jest.fn() as jest.MockedFunction<GetOptions>;
getOptionsMock.mockResolvedValue({
data: [] as IRawOption[],
headers: {},
} as AxiosResponse<IRawOption[]>);
});

await render({
via: 'api',
type: 'single',
mapping: {},
queryParameters: {},
fetchOptions: fetchOptionsMock,
getOptions: getOptionsMock,
staticOptions: {
myOptions: { options: [{ label: 'Static list', value: 'static' }] },
},
Expand All @@ -328,7 +335,7 @@ describe('useGetOptions', () => {
expect(JSON.parse(screen.getByTestId('options').textContent ?? 'null')).toEqual([
{ label: 'Static list', value: 'static' },
]);
expect(fetchOptionsMock).not.toHaveBeenCalled();
expect(getOptionsMock).not.toHaveBeenCalled();
});

it('should produce a warning if options are filtered out when selected', async () => {
Expand Down
Loading
Loading