Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Fix handling of ITaskValidator validations #2462

Merged
merged 10 commits into from
Sep 19, 2024
30 changes: 21 additions & 9 deletions src/components/message/ErrorReport.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -90,17 +90,29 @@ describe('ErrorReport', () => {
});

it('should list unbound mapped error as unclickable', async () => {
await render([
{
customTextKey: 'some unbound mapped error',
field: 'unboundField',
dataElementId: defaultMockDataElementId,
severity: BackendValidationSeverity.Error,
source: 'custom',
} as BackendValidationIssue,
]);
const { mutations } = await render();

await userEvent.click(screen.getByRole('button', { name: 'Submit' }));

mutations.doProcessNext.reject({
name: 'AxiosError',
message: 'Request failed with status code 409',
response: {
status: 409,
data: {
validationIssues: [
{
customTextKey: 'some unbound mapped error',
field: 'unboundField',
dataElementId: defaultMockDataElementId,
severity: BackendValidationSeverity.Error,
source: 'custom',
} as BackendValidationIssue,
],
},
},
} as AxiosError);

await screen.findByTestId('ErrorReport');

// mapped errors not bound to any component should not be clickable
Expand Down
50 changes: 4 additions & 46 deletions src/features/datamodel/DataModelsProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,13 +24,11 @@ import { useLayouts } from 'src/features/form/layout/LayoutsContext';
import { useFormDataQuery } from 'src/features/formData/useFormDataQuery';
import { useLaxInstanceData } from 'src/features/instance/InstanceContext';
import { MissingRolesError } from 'src/features/instantiate/containers/MissingRolesError';
import { useBackendValidationQuery } from 'src/features/validation/backendValidation/backendValidationQuery';
import { useShouldValidateInitial } from 'src/features/validation/backendValidation/backendValidationUtils';
import { useIsPdf } from 'src/hooks/useIsPdf';
import { isAxiosError } from 'src/utils/isAxiosError';
import { HttpStatusCodes } from 'src/utils/network/networking';
import type { SchemaLookupTool } from 'src/features/datamodel/useDataModelSchemaQuery';
import type { BackendValidationIssue, IExpressionValidations } from 'src/features/validation';
import type { IExpressionValidations } from 'src/features/validation';
import type { IDataModelReference } from 'src/layout/common.generated';

interface DataModelsState {
Expand All @@ -39,7 +37,6 @@ interface DataModelsState {
writableDataTypes: string[] | null;
initialData: { [dataType: string]: object };
dataElementIds: { [dataType: string]: string | null };
initialValidations: BackendValidationIssue[] | null;
schemas: { [dataType: string]: JSONSchema7 };
schemaLookup: { [dataType: string]: SchemaLookupTool };
expressionValidationConfigs: { [dataType: string]: IExpressionValidations | null };
Expand All @@ -49,7 +46,6 @@ interface DataModelsState {
interface DataModelsMethods {
setDataTypes: (allDataTypes: string[], writableDataTypes: string[], defaultDataType: string | undefined) => void;
setInitialData: (dataType: string, initialData: object, dataElementId: string | null) => void;
setInitialValidations: (initialValidations: BackendValidationIssue[]) => void;
setDataModelSchema: (dataType: string, schema: JSONSchema7, lookupTool: SchemaLookupTool) => void;
setExpressionValidationConfig: (dataType: string, config: IExpressionValidations | null) => void;
setError: (error: Error) => void;
Expand Down Expand Up @@ -83,7 +79,6 @@ function initialCreateStore() {
},
}));
},
setInitialValidations: (initialValidations) => set({ initialValidations }),
setDataModelSchema: (dataType, schema, lookupTool) => {
set((state) => ({
schemas: {
Expand Down Expand Up @@ -187,7 +182,6 @@ function DataModelsLoader() {
<LoadSchema dataType={dataType} />
</React.Fragment>
))}
<LoadInitialValidations />
{writableDataTypes?.map((dataType) => (
<React.Fragment key={dataType}>
<LoadExpressionValidationConfig dataType={dataType} />
Expand All @@ -198,17 +192,10 @@ function DataModelsLoader() {
}

function BlockUntilLoaded({ children }: PropsWithChildren) {
const {
allDataTypes,
writableDataTypes,
initialData,
initialValidations,
schemas,
expressionValidationConfigs,
error,
} = useSelector((state) => state);
const { allDataTypes, writableDataTypes, initialData, schemas, expressionValidationConfigs, error } = useSelector(
(state) => state,
);
bjosttveit marked this conversation as resolved.
Show resolved Hide resolved
const isPDF = useIsPdf();
const shouldValidateInitial = useShouldValidateInitial();
const isLoadingFormData = useIsLoadingFormData();

if (error) {
Expand Down Expand Up @@ -241,10 +228,6 @@ function BlockUntilLoaded({ children }: PropsWithChildren) {
}
}

if (shouldValidateInitial && !initialValidations) {
return <Loader reason='initial-validations' />;
}

for (const dataType of writableDataTypes) {
if (!isPDF && !Object.keys(expressionValidationConfigs).includes(dataType)) {
return <Loader reason='expression-validation-config' />;
Expand Down Expand Up @@ -291,29 +274,6 @@ function LoadInitialData({ dataType }: LoaderProps) {
return null;
}

function LoadInitialValidations() {
const setInitialValidations = useSelector((state) => state.setInitialValidations);
const setError = useSelector((state) => state.setError);
// No need to load validations in PDF or stateless apps
const isStateless = useApplicationMetadata().isStatelessApp;
const enabled = useShouldValidateInitial();
const { data, error } = useBackendValidationQuery(enabled);

useEffect(() => {
if (isStateless) {
setInitialValidations([]);
} else if (data) {
setInitialValidations(data);
}
}, [data, isStateless, setInitialValidations]);

useEffect(() => {
error && setError(error);
}, [error, setError]);

return null;
}

function LoadSchema({ dataType }: LoaderProps) {
const setDataModelSchema = useSelector((state) => state.setDataModelSchema);
const setError = useSelector((state) => state.setError);
Expand Down Expand Up @@ -365,8 +325,6 @@ export const DataModels = {

useWritableDataTypes: () => useMemoSelector((state) => state.writableDataTypes!),

useInitialValidations: () => useMemoSelector((state) => state.initialValidations),

useDataModelSchema: (dataType: string) => useSelector((state) => state.schemas[dataType]),

useLookupBinding: () => {
Expand Down
4 changes: 3 additions & 1 deletion src/features/formData/FormDataWrite.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import { ALTINN_ROW_ID } from 'src/features/formData/types';
import { getFormDataQueryKey } from 'src/features/formData/useFormDataQuery';
import { useLaxInstance } from 'src/features/instance/InstanceContext';
import { type BackendValidationIssueGroups, IgnoredValidators } from 'src/features/validation';
import { useIsUpdatingInitialValidations } from 'src/features/validation/backendValidation/backendValidationQuery';
import { useAsRef } from 'src/hooks/useAsRef';
import { useWaitForState } from 'src/hooks/useWaitForState';
import { doPatchMultipleFormData } from 'src/queries/queries';
Expand Down Expand Up @@ -312,6 +313,7 @@ function FormDataEffects() {

const { mutate: performSave, error } = useFormDataSaveMutation();
const isSaving = useIsSaving();
const isUpdatingInitialValidaitons = useIsUpdatingInitialValidations();
const debounce = useDebounceImmediately();
const hasUnsavedChangesNow = useHasUnsavedChangesNow();

Expand Down Expand Up @@ -356,7 +358,7 @@ function FormDataEffects() {

// Save the data model when the data has been frozen/debounced, and we're ready
const needsToSave = useSelector(hasDebouncedUnsavedChanges);
const canSaveNow = !isSaving && !lockedBy;
const canSaveNow = !isSaving && !lockedBy && !isUpdatingInitialValidaitons;
const shouldSave = (needsToSave && canSaveNow && autoSaving) || manualSaveRequested;

useEffect(() => {
Expand Down
19 changes: 7 additions & 12 deletions src/features/instance/ProcessNavigationContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import { useHasPendingAttachments } from 'src/features/attachments/hooks';
import { useLaxInstance, useStrictInstance } from 'src/features/instance/InstanceContext';
import { useLaxProcessData, useSetProcessData } from 'src/features/instance/ProcessContext';
import { useCurrentLanguage } from 'src/features/language/LanguageProvider';
import { mapBackendIssuesToTaskValidations } from 'src/features/validation/backendValidation/backendValidationUtils';
import { useUpdateInitialValidations } from 'src/features/validation/backendValidation/backendValidationQuery';
import { useOnFormSubmitValidation } from 'src/features/validation/callbacks/onFormSubmitValidation';
import { Validation } from 'src/features/validation/validationContext';
import { useNavigatePage } from 'src/hooks/useNavigatePage';
Expand All @@ -33,7 +33,8 @@ function useProcessNext() {
const { navigateToTask } = useNavigatePage();
const instanceId = useLaxInstance()?.instanceId;
const onFormSubmitValidation = useOnFormSubmitValidation();
const updateTaskValidations = Validation.useUpdateTaskValidations();
const updateInitialValidations = useUpdateInitialValidations();
const setShowAllErrors = Validation.useSetShowAllErrors();

const utils = useMutation({
mutationFn: async ({ action }: ProcessNextProps = {}) => {
Expand All @@ -45,14 +46,6 @@ function useProcessNext() {
.catch((error) => {
// If process next failed due to validation, return validationIssues instead of throwing
if (error.response?.status === 409 && error.response?.data?.['validationIssues']?.length) {
if (updateTaskValidations === ContextNotProvided) {
window.logError(
"PUT 'process/next' returned validation issues, but there is no ValidationProvider available.",
);
throw error;
}

// Return validation issues
return [null, error.response.data['validationIssues'] as BackendValidationIssue[]] as const;
} else {
throw error;
Expand All @@ -64,8 +57,10 @@ function useProcessNext() {
await reFetchInstanceData();
setProcessData?.({ ...processData, processTasks: currentProcessData?.processTasks });
navigateToTask(processData?.currentTask?.elementId);
} else if (validationIssues && updateTaskValidations !== ContextNotProvided) {
updateTaskValidations(mapBackendIssuesToTaskValidations(validationIssues));
} else if (validationIssues) {
// Set initial validation to validation issues from process/next and make all errors visible
updateInitialValidations(validationIssues);
setShowAllErrors !== ContextNotProvided && setShowAllErrors();
}
},
onError: (error: HttpClientError) => {
Expand Down
53 changes: 34 additions & 19 deletions src/features/validation/backendValidation/BackendValidation.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,38 +4,37 @@ import deepEqual from 'fast-deep-equal';

import { DataModels } from 'src/features/datamodel/DataModelsProvider';
import { FD } from 'src/features/formData/FormDataWrite';
import {
type BackendFieldValidatorGroups,
type BuiltInValidationIssueSources,
IgnoredValidators,
} from 'src/features/validation';
import { type BackendFieldValidatorGroups } from 'src/features/validation';
import { useBackendValidationQuery } from 'src/features/validation/backendValidation/backendValidationQuery';
import {
mapBackendIssuesToFieldValdiations,
mapBackendIssuesToTaskValidations,
mapValidatorGroupsToDataModelValidations,
useShouldValidateInitial,
} from 'src/features/validation/backendValidation/backendValidationUtils';
import { Validation } from 'src/features/validation/validationContext';

const emptyObject = {};
const emptyArray = [];

export function BackendValidation({ dataTypes }: { dataTypes: string[] }) {
const updateBackendValidations = Validation.useUpdateBackendValidations();
const getDataTypeForElementId = DataModels.useGetDataTypeForDataElementId();
const lastSaveValidations = FD.useLastSaveValidationIssues();
const validatorGroups = useRef<BackendFieldValidatorGroups>({});

// Map initial validations
const initialValidations = DataModels.useInitialValidations();
const enabled = useShouldValidateInitial();
const { data: initialValidations, isFetching } = useBackendValidationQuery(enabled);
const initialValidatorGroups: BackendFieldValidatorGroups = useMemo(() => {
if (!initialValidations) {
return {};
return emptyObject;
}
// Note that we completely ignore task validations (validations not related to form data) on initial validations,
// this is because validations like minimum number of attachments in application metadata is not really useful to show initially
const fieldValidations = mapBackendIssuesToFieldValdiations(initialValidations, getDataTypeForElementId);
const validatorGroups: BackendFieldValidatorGroups = {};
for (const validation of fieldValidations) {
// Do not include ignored ignored validators in initial validations
if (IgnoredValidators.includes(validation.source as BuiltInValidationIssueSources)) {
continue;
}

if (!validatorGroups[validation.source]) {
validatorGroups[validation.source] = [];
}
Expand All @@ -44,13 +43,29 @@ export function BackendValidation({ dataTypes }: { dataTypes: string[] }) {
return validatorGroups;
}, [getDataTypeForElementId, initialValidations]);

// Map task validations
const initialTaskValidations = useMemo(() => {
if (!initialValidations) {
return emptyArray;
}
return mapBackendIssuesToTaskValidations(initialValidations);
}, [initialValidations]);

// Initial validation
useEffect(() => {
const backendValidations = mapValidatorGroupsToDataModelValidations(initialValidatorGroups, dataTypes);
updateBackendValidations(backendValidations, initialValidatorGroups);
}, [dataTypes, initialValidatorGroups, updateBackendValidations]);

const validatorGroups = useRef<BackendFieldValidatorGroups>(initialValidatorGroups);
if (!isFetching) {
validatorGroups.current = initialValidatorGroups;
const backendValidations = mapValidatorGroupsToDataModelValidations(initialValidatorGroups, dataTypes);
updateBackendValidations(backendValidations, { initial: initialValidations }, initialTaskValidations);
}
}, [
dataTypes,
initialTaskValidations,
initialValidations,
initialValidatorGroups,
isFetching,
updateBackendValidations,
]);

// Incremental validation: Update validators and propagate changes to validationcontext
useEffect(() => {
Expand All @@ -63,13 +78,13 @@ export function BackendValidation({ dataTypes }: { dataTypes: string[] }) {

if (deepEqual(validatorGroups.current, newValidatorGroups)) {
// Dont update any validations, only set last saved validations
updateBackendValidations(undefined, lastSaveValidations);
updateBackendValidations(undefined, { incremental: lastSaveValidations });
return;
}

validatorGroups.current = newValidatorGroups;
const backendValidations = mapValidatorGroupsToDataModelValidations(validatorGroups.current, dataTypes);
updateBackendValidations(backendValidations, lastSaveValidations);
updateBackendValidations(backendValidations, { incremental: lastSaveValidations });
}
}, [dataTypes, getDataTypeForElementId, lastSaveValidations, updateBackendValidations]);

Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { useEffect } from 'react';
import { useCallback, useEffect } from 'react';

import { useQuery } from '@tanstack/react-query';
import { useIsFetching, useQuery, useQueryClient } from '@tanstack/react-query';

import type { BackendValidationIssue } from '..';

Expand All @@ -26,6 +26,51 @@ export function useBackendValidationQueryDef(
};
}

export function useGetCachedInitialValidations() {
const instance = useLaxInstance();
const instanceId = instance?.instanceId;
const currentProcessTaskId = useLaxProcessData()?.currentTask?.elementId;
const client = useQueryClient();

return useCallback(() => {
const queryKey = ['validation', instanceId, currentProcessTaskId, true];
return {
isFetching: client.isFetching({ queryKey }),
cachedInitialValidations: client.getQueryData(queryKey),
};
}, [client, currentProcessTaskId, instanceId]);
}

export function useUpdateInitialValidations() {
const instance = useLaxInstance();
const instanceId = instance?.instanceId;
const currentProcessTaskId = useLaxProcessData()?.currentTask?.elementId;
const client = useQueryClient();

return useCallback(
(validations: BackendValidationIssue[]) => {
client.setQueryData(['validation', instanceId, currentProcessTaskId, true], validations);
},
[client, currentProcessTaskId, instanceId],
);
}

export function useIsUpdatingInitialValidations() {
return useIsFetching({ queryKey: ['validation'] }) > 0;
}

export function useInvalidateInitialValidations() {
const instance = useLaxInstance();
const instanceId = instance?.instanceId;
const currentProcessTaskId = useLaxProcessData()?.currentTask?.elementId;
const client = useQueryClient();

return useCallback(
() => client.invalidateQueries({ queryKey: ['validation', instanceId, currentProcessTaskId, true] }),
[client, currentProcessTaskId, instanceId],
);
}

export function useBackendValidationQuery(enabled: boolean) {
const currentLanguage = useCurrentLanguage();
const instance = useLaxInstance();
Expand Down
Loading
Loading