From dc26c52e936d78f425adec3a4c9d296a72881620 Mon Sep 17 00:00:00 2001 From: Camilla Marie Dalan Date: Tue, 5 Aug 2025 18:53:21 +0200 Subject: [PATCH 01/10] consolidate data model url hooks --- src/features/datamodel/DataModelsProvider.tsx | 4 ++-- src/features/formData/FormDataReaders.tsx | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/features/datamodel/DataModelsProvider.tsx b/src/features/datamodel/DataModelsProvider.tsx index 09e134e6cf..cb52fdb808 100644 --- a/src/features/datamodel/DataModelsProvider.tsx +++ b/src/features/datamodel/DataModelsProvider.tsx @@ -14,7 +14,7 @@ import { useApplicationMetadata } from 'src/features/applicationMetadata/Applica import { getFirstDataElementId } from 'src/features/applicationMetadata/appMetadataUtils'; import { useCustomValidationConfigQuery } from 'src/features/customValidation/useCustomValidationQuery'; import { UpdateDataElementIdsForCypress } from 'src/features/datamodel/DataElementIdsForCypress'; -import { useCurrentDataModelName, useDataModelUrl } from 'src/features/datamodel/useBindingSchema'; +import { useCurrentDataModelName, useGetDataModelUrl } from 'src/features/datamodel/useBindingSchema'; import { useDataModelSchemaQuery } from 'src/features/datamodel/useDataModelSchemaQuery'; import { getAllReferencedDataTypes, @@ -321,7 +321,7 @@ function LoadInitialData({ dataType, overrideDataElement }: LoaderProps & { over const dataElementId = overrideDataElement ?? getFirstDataElementId(dataElements, dataType); const metaData = useApplicationMetadata(); - const url = useDataModelUrl({ + const url = useGetDataModelUrl()({ dataType, dataElementId, prefillFromQueryParams: getValidPrefillDataFromQueryParams(metaData, dataType), diff --git a/src/features/formData/FormDataReaders.tsx b/src/features/formData/FormDataReaders.tsx index b525a0d9c2..b4d87cbcdb 100644 --- a/src/features/formData/FormDataReaders.tsx +++ b/src/features/formData/FormDataReaders.tsx @@ -6,7 +6,7 @@ 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 { useGetDataModelUrl } from 'src/features/datamodel/useBindingSchema'; import { useFormDataQuery } from 'src/features/formData/useFormDataQuery'; import { useInstanceDataElements } from 'src/features/instance/InstanceContext'; import { useCurrentLanguage } from 'src/features/language/LanguageProvider'; @@ -198,7 +198,7 @@ function SpecificDataModelFetcher({ reader, isAvailable }: { reader: DataModelRe const dataType = reader.getName(); const dataElements = useInstanceDataElements(dataType); const dataElementId = getFirstDataElementId(dataElements, dataType); - const url = useDataModelUrl({ dataType, dataElementId, language: useCurrentLanguage() }); + const url = useGetDataModelUrl()({ dataType, dataElementId, language: useCurrentLanguage() }); const enabled = isAvailable && reader.isLoading(); const { data, error } = useFormDataQuery(enabled ? url : undefined); const { updateModel } = useCtx(); From 4e3d86db2eb1ea2f3153c70460c8456f17798df0 Mon Sep 17 00:00:00 2001 From: Camilla Marie Dalan Date: Tue, 5 Aug 2025 19:24:24 +0200 Subject: [PATCH 02/10] simpify datamodel url creation further --- src/features/datamodel/useBindingSchema.tsx | 65 +++++---------------- src/utils/urls/appUrlHelper.ts | 27 +++++++-- 2 files changed, 35 insertions(+), 57 deletions(-) diff --git a/src/features/datamodel/useBindingSchema.tsx b/src/features/datamodel/useBindingSchema.tsx index 9ae752f133..fddebb9ea6 100644 --- a/src/features/datamodel/useBindingSchema.tsx +++ b/src/features/datamodel/useBindingSchema.tsx @@ -1,4 +1,4 @@ -import { useCallback, useMemo } from 'react'; +import { useMemo } from 'react'; import type { JSONSchema7 } from 'json-schema'; @@ -15,15 +15,10 @@ 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, - getStatefulDataModelUrl, - getStatelessDataModelUrl, - getStatelessDataModelUrlWithPrefill, -} 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'; +import { getStatefulDataModelUrl, getStatelessDataModelUrl } from 'src/utils/urls/appUrlHelper'; +import { getUrlWithLanguage } from 'src/utils/urls/urlHelper'; export type AsSchema = { [P in keyof T]: JSONSchema7 | null; @@ -52,7 +47,6 @@ export function useCurrentDataModelDataElementId() { type DataModelDeps = { language: string; isAnonymous: boolean; - isStateless: boolean; instanceId?: string; }; @@ -68,20 +62,11 @@ function getDataModelUrl({ dataElementId, language, isAnonymous, - isStateless, instanceId, prefillFromQueryParams, }: DataModelDeps & DataModelProps) { - if (prefillFromQueryParams && !isAnonymous && isStateless && dataType) { - return getUrlWithLanguage(getStatelessDataModelUrlWithPrefill(dataType, prefillFromQueryParams), language); - } - - if (isStateless && isAnonymous && dataType) { - return getUrlWithLanguage(getAnonymousStatelessDataModelUrl(dataType), language); - } - - if (isStateless && !isAnonymous && dataType) { - return getUrlWithLanguage(getStatelessDataModelUrl(dataType), language); + if (!instanceId && dataType) { + return getUrlWithLanguage(getStatelessDataModelUrl({ dataType, prefillFromQueryParams, isAnonymous }), language); } if (instanceId && dataElementId) { @@ -93,40 +78,18 @@ function getDataModelUrl({ export function useGetDataModelUrl() { const isAnonymous = useAllowAnonymous(); - const isStateless = useApplicationMetadata().isStatelessApp; const instanceId = useLaxInstanceId(); const currentLanguage = useAsRef(useCurrentLanguage()); - return useCallback( - ({ dataType, dataElementId, language }: DataModelProps) => - getDataModelUrl({ - dataType, - dataElementId, - language: language ?? currentLanguage.current, - isAnonymous, - isStateless, - instanceId, - }), - [currentLanguage, instanceId, isAnonymous, isStateless], - ); -} - -// 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, language, prefillFromQueryParams }: DataModelProps) { - const isAnonymous = useAllowAnonymous(); - const isStateless = useApplicationMetadata().isStatelessApp; - const instanceId = useLaxInstanceId(); - const currentLanguage = useAsRef(useCurrentLanguage()); - - return getDataModelUrl({ - dataType, - dataElementId, - language: language ?? currentLanguage.current, - isAnonymous, - isStateless, - instanceId, - prefillFromQueryParams, - }); + return ({ dataType, dataElementId, language, prefillFromQueryParams }: DataModelProps) => + getDataModelUrl({ + dataType, + dataElementId, + language: language ?? currentLanguage.current, + isAnonymous, + instanceId, + prefillFromQueryParams, + }); } export function useCurrentDataModelName() { diff --git a/src/utils/urls/appUrlHelper.ts b/src/utils/urls/appUrlHelper.ts index 33d0ded008..6655a3c486 100644 --- a/src/utils/urls/appUrlHelper.ts +++ b/src/utils/urls/appUrlHelper.ts @@ -48,14 +48,29 @@ export const getFileTagUrl = (instanceId: string, dataElementId: string, tag: st return `${appPath}/instances/${instanceId}/data/${dataElementId}/tags`; }; -export const getAnonymousStatelessDataModelUrl = (dataType: string) => - `${appPath}/v1/data/anonymous?dataType=${dataType}&includeRowId=true`; +export const getStatelessDataModelUrl = ({ + dataType, + prefillFromQueryParams, + isAnonymous = false, +}: { + dataType: string; + prefillFromQueryParams?: string; + isAnonymous?: boolean; +}) => { + const queryParams = new URLSearchParams({ + dataType, + includeRowId: 'true', + }); + if (isAnonymous) { + return `${appPath}/v1/data/anonymous?${queryParams}`; + } -export const getStatelessDataModelUrlWithPrefill = (dataType: string, prefillFromQueryParams: string) => - `${appPath}/v1/data?dataType=${dataType}&includeRowId=true&prefill=${prefillFromQueryParams}`; + if (prefillFromQueryParams) { + queryParams.append('prefill', prefillFromQueryParams); + } -export const getStatelessDataModelUrl = (dataType: string) => - `${appPath}/v1/data?dataType=${dataType}&includeRowId=true`; + return `${appPath}/v1/data?${queryParams}`; +}; export const getStatefulDataModelUrl = (instanceId: string, dataElementId: string) => `${appPath}/instances/${instanceId}/data/${dataElementId}?includeRowId=true`; From 562225abd1db94f6d746271c511ebc092a05c1da Mon Sep 17 00:00:00 2001 From: Camilla Marie Dalan Date: Tue, 5 Aug 2025 20:27:50 +0200 Subject: [PATCH 03/10] refactor useformdataquery further --- src/features/datamodel/DataModelsProvider.tsx | 13 +- src/features/datamodel/useBindingSchema.tsx | 53 +---- src/features/formData/FormDataReaders.tsx | 13 +- src/features/formData/FormDataWrite.tsx | 13 +- src/features/formData/useFormDataQuery.tsx | 195 +++++++++++------- src/layout/Subform/utils.ts | 8 +- src/utils/maybeAuthenticationRedirect.ts | 18 -- 7 files changed, 155 insertions(+), 158 deletions(-) delete mode 100644 src/utils/maybeAuthenticationRedirect.ts diff --git a/src/features/datamodel/DataModelsProvider.tsx b/src/features/datamodel/DataModelsProvider.tsx index cb52fdb808..e66d0ca5d5 100644 --- a/src/features/datamodel/DataModelsProvider.tsx +++ b/src/features/datamodel/DataModelsProvider.tsx @@ -14,7 +14,7 @@ import { useApplicationMetadata } from 'src/features/applicationMetadata/Applica import { getFirstDataElementId } from 'src/features/applicationMetadata/appMetadataUtils'; import { useCustomValidationConfigQuery } from 'src/features/customValidation/useCustomValidationQuery'; import { UpdateDataElementIdsForCypress } from 'src/features/datamodel/DataElementIdsForCypress'; -import { useCurrentDataModelName, useGetDataModelUrl } from 'src/features/datamodel/useBindingSchema'; +import { useCurrentDataModelName } from 'src/features/datamodel/useBindingSchema'; import { useDataModelSchemaQuery } from 'src/features/datamodel/useDataModelSchemaQuery'; import { getAllReferencedDataTypes, @@ -31,6 +31,7 @@ import { instanceQueries, useInstanceDataElements, useInstanceDataQueryArgs, + useLaxInstanceId, } from 'src/features/instance/InstanceContext'; import { MissingRolesError } from 'src/features/instantiate/containers/MissingRolesError'; import { useIsPdf } from 'src/hooks/useIsPdf'; @@ -320,22 +321,22 @@ function LoadInitialData({ dataType, overrideDataElement }: LoaderProps & { over const dataElements = useInstanceDataElements(dataType); const dataElementId = overrideDataElement ?? getFirstDataElementId(dataElements, dataType); const metaData = useApplicationMetadata(); + const instanceId = useLaxInstanceId(); - const url = useGetDataModelUrl()({ + const { data, error } = useFormDataQuery({ dataType, dataElementId, + instanceId, prefillFromQueryParams: getValidPrefillDataFromQueryParams(metaData, dataType), }); - const { data, error } = useFormDataQuery(url); - useEffect(() => { - if (!data || !url) { + if (!data) { return; } sessionStorage.removeItem('queryParams'); setInitialData(dataType, data); - }, [data, dataType, metaData.id, setInitialData, url]); + }, [data, dataType, metaData.id, setInitialData]); useEffect(() => { setDataElementId(dataType, dataElementId ?? null); diff --git a/src/features/datamodel/useBindingSchema.tsx b/src/features/datamodel/useBindingSchema.tsx index fddebb9ea6..342670fd2d 100644 --- a/src/features/datamodel/useBindingSchema.tsx +++ b/src/features/datamodel/useBindingSchema.tsx @@ -10,11 +10,8 @@ import { } from 'src/features/applicationMetadata/appMetadataUtils'; import { DataModels } from 'src/features/datamodel/DataModelsProvider'; import { useLayoutSets } from 'src/features/form/layoutSets/LayoutSetsProvider'; -import { useInstanceDataQuery, useLaxInstanceId } from 'src/features/instance/InstanceContext'; +import { useInstanceDataQuery } 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 type { IDataModelReference } from 'src/layout/common.generated'; import type { IDataModelBindings } from 'src/layout/layout'; import { getStatefulDataModelUrl, getStatelessDataModelUrl } from 'src/utils/urls/appUrlHelper'; @@ -44,54 +41,6 @@ export function useCurrentDataModelDataElementId() { }).data; } -type DataModelDeps = { - language: string; - isAnonymous: boolean; - instanceId?: string; -}; - -type DataModelProps = { - dataType?: string; - dataElementId?: string; - language?: string; - prefillFromQueryParams?: string; -}; - -function getDataModelUrl({ - dataType, - dataElementId, - language, - isAnonymous, - instanceId, - prefillFromQueryParams, -}: DataModelDeps & DataModelProps) { - if (!instanceId && dataType) { - return getUrlWithLanguage(getStatelessDataModelUrl({ dataType, prefillFromQueryParams, isAnonymous }), language); - } - - if (instanceId && dataElementId) { - return getUrlWithLanguage(getStatefulDataModelUrl(instanceId, dataElementId), language); - } - - return undefined; -} - -export function useGetDataModelUrl() { - const isAnonymous = useAllowAnonymous(); - const instanceId = useLaxInstanceId(); - const currentLanguage = useAsRef(useCurrentLanguage()); - - return ({ dataType, dataElementId, language, prefillFromQueryParams }: DataModelProps) => - getDataModelUrl({ - dataType, - dataElementId, - language: language ?? currentLanguage.current, - isAnonymous, - instanceId, - prefillFromQueryParams, - }); -} - export function useCurrentDataModelName() { const overriddenDataModelType = useTaskStore((state) => state.overriddenDataModelType); diff --git a/src/features/formData/FormDataReaders.tsx b/src/features/formData/FormDataReaders.tsx index b4d87cbcdb..98cde14b97 100644 --- a/src/features/formData/FormDataReaders.tsx +++ b/src/features/formData/FormDataReaders.tsx @@ -6,10 +6,8 @@ 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 { useGetDataModelUrl } from 'src/features/datamodel/useBindingSchema'; import { useFormDataQuery } from 'src/features/formData/useFormDataQuery'; -import { useInstanceDataElements } from 'src/features/instance/InstanceContext'; -import { useCurrentLanguage } from 'src/features/language/LanguageProvider'; +import { useInstanceDataElements, useLaxInstanceId } from 'src/features/instance/InstanceContext'; import { useNavigationParam } from 'src/hooks/navigation'; import { useAsRef } from 'src/hooks/useAsRef'; import type { IDataModelReference } from 'src/layout/common.generated'; @@ -198,9 +196,14 @@ function SpecificDataModelFetcher({ reader, isAvailable }: { reader: DataModelRe const dataType = reader.getName(); const dataElements = useInstanceDataElements(dataType); const dataElementId = getFirstDataElementId(dataElements, dataType); - const url = useGetDataModelUrl()({ dataType, dataElementId, language: useCurrentLanguage() }); + const instanceId = useLaxInstanceId(); const enabled = isAvailable && reader.isLoading(); - const { data, error } = useFormDataQuery(enabled ? url : undefined); + const { data, error } = useFormDataQuery({ + enabled, + dataType, + dataElementId, + instanceId, + }); const { updateModel } = useCtx(); useEffect(() => { diff --git a/src/features/formData/FormDataWrite.tsx b/src/features/formData/FormDataWrite.tsx index 18d07ee92d..ae62eea91f 100644 --- a/src/features/formData/FormDataWrite.tsx +++ b/src/features/formData/FormDataWrite.tsx @@ -11,14 +11,13 @@ import { ContextNotProvided } from 'src/core/contexts/context'; import { createZustandContext } from 'src/core/contexts/zustandContext'; import { useApplicationMetadata } from 'src/features/applicationMetadata/ApplicationMetadataProvider'; import { DataModels } from 'src/features/datamodel/DataModelsProvider'; -import { 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 { getFormDataQueryKey } from 'src/features/formData/useFormDataQuery'; +import { formDataQueries, useGetDataModelUrl } from 'src/features/formData/useFormDataQuery'; import { useLaxInstanceId, useOptimisticallyUpdateCachedInstance } from 'src/features/instance/InstanceContext'; import { useCurrentLanguage } from 'src/features/language/LanguageProvider'; import { useSelectedParty } from 'src/features/party/PartiesProvider'; @@ -110,11 +109,15 @@ function useFormDataSaveMutation() { // the main form and a subform). function updateQueryCache(result: FDSaveFinished) { for (const { dataType, data, dataElementId } of result.newDataModels) { - const url = getDataModelUrl({ dataType, dataElementId }); - if (!url) { + if (!dataType) { continue; } - const queryKey = getFormDataQueryKey(url); + const queryKey = formDataQueries.formDataKey({ + dataType, + dataElementId, + isAnonymous: false, + instanceId, + }); queryClient.setQueryData(queryKey, data); } } diff --git a/src/features/formData/useFormDataQuery.tsx b/src/features/formData/useFormDataQuery.tsx index 940fc2debc..0e7c8ae7b0 100644 --- a/src/features/formData/useFormDataQuery.tsx +++ b/src/features/formData/useFormDataQuery.tsx @@ -1,98 +1,155 @@ import { useEffect } from 'react'; -import { skipToken, useQuery } from '@tanstack/react-query'; +import { queryOptions, skipToken, useQuery } from '@tanstack/react-query'; import type { QueryClient } from '@tanstack/react-query'; -import type { AxiosRequestConfig } from 'axios'; +import type { AxiosError, 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 { useLaxInstanceId } from 'src/features/instance/InstanceContext'; +import { useCurrentLanguage } from 'src/features/language/LanguageProvider'; import { useSelectedParty } from 'src/features/party/PartiesProvider'; -import { useMemoDeepEqual } from 'src/hooks/useStateDeepEqual'; +import { useAllowAnonymous } from 'src/features/stateless/getAllowAnonymous'; +import { useAsRef } from 'src/hooks/useAsRef'; +import { fetchFormData } from 'src/queries/queries'; import { isAxiosError } from 'src/utils/isAxiosError'; -import { maybeAuthenticationRedirect } from 'src/utils/maybeAuthenticationRedirect'; - -export function useFormDataQueryDef(url: string | undefined): QueryDefinition { - const { fetchFormData } = useAppQueries(); - const queryKey = useFormDataQueryKey(url); - const options = useFormDataQueryOptions(); - const isStateless = useApplicationMetadata().isStatelessApp; - - const queryFn = url ? () => fetchFormData(url, options) : skipToken; +import { putWithoutConfig } from 'src/utils/network/networking'; +import { + getStatefulDataModelUrl, + getStatelessDataModelUrl, + invalidateCookieUrl, + redirectToUpgrade, +} from 'src/utils/urls/appUrlHelper'; +import { getUrlWithLanguage } from 'src/utils/urls/urlHelper'; + +type FormDataKeyParams = { + isAnonymous: boolean; + dataType?: string; + dataElementId?: string; + prefillFromQueryParams?: string; + instanceId?: string; +}; +type FormDataParams = FormDataKeyParams & { enabled?: boolean; options: AxiosRequestConfig; language: string }; + +export const formDataQueries = { + allKey: ['formData'], + formDataKey: (formDataKeyParams: FormDataKeyParams) => [...formDataQueries.allKey, formDataKeyParams], + formData: ({ enabled = true, options, language, ...formDataKeyParams }: FormDataParams) => { + const url = getDataModelUrl({ ...formDataKeyParams, language }); + const queryKey = formDataQueries.formDataKey(formDataKeyParams); + + const queryFn = enabled && url ? () => fetchFormData(url, options) : skipToken; + + if (!formDataKeyParams.dataElementId) { + // We need to refetch for stateless apps as caching will break some apps. + // See this issue: https://github.com/Altinn/app-frontend-react/issues/2564 + return queryOptions({ + queryKey, + queryFn, + gcTime: 0, + }); + } - if (isStateless) { - // We need to refetch for stateless apps as caching will break some apps. - // See this issue: https://github.com/Altinn/app-frontend-react/issues/2564 - return { + return queryOptions({ queryKey, queryFn, - gcTime: 0, - }; - } - - return { - queryKey, - queryFn, - refetchInterval: false, - }; -} - -export function useFormDataQueryKey(url: string | undefined) { - return useMemoDeepEqual(() => getFormDataQueryKey(url), [url]); -} - -const formDataQueryKeys = { - all: ['fetchFormData'] as const, - withUrl: (url: string | undefined) => [...formDataQueryKeys.all, url ? getFormDataCacheKeyUrl(url) : url] as const, + refetchInterval: false, + }); + }, }; -export function getFormDataQueryKey(url: string | undefined) { - return formDataQueryKeys.withUrl(url); -} - export async function invalidateFormDataQueries(queryClient: QueryClient) { - await queryClient.invalidateQueries({ queryKey: formDataQueryKeys.all }); + await queryClient.invalidateQueries({ queryKey: formDataQueries.allKey }); } -export function useFormDataQueryOptions() { +type FormDataQueryParams = { + enabled?: boolean; + instanceId?: string; + dataType?: string; + dataElementId?: string; + prefillFromQueryParams?: string; +}; + +export function useFormDataQuery(params: FormDataQueryParams) { + const language = useCurrentLanguage(); + const isAnonymous = useAllowAnonymous(); const selectedPartyId = useSelectedParty()?.partyId; - const isStateless = useApplicationMetadata().isStatelessApp; const options: AxiosRequestConfig = {}; - if (isStateless && selectedPartyId !== undefined) { + if (params.dataElementId && selectedPartyId !== undefined) { options.headers = { party: `partyid:${selectedPartyId}`, }; } - return options; -} - -// 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. -function getFormDataCacheKeyUrl(url: string | 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) { - const def = useFormDataQueryDef(url); - const utils = useQuery(def); + const query = useQuery(formDataQueries.formData({ ...params, language, isAnonymous, options })); + const error = query.error; useEffect(() => { - if (utils.error && isAxiosError(utils.error)) { - if (utils.error.message?.includes('403')) { + if (error && isAxiosError(error)) { + if (error.message?.includes('403')) { window.logInfo('Current party is missing roles'); } else { - window.logError('Fetching form data failed:\n', utils.error); + window.logError('Fetching form data failed:\n', error); } - maybeAuthenticationRedirect(utils.error).then(); + maybeAuthenticationRedirect(error); } - }, [url, utils.error]); + }, [error]); + + return query; +} + +async function maybeAuthenticationRedirect(error: AxiosError) { + if (error.response && error.response.status === 403 && error.response.data) { + const reqAuthLevel = error.response.data['RequiredAuthenticationLevel']; + if (reqAuthLevel) { + await putWithoutConfig(invalidateCookieUrl); + redirectToUpgrade(reqAuthLevel); + } + } +} + +type DataModelDeps = { + language: string; + isAnonymous?: boolean; + instanceId?: string; +}; + +type DataModelProps = { + dataType?: string; + dataElementId?: string; + language?: string; + prefillFromQueryParams?: string; +}; + +function getDataModelUrl({ + dataType, + dataElementId, + isAnonymous = false, + language, + instanceId, + prefillFromQueryParams, +}: DataModelDeps & DataModelProps) { + if (!instanceId && dataType) { + return getUrlWithLanguage(getStatelessDataModelUrl({ dataType, prefillFromQueryParams, isAnonymous }), language); + } + + if (instanceId && dataElementId) { + return getUrlWithLanguage(getStatefulDataModelUrl(instanceId, dataElementId), language); + } + + return undefined; +} - return utils; +export function useGetDataModelUrl() { + const isAnonymous = useAllowAnonymous(); + const instanceId = useLaxInstanceId(); + const currentLanguage = useAsRef(useCurrentLanguage()); + + return ({ dataType, dataElementId, language, prefillFromQueryParams }: DataModelProps) => + getDataModelUrl({ + dataType, + dataElementId, + language: language ?? currentLanguage.current, + isAnonymous, + instanceId, + prefillFromQueryParams, + }); } diff --git a/src/layout/Subform/utils.ts b/src/layout/Subform/utils.ts index 8a018b5dc6..7ec88866d2 100644 --- a/src/layout/Subform/utils.ts +++ b/src/layout/Subform/utils.ts @@ -15,14 +15,16 @@ import { type ExpressionDataSources, useExpressionDataSources, } from 'src/utils/layout/useExpressionDataSources'; -import { getStatefulDataModelUrl } from 'src/utils/urls/appUrlHelper'; import type { ExprValToActualOrExpr } from 'src/features/expressions/types'; import type { IDataModelReference } from 'src/layout/common.generated'; export function useSubformFormData(dataElementId: string) { const instanceId = useStrictInstanceId(); - const url = getStatefulDataModelUrl(instanceId, dataElementId); - const { isFetching: isSubformDataFetching, data: subformData, error: subformDataError } = useFormDataQuery(url); + const { + isFetching: isSubformDataFetching, + data: subformData, + error: subformDataError, + } = useFormDataQuery({ instanceId, dataElementId }); useEffect(() => { if (subformDataError) { diff --git a/src/utils/maybeAuthenticationRedirect.ts b/src/utils/maybeAuthenticationRedirect.ts deleted file mode 100644 index 9749d4a66e..0000000000 --- a/src/utils/maybeAuthenticationRedirect.ts +++ /dev/null @@ -1,18 +0,0 @@ -import type { AxiosError } from 'axios'; - -import { putWithoutConfig } from 'src/utils/network/networking'; -import { invalidateCookieUrl, redirectToUpgrade } from 'src/utils/urls/appUrlHelper'; - -export async function maybeAuthenticationRedirect(error: AxiosError): Promise { - if (error.response && error.response.status === 403 && error.response.data) { - const reqAuthLevel = error.response.data['RequiredAuthenticationLevel']; - if (reqAuthLevel) { - await putWithoutConfig(invalidateCookieUrl); - redirectToUpgrade(reqAuthLevel); - - return true; - } - } - - return false; -} From 760a26cdc178c45b586c635945160584be68be65 Mon Sep 17 00:00:00 2001 From: Camilla Marie Dalan Date: Thu, 7 Aug 2025 13:36:05 +0200 Subject: [PATCH 04/10] fixes jest tests --- src/components/form/Form.test.tsx | 20 +++---- .../expressions/shared-context.test.tsx | 6 +-- .../expressions/shared-functions.test.tsx | 42 ++++++++------- src/features/formData/FormData.test.tsx | 53 ++++++++----------- .../formData/FormDataReaders.test.tsx | 46 +++++++--------- .../formData/useDataModelBindings.test.tsx | 3 +- .../navigation/AppNavigation.test.tsx | 8 ++- src/features/options/useGetOptions.test.tsx | 19 +++---- .../receipt/ReceiptContainer.test.tsx | 1 - .../validation/ValidationPlugin.test.tsx | 9 ++-- .../ExpressionValidation.test.tsx | 3 +- src/layout/Address/AddressComponent.test.tsx | 27 +++++----- .../CheckboxesContainerComponent.test.tsx | 17 +++--- .../Datepicker/DatepickerComponent.test.tsx | 8 ++- .../Dropdown/DropdownComponent.test.tsx | 11 ++-- .../FileUpload/FileUploadComponent.test.tsx | 26 +++------ .../Group/SummaryGroupComponent.test.tsx | 13 ++--- src/layout/Input/InputComponent.test.tsx | 21 +++----- src/layout/Likert/LikertTestUtils.tsx | 4 +- src/layout/Map/MapComponent.test.tsx | 24 ++++----- .../MultipleSelectComponent.test.tsx | 6 +-- .../ControlledRadioGroup.test.tsx | 15 +++--- .../RepeatingGroupContainer.test.tsx | 40 +++++++------- .../RepeatingGroupEditContainer.test.tsx | 34 ++++++------ .../Providers/OpenByDefaultProvider.test.tsx | 7 +-- .../Summary/SummaryRepeatingGroup.test.tsx | 19 +++---- .../RepeatingGroupTableSummary.test.tsx | 12 +++-- .../Table/RepeatingGroupTable.test.tsx | 23 ++++---- .../TextArea/TextAreaComponent.test.tsx | 32 +++++------ src/queries/types.ts | 3 +- src/setupTests.ts | 7 ++- src/test/renderWithProviders.tsx | 52 +----------------- src/utils/layout/all.test.tsx | 4 +- 33 files changed, 279 insertions(+), 336 deletions(-) diff --git a/src/components/form/Form.test.tsx b/src/components/form/Form.test.tsx index 6099eea918..ea399b4a3f 100644 --- a/src/components/form/Form.test.tsx +++ b/src/components/form/Form.test.tsx @@ -7,6 +7,7 @@ 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 { fetchFormData } from 'src/queries/queries'; import { renderWithInstanceAndLayout } from 'src/test/renderWithProviders'; import type { CompExternal, ILayout } from 'src/layout/layout'; import type { CompSummaryExternal } from 'src/layout/Summary/config.generated'; @@ -224,19 +225,20 @@ describe('Form', () => { }); async function render(layout = mockComponents, validationIssues: BackendValidationIssue[] = []) { + jest.mocked(fetchFormData).mockImplementation(async () => ({ + Group: [ + { + prop1: 'value1', + prop2: 'value2', + prop3: 'value3', + }, + ], + })); + await renderWithInstanceAndLayout({ renderer: () =>
, initialPage: 'FormLayout', queries: { - fetchFormData: async () => ({ - Group: [ - { - prop1: 'value1', - prop2: 'value2', - prop3: 'value3', - }, - ], - }), fetchLayouts: () => Promise.resolve({ FormLayout: { diff --git a/src/features/expressions/shared-context.test.tsx b/src/features/expressions/shared-context.test.tsx index f8ad693811..f09c3de459 100644 --- a/src/features/expressions/shared-context.test.tsx +++ b/src/features/expressions/shared-context.test.tsx @@ -6,7 +6,7 @@ import { screen } from '@testing-library/react'; import { getApplicationMetadataMock } from 'src/__mocks__/getApplicationMetadataMock'; import { getInstanceDataMock } from 'src/__mocks__/getInstanceDataMock'; import { getSharedTests } from 'src/features/expressions/shared'; -import { fetchApplicationMetadata } from 'src/queries/queries'; +import { fetchApplicationMetadata, fetchFormData } from 'src/queries/queries'; import { renderWithInstanceAndLayout } from 'src/test/renderWithProviders'; import { NodesInternal } from 'src/utils/layout/NodesContext'; import { splitDashedKey } from 'src/utils/splitDashedKey'; @@ -93,6 +93,8 @@ describe('Expressions shared context tests', () => { const applicationMetadata = getApplicationMetadataMock(instance ? {} : { onEntry: { show: 'stateless' } }); jest.mocked(fetchApplicationMetadata).mockImplementation(async () => applicationMetadata); + // TODO(Datamodels): add support for multiple data models + jest.mocked(fetchFormData).mockImplementation(async () => dataModel ?? {}); if (instanceDataElements) { for (const element of instanceDataElements) { @@ -111,8 +113,6 @@ 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 fe1fc30a25..35c4d91444 100644 --- a/src/features/expressions/shared-functions.test.tsx +++ b/src/features/expressions/shared-functions.test.tsx @@ -20,7 +20,13 @@ import { isRepeatingComponent, RepeatingComponents, } from 'src/features/form/layout/utils/repeating'; -import { fetchApplicationMetadata, fetchInstanceData, fetchProcessState, fetchUserProfile } from 'src/queries/queries'; +import { + fetchApplicationMetadata, + fetchFormData, + fetchInstanceData, + fetchProcessState, + fetchUserProfile, +} from 'src/queries/queries'; import { AppQueries } from 'src/queries/types'; import { renderWithInstanceAndLayout, @@ -292,23 +298,6 @@ describe('Expressions shared function tests', () => { 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)}`); - } - // Clear localstorage, because LanguageProvider uses it to cache selected languages localStorage.clear(); @@ -332,6 +321,22 @@ describe('Expressions shared function tests', () => { } return instanceData; }); + jest.mocked(fetchFormData).mockImplementation(async (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)}`); + }); const toRender = ( { sets: [{ id: 'layout-set', dataType: 'default', tasks: ['Task_1'] }, getSubFormLayoutSetMock()], }), fetchLayouts: async () => layouts, - fetchFormData, ...(frontendSettings ? { fetchApplicationSettings: async () => frontendSettings } : {}), fetchTextResources: async () => ({ language: 'nb', diff --git a/src/features/formData/FormData.test.tsx b/src/features/formData/FormData.test.tsx index 6a9ad946b1..18c2a4a258 100644 --- a/src/features/formData/FormData.test.tsx +++ b/src/features/formData/FormData.test.tsx @@ -22,7 +22,7 @@ import { GlobalFormDataReadersProvider } from 'src/features/formData/FormDataRea import { FD, FormDataWriteProvider } from 'src/features/formData/FormDataWrite'; import { FormDataWriteProxyProvider } from 'src/features/formData/FormDataWriteProxies'; import { useDataModelBindings } from 'src/features/formData/useDataModelBindings'; -import { fetchApplicationMetadata } from 'src/queries/queries'; +import { fetchApplicationMetadata, fetchFormData } from 'src/queries/queries'; import { makeFormDataMethodProxies, renderWithInstanceAndLayout, @@ -158,7 +158,6 @@ async function statelessRender(props: RenderProps) { ), queries: { fetchDataModelSchema: async () => mockSchema, - fetchFormData: async () => ({}), fetchLayouts: async () => ({}), ...props.queries, }, @@ -175,7 +174,6 @@ async function statefulRender(props: RenderProps) { alwaysRouteToChildren: true, queries: { fetchDataModelSchema: async () => mockSchema, - fetchFormData: async () => ({}), fetchLayouts: async () => ({}), ...props.queries, }, @@ -216,6 +214,16 @@ describe('FormData', () => { } async function render(props: MinimalRenderProps = {}) { + jest.mocked(fetchFormData).mockImplementationOnce(async () => ({ + obj1: { + prop1: 'value1', + prop2: 'value2', + }, + obj2: { + prop1: 'value3', + }, + })); + const renderCounts: RenderCounts = { ReaderObj1Prop1: 0, ReaderObj1Prop2: 0, @@ -262,15 +270,6 @@ describe('FormData', () => { ), queries: { - fetchFormData: async () => ({ - obj1: { - prop1: 'value1', - prop2: 'value2', - }, - obj2: { - prop1: 'value3', - }, - }), ...props.queries, }, ...props, @@ -382,6 +381,11 @@ describe('FormData', () => { } async function render(props: MinimalRenderProps = {}) { + jest.mocked(fetchFormData).mockImplementationOnce(async () => ({ + obj1: { + prop1: 'value1', + }, + })); return statefulRender({ renderer: ( <> @@ -409,11 +413,6 @@ describe('FormData', () => { ), queries: { - fetchFormData: async () => ({ - obj1: { - prop1: 'value1', - }, - }), ...props.queries, }, ...props, @@ -611,7 +610,6 @@ describe('FormData', () => { ), queries: { - fetchFormData: async () => ({}), ...props.queries, }, ...props, @@ -649,13 +647,12 @@ describe('FormData', () => { it('Navigating away and back again should restore the form data', async () => { const user = userEvent.setup({ delay: null }); - const { mutations, queries } = await render(); + const { mutations } = await render(); await user.type(screen.getByTestId('obj2.prop1'), 'a'); expect(screen.getByTestId('obj2.prop1')).toHaveValue('a'); expect(screen.getByTestId('hasUnsavedChanges')).toHaveTextContent('true'); - expect(queries.fetchFormData).toHaveBeenCalledTimes(1); await user.click(screen.getByRole('button', { name: 'Navigate to a different page' })); await screen.findByText('something different'); @@ -668,11 +665,6 @@ describe('FormData', () => { await user.click(screen.getByRole('button', { name: 'Navigate back' })); await screen.findByTestId('obj2.prop1'); - // We tried to cache the form data, however that broke back button functionality for some apps. - // See this issue: https://github.com/Altinn/app-frontend-react/issues/2564 - // Also see src/features/formData/useFormDataQuery.tsx where we prevent caching for statless apps - 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(''); @@ -719,14 +711,15 @@ describe('FormData', () => { } async function render(props: MinimalRenderProps = {}) { + jest.mocked(fetchFormData).mockImplementationOnce(async () => ({ + obj3: { + prop1: null, + }, + })); + const utils = await statelessRender({ renderer: , queries: { - fetchFormData: async () => ({ - obj3: { - prop1: null, - }, - }), ...props.queries, }, ...props, diff --git a/src/features/formData/FormDataReaders.test.tsx b/src/features/formData/FormDataReaders.test.tsx index 29ed4168f2..d3e57adb8e 100644 --- a/src/features/formData/FormDataReaders.test.tsx +++ b/src/features/formData/FormDataReaders.test.tsx @@ -2,6 +2,7 @@ import React from 'react'; import { beforeAll, expect, jest } from '@jest/globals'; import { screen, waitFor } from '@testing-library/react'; +import { AxiosError } from 'axios'; import { v4 as uuidv4 } from 'uuid'; import { getIncomingApplicationMetadataMock } from 'src/__mocks__/getApplicationMetadataMock'; @@ -9,7 +10,7 @@ import { getInstanceDataMock } from 'src/__mocks__/getInstanceDataMock'; import { getLayoutSetsMock } from 'src/__mocks__/getLayoutSetsMock'; import { DataModelFetcher } from 'src/features/formData/FormDataReaders'; import { Lang } from 'src/features/language/Lang'; -import { fetchApplicationMetadata, fetchInstanceData } from 'src/queries/queries'; +import { fetchApplicationMetadata, fetchFormData, fetchInstanceData } from 'src/queries/queries'; import { renderWithInstanceAndLayout } from 'src/test/renderWithProviders'; import type { IRawTextResource } from 'src/features/language/textResources'; import type { IData, IDataType } from 'src/types/shared'; @@ -57,6 +58,20 @@ async function render(props: TestProps) { }), ); jest.mocked(fetchInstanceData).mockImplementationOnce(async () => instanceData); + jest.mocked(fetchFormData).mockImplementation(async (url) => { + const path = new URL(url).pathname; + const id = path.split('/').pop(); + const modelName = idToNameMap[id!]; + const formData = props.dataModels[modelName]; + if (formData instanceof Error) { + return Promise.reject(formData); + } + if (!formData) { + throw new Error(`No form data mocked for testing (modelName = ${modelName})`); + } + + return formData; + }); function generateDataElements(instanceId: string): IData[] { return dataModelNames.map((name) => { @@ -123,19 +138,6 @@ async function render(props: TestProps) { resources: props.textResources, language: 'nb', }), - fetchFormData: async (url) => { - const path = new URL(url).pathname; - const id = path.split('/').pop(); - const modelName = idToNameMap[id!]; - const formData = props.dataModels[modelName]; - if (formData instanceof Error) { - return Promise.reject(formData); - } - if (!formData) { - throw new Error(`No form data mocked for testing (modelName = ${modelName})`); - } - return formData; - }, }, }); @@ -161,7 +163,7 @@ describe('FormDataReaders', () => { it.each(['someModel', 'someModel1.0'])( 'simple, should render a resource with a variable lookup - %s', async (modelName: string) => { - const { queries, urlFor } = await render({ + await render({ ids: ['test'], textResources: [ { @@ -185,9 +187,6 @@ describe('FormDataReaders', () => { await waitFor(() => expect(screen.getByTestId('test')).toHaveTextContent('Hello World')); - expect(queries.fetchFormData).toHaveBeenCalledTimes(1); - expect(queries.fetchFormData).toHaveBeenCalledWith(urlFor(modelName), {}); - expect(window.logError).not.toHaveBeenCalled(); expect(window.logErrorOnce).not.toHaveBeenCalled(); }, @@ -196,13 +195,13 @@ describe('FormDataReaders', () => { it('advanced, should fetch data from multiple models, handle failures', async () => { jest.useFakeTimers(); const missingError = new Error('This should fail when fetching'); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (missingError as any).isAxiosError = true; + + (missingError as AxiosError).isAxiosError = true; const model2Promise = new Promise((resolve) => { setTimeout(() => resolve({ name: 'Universe' }), 100); }); - const { queries, urlFor } = await render({ + await render({ ids: ['test1', 'test2', 'test3', 'testDefault', 'testMissing', 'testMissingWithDefault'], textResources: [ { @@ -309,11 +308,6 @@ describe('FormDataReaders', () => { await waitFor(() => expect(screen.getByTestId('test2')).toHaveTextContent('Hello Universe')); expect(screen.getByTestId('test3')).toHaveTextContent('You are [missing] year(s) old'); - expect(queries.fetchFormData).toHaveBeenCalledTimes(3); - expect(queries.fetchFormData).toHaveBeenCalledWith(urlFor('model1'), {}); - expect(queries.fetchFormData).toHaveBeenCalledWith(urlFor('model2'), {}); - expect(queries.fetchFormData).toHaveBeenCalledWith(urlFor('modelMissing'), {}); - expect(window.logError).toHaveBeenCalledTimes(1); expect(window.logError).toHaveBeenCalledWith('Fetching form data failed:\n', missingError); diff --git a/src/features/formData/useDataModelBindings.test.tsx b/src/features/formData/useDataModelBindings.test.tsx index f882cde46d..4e54bb16da 100644 --- a/src/features/formData/useDataModelBindings.test.tsx +++ b/src/features/formData/useDataModelBindings.test.tsx @@ -7,6 +7,7 @@ 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 { fetchFormData } from 'src/queries/queries'; import { renderWithInstanceAndLayout } from 'src/test/renderWithProviders'; import type { IDataModelPatchResponse } from 'src/features/formData/types'; @@ -92,10 +93,10 @@ describe('useDataModelBindings', () => { } async function render({ formData = {} }: { formData?: object } = {}) { + jest.mocked(fetchFormData).mockImplementationOnce(async () => formData); return await renderWithInstanceAndLayout({ renderer: , queries: { - fetchFormData: async () => formData, fetchDataModelSchema: async () => ({ type: 'object', properties: { diff --git a/src/features/navigation/AppNavigation.test.tsx b/src/features/navigation/AppNavigation.test.tsx index ce1109b3da..2cd5d6fcb1 100644 --- a/src/features/navigation/AppNavigation.test.tsx +++ b/src/features/navigation/AppNavigation.test.tsx @@ -1,5 +1,6 @@ import React from 'react'; +import { jest } from '@jest/globals'; import { screen, waitFor, within } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; @@ -7,6 +8,7 @@ import { getLayoutSetsMock } from 'src/__mocks__/getLayoutSetsMock'; import { AppNavigation } from 'src/features/navigation/AppNavigation'; import { BackendValidationSeverity } from 'src/features/validation'; import * as UseNavigatePage from 'src/hooks/useNavigatePage'; +import { fetchFormData } from 'src/queries/queries'; import { renderWithInstanceAndLayout } from 'src/test/renderWithProviders'; import type { ILayoutFile, @@ -39,6 +41,11 @@ describe('AppNavigation', () => { overrideTaskNavigation?: (Omit | Omit)[]; }) { const rawOrder = order ?? groups?.flatMap((g) => g.order) ?? []; + + jest + .mocked(fetchFormData) + .mockImplementationOnce(async () => Object.fromEntries(rawOrder.map((page) => [`field-${page}`, 'some value']))); + return renderWithInstanceAndLayout({ renderer: () => , initialPage: initialPage ?? order?.[0] ?? groups?.[0].order[0], @@ -108,7 +115,6 @@ describe('AppNavigation', () => { ]), ), }), - fetchFormData: async () => Object.fromEntries(rawOrder.map((page) => [`field-${page}`, 'some value'])), }, }); } diff --git a/src/features/options/useGetOptions.test.tsx b/src/features/options/useGetOptions.test.tsx index 232ed4093f..ddb10146ff 100644 --- a/src/features/options/useGetOptions.test.tsx +++ b/src/features/options/useGetOptions.test.tsx @@ -8,13 +8,13 @@ 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 { fetchFormData, type fetchOptions } from 'src/queries/queries'; import { renderWithInstanceAndLayout } from 'src/test/renderWithProviders'; import { useExternalItem } from 'src/utils/layout/hooks'; import type { ExprVal, ExprValToActualOrExpr } from 'src/features/expressions/types'; import type { IOptionInternal } from 'src/features/options/castOptionsToStrings'; import type { IRawOption, ISelectionComponentFull } from 'src/layout/common.generated'; import type { ILayout } from 'src/layout/layout'; -import type { fetchOptions } from 'src/queries/queries'; interface RenderProps { type: 'single' | 'multi'; @@ -66,6 +66,15 @@ async function render(props: RenderProps) { preselectedOptionIndex: props.preselectedOptionIndex, }; + jest.mocked(fetchFormData).mockImplementationOnce(async () => ({ + Group: structuredClone(props.options ?? []).map((option, index) => ({ + [ALTINN_ROW_ID]: `row-${index}`, + ...option, + })), + result: props.selected ?? '', + someOther: 'value', + })); + return renderWithInstanceAndLayout({ renderer: , queries: { @@ -89,14 +98,6 @@ async function render(props: RenderProps) { }, }, }), - fetchFormData: async () => ({ - Group: structuredClone(props.options ?? []).map((option, index) => ({ - [ALTINN_ROW_ID]: `row-${index}`, - ...option, - })), - result: props.selected ?? '', - someOther: 'value', - }), fetchOptions: props.fetchOptions ?? (async () => diff --git a/src/features/receipt/ReceiptContainer.test.tsx b/src/features/receipt/ReceiptContainer.test.tsx index 9a0859383c..0f2c38d374 100644 --- a/src/features/receipt/ReceiptContainer.test.tsx +++ b/src/features/receipt/ReceiptContainer.test.tsx @@ -133,7 +133,6 @@ const render = async ({ autoDeleteOnProcessEnd = false, hasPdf = true }: IRender }, }, }), - fetchFormData: async () => ({}), }, }); }; diff --git a/src/features/validation/ValidationPlugin.test.tsx b/src/features/validation/ValidationPlugin.test.tsx index 6123d49237..353853cecf 100644 --- a/src/features/validation/ValidationPlugin.test.tsx +++ b/src/features/validation/ValidationPlugin.test.tsx @@ -1,5 +1,6 @@ import React, { useEffect, useState } from 'react'; +import { jest } from '@jest/globals'; import { screen, waitFor, within } from '@testing-library/react'; import { userEvent } from '@testing-library/user-event'; @@ -9,6 +10,7 @@ import { defaultMockDataElementId } from 'src/__mocks__/getInstanceDataMock'; import { defaultDataTypeMock } from 'src/__mocks__/getLayoutSetsMock'; import { Form } from 'src/components/form/Form'; import { FD } from 'src/features/formData/FormDataWrite'; +import { fetchFormData } from 'src/queries/queries'; import { renderWithInstanceAndLayout } from 'src/test/renderWithProviders'; import type { AllowedValidationMasks } from 'src/layout/common.generated'; @@ -37,6 +39,9 @@ describe('ValidationPlugin', () => { validateOnNext: AllowedValidationMasks; backendValidations?: string[]; }) { + jest.mocked(fetchFormData).mockImplementationOnce(async () => ({ + TextField: text, + })); return renderWithInstanceAndLayout({ renderer: () => ( <> @@ -46,10 +51,6 @@ describe('ValidationPlugin', () => { ), initialPage: 'Form', queries: { - fetchFormData: () => - Promise.resolve({ - TextField: text, - }), fetchBackendValidations: () => Promise.resolve( backendValidations.map( diff --git a/src/features/validation/expressionValidation/ExpressionValidation.test.tsx b/src/features/validation/expressionValidation/ExpressionValidation.test.tsx index 651ad967bd..87af388e17 100644 --- a/src/features/validation/expressionValidation/ExpressionValidation.test.tsx +++ b/src/features/validation/expressionValidation/ExpressionValidation.test.tsx @@ -8,6 +8,7 @@ import { DataModels } from 'src/features/datamodel/DataModelsProvider'; import { FD } from 'src/features/formData/FormDataWrite'; import { ExpressionValidation } from 'src/features/validation/expressionValidation/ExpressionValidation'; import { Validation } from 'src/features/validation/validationContext'; +import { fetchFormData } from 'src/queries/queries'; import { renderWithInstanceAndLayout } from 'src/test/renderWithProviders'; import type { IRawTextResource } from 'src/features/language/textResources'; import type { FieldValidations, IExpressionValidationConfig } from 'src/features/validation'; @@ -82,13 +83,13 @@ describe('Expression validation shared tests', () => { result = validations; }); jest.spyOn(Validation, 'useUpdateDataModelValidations').mockImplementation(() => updateDataModelValidations); + jest.mocked(fetchFormData).mockImplementation(async () => formData); await renderWithInstanceAndLayout({ renderer: () => , queries: { fetchLayouts: async () => layouts, fetchCustomValidationConfig: async () => validationConfig, - fetchFormData: async () => formData, fetchTextResources: async (language) => ({ language, resources: textResources ?? [], diff --git a/src/layout/Address/AddressComponent.test.tsx b/src/layout/Address/AddressComponent.test.tsx index c4af6df36f..f68ed0f601 100644 --- a/src/layout/Address/AddressComponent.test.tsx +++ b/src/layout/Address/AddressComponent.test.tsx @@ -1,10 +1,12 @@ import React from 'react'; +import { 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 { AddressComponent } from 'src/layout/Address/AddressComponent'; +import { fetchFormData } from 'src/queries/queries'; import { renderGenericComponentTest } from 'src/test/renderWithProviders'; import type { RenderGenericComponentTestProps } from 'src/test/renderWithProviders'; @@ -105,6 +107,7 @@ describe('AddressComponent', () => { }); it('should show error message on blur if zipcode is invalid', async () => { + jest.mocked(fetchFormData).mockImplementationOnce(async () => ({ address: 'initial address', zipCode: '0001' })); await render({ component: { showValidations: ['Component'], @@ -112,7 +115,6 @@ describe('AddressComponent', () => { simplified: true, }, queries: { - fetchFormData: async () => ({ address: 'initial address', zipCode: '0001' }), fetchPostPlace: (zipCode: string) => zipCode === '0001' ? Promise.resolve({ valid: true, result: 'OSLO' }) @@ -129,14 +131,13 @@ describe('AddressComponent', () => { }); it('should update postplace on mount', async () => { + jest.mocked(fetchFormData).mockImplementationOnce(async () => ({ address: 'initial address', zipCode: '0001' })); + const { formDataMethods } = await render({ component: { required: true, simplified: false, }, - queries: { - fetchFormData: async () => ({ address: 'initial address', zipCode: '0001' }), - }, }); await screen.findByDisplayValue('OSLO'); @@ -167,11 +168,11 @@ describe('AddressComponent', () => { }); it('should call dispatch for post place when zip code is cleared', async () => { - const { formDataMethods, queries } = await render({ - queries: { - fetchFormData: async () => ({ address: 'a', zipCode: '0001', postPlace: 'Oslo' }), - }, - }); + jest + .mocked(fetchFormData) + .mockImplementationOnce(async () => ({ address: 'a', zipCode: '0001', postPlace: 'Oslo' })); + + const { formDataMethods, queries } = await render(); expect(screen.getByDisplayValue('0001')).toBeInTheDocument(); expect(screen.getByDisplayValue('OSLO')).toBeInTheDocument(); @@ -192,11 +193,9 @@ describe('AddressComponent', () => { }); it('should only call fetchPostPlace once at the end, when debouncing', async () => { - const { queries } = await render({ - queries: { - fetchFormData: async () => ({ address: 'a', zipCode: '', postPlace: '' }), - }, - }); + jest.mocked(fetchFormData).mockImplementationOnce(async () => ({ address: 'a', zipCode: '', postPlace: '' })); + + const { queries } = await render(); await userEvent.type(screen.getByRole('textbox', { name: 'Postnr' }), '0001{backspace}2'); await waitFor(() => expect(screen.getByRole('textbox', { name: 'Poststed' })).toHaveDisplayValue('BERGEN'), { diff --git a/src/layout/Checkboxes/CheckboxesContainerComponent.test.tsx b/src/layout/Checkboxes/CheckboxesContainerComponent.test.tsx index 57ecfdfdf0..e737cb7318 100644 --- a/src/layout/Checkboxes/CheckboxesContainerComponent.test.tsx +++ b/src/layout/Checkboxes/CheckboxesContainerComponent.test.tsx @@ -1,5 +1,6 @@ import React from 'react'; +import { jest } from '@jest/globals'; import { fireEvent, screen, waitFor } from '@testing-library/react'; import { userEvent } from '@testing-library/user-event'; import type { AxiosResponse } from 'axios'; @@ -8,6 +9,7 @@ import { getFormDataMockForRepGroup } from 'src/__mocks__/getFormDataMockForRepG import { defaultDataTypeMock } from 'src/__mocks__/getLayoutSetsMock'; import { CheckboxContainerComponent } from 'src/layout/Checkboxes/CheckboxesContainerComponent'; import { LayoutStyle } from 'src/layout/common.generated'; +import { fetchFormData } from 'src/queries/queries'; import { renderGenericComponentTest } from 'src/test/renderWithProviders'; import type { IRawOption } from 'src/layout/common.generated'; import type { AppQueries } from 'src/queries/types'; @@ -45,8 +47,9 @@ const render = async ({ formData, groupData = getFormDataMockForRepGroup(), queries, -}: Props = {}) => - await renderGenericComponentTest({ +}: Props = {}) => { + jest.mocked(fetchFormData).mockImplementationOnce(async () => ({ selectedValues: formData, ...groupData })); + return await renderGenericComponentTest({ type: 'Checkboxes', renderer: (props) => , component: { @@ -65,10 +68,10 @@ const render = async ({ ? // eslint-disable-next-line @typescript-eslint/no-explicit-any Promise.resolve({ data: options, headers: {} } as AxiosResponse) : Promise.reject(new Error('No options provided to render()')), - fetchFormData: async () => (formData ? { selectedValues: formData, ...groupData } : { ...groupData }), ...queries, }, }); +}; const getCheckbox = ({ name, isChecked = false }) => screen.getByRole('checkbox', { @@ -302,6 +305,7 @@ describe('CheckboxesContainerComponent', () => { }); it('required validation should show for simpleBinding', async () => { + jest.mocked(fetchFormData).mockImplementationOnce(async () => ({ simpleBinding: '', label: '', metadata: '' })); await render({ component: { showValidations: ['Required'], @@ -313,9 +317,6 @@ describe('CheckboxesContainerComponent', () => { }, }, options: [], - queries: { - fetchFormData: () => Promise.resolve({ simpleBinding: '', label: '', metadata: '' }), - }, }); expect(screen.getAllByRole('listitem')).toHaveLength(1); @@ -323,6 +324,7 @@ describe('CheckboxesContainerComponent', () => { }); it('required validation should show for group', async () => { + jest.mocked(fetchFormData).mockImplementationOnce(async () => ({ simpleBinding: '', group: [] })); await render({ component: { showValidations: ['Required'], @@ -334,9 +336,6 @@ describe('CheckboxesContainerComponent', () => { deletionStrategy: 'hard', }, options: [], - queries: { - fetchFormData: () => Promise.resolve({ simpleBinding: '', group: [] }), - }, }); expect(screen.getAllByRole('listitem')).toHaveLength(1); diff --git a/src/layout/Datepicker/DatepickerComponent.test.tsx b/src/layout/Datepicker/DatepickerComponent.test.tsx index bcd429dd33..206f031807 100644 --- a/src/layout/Datepicker/DatepickerComponent.test.tsx +++ b/src/layout/Datepicker/DatepickerComponent.test.tsx @@ -6,6 +6,7 @@ import { userEvent } from '@testing-library/user-event'; import { defaultDataTypeMock } from 'src/__mocks__/getLayoutSetsMock'; import { DatepickerComponent } from 'src/layout/Datepicker/DatepickerComponent'; +import { fetchFormData } from 'src/queries/queries'; import { mockMediaQuery } from 'src/test/mockMediaQuery'; import { renderGenericComponentTest } from 'src/test/renderWithProviders'; import type { RenderGenericComponentTestProps } from 'src/test/renderWithProviders'; @@ -120,11 +121,8 @@ describe('DatepickerComponent', () => { }); it('should call setLeafValue if date is cleared', async () => { - const { formDataMethods } = await render({ - queries: { - fetchFormData: async () => ({ myDate: '2022-12-31' }), - }, - }); + jest.mocked(fetchFormData).mockImplementationOnce(async () => ({ myDate: '2022-12-31' })); + const { formDataMethods } = await render(); await userEvent.clear(screen.getByRole('textbox')); await userEvent.tab(); expect(formDataMethods.setLeafValue).toHaveBeenCalledWith({ diff --git a/src/layout/Dropdown/DropdownComponent.test.tsx b/src/layout/Dropdown/DropdownComponent.test.tsx index 22416ba396..e36f23c897 100644 --- a/src/layout/Dropdown/DropdownComponent.test.tsx +++ b/src/layout/Dropdown/DropdownComponent.test.tsx @@ -9,6 +9,7 @@ import { getFormDataMockForRepGroup } from 'src/__mocks__/getFormDataMockForRepG import { defaultDataTypeMock } from 'src/__mocks__/getLayoutSetsMock'; import { useDataModelBindings } from 'src/features/formData/useDataModelBindings'; import { DropdownComponent } from 'src/layout/Dropdown/DropdownComponent'; +import { fetchFormData } from 'src/queries/queries'; import { queryPromiseMock, renderGenericComponentTest } from 'src/test/renderWithProviders'; import type { IRawOption } from 'src/layout/common.generated'; import type { RenderGenericComponentTestProps } from 'src/test/renderWithProviders'; @@ -47,6 +48,9 @@ function MySuperSimpleInput() { } const render = async ({ component, options, ...rest }: Props = {}) => { + jest.mocked(fetchFormData).mockImplementation(async () => ({ + ...getFormDataMockForRepGroup(), + })); const fetchOptions = queryPromiseMock('fetchOptions'); const utils = await renderGenericComponentTest({ type: 'Dropdown', @@ -69,9 +73,6 @@ const render = async ({ component, options, ...rest }: Props = {}) => { }, ...rest, queries: { - fetchFormData: async () => ({ - ...getFormDataMockForRepGroup(), - }), fetchOptions: (...args) => options === undefined ? fetchOptions.mock(...args) @@ -305,6 +306,7 @@ describe('DropdownComponent', () => { }); it('required validation should only show for simpleBinding', async () => { + jest.mocked(fetchFormData).mockImplementationOnce(async () => ({ simpleBinding: '', label: '', metadata: '' })); await render({ component: { showValidations: ['Required'], @@ -316,9 +318,6 @@ describe('DropdownComponent', () => { }, }, options: countries, - queries: { - fetchFormData: () => Promise.resolve({ simpleBinding: '', label: '', metadata: '' }), - }, }); expect(screen.getAllByRole('listitem')).toHaveLength(1); diff --git a/src/layout/FileUpload/FileUploadComponent.test.tsx b/src/layout/FileUpload/FileUploadComponent.test.tsx index 5edf9a1452..a797e1bcbe 100644 --- a/src/layout/FileUpload/FileUploadComponent.test.tsx +++ b/src/layout/FileUpload/FileUploadComponent.test.tsx @@ -12,7 +12,7 @@ import { getInstanceDataMock } from 'src/__mocks__/getInstanceDataMock'; import { defaultDataTypeMock } from 'src/__mocks__/getLayoutSetsMock'; import { FileUploadComponent } from 'src/layout/FileUpload/FileUploadComponent'; import { GenericComponent } from 'src/layout/GenericComponent'; -import { fetchApplicationMetadata, fetchInstanceData } from 'src/queries/queries'; +import { fetchApplicationMetadata, fetchFormData, fetchInstanceData } from 'src/queries/queries'; import { renderGenericComponentTest, renderWithInstanceAndLayout } from 'src/test/renderWithProviders'; import type { IGetAttachmentsMock } from 'src/__mocks__/getAttachmentsMock'; import type { IRawOption } from 'src/layout/common.generated'; @@ -204,15 +204,13 @@ describe('File uploading components', () => { }); it('should evaluate conditional expression for maxNumberOfAttachments', async () => { + jest.mocked(fetchFormData).mockImplementationOnce(async () => ({ user: { type: 'admin' } })); await render({ component: { maxNumberOfAttachments: ['if', ['equals', ['dataModel', 'user.type'], 'admin'], 10, 'else', 3], // Conditional expression displayMode: 'list', }, attachments: (dataType) => getDataElements({ count: 2, dataType }), - queries: { - fetchFormData: () => Promise.resolve({ user: { type: 'admin' } }), - }, }); // Should show drop area since user is admin (max=10) and we only have 2 attachments @@ -221,15 +219,13 @@ describe('File uploading components', () => { }); it('should evaluate conditional expression for non-admin user', async () => { + jest.mocked(fetchFormData).mockImplementationOnce(async () => ({ user: { type: 'regular' } })); await render({ component: { maxNumberOfAttachments: ['if', ['equals', ['dataModel', 'user.type'], 'admin'], 10, 'else', 3], // Conditional expression displayMode: 'list', }, attachments: (dataType) => getDataElements({ count: 3, dataType }), - queries: { - fetchFormData: () => Promise.resolve({ user: { type: 'regular' } }), - }, }); // Should not show drop area since user is regular (max=3) and we have 3 attachments @@ -238,6 +234,7 @@ describe('File uploading components', () => { }); it('should evaluate dataModel expression for minNumberOfAttachments validation', async () => { + jest.mocked(fetchFormData).mockImplementationOnce(async () => ({ form: { requiredFiles: 3 } })); await render({ component: { minNumberOfAttachments: ['dataModel', 'form.requiredFiles'], // Expression using form data @@ -246,9 +243,6 @@ describe('File uploading components', () => { showValidations: ['Required'], }, attachments: (dataType) => getDataElements({ count: 1, dataType }), - queries: { - fetchFormData: () => Promise.resolve({ form: { requiredFiles: 3 } }), - }, }); // Should show validation error since expression resolves to 3 but we only have 1 @@ -258,6 +252,7 @@ describe('File uploading components', () => { }); it('should evaluate complex expression with greaterThan for minNumberOfAttachments', async () => { + jest.mocked(fetchFormData).mockImplementationOnce(async () => ({ form: { priority: 8 } })); await render({ component: { minNumberOfAttachments: ['if', ['greaterThan', ['dataModel', 'form.priority'], 5], 2, 'else', 1], // Complex expression @@ -266,9 +261,6 @@ describe('File uploading components', () => { showValidations: ['Required'], }, attachments: (dataType) => getDataElements({ count: 1, dataType }), - queries: { - fetchFormData: () => Promise.resolve({ form: { priority: 8 } }), - }, }); // Should show validation error since priority > 5, so min=2 but we only have 1 @@ -278,6 +270,7 @@ describe('File uploading components', () => { }); it('should handle expression that resolves to zero for minNumberOfAttachments', async () => { + jest.mocked(fetchFormData).mockImplementationOnce(async () => ({ form: { optionalFiles: 0 } })); await render({ component: { minNumberOfAttachments: ['dataModel', 'form.optionalFiles'], // Expression that resolves to 0 @@ -285,9 +278,6 @@ describe('File uploading components', () => { displayMode: 'list', }, attachments: (dataType) => getDataElements({ count: 0, dataType }), - queries: { - fetchFormData: () => Promise.resolve({ form: { optionalFiles: 0 } }), - }, }); // Should not show validation error since expression resolves to 0 @@ -295,6 +285,7 @@ describe('File uploading components', () => { }); it('should use default values when expressions resolve to null/undefined', async () => { + jest.mocked(fetchFormData).mockImplementationOnce(async () => ({ form: {} })); // Empty form data await render({ component: { minNumberOfAttachments: ['dataModel', 'form.nonExistentMin'], // Expression that resolves to undefined @@ -302,9 +293,6 @@ describe('File uploading components', () => { displayMode: 'list', }, attachments: (dataType) => getDataElements({ count: 1, dataType }), - queries: { - fetchFormData: () => Promise.resolve({ form: {} }), // Empty form data - }, }); // Should show drop area since default maxNumberOfAttachments is Infinity diff --git a/src/layout/Group/SummaryGroupComponent.test.tsx b/src/layout/Group/SummaryGroupComponent.test.tsx index 6aeb0a6985..b979dbdda1 100644 --- a/src/layout/Group/SummaryGroupComponent.test.tsx +++ b/src/layout/Group/SummaryGroupComponent.test.tsx @@ -4,6 +4,7 @@ import { jest } from '@jest/globals'; import { defaultDataTypeMock } from 'src/__mocks__/getLayoutSetsMock'; import { SummaryGroupComponent } from 'src/layout/Group/SummaryGroupComponent'; +import { fetchFormData } from 'src/queries/queries'; import { renderWithInstanceAndLayout } from 'src/test/renderWithProviders'; describe('SummaryGroupComponent', () => { @@ -19,6 +20,12 @@ describe('SummaryGroupComponent', () => { }); async function render() { + jest.mocked(fetchFormData).mockImplementation(async () => ({ + mockGroup: { + mockDataBinding1: '1', + mockDataBinding2: '2', + }, + })); return await renderWithInstanceAndLayout({ renderer: ( { /> ), queries: { - fetchFormData: async () => ({ - mockGroup: { - mockDataBinding1: '1', - mockDataBinding2: '2', - }, - }), fetchLayouts: async () => ({ FormLayout: { data: { diff --git a/src/layout/Input/InputComponent.test.tsx b/src/layout/Input/InputComponent.test.tsx index 7c40a90082..eae7f000e4 100644 --- a/src/layout/Input/InputComponent.test.tsx +++ b/src/layout/Input/InputComponent.test.tsx @@ -1,10 +1,12 @@ import React from 'react'; +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 { InputComponent } from 'src/layout/Input/InputComponent'; +import { fetchFormData } from 'src/queries/queries'; import { renderGenericComponentTest } from 'src/test/renderWithProviders'; import type { RenderGenericComponentTestProps } from 'src/test/renderWithProviders'; @@ -17,11 +19,8 @@ describe('InputComponent', () => { }); it('should have correct value with specified form data', async () => { - await render({ - queries: { - fetchFormData: () => Promise.resolve({ some: { field: 'some value' } }), - }, - }); + jest.mocked(fetchFormData).mockImplementationOnce(async () => ({ some: { field: 'some value' } })); + await render(); const inputComponent = screen.getByRole('textbox') as HTMLInputElement; expect(inputComponent.value).toEqual('some value'); @@ -58,6 +57,7 @@ describe('InputComponent', () => { const typedValue = '789'; const finalValuePlainText = `${inputValuePlainText}${typedValue}`; const finalValueFormatted = '$123,456,789'; + jest.mocked(fetchFormData).mockImplementationOnce(async () => ({ some: { field: inputValuePlainText } })); const { formDataMethods } = await render({ component: { formatting: { @@ -67,9 +67,6 @@ describe('InputComponent', () => { }, }, }, - queries: { - fetchFormData: () => Promise.resolve({ some: { field: inputValuePlainText } }), - }, }); const inputComponent = screen.getByRole('textbox'); expect(inputComponent).toHaveValue(inputValueFormatted); @@ -171,13 +168,11 @@ describe('InputComponent', () => { it('should prevent pasting when readOnly is true', async () => { const initialValue = 'initial value'; + jest.mocked(fetchFormData).mockImplementationOnce(async () => ({ some: { field: initialValue } })); await render({ component: { readOnly: true, }, - queries: { - fetchFormData: () => Promise.resolve({ some: { field: initialValue } }), - }, }); const inputComponent = screen.getByRole('textbox') as HTMLInputElement; @@ -192,13 +187,11 @@ describe('InputComponent', () => { it('should render autocomplete prop if provided', async () => { const initialValue = 'initial value'; + jest.mocked(fetchFormData).mockImplementationOnce(async () => ({ some: { field: initialValue } })); await render({ component: { autocomplete: 'name', }, - queries: { - fetchFormData: () => Promise.resolve({ some: { field: initialValue } }), - }, }); const inputComponent = screen.getByRole('textbox') as HTMLInputElement; diff --git a/src/layout/Likert/LikertTestUtils.tsx b/src/layout/Likert/LikertTestUtils.tsx index a18663c68a..07d808047d 100644 --- a/src/layout/Likert/LikertTestUtils.tsx +++ b/src/layout/Likert/LikertTestUtils.tsx @@ -1,5 +1,6 @@ import React from 'react'; +import { jest } from '@jest/globals'; import { v4 as uuidv4 } from 'uuid'; import type { AxiosResponse } from 'axios'; @@ -8,6 +9,7 @@ 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'; +import { fetchFormData } from 'src/queries/queries'; import { mockMediaQuery } from 'src/test/mockMediaQuery'; import { renderWithInstanceAndLayout } from 'src/test/renderWithProviders'; import type { IRawTextResource, ITextResourceResult } from 'src/features/language/textResources'; @@ -119,6 +121,7 @@ export const render = async ({ }: IRenderProps) => { const mockLikertLayout = createLikertLayout(likertProps); + jest.mocked(fetchFormData).mockImplementationOnce(async () => generateMockFormData(mockQuestions)); setScreenWidth(mobileView ? 600 : 1200); return await renderWithInstanceAndLayout({ renderer: () => , @@ -126,7 +129,6 @@ export const render = async ({ // eslint-disable-next-line @typescript-eslint/no-explicit-any fetchOptions: async () => ({ data: mockOptions, headers: {} }) as AxiosResponse, fetchTextResources: async () => createTextResource(mockQuestions, extraTextResources), - fetchFormData: async () => generateMockFormData(mockQuestions), fetchLayouts: async () => ({ FormLayout: { data: { diff --git a/src/layout/Map/MapComponent.test.tsx b/src/layout/Map/MapComponent.test.tsx index 1de1c8a288..2d3f30716a 100644 --- a/src/layout/Map/MapComponent.test.tsx +++ b/src/layout/Map/MapComponent.test.tsx @@ -1,10 +1,12 @@ import React from 'react'; +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 { MapComponent } from 'src/layout/Map/MapComponent'; +import { fetchFormData } from 'src/queries/queries'; import { renderGenericComponentTest } from 'src/test/renderWithProviders'; import type { FDNewValue } from 'src/features/formData/FormDataWriteStateMachine'; import type { RenderGenericComponentTestProps } from 'src/test/renderWithProviders'; @@ -51,26 +53,20 @@ describe('MapComponent', () => { }); it('should show correct footer text when location is set', async () => { - await render({ - queries: { - fetchFormData: async () => ({ - myCoords: '59.2641592,10.4036248', - }), - }, - }); + jest.mocked(fetchFormData).mockImplementationOnce(async () => ({ + myCoords: '59.2641592,10.4036248', + })); + await render(); expect(screen.queryByText('Ingen lokasjon valgt')).not.toBeInTheDocument(); expect(screen.getByText('Valgt lokasjon: 59.2641592° nord, 10.4036248° øst')).toBeInTheDocument(); }); it('should show marker when marker is set', async () => { - await render({ - queries: { - fetchFormData: async () => ({ - myCoords: '59.2641592,10.4036248', - }), - }, - }); + jest.mocked(fetchFormData).mockImplementationOnce(async () => ({ + myCoords: '59.2641592,10.4036248', + })); + await render(); expect(screen.queryByRole('button', { name: 'Marker' })).toBeInTheDocument(); }); diff --git a/src/layout/MultipleSelect/MultipleSelectComponent.test.tsx b/src/layout/MultipleSelect/MultipleSelectComponent.test.tsx index fa7eb7bca3..ae90e1b4fe 100644 --- a/src/layout/MultipleSelect/MultipleSelectComponent.test.tsx +++ b/src/layout/MultipleSelect/MultipleSelectComponent.test.tsx @@ -1,9 +1,11 @@ import React from 'react'; +import { jest } from '@jest/globals'; import { screen } from '@testing-library/react'; import { defaultDataTypeMock } from 'src/__mocks__/getLayoutSetsMock'; import { MultipleSelectComponent } from 'src/layout/MultipleSelect/MultipleSelectComponent'; +import { fetchFormData } from 'src/queries/queries'; import { renderGenericComponentTest } from 'src/test/renderWithProviders'; import type { RenderGenericComponentTestProps } from 'src/test/renderWithProviders'; @@ -37,6 +39,7 @@ const render = async ({ component, ...rest }: Partial { it('required validation should only show for simpleBinding', async () => { + jest.mocked(fetchFormData).mockImplementationOnce(async () => ({ simpleBinding: '', label: '', metadata: '' })); await render({ component: { showValidations: ['Required'], @@ -47,9 +50,6 @@ describe('MultipleSelect', () => { metadata: { dataType: defaultDataTypeMock, field: 'metadata' }, }, }, - queries: { - fetchFormData: () => Promise.resolve({ simpleBinding: '', label: '', metadata: '' }), - }, }); expect(screen.getAllByRole('listitem')).toHaveLength(1); diff --git a/src/layout/RadioButtons/ControlledRadioGroup.test.tsx b/src/layout/RadioButtons/ControlledRadioGroup.test.tsx index efb2c9bdda..cd0e071509 100644 --- a/src/layout/RadioButtons/ControlledRadioGroup.test.tsx +++ b/src/layout/RadioButtons/ControlledRadioGroup.test.tsx @@ -1,5 +1,6 @@ import React from 'react'; +import { jest } from '@jest/globals'; import { act, fireEvent, screen, waitFor } from '@testing-library/react'; import { userEvent } from '@testing-library/user-event'; import type { AxiosResponse } from 'axios'; @@ -7,6 +8,7 @@ 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 { fetchFormData } from 'src/queries/queries'; import { renderGenericComponentTest } from 'src/test/renderWithProviders'; import type { IRawOption } from 'src/layout/common.generated'; import type { AppQueries } from 'src/queries/types'; @@ -41,8 +43,11 @@ const render = async ({ formData, groupData = getFormDataMockForRepGroup(), queries, -}: Props = {}) => - await renderGenericComponentTest({ +}: Props = {}) => { + jest + .mocked(fetchFormData) + .mockImplementation(async () => (formData ? { myRadio: formData, ...groupData } : { ...groupData })); + return await renderGenericComponentTest({ type: 'RadioButtons', renderer: (props) => , component: { @@ -60,10 +65,10 @@ const render = async ({ ? // eslint-disable-next-line @typescript-eslint/no-explicit-any Promise.resolve({ data: options, headers: {} } as AxiosResponse) : Promise.reject(new Error('No options provided to render()')), - fetchFormData: async () => (formData ? { myRadio: formData, ...groupData } : { ...groupData }), ...queries, }, }); +}; const getRadio = ({ name, isChecked = false }) => screen.getByRole('radio', { @@ -252,6 +257,7 @@ describe('RadioButtonsContainerComponent', () => { }); it('required validation should only show for simpleBinding', async () => { + jest.mocked(fetchFormData).mockImplementationOnce(async () => ({ simpleBinding: '', label: '', metadata: '' })); await render({ component: { showValidations: ['Required'], @@ -263,9 +269,6 @@ describe('RadioButtonsContainerComponent', () => { }, }, options: [], - queries: { - fetchFormData: () => Promise.resolve({ simpleBinding: '', label: '', metadata: '' }), - }, }); expect(screen.getAllByRole('listitem')).toHaveLength(1); diff --git a/src/layout/RepeatingGroup/Container/RepeatingGroupContainer.test.tsx b/src/layout/RepeatingGroup/Container/RepeatingGroupContainer.test.tsx index d87e6a4560..a1b2de4656 100644 --- a/src/layout/RepeatingGroup/Container/RepeatingGroupContainer.test.tsx +++ b/src/layout/RepeatingGroup/Container/RepeatingGroupContainer.test.tsx @@ -1,6 +1,6 @@ import React from 'react'; -import { beforeAll } from '@jest/globals'; +import { beforeAll, jest } from '@jest/globals'; import { screen, waitFor, within } from '@testing-library/react'; import { userEvent } from '@testing-library/user-event'; import { v4 as uuidv4 } from 'uuid'; @@ -16,6 +16,7 @@ import { useRepeatingGroupRowState, useRepeatingGroupSelector, } from 'src/layout/RepeatingGroup/Providers/RepeatingGroupContext'; +import { fetchFormData } from 'src/queries/queries'; import { mockMediaQuery } from 'src/test/mockMediaQuery'; import { renderWithInstanceAndLayout } from 'src/test/renderWithProviders'; import type { ILayout } from 'src/layout/layout'; @@ -97,6 +98,14 @@ async function render({ container, numRows = 3, validationIssues = [] }: IRender }, }); + jest.mocked(fetchFormData).mockImplementation(async (_url: string) => ({ + Group: Array.from({ length: numRows }).map((_, index) => ({ + [ALTINN_ROW_ID]: uuidv4(), + prop1: `value${index + 1}`, + checkboxBinding: ['option.value'], + })), + })); + return await renderWithInstanceAndLayout({ renderer: ( @@ -122,16 +131,8 @@ async function render({ container, numRows = 3, validationIssues = [] }: IRender { id: 'button.save', value: 'New save text' }, ], }), - fetchFormData: async () => ({ - Group: Array.from({ length: numRows }).map((_, index) => ({ - [ALTINN_ROW_ID]: uuidv4(), - prop1: `value${index + 1}`, - checkboxBinding: ['option.value'], - })), - }), fetchBackendValidations: async () => validationIssues, }, - mockFormDataSaving: true, }); } @@ -175,6 +176,7 @@ describe('RepeatingGroupContainer', () => { }); it('displays components on multiple pages', async () => { + // Render with a row already in edit mode await render({ container: { edit: { @@ -183,19 +185,21 @@ describe('RepeatingGroupContainer', () => { }, children: ['0:field1', '0:field2', '1:field3', '1:field4'], }, + numRows: 4, }); - expect(screen.getAllByRole('row')).toHaveLength(4); // 3 rows, 1 header, 0 edit container - expect(screen.getByTestId('editIndex')).toHaveTextContent('undefined'); - const addButton = screen.getAllByRole('button', { - name: /Legg til ny/i, - })[0]; - await userEvent.click(addButton); + // Manually trigger edit mode for the last row (index 3) + const rows = screen.getAllByRole('row'); + expect(rows).toHaveLength(5); // 4 rows + 1 header + + // Click the edit button on the last row + const editButtons = screen.getAllByRole('button', { name: /Rediger/i }); + await userEvent.click(editButtons[3]); // Edit the 4th row (index 3) - await waitFor(() => expect(screen.getAllByRole('row')).toHaveLength(6)); // 4 rows, 1 header, 1 edit container - expect(screen.getByTestId('editIndex')).toHaveTextContent('3'); // Editing the last row we just added - const editContainer = screen.getByTestId('group-edit-container'); + // Wait for the edit container to appear + const editContainer = await screen.findByTestId('group-edit-container'); expect(editContainer).toBeInTheDocument(); + expect(screen.getByTestId('editIndex')).toHaveTextContent('3'); expect(within(editContainer).getByText('Title1')).toBeInTheDocument(); expect(within(editContainer).getByText('Title2')).toBeInTheDocument(); diff --git a/src/layout/RepeatingGroup/EditContainer/RepeatingGroupEditContainer.test.tsx b/src/layout/RepeatingGroup/EditContainer/RepeatingGroupEditContainer.test.tsx index 1f8a1ac34f..a249f73395 100644 --- a/src/layout/RepeatingGroup/EditContainer/RepeatingGroupEditContainer.test.tsx +++ b/src/layout/RepeatingGroup/EditContainer/RepeatingGroupEditContainer.test.tsx @@ -1,5 +1,6 @@ import React from 'react'; +import { jest } from '@jest/globals'; import { screen } from '@testing-library/react'; import { userEvent } from '@testing-library/user-event'; @@ -13,6 +14,7 @@ import { useRepeatingGroupRowState, useRepeatingGroupSelector, } from 'src/layout/RepeatingGroup/Providers/RepeatingGroupContext'; +import { fetchFormData } from 'src/queries/queries'; import { renderWithInstanceAndLayout } from 'src/test/renderWithProviders'; import type { CompCheckboxesExternal } from 'src/layout/Checkboxes/config.generated'; import type { IRawOption } from 'src/layout/common.generated'; @@ -83,6 +85,22 @@ describe('RepeatingGroupsEditContainer', () => { }); const render = async () => { + jest.mocked(fetchFormData).mockImplementation(async () => ({ + multipageGroup: [ + { + [ALTINN_ROW_ID]: 'abc123', + prop1: 'prop1', + prop2: 'prop2', + prop3: 'prop3', + }, + { + [ALTINN_ROW_ID]: 'def456', + prop1: 'prop4', + prop2: 'prop5', + prop3: 'prop6', + }, + ], + })); const multiPageGroup = getMultiPageGroupMock({ id: 'group' }); multiPageGroup.edit!.saveAndNextButton = true; @@ -109,22 +127,6 @@ describe('RepeatingGroupsEditContainer', () => { }, ], }), - fetchFormData: async () => ({ - multipageGroup: [ - { - [ALTINN_ROW_ID]: 'abc123', - prop1: 'prop1', - prop2: 'prop2', - prop3: 'prop3', - }, - { - [ALTINN_ROW_ID]: 'def456', - prop1: 'prop4', - prop2: 'prop5', - prop3: 'prop6', - }, - ], - }), }, }); }; diff --git a/src/layout/RepeatingGroup/Providers/OpenByDefaultProvider.test.tsx b/src/layout/RepeatingGroup/Providers/OpenByDefaultProvider.test.tsx index bfd97349d6..6a6c09607c 100644 --- a/src/layout/RepeatingGroup/Providers/OpenByDefaultProvider.test.tsx +++ b/src/layout/RepeatingGroup/Providers/OpenByDefaultProvider.test.tsx @@ -13,6 +13,7 @@ import { useRepeatingGroupRowState, useRepeatingGroupSelector, } from 'src/layout/RepeatingGroup/Providers/RepeatingGroupContext'; +import { fetchFormData } from 'src/queries/queries'; import { renderWithInstanceAndLayout } from 'src/test/renderWithProviders'; import type { JsonPatch } from 'src/features/formData/jsonPatch/types'; import type { ILayout } from 'src/layout/layout'; @@ -73,6 +74,9 @@ describe('openByDefault', () => { } async function render({ existingRows, edit, hiddenRow }: Props) { + jest.mocked(fetchFormData).mockImplementation(async () => ({ + MyGroup: existingRows ?? [], + })); const layout: ILayout = [ { id: 'myGroup', @@ -103,9 +107,6 @@ describe('openByDefault', () => { ), queries: { - fetchFormData: async () => ({ - MyGroup: existingRows ?? [], - }), fetchLayouts: async () => ({ FormLayout: { data: { layout }, diff --git a/src/layout/RepeatingGroup/Summary/SummaryRepeatingGroup.test.tsx b/src/layout/RepeatingGroup/Summary/SummaryRepeatingGroup.test.tsx index 0559c34038..1ac35e8ca2 100644 --- a/src/layout/RepeatingGroup/Summary/SummaryRepeatingGroup.test.tsx +++ b/src/layout/RepeatingGroup/Summary/SummaryRepeatingGroup.test.tsx @@ -5,6 +5,7 @@ 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 { fetchFormData } from 'src/queries/queries'; import { renderWithInstanceAndLayout } from 'src/test/renderWithProviders'; describe('SummaryRepeatingGroup', () => { @@ -20,6 +21,15 @@ describe('SummaryRepeatingGroup', () => { }); async function render() { + jest.mocked(fetchFormData).mockImplementation(async () => ({ + mockGroup: [ + { + [ALTINN_ROW_ID]: 'abc123', + mockDataBinding1: '1', + mockDataBinding2: '2', + }, + ], + })); return await renderWithInstanceAndLayout({ renderer: ( { /> ), queries: { - fetchFormData: async () => ({ - mockGroup: [ - { - [ALTINN_ROW_ID]: 'abc123', - mockDataBinding1: '1', - mockDataBinding2: '2', - }, - ], - }), fetchLayouts: async () => ({ FormLayout: { data: { diff --git a/src/layout/RepeatingGroup/Summary2/RepeatingGroupTableSummary/RepeatingGroupTableSummary.test.tsx b/src/layout/RepeatingGroup/Summary2/RepeatingGroupTableSummary/RepeatingGroupTableSummary.test.tsx index 3090d287ec..dddd6062f2 100644 --- a/src/layout/RepeatingGroup/Summary2/RepeatingGroupTableSummary/RepeatingGroupTableSummary.test.tsx +++ b/src/layout/RepeatingGroup/Summary2/RepeatingGroupTableSummary/RepeatingGroupTableSummary.test.tsx @@ -1,5 +1,6 @@ import React from 'react'; +import { jest } from '@jest/globals'; import { screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; @@ -8,6 +9,7 @@ import { ALTINN_ROW_ID } from 'src/features/formData/types'; import * as useNavigatePageModule from 'src/hooks/useNavigatePage'; import { RepeatingGroupProvider } from 'src/layout/RepeatingGroup/Providers/RepeatingGroupContext'; import { RepeatingGroupTableSummary } from 'src/layout/RepeatingGroup/Summary2/RepeatingGroupTableSummary/RepeatingGroupTableSummary'; +import { fetchFormData } from 'src/queries/queries'; import { renderWithInstanceAndLayout } from 'src/test/renderWithProviders'; import type { ILayoutCollection } from 'src/layout/layout'; @@ -146,8 +148,13 @@ describe('RepeatingGroupTableSummary', () => { }; const render = async ({ navigate, layout = layoutWithHidden([]) }: IRenderProps = {}) => { + jest.mocked(fetchFormData).mockImplementation(async () => ({ + group: [{ field1: 'field1-row0', field2: 'field2-row0', field3: 'field3-row0', [ALTINN_ROW_ID]: 'abc123' }], + })); if (navigate) { - jest.spyOn(useNavigatePageModule, 'useNavigateToComponent').mockReturnValue(navigate); + jest + .spyOn(useNavigatePageModule, 'useNavigateToComponent') + .mockReturnValue(navigate as ReturnType); } return await renderWithInstanceAndLayout({ @@ -159,9 +166,6 @@ describe('RepeatingGroupTableSummary', () => { initialPage: 'FormPage2', queries: { fetchLayouts: async () => layout, - fetchFormData: async () => ({ - group: [{ field1: 'field1-row0', field2: 'field2-row0', field3: 'field3-row0', [ALTINN_ROW_ID]: 'abc123' }], - }), fetchLayoutSettings: async () => ({ pages: { order: ['FormPage1', 'FormPage2'], diff --git a/src/layout/RepeatingGroup/Table/RepeatingGroupTable.test.tsx b/src/layout/RepeatingGroup/Table/RepeatingGroupTable.test.tsx index c1929b86a7..ef01391a14 100644 --- a/src/layout/RepeatingGroup/Table/RepeatingGroupTable.test.tsx +++ b/src/layout/RepeatingGroup/Table/RepeatingGroupTable.test.tsx @@ -1,5 +1,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 ResizeObserverModule from 'resize-observer-polyfill'; @@ -14,6 +15,7 @@ import { useRepeatingGroupSelector, } from 'src/layout/RepeatingGroup/Providers/RepeatingGroupContext'; import { RepeatingGroupTable } from 'src/layout/RepeatingGroup/Table/RepeatingGroupTable'; +import { fetchFormData } from 'src/queries/queries'; import { mockMediaQuery } from 'src/test/mockMediaQuery'; import { renderWithInstanceAndLayout } from 'src/test/renderWithProviders'; import type { CompCheckboxesExternal } from 'src/layout/Checkboxes/config.generated'; @@ -172,8 +174,16 @@ describe('RepeatingGroupTable', () => { }); }); - const render = async (layout = getLayout(group, components)) => - await renderWithInstanceAndLayout({ + const render = async (layout = getLayout(group, components)) => { + jest.mocked(fetchFormData).mockImplementation(async () => ({ + 'some-group': [ + { [ALTINN_ROW_ID]: uuidv4(), checkBoxBinding: 'option.value', prop1: 'test row 0' }, + { [ALTINN_ROW_ID]: uuidv4(), checkBoxBinding: 'option.value', prop1: 'test row 1' }, + { [ALTINN_ROW_ID]: uuidv4(), checkBoxBinding: 'option.value', prop1: 'test row 2' }, + { [ALTINN_ROW_ID]: uuidv4(), checkBoxBinding: 'option.value', prop1: 'test row 3' }, + ], + })); + return await renderWithInstanceAndLayout({ renderer: ( @@ -191,16 +201,9 @@ describe('RepeatingGroupTable', () => { }, ], }), - fetchFormData: async () => ({ - 'some-group': [ - { [ALTINN_ROW_ID]: uuidv4(), checkBoxBinding: 'option.value', prop1: 'test row 0' }, - { [ALTINN_ROW_ID]: uuidv4(), checkBoxBinding: 'option.value', prop1: 'test row 1' }, - { [ALTINN_ROW_ID]: uuidv4(), checkBoxBinding: 'option.value', prop1: 'test row 2' }, - { [ALTINN_ROW_ID]: uuidv4(), checkBoxBinding: 'option.value', prop1: 'test row 3' }, - ], - }), }, }); + }; }); function LeakEditIndex() { diff --git a/src/layout/TextArea/TextAreaComponent.test.tsx b/src/layout/TextArea/TextAreaComponent.test.tsx index dcb0cdc4d0..518c68092b 100644 --- a/src/layout/TextArea/TextAreaComponent.test.tsx +++ b/src/layout/TextArea/TextAreaComponent.test.tsx @@ -1,22 +1,21 @@ import React from 'react'; +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 { TextAreaComponent } from 'src/layout/TextArea/TextAreaComponent'; +import { fetchFormData } from 'src/queries/queries'; import { renderGenericComponentTest } from 'src/test/renderWithProviders'; import type { RenderGenericComponentTestProps } from 'src/test/renderWithProviders'; describe('TextAreaComponent', () => { it('should render with initial text value', async () => { - await render({ - queries: { - fetchFormData: async () => ({ - myTextArea: 'initial text content', - }), - }, - }); + jest.mocked(fetchFormData).mockImplementationOnce(async () => ({ + myTextArea: 'initial text content', + })); + await render(); const textarea = screen.getByRole('textbox'); @@ -27,13 +26,10 @@ describe('TextAreaComponent', () => { const initialText = 'initial text content'; const addedText = ' + added content'; - const { formDataMethods } = await render({ - queries: { - fetchFormData: async () => ({ - myTextArea: initialText, - }), - }, - }); + jest.mocked(fetchFormData).mockImplementationOnce(async () => ({ + myTextArea: initialText, + })); + const { formDataMethods } = await render(); const textarea = screen.getByRole('textbox'); await userEvent.type(textarea, addedText); @@ -48,15 +44,13 @@ describe('TextAreaComponent', () => { const initialText = 'initial text content'; const addedText = ' + added content'; + jest.mocked(fetchFormData).mockImplementationOnce(async () => ({ + myTextArea: initialText, + })); const { formDataMethods } = await render({ component: { readOnly: true, }, - queries: { - fetchFormData: async () => ({ - myTextArea: initialText, - }), - }, }); const textarea = screen.getByRole('textbox'); diff --git a/src/queries/types.ts b/src/queries/types.ts index 26430cb114..e650d1669a 100644 --- a/src/queries/types.ts +++ b/src/queries/types.ts @@ -1,6 +1,6 @@ import type * as queries from 'src/queries/queries'; -type IgnoredQueriesAndMutations = keyof Pick< +export type IgnoredQueriesAndMutations = keyof Pick< typeof queries, | 'fetchApplicationMetadata' | 'fetchExternalApi' @@ -8,6 +8,7 @@ type IgnoredQueriesAndMutations = keyof Pick< | 'doProcessNext' | 'fetchUserProfile' | 'fetchInstanceData' + | 'fetchFormData' >; type KeysStartingWith = { diff --git a/src/setupTests.ts b/src/setupTests.ts index 440bd6502a..95ed6717d0 100644 --- a/src/setupTests.ts +++ b/src/setupTests.ts @@ -20,11 +20,12 @@ import { getProfileMock } from 'src/__mocks__/getProfileMock'; import type { doProcessNext, fetchApplicationMetadata, + fetchFormData, fetchInstanceData, fetchProcessState, fetchUserProfile, } from 'src/queries/queries'; -import type { AppQueries } from 'src/queries/types'; +import type { IgnoredQueriesAndMutations } from 'src/queries/types'; import 'src/index.css'; import 'src/styles/shared.css'; @@ -112,7 +113,8 @@ testingLibraryConfigure({ }); jest.mock('src/queries/queries', () => ({ - ...jest.requireActual('src/queries/queries'), + // eslint-disable-next-line @typescript-eslint/consistent-type-imports + ...jest.requireActual>('src/queries/queries'), fetchApplicationMetadata: jest .fn() .mockImplementation(async () => getIncomingApplicationMetadataMock()), @@ -120,4 +122,5 @@ jest.mock('src/queries/queries', () => ({ doProcessNext: jest.fn(async () => getProcessDataMock()), fetchUserProfile: jest.fn(async () => getProfileMock()), fetchInstanceData: jest.fn(async () => getInstanceDataMock()), + fetchFormData: jest.fn(async () => ({})), })); diff --git a/src/test/renderWithProviders.tsx b/src/test/renderWithProviders.tsx index 0094399731..e9973a42bb 100644 --- a/src/test/renderWithProviders.tsx +++ b/src/test/renderWithProviders.tsx @@ -6,7 +6,6 @@ import { jest } from '@jest/globals'; import { QueryClient } from '@tanstack/react-query'; import { act, render as rtlRender, waitFor } from '@testing-library/react'; import dotenv from 'dotenv'; -import { applyPatch } from 'fast-json-patch'; import type { RenderOptions, waitForOptions } from '@testing-library/react'; import type { AxiosResponse } from 'axios'; import type { JSONSchema7 } from 'json-schema'; @@ -43,22 +42,17 @@ import { PageNavigationRouter } from 'src/test/routerUtils'; import type { IFooterLayout } from 'src/features/footer/types'; import type { FormDataWriteProxies, Proxy } from 'src/features/formData/FormDataWriteProxies'; import type { FormDataMethods } from 'src/features/formData/FormDataWriteStateMachine'; -import type { IDataModelPatchRequest, IDataModelPatchResponse } from 'src/features/formData/types'; import type { IComponentProps, PropsFromGenericComponent } from 'src/layout'; import type { IRawOption } from 'src/layout/common.generated'; import type { CompExternal, CompExternalExact, CompTypes } from 'src/layout/layout'; import type { AppMutations, AppQueries, AppQueriesContext } from 'src/queries/types'; -interface ExtendedRenderOptions extends Omit { +export interface ExtendedRenderOptions extends Omit { renderer: (() => React.ReactElement) | React.ReactElement; router?: (props: PropsWithChildren) => React.ReactNode; waitUntilLoaded?: boolean; queries?: Partial; initialRenderRef?: InitialRenderRef; - - // Setting this allows you to pretend to be the backend (true = all requests are resolved successfully). When - // using a callback function you can simulate ProcessDataWrite by returning a new model. - mockFormDataSaving?: true | ((data: unknown, url: string) => unknown); } interface InstanceRouterProps { @@ -141,7 +135,6 @@ const defaultQueryMocks: AppQueries = { fetchPartiesAllowedToInstantiate: async () => [getPartyMock()], fetchRefreshJwtToken: async () => ({}), fetchCustomValidationConfig: async () => null, - fetchFormData: async () => ({}), fetchOptions: async () => ({ data: [], headers: {} }) as unknown as AxiosResponse, fetchDataList: async () => getDataListMock(), fetchPdfFormat: async () => ({ excludedPages: [], excludedComponents: [] }), @@ -442,44 +435,6 @@ export function setupFakeApp({ queries, mutations }: SetupFakeAppProps = {}) { }; } -function injectFormDataSavingSimulator( - queryMocks: AppQueries, - mutationMocks: AppMutations, - mockBackend: Required['mockFormDataSaving'], -) { - const models: Record = {}; - const originalFetchFormData = queryMocks.fetchFormData; - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (queryMocks as any).fetchFormData = jest.fn().mockImplementation(async (url: string) => { - const result = await originalFetchFormData(url); - models[url] = result; - return result; - }); - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (mutationMocks as any).doPatchFormData = jest - .fn() - .mockImplementation(async (url: string, req: IDataModelPatchRequest): Promise => { - const model = structuredClone(models[url] ?? {}); - applyPatch(model, req.patch); - const afterProcessing = typeof mockBackend === 'function' ? mockBackend(model, url) : model; - models[url] = afterProcessing; - - return { - newDataModel: afterProcessing as object, - validationIssues: {}, - }; - }); - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (mutationMocks as any).doPostStatelessFormData = jest.fn().mockImplementation(async (url: string, data: unknown) => { - const afterProcessing = typeof mockBackend === 'function' ? mockBackend(data, url) : data; - models[url] = afterProcessing; - return afterProcessing; - }); -} - const renderBase = async ({ renderer, router, @@ -487,7 +442,6 @@ const renderBase = async ({ waitUntilLoaded = true, Providers = DefaultProviders, initialRenderRef = { current: true }, - mockFormDataSaving, ...renderOptions }: BaseRenderOptions) => { const { queryClient, queriesOnly: finalQueries } = setupFakeApp({ queries }); @@ -501,10 +455,6 @@ const renderBase = async ({ Object.entries(mutations).map(([key, value]) => [key, value.mock]), ) as AppMutations; - if (mockFormDataSaving) { - injectFormDataSavingSimulator(queryMocks, mutationMocks, mockFormDataSaving); - } - if (!router) { throw new Error('No router provided'); } diff --git a/src/utils/layout/all.test.tsx b/src/utils/layout/all.test.tsx index 8c7bbf47c0..54c758541f 100644 --- a/src/utils/layout/all.test.tsx +++ b/src/utils/layout/all.test.tsx @@ -13,7 +13,7 @@ import { TaskStoreProvider } from 'src/core/contexts/taskStoreContext'; import { quirks } from 'src/features/form/layout/quirks'; import { GenericComponent } from 'src/layout/GenericComponent'; import { SubformWrapper } from 'src/layout/Subform/SubformWrapper'; -import { fetchApplicationMetadata, fetchInstanceData, fetchProcessState } from 'src/queries/queries'; +import { fetchApplicationMetadata, fetchFormData, fetchInstanceData, fetchProcessState } from 'src/queries/queries'; import { ensureAppsDirIsSet, getAllApps } from 'src/test/allApps'; import { renderWithInstanceAndLayout } from 'src/test/renderWithProviders'; import { NodesInternal } from 'src/utils/layout/NodesContext'; @@ -147,6 +147,7 @@ describe('All known layout sets should evaluate as a hierarchy', () => { jest.mocked(fetchApplicationMetadata).mockImplementation(async () => set.app.getAppMetadata()); jest.mocked(fetchProcessState).mockImplementation(async () => mainSet.simulateProcessData()); jest.mocked(fetchInstanceData).mockImplementation(async () => set.simulateInstance()); + jest.mocked(fetchFormData).mockImplementation(async (url) => set.getModel({ url }).simulateDataModel()); const children = env.parsed?.ALTINN_ALL_APPS_RENDER_COMPONENTS === 'true' ? : ; await renderWithInstanceAndLayout({ @@ -157,7 +158,6 @@ describe('All known layout sets should evaluate as a hierarchy', () => { fetchLayoutSets: async () => set.app.getRawLayoutSets(), fetchLayouts: async (setId) => set.app.getLayoutSet(setId).getLayouts(), fetchLayoutSettings: async (setId) => set.app.getLayoutSet(setId).getSettings(), - fetchFormData: async (url) => set.getModel({ url }).simulateDataModel(), fetchDataModelSchema: async (name) => set.getModel({ name }).getSchema(), fetchLayoutSchema: async () => layoutSchema as unknown as JSONSchema7, fetchRuleHandler: async (setId) => set.app.getLayoutSet(setId).getRuleHandler(), From 694892b8f2476903290500542f6ca4838f8499e2 Mon Sep 17 00:00:00 2001 From: Camilla Marie Dalan Date: Thu, 7 Aug 2025 15:23:24 +0200 Subject: [PATCH 05/10] invalidate formdata on subform exit --- src/components/form/Form.tsx | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/components/form/Form.tsx b/src/components/form/Form.tsx index 8828321cd8..8cf7bdd43d 100644 --- a/src/components/form/Form.tsx +++ b/src/components/form/Form.tsx @@ -1,6 +1,8 @@ import React, { useEffect, useMemo } from 'react'; import { useLocation, useNavigate, useSearchParams } from 'react-router-dom'; +import { useQueryClient } from '@tanstack/react-query'; + import { Flex } from 'src/app-components/Flex/Flex'; import classes from 'src/components/form/Form.module.css'; import { MessageBanner } from 'src/components/form/MessageBanner'; @@ -14,6 +16,7 @@ import { FileScanResults } from 'src/features/attachments/types'; import { useExpandedWidthLayouts, useLayoutLookups } from 'src/features/form/layout/LayoutsContext'; import { useUiConfigContext } from 'src/features/form/layout/UiConfigContext'; import { usePageSettings } from 'src/features/form/layoutSettings/LayoutSettingsContext'; +import { invalidateFormDataQueries } from 'src/features/formData/useFormDataQuery'; import { useLaxInstanceId } from 'src/features/instance/InstanceContext'; import { useLanguage } from 'src/features/language/useLanguage'; import { useOnFormSubmitValidation } from 'src/features/validation/callbacks/onFormSubmitValidation'; @@ -241,6 +244,7 @@ function HandleNavigationFocusComponent() { const validate = useQueryKey(SearchParams.Validate)?.toLocaleLowerCase() === 'true'; const navigate = useNavigate(); const searchStringRef = useAsRef(useLocation().search); + const queryClient = useQueryClient(); React.useEffect(() => { (async () => { @@ -250,10 +254,13 @@ function HandleNavigationFocusComponent() { location.delete(SearchParams.ExitSubform); const baseHash = window.location.hash.slice(1).split('?')[0]; const nextLocation = location.size > 0 ? `${baseHash}?${location.toString()}` : baseHash; + if (exitSubform) { + invalidateFormDataQueries(queryClient); + } navigate(nextLocation, { replace: true }); } })(); - }, [navigate, searchStringRef, exitSubform, validate, onFormSubmitValidation]); + }, [navigate, searchStringRef, exitSubform, validate, onFormSubmitValidation, queryClient]); return null; } From 6ba3e7dc498055806aeacbe94bccefe633ea3531 Mon Sep 17 00:00:00 2001 From: Camilla Marie Dalan Date: Fri, 8 Aug 2025 08:47:54 +0200 Subject: [PATCH 06/10] minor changes to formdatareaders --- src/features/datamodel/useAvailableDataModels.tsx | 6 ------ src/features/formData/FormDataReaders.tsx | 8 ++++++-- 2 files changed, 6 insertions(+), 8 deletions(-) diff --git a/src/features/datamodel/useAvailableDataModels.tsx b/src/features/datamodel/useAvailableDataModels.tsx index 67f4baf5dc..d7f30461a6 100644 --- a/src/features/datamodel/useAvailableDataModels.tsx +++ b/src/features/datamodel/useAvailableDataModels.tsx @@ -1,11 +1,5 @@ -import { useApplicationMetadata } from 'src/features/applicationMetadata/ApplicationMetadataProvider'; import type { IDataType } from 'src/types/shared'; -export function useAvailableDataModels() { - const dataTypes = useApplicationMetadata().dataTypes; - return dataTypes.filter((dataType) => getDataTypeVariant(dataType) === DataTypeVariant.DataModel); -} - export enum DataTypeVariant { Pdf = 'pdf', Attachment = 'attachment', diff --git a/src/features/formData/FormDataReaders.tsx b/src/features/formData/FormDataReaders.tsx index 98cde14b97..4eaf992d0d 100644 --- a/src/features/formData/FormDataReaders.tsx +++ b/src/features/formData/FormDataReaders.tsx @@ -4,8 +4,9 @@ import type { PropsWithChildren } from 'react'; import dot from 'dot-object'; import { ContextNotProvided, createContext } from 'src/core/contexts/context'; +import { useApplicationMetadata } from 'src/features/applicationMetadata/ApplicationMetadataProvider'; import { getFirstDataElementId } from 'src/features/applicationMetadata/appMetadataUtils'; -import { useAvailableDataModels } from 'src/features/datamodel/useAvailableDataModels'; +import { DataTypeVariant, getDataTypeVariant } from 'src/features/datamodel/useAvailableDataModels'; import { useFormDataQuery } from 'src/features/formData/useFormDataQuery'; import { useInstanceDataElements, useLaxInstanceId } from 'src/features/instance/InstanceContext'; import { useNavigationParam } from 'src/hooks/navigation'; @@ -102,7 +103,10 @@ export class DataModelReaders { * to render FormDataReadersProvider somewhere inside if rendering in a form, and render DataModelFetcher. */ export function GlobalFormDataReadersProvider({ children }: PropsWithChildren) { - const availableModels = useAvailableDataModels().map((dm) => dm.id); + const dataTypes = useApplicationMetadata().dataTypes; + const availableModels = dataTypes + .filter((dataType) => getDataTypeVariant(dataType) === DataTypeVariant.DataModel) + .map((dm) => dm.id); const [readerMap, setReaderMap] = useState<{ [name: string]: DataModelReader }>({}); const updateModel = useCallback((newModel: DataModelReader) => { From 7821ad7c21e4d02d25c860a0c8e38d92e61459bf Mon Sep 17 00:00:00 2001 From: Camilla Marie Dalan Date: Fri, 8 Aug 2025 14:56:06 +0200 Subject: [PATCH 07/10] removes memoization that sometimes causes stale data --- src/features/formData/useDataModelBindings.ts | 5 +-- src/features/options/useGetOptions.ts | 32 +++++++------------ 2 files changed, 12 insertions(+), 25 deletions(-) diff --git a/src/features/formData/useDataModelBindings.ts b/src/features/formData/useDataModelBindings.ts index 5db0cabb02..7af83c0a41 100644 --- a/src/features/formData/useDataModelBindings.ts +++ b/src/features/formData/useDataModelBindings.ts @@ -50,10 +50,7 @@ export function useDataModelBindings ({ formData: formData as Output['formData'], setValue, setValues, isValid }), - [formData, isValid, setValue, setValues], - ); + return { formData: formData as Output['formData'], setValue, setValues, isValid }; } export function useSaveDataModelBindings( diff --git a/src/features/options/useGetOptions.ts b/src/features/options/useGetOptions.ts index c581f2435b..6dba7a01b0 100644 --- a/src/features/options/useGetOptions.ts +++ b/src/features/options/useGetOptions.ts @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useMemo } from 'react'; +import { useEffect, useMemo } from 'react'; import { evalExpr } from 'src/features/expressions'; import { ExprVal } from 'src/features/expressions/types'; @@ -78,26 +78,16 @@ export function useSetOptions( const { formData, setValue } = useDataModelBindings(dataModelBindings); const value = formData.simpleBinding ?? ''; - const currentValues = useMemo( - () => (value && value.length > 0 ? (valueType === 'multi' ? value.split(',') : [value]) : []), - [value, valueType], - ); - - const selectedValues = useMemo( - () => currentValues.filter((value) => options.find((option) => option.value === value)), - [options, currentValues], - ); - - const setData = useCallback( - (values: string[]) => { - if (valueType === 'single') { - setValue('simpleBinding', values.at(0)); - } else if (valueType === 'multi') { - setValue('simpleBinding', values.join(',')); - } - }, - [setValue, valueType], - ); + const currentValues = value && value.length > 0 ? (valueType === 'multi' ? value.split(',') : [value]) : []; + const selectedValues = currentValues.filter((value) => options.find((option) => option.value === value)); + + function setData(values: string[]) { + if (valueType === 'single') { + setValue('simpleBinding', values.at(0)); + } else if (valueType === 'multi') { + setValue('simpleBinding', values.join(',')); + } + } return { rawData: value, From a2ad460ebb53f44d3299df13107c6a1a30f08f04 Mon Sep 17 00:00:00 2001 From: Camilla Marie Dalan Date: Mon, 11 Aug 2025 14:09:31 +0200 Subject: [PATCH 08/10] linting fixes --- src/features/datamodel/useBindingSchema.tsx | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/features/datamodel/useBindingSchema.tsx b/src/features/datamodel/useBindingSchema.tsx index 342670fd2d..874fe22c6d 100644 --- a/src/features/datamodel/useBindingSchema.tsx +++ b/src/features/datamodel/useBindingSchema.tsx @@ -14,8 +14,6 @@ import { useInstanceDataQuery } from 'src/features/instance/InstanceContext'; import { useProcessTaskId } from 'src/features/instance/useProcessTaskId'; import type { IDataModelReference } from 'src/layout/common.generated'; import type { IDataModelBindings } from 'src/layout/layout'; -import { getStatefulDataModelUrl, getStatelessDataModelUrl } from 'src/utils/urls/appUrlHelper'; -import { getUrlWithLanguage } from 'src/utils/urls/urlHelper'; export type AsSchema = { [P in keyof T]: JSONSchema7 | null; From b76e711f212a83b3912e8ddfccfc1c4baa5f4566 Mon Sep 17 00:00:00 2001 From: Camilla Marie Dalan Date: Mon, 11 Aug 2025 14:33:02 +0200 Subject: [PATCH 09/10] cypress: wait for load before clicking in subform table --- test/e2e/integration/subform-test/subform.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/test/e2e/integration/subform-test/subform.ts b/test/e2e/integration/subform-test/subform.ts index 94eb5dab49..90a587b8d9 100644 --- a/test/e2e/integration/subform-test/subform.ts +++ b/test/e2e/integration/subform-test/subform.ts @@ -207,6 +207,7 @@ describe('Subform test', () => { 'contain.text', 'Det er feil i en eller flere moped oppføringer', ); + cy.waitForLoad(); cy.findAllByRole('button', { name: /slett/i }).last().clickAndGone(); // Test that fixing the validations works From 3d65218b11bbff04e20837ec9396f4b22707b203 Mon Sep 17 00:00:00 2001 From: Camilla Marie Dalan Date: Tue, 12 Aug 2025 09:17:06 +0200 Subject: [PATCH 10/10] WIP --- src/layout/SigneeList/api.ts | 13 ++++++++--- .../SigningActions/OnBehalfOfChooser.tsx | 12 +++++----- .../PanelAwaitingCurrentUserSignature.tsx | 12 ++++++---- src/layout/SigningActions/api.ts | 23 +++++++++++++++---- 4 files changed, 43 insertions(+), 17 deletions(-) diff --git a/src/layout/SigneeList/api.ts b/src/layout/SigneeList/api.ts index 9c94bfd1ee..80bd4f97e5 100644 --- a/src/layout/SigneeList/api.ts +++ b/src/layout/SigneeList/api.ts @@ -33,10 +33,17 @@ export type SigneeState = z.infer; export const signingQueries = { all: ['signing'] as const, - signeeList: (partyId: string | undefined, instanceGuid: string | undefined, taskId: string | undefined) => + signeeList: ( + instanceOwnerPartyId: string | undefined, + instanceGuid: string | undefined, + taskId: string | undefined, + ) => queryOptions({ - queryKey: [...signingQueries.all, 'signeeList', partyId, instanceGuid, taskId], - queryFn: partyId && instanceGuid && taskId ? () => fetchSigneeList(partyId, instanceGuid) : skipToken, + queryKey: [...signingQueries.all, 'signeeList', instanceOwnerPartyId, instanceGuid, taskId], + queryFn: + instanceOwnerPartyId && instanceGuid && taskId + ? () => fetchSigneeList(instanceOwnerPartyId, instanceGuid) + : skipToken, refetchInterval: 1000 * 60, // 1 minute refetchOnMount: 'always', }), diff --git a/src/layout/SigningActions/OnBehalfOfChooser.tsx b/src/layout/SigningActions/OnBehalfOfChooser.tsx index f8ae3d81e2..e8b87f2807 100644 --- a/src/layout/SigningActions/OnBehalfOfChooser.tsx +++ b/src/layout/SigningActions/OnBehalfOfChooser.tsx @@ -9,12 +9,12 @@ import { RequiredIndicator } from 'src/components/form/RequiredIndicator'; import { Lang } from 'src/features/language/Lang'; import { useLanguage } from 'src/features/language/useLanguage'; import { type SigneeState } from 'src/layout/SigneeList/api'; -import type { AuthorizedOrganizationDetails } from 'src/layout/SigningActions/api'; +import type { AuthorizedOrganizationDetails, OnBehalfOf } from 'src/layout/SigningActions/api'; interface OnBehalfOfChooserProps { currentUserSignee: SigneeState | undefined; authorizedOrganizationDetails: AuthorizedOrganizationDetails['organizations']; - onBehalfOfOrg: string | null; + onBehalfOf: OnBehalfOf | null; onChange: (event: ChangeEvent) => void; error: boolean; } @@ -22,7 +22,7 @@ interface OnBehalfOfChooserProps { export const OnBehalfOfChooser = ({ currentUserSignee, authorizedOrganizationDetails, - onBehalfOfOrg, + onBehalfOf, onChange, error = false, }: Readonly) => { @@ -42,18 +42,18 @@ export const OnBehalfOfChooser = ({ name='onBehalfOf' key={currentUserSignee.partyId} onChange={onChange} - checked={onBehalfOfOrg === ''} + checked={onBehalfOf?.orgNoOrSsn === ''} /> )} {authorizedOrganizationDetails.map((org) => ( ))} {error && ( diff --git a/src/layout/SigningActions/PanelAwaitingCurrentUserSignature.tsx b/src/layout/SigningActions/PanelAwaitingCurrentUserSignature.tsx index 9cf880fc03..139c09cec6 100644 --- a/src/layout/SigningActions/PanelAwaitingCurrentUserSignature.tsx +++ b/src/layout/SigningActions/PanelAwaitingCurrentUserSignature.tsx @@ -20,6 +20,7 @@ import { SigningPanel } from 'src/layout/SigningActions/PanelSigning'; import classes from 'src/layout/SigningActions/SigningActions.module.css'; import { SubmitSigningButton } from 'src/layout/SigningActions/SubmitSigningButton'; import { useItemWhenType } from 'src/utils/layout/useNodeItem'; +import type { OnBehalfOf } from 'src/layout/SigningActions/api'; type AwaitingCurrentUserSignaturePanelProps = { baseComponentId: string; @@ -47,7 +48,7 @@ export function AwaitingCurrentUserSignaturePanel({ const signingButtonText = textResourceBindings?.signingButton ?? 'signing.sign_button'; const [confirmReadDocuments, setConfirmReadDocuments] = useState(false); - const [onBehalfOf, setOnBehalfOf] = useState(null); + const [onBehalfOf, setOnBehalfOf] = useState(null); const [onBehalfOfError, setOnBehalfOfError] = useState(false); const [confirmReadDocumentsError, setConfirmReadDocumentsError] = useState(false); @@ -95,7 +96,10 @@ export function AwaitingCurrentUserSignaturePanel({ unsignedUserSigneeParties.length === 1 && unsignedUserSigneeParties[0].partyId === unsignedAuthorizedOrgSignees.at(0)?.partyId ) { - setOnBehalfOf(unsignedAuthorizedOrgSignees[0].orgNumber); + setOnBehalfOf({ + orgNoOrSsn: unsignedAuthorizedOrgSignees[0].orgNumber, + partyId: unsignedAuthorizedOrgSignees[0].partyId.toString(), + }); } }, [unsignedUserSigneeParties, unsignedAuthorizedOrgSignees]); @@ -142,10 +146,10 @@ export function AwaitingCurrentUserSignaturePanel({ s.partyId === currentUserPartyId)} authorizedOrganizationDetails={unsignedAuthorizedOrgSignees} - onBehalfOfOrg={onBehalfOf} + onBehalfOf={onBehalfOf} error={onBehalfOfError} onChange={(e) => { - setOnBehalfOf(e.target.value); + setOnBehalfOf(JSON.parse(e.target.value)); setOnBehalfOfError(false); }} /> diff --git a/src/layout/SigningActions/api.ts b/src/layout/SigningActions/api.ts index 0d02705862..8a63568c35 100644 --- a/src/layout/SigningActions/api.ts +++ b/src/layout/SigningActions/api.ts @@ -11,6 +11,7 @@ import { doPerformAction } from 'src/queries/queries'; import { httpGet } from 'src/utils/network/sharedNetworking'; import { capitalizeName } from 'src/utils/stringHelper'; import { appPath } from 'src/utils/urls/appUrlHelper'; +import type { SigneeState } from 'src/layout/SigneeList/api'; const authorizedOrganizationDetailsSchema = z.object({ organizations: z.array( @@ -81,24 +82,38 @@ export function useUserSigneeParties() { return signeeList.filter((signee) => authorizedPartyIds.includes(signee.partyId)); } +export type OnBehalfOf = { orgNoOrSsn: string; partyId: string }; + export function useSigningMutation() { - const { instanceOwnerPartyId, instanceGuid } = useParams(); + const { instanceOwnerPartyId, instanceGuid, taskId } = useParams(); const selectedLanguage = useCurrentLanguage(); const queryClient = useQueryClient(); + const currentUserPartyId = useProfile()?.partyId.toString(); return useMutation({ - mutationFn: async (onBehalfOf: string | null) => { + mutationFn: async (onBehalfOf: OnBehalfOf | null) => { if (instanceOwnerPartyId && instanceGuid) { return doPerformAction( instanceOwnerPartyId, instanceGuid, - { action: 'sign', ...(onBehalfOf ? { onBehalfOf } : {}) }, + { action: 'sign', ...(onBehalfOf ? { onBehalfOf: onBehalfOf.orgNoOrSsn } : {}) }, selectedLanguage, queryClient, ); } }, - onSuccess: () => { + onSuccess: (_data, onBehalfOf) => { + // optimistically update data in cache + const signeeListQueryKey = signingQueries.signeeList(instanceOwnerPartyId, instanceGuid, taskId).queryKey; + queryClient.setQueryData(signeeListQueryKey, (signeeList) => { + const partyId = onBehalfOf?.partyId ?? currentUserPartyId; + if (!signeeList || !partyId) { + return undefined; + } + + return signeeList.map((signee) => ({ ...signee, hasSigned: signee.partyId.toString() === partyId })); + }); + // Refetch all queries related to signing to ensure we have the latest data queryClient.invalidateQueries({ queryKey: signingQueries.all }); },