Skip to content

Commit

Permalink
refactor: Simplify file uploader component (#13804)
Browse files Browse the repository at this point in the history
Co-authored-by: Konrad-Simso <konrad.simso@digdir.no>
  • Loading branch information
TomasEng and Konrad-Simso authored Oct 16, 2024
1 parent 1b47df6 commit 4cc0977
Show file tree
Hide file tree
Showing 19 changed files with 375 additions and 293 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export type FileNameError = 'invalidFileName' | 'fileExists';
Original file line number Diff line number Diff line change
Expand Up @@ -6,21 +6,21 @@ import { textMock } from '@studio/testing/mocks/i18nMock';
import type { QueryClient } from '@tanstack/react-query';
import { createQueryClientMock } from 'app-shared/mocks/queryClientMock';
import { app, org } from '@studio/testing/testids';
import { renderWithProviders } from '../../../../test/mocks';
import { renderWithProviders } from '../../../../../test/mocks';
import type { ServicesContextProps } from 'app-shared/contexts/ServicesContext';
import { createApiErrorMock } from 'app-shared/mocks/apiErrorMock';
import { QueryKey } from 'app-shared/types/QueryKey';
import { queriesMock } from 'app-shared/mocks/queriesMock';

const user = userEvent.setup();

jest.mock('../../../../hooks/mutations/useUploadDataModelMutation', () => ({
jest.mock('../../../../../hooks/mutations/useUploadDataModelMutation', () => ({
__esModule: true,
...jest.requireActual('../../../../hooks/mutations/useUploadDataModelMutation'),
...jest.requireActual('../../../../../hooks/mutations/useUploadDataModelMutation'),
}));

const useUploadDataModelMutationSpy = jest.spyOn(
require('../../../../hooks/mutations/useUploadDataModelMutation'),
require('../../../../../hooks/mutations/useUploadDataModelMutation'),
'useUploadDataModelMutation',
);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,21 @@ import React from 'react';
import type { StudioButtonProps } from '@studio/components';
import { StudioFileUploader, StudioSpinner } from '@studio/components';
import { useTranslation } from 'react-i18next';
import { useUploadDataModelMutation } from '../../../../hooks/mutations/useUploadDataModelMutation';
import { useUploadDataModelMutation } from '../../../../../hooks/mutations/useUploadDataModelMutation';
import type { AxiosError } from 'axios';
import type { ApiError } from 'app-shared/types/api/ApiError';
import { toast } from 'react-toastify';
import type { MetadataOption } from '../../../../types/MetadataOption';
import type { MetadataOption } from '../../../../../types/MetadataOption';
import { useStudioEnvironmentParams } from 'app-shared/hooks/useStudioEnvironmentParams';
import { useAppMetadataQuery } from 'app-shared/hooks/queries';
import { useValidateFileName } from './useValidateFileName';
import { useValidationAlert } from './useValidationAlert';
import { removeExtension } from 'app-shared/utils/filenameUtils';
import {
doesFileExistInMetadataWithClassRef,
doesFileExistInMetadataWithoutClassRef,
findFileNameError,
} from './validationUtils';
import type { FileNameError } from './FileNameError';

export interface XSDUploadProps {
selectedOption?: MetadataOption;
Expand All @@ -32,36 +38,43 @@ export const XSDUpload = ({
hideDefaultError: (error: AxiosError<ApiError>) => !error.response?.data?.errorCode,
},
);
const {
validateFileName,
getDuplicatedDataTypeIdNotBeingDataModelInAppMetadata,
getDuplicatedDataModelIdsInAppMetadata,
} = useValidateFileName(appMetadata);
const validationAlert = useValidationAlert();

const uploadButton = React.useRef(null);

const handleUpload = (formData: FormData) => {
uploadDataModel(formData, {
const handleSubmit = (file: File): void => {
const fileNameError = findFileNameError(file.name, appMetadata);
if (fileNameError) {
handleInvalidFileName(file, fileNameError);
uploadButton.current.value = '';
} else {
handleUpload(file);
}
};

const handleUpload = (file: File): void => {
uploadDataModel(file, {
onError: (error: AxiosError<ApiError>) => {
if (!error.response?.data?.errorCode)
toast.error(t('form_filler.file_uploader_validation_error_upload'));
},
});
};

const handleInvalidFileName = (file?: FormData, fileName?: string) => {
const fileNameWithoutExtension = removeExtension(fileName);
if (getDuplicatedDataModelIdsInAppMetadata(appMetadata, fileNameWithoutExtension)) {
const handleInvalidFileName = (file: File, fileNameError: FileNameError): void => {
if (fileNameError) {
validationAlert(fileNameError);
}
const fileNameWithoutExtension = removeExtension(file.name);
if (doesFileExistInMetadataWithClassRef(appMetadata, fileNameWithoutExtension)) {
const userConfirmed = window.confirm(
t('schema_editor.error_upload_data_model_id_exists_override_option'),
);
if (userConfirmed) {
uploadDataModel(file);
}
}
if (
getDuplicatedDataTypeIdNotBeingDataModelInAppMetadata(appMetadata, fileNameWithoutExtension)
) {
if (doesFileExistInMetadataWithoutClassRef(appMetadata, fileNameWithoutExtension)) {
// Only show error if there are duplicates that does not have AppLogic.classRef
toast.error(t('schema_editor.error_data_type_name_exists'));
}
Expand All @@ -73,14 +86,10 @@ export const XSDUpload = ({
<StudioSpinner spinnerTitle={t('app_data_modelling.uploading_xsd')} showSpinnerTitle />
) : (
<StudioFileUploader
onUploadFile={handleUpload}
onSubmit={handleSubmit}
accept='.xsd'
variant={uploaderButtonVariant}
uploaderButtonText={uploadButtonText}
customFileValidation={{
validateFileName: validateFileName,
onInvalidFileName: handleInvalidFileName,
}}
/>
)}
</span>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './XSDUpload';
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { useTranslation } from 'react-i18next';
import { useCallback } from 'react';
import type { FileNameError } from './FileNameError';

export const useValidationAlert = () => {
const { t } = useTranslation();

return useCallback(
(error: FileNameError): void => {
if (error === 'invalidFileName') {
alert(t('app_data_modelling.upload_xsd_invalid_name_error'));
}
},
[t],
);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import type { ApplicationMetadata } from 'app-shared/types/ApplicationMetadata';
import { removeExtension } from 'app-shared/utils/filenameUtils';
import type { FileNameError } from './FileNameError';

export const doesFileExistInMetadataWithClassRef = (
appMetadata: ApplicationMetadata,
fileNameWithoutExtension: string,
): boolean => {
return Boolean(
appMetadata.dataTypes
?.filter((dataType) => dataType.appLogic?.classRef !== undefined)
.find((dataType) => dataType.id === fileNameWithoutExtension),
);
};

export const doesFileExistInMetadataWithoutClassRef = (
appMetadata: ApplicationMetadata,
fileNameWithoutExtension: string,
): boolean => {
return Boolean(
appMetadata.dataTypes
?.filter((dataType) => dataType.appLogic?.classRef === undefined)
.find((dataType) => dataType.id.toLowerCase() === fileNameWithoutExtension.toLowerCase()),
);
};

export const findFileNameError = (
fileName: string,
appMetadata: ApplicationMetadata,
): FileNameError | null => {
const fileNameWithoutExtension = removeExtension(fileName);
if (!isNameFormatValid(fileNameWithoutExtension)) {
return 'invalidFileName';
} else if (doesFileExistInMetadata(appMetadata, fileNameWithoutExtension)) {
return 'fileExists';
} else {
return null;
}
};

const isNameFormatValid = (fileNameWithoutExtension: string): boolean => {
const fileNameRegex: RegExp = /^[a-zA-Z][a-zA-Z0-9_.\-æÆøØåÅ ]*$/;
return Boolean(fileNameWithoutExtension.match(fileNameRegex));
};

const doesFileExistInMetadata = (
appMetadata: ApplicationMetadata,
fileNameWithoutExtension: string,
): boolean => appMetadata.dataTypes?.some((dataType) => dataType.id === fileNameWithoutExtension);

This file was deleted.

Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { QueryKey } from 'app-shared/types/QueryKey';
import { createQueryClientMock } from 'app-shared/mocks/queryClientMock';
import type { QueryClient } from '@tanstack/react-query';
import { app, org } from '@studio/testing/testids';
import Mock = jest.Mock;

// Test data:
const file = new File(['hello'], 'hello.xsd', { type: 'text/xml' });
Expand All @@ -30,7 +31,12 @@ describe('useUploadDataModelMutation', () => {
await renderHook();

expect(queriesMock.uploadDataModel).toHaveBeenCalledTimes(1);
expect(queriesMock.uploadDataModel).toHaveBeenCalledWith(org, app, file);
const parameters = (queriesMock.uploadDataModel as Mock).mock.calls[0];
const [orgParam, appParam, formDataParam] = parameters;
expect(orgParam).toBe(org);
expect(appParam).toBe(app);
expect(formDataParam).toBeInstanceOf(FormData);
expect(formDataParam.get('file')).toBe(file);
});

it('invalidates metadata queries when upload is successful', async () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,14 @@ export const useUploadDataModelMutation = (modelPath?: string, meta?: MutationMe
const { uploadDataModel } = useServicesContext();
const { org, app } = useStudioEnvironmentParams();
const queryClient = useQueryClient();

const mutationFn = (file: File) => {
const formData = createFormDataWithFile(file);
return uploadDataModel(org, app, formData);
};

return useMutation({
mutationFn: (file: FormData) => uploadDataModel(org, app, file),
mutationFn,
onSuccess: async () => {
await Promise.all([
queryClient.invalidateQueries({ queryKey: [QueryKey.DataModelsJson, org, app] }),
Expand All @@ -23,3 +29,9 @@ export const useUploadDataModelMutation = (modelPath?: string, meta?: MutationMe
meta,
});
};

const createFormDataWithFile = (file: File): FormData => {
const formData = new FormData();
formData.append('file', file);
return formData;
};
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
import React from 'react';
import type { Meta, StoryFn } from '@storybook/react';
import { StudioFileUploadWrapper } from './StudioFileUploaderWrapper';
import { StudioFileUploader } from './StudioFileUploader';

type Story = StoryFn<typeof StudioFileUploadWrapper>;
type Story = StoryFn<typeof StudioFileUploader>;

const meta: Meta = {
title: 'Components/StudioFileUploader',
component: StudioFileUploadWrapper,
component: StudioFileUploader,
argTypes: {
size: {
control: 'select',
Expand All @@ -23,43 +23,20 @@ const meta: Meta = {
control: 'text',
type: 'string',
},
validateFileName: {
control: 'boolean',
description:
'Set to `true` to simulate that the file name is valid. Set to `false` to simulate that file name is invalid.',
},
onInvalidFileName: {
control: 'boolean',
description:
'Set to `true` to simulate that an invalid file name is handled. Set to `false` to simulate no file name validation handling.',
},
fileSizeLimitMb: {
control: 'number',
description: 'Set to a number of MB that is the maximum allowed to upoload.',
},
onInvalidFileSize: {
control: 'boolean',
description:
'Set to `true` to simulate that an invalid file size is handled. Set to `false` to simulate no file size validation handling.',
},
onUploadFile: {
onSubmit: {
table: { disable: true },
},
},
};

export const Preview: Story = (args): React.ReactElement => {
return <StudioFileUploadWrapper {...args} />;
return <StudioFileUploader {...args} />;
};

Preview.args = {
uploaderButtonText: 'Last opp fil',
variant: 'tertiary',
onUploadFile: () => {},
validateFileName: true,
onInvalidFileName: false,
fileSizeLimitMb: 1,
onInvalidFileSize: false,
onSubmit: () => {},
disabled: false,
};
export default meta;
Loading

0 comments on commit 4cc0977

Please sign in to comment.