diff --git a/frontend/app-development/features/dataModelling/SchemaEditorWithToolbar/TopToolbar/XSDUpload/FileNameError.ts b/frontend/app-development/features/dataModelling/SchemaEditorWithToolbar/TopToolbar/XSDUpload/FileNameError.ts new file mode 100644 index 00000000000..eb7520a15de --- /dev/null +++ b/frontend/app-development/features/dataModelling/SchemaEditorWithToolbar/TopToolbar/XSDUpload/FileNameError.ts @@ -0,0 +1 @@ +export type FileNameError = 'invalidFileName' | 'fileExists'; diff --git a/frontend/app-development/features/dataModelling/SchemaEditorWithToolbar/TopToolbar/XSDUpload.test.tsx b/frontend/app-development/features/dataModelling/SchemaEditorWithToolbar/TopToolbar/XSDUpload/XSDUpload.test.tsx similarity index 95% rename from frontend/app-development/features/dataModelling/SchemaEditorWithToolbar/TopToolbar/XSDUpload.test.tsx rename to frontend/app-development/features/dataModelling/SchemaEditorWithToolbar/TopToolbar/XSDUpload/XSDUpload.test.tsx index f210f2e926b..af38ca89f6e 100644 --- a/frontend/app-development/features/dataModelling/SchemaEditorWithToolbar/TopToolbar/XSDUpload.test.tsx +++ b/frontend/app-development/features/dataModelling/SchemaEditorWithToolbar/TopToolbar/XSDUpload/XSDUpload.test.tsx @@ -6,7 +6,7 @@ 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'; @@ -14,13 +14,13 @@ 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', ); diff --git a/frontend/app-development/features/dataModelling/SchemaEditorWithToolbar/TopToolbar/XSDUpload.tsx b/frontend/app-development/features/dataModelling/SchemaEditorWithToolbar/TopToolbar/XSDUpload/XSDUpload.tsx similarity index 64% rename from frontend/app-development/features/dataModelling/SchemaEditorWithToolbar/TopToolbar/XSDUpload.tsx rename to frontend/app-development/features/dataModelling/SchemaEditorWithToolbar/TopToolbar/XSDUpload/XSDUpload.tsx index 72e7d26edfd..5919dfbd758 100644 --- a/frontend/app-development/features/dataModelling/SchemaEditorWithToolbar/TopToolbar/XSDUpload.tsx +++ b/frontend/app-development/features/dataModelling/SchemaEditorWithToolbar/TopToolbar/XSDUpload/XSDUpload.tsx @@ -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; @@ -32,16 +38,22 @@ export const XSDUpload = ({ hideDefaultError: (error: AxiosError) => !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) => { if (!error.response?.data?.errorCode) toast.error(t('form_filler.file_uploader_validation_error_upload')); @@ -49,9 +61,12 @@ export const XSDUpload = ({ }); }; - 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'), ); @@ -59,9 +74,7 @@ export const XSDUpload = ({ 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')); } @@ -73,14 +86,10 @@ export const XSDUpload = ({ ) : ( )} diff --git a/frontend/app-development/features/dataModelling/SchemaEditorWithToolbar/TopToolbar/XSDUpload/index.ts b/frontend/app-development/features/dataModelling/SchemaEditorWithToolbar/TopToolbar/XSDUpload/index.ts new file mode 100644 index 00000000000..a4bde7e8040 --- /dev/null +++ b/frontend/app-development/features/dataModelling/SchemaEditorWithToolbar/TopToolbar/XSDUpload/index.ts @@ -0,0 +1 @@ +export * from './XSDUpload'; diff --git a/frontend/app-development/features/dataModelling/SchemaEditorWithToolbar/TopToolbar/XSDUpload/useValidationAlert.ts b/frontend/app-development/features/dataModelling/SchemaEditorWithToolbar/TopToolbar/XSDUpload/useValidationAlert.ts new file mode 100644 index 00000000000..8deda17be23 --- /dev/null +++ b/frontend/app-development/features/dataModelling/SchemaEditorWithToolbar/TopToolbar/XSDUpload/useValidationAlert.ts @@ -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], + ); +}; diff --git a/frontend/app-development/features/dataModelling/SchemaEditorWithToolbar/TopToolbar/XSDUpload/validationUtils.ts b/frontend/app-development/features/dataModelling/SchemaEditorWithToolbar/TopToolbar/XSDUpload/validationUtils.ts new file mode 100644 index 00000000000..768ef1ee4d6 --- /dev/null +++ b/frontend/app-development/features/dataModelling/SchemaEditorWithToolbar/TopToolbar/XSDUpload/validationUtils.ts @@ -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); diff --git a/frontend/app-development/features/dataModelling/SchemaEditorWithToolbar/TopToolbar/useValidateFileName.ts b/frontend/app-development/features/dataModelling/SchemaEditorWithToolbar/TopToolbar/useValidateFileName.ts deleted file mode 100644 index 378bd4a4de8..00000000000 --- a/frontend/app-development/features/dataModelling/SchemaEditorWithToolbar/TopToolbar/useValidateFileName.ts +++ /dev/null @@ -1,49 +0,0 @@ -import type { ApplicationMetadata } from 'app-shared/types/ApplicationMetadata'; -import { removeExtension } from 'app-shared/utils/filenameUtils'; -import { useTranslation } from 'react-i18next'; - -export const useValidateFileName = (appMetadata: ApplicationMetadata) => { - const { t } = useTranslation(); - - const fileNameRegEx: RegExp = /^[a-zA-Z][a-zA-Z0-9_.\-æÆøØåÅ ]*$/; - const validateFileName = (fileName: string): boolean => { - const fileNameWithoutExtension = removeExtension(fileName); - const nameFollowsRegexRules = Boolean(fileName.match(fileNameRegEx)); - - if (!nameFollowsRegexRules) { - alert(t('app_data_modelling.upload_xsd_invalid_name_error')); - return false; - } - return !Boolean( - appMetadata.dataTypes?.find((dataType) => dataType.id === fileNameWithoutExtension), - ); - }; - - const getDuplicatedDataModelIdsInAppMetadata = ( - appMetadata: ApplicationMetadata, - fileNameWithoutExtension: string, - ): boolean => { - return Boolean( - appMetadata.dataTypes - ?.filter((dataType) => dataType.appLogic?.classRef !== undefined) - .find((dataType) => dataType.id === fileNameWithoutExtension), - ); - }; - - const getDuplicatedDataTypeIdNotBeingDataModelInAppMetadata = ( - appMetadata: ApplicationMetadata, - fileNameWithoutExtension: string, - ): boolean => { - return Boolean( - appMetadata.dataTypes - ?.filter((dataType) => dataType.appLogic?.classRef === undefined) - .find((dataType) => dataType.id.toLowerCase() === fileNameWithoutExtension.toLowerCase()), - ); - }; - - return { - validateFileName, - getDuplicatedDataModelIdsInAppMetadata, - getDuplicatedDataTypeIdNotBeingDataModelInAppMetadata, - }; -}; diff --git a/frontend/app-development/hooks/mutations/useUploadDataModelMutation.test.ts b/frontend/app-development/hooks/mutations/useUploadDataModelMutation.test.ts index 09b246f072d..963ef5b1b16 100644 --- a/frontend/app-development/hooks/mutations/useUploadDataModelMutation.test.ts +++ b/frontend/app-development/hooks/mutations/useUploadDataModelMutation.test.ts @@ -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' }); @@ -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 () => { diff --git a/frontend/app-development/hooks/mutations/useUploadDataModelMutation.ts b/frontend/app-development/hooks/mutations/useUploadDataModelMutation.ts index e98677a9118..5a00fc129b7 100644 --- a/frontend/app-development/hooks/mutations/useUploadDataModelMutation.ts +++ b/frontend/app-development/hooks/mutations/useUploadDataModelMutation.ts @@ -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] }), @@ -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; +}; diff --git a/frontend/libs/studio-components/src/components/StudioFileUploader/StudioFileUploader.stories.tsx b/frontend/libs/studio-components/src/components/StudioFileUploader/StudioFileUploader.stories.tsx index 7e87b10c8b5..7ca14c3435e 100644 --- a/frontend/libs/studio-components/src/components/StudioFileUploader/StudioFileUploader.stories.tsx +++ b/frontend/libs/studio-components/src/components/StudioFileUploader/StudioFileUploader.stories.tsx @@ -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; +type Story = StoryFn; const meta: Meta = { title: 'Components/StudioFileUploader', - component: StudioFileUploadWrapper, + component: StudioFileUploader, argTypes: { size: { control: 'select', @@ -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 ; + return ; }; Preview.args = { uploaderButtonText: 'Last opp fil', variant: 'tertiary', - onUploadFile: () => {}, - validateFileName: true, - onInvalidFileName: false, - fileSizeLimitMb: 1, - onInvalidFileSize: false, + onSubmit: () => {}, disabled: false, }; export default meta; diff --git a/frontend/libs/studio-components/src/components/StudioFileUploader/StudioFileUploader.test.tsx b/frontend/libs/studio-components/src/components/StudioFileUploader/StudioFileUploader.test.tsx index 0432ad70894..26136f4c942 100644 --- a/frontend/libs/studio-components/src/components/StudioFileUploader/StudioFileUploader.test.tsx +++ b/frontend/libs/studio-components/src/components/StudioFileUploader/StudioFileUploader.test.tsx @@ -2,8 +2,8 @@ import type { ForwardedRef } from 'react'; import React from 'react'; import type { RenderResult } from '@testing-library/react'; import { render, screen } from '@testing-library/react'; -import type { FileValidation, StudioFileUploaderProps } from './StudioFileUploader'; -import { BITS_IN_A_MEGA_BYTE, StudioFileUploader } from './StudioFileUploader'; +import type { StudioFileUploaderProps } from './'; +import { StudioFileUploader } from './'; import userEvent from '@testing-library/user-event'; import { testRootClassNameAppending } from '../../test-utils/testRootClassNameAppending'; import { testCustomAttributes } from '../../test-utils/testCustomAttributes'; @@ -11,9 +11,9 @@ import { testRefForwarding } from '../../test-utils/testRefForwarding'; // Test data: const uploaderButtonText = 'Upload file'; -const onUploadFile = jest.fn(); +const onSubmit = jest.fn(); const defaultProps: StudioFileUploaderProps = { - onUploadFile, + onSubmit, uploaderButtonText, }; @@ -37,10 +37,8 @@ describe('StudioFileUploader', () => { renderFileUploader(); const file = new File(['test'], fileNameMock, { type: 'image/png' }); await user.upload(getFileInputElement(), file); - const formDataMock = new FormData(); - formDataMock.append('file', file); - expect(onUploadFile).toHaveBeenCalledTimes(1); - expect(onUploadFile).toHaveBeenCalledWith(formDataMock, fileNameMock); + expect(onSubmit).toHaveBeenCalledTimes(1); + expect(onSubmit).toHaveBeenCalledWith(file); }); it('should render uploadButton as disabled and not trigger callback on upload when disabled prop is set', async () => { @@ -50,7 +48,7 @@ describe('StudioFileUploader', () => { const file = new File(['test'], 'fileNameMock', { type: 'image/png' }); await user.upload(getFileInputElement(), file); - expect(onUploadFile).not.toHaveBeenCalled(); + expect(onSubmit).not.toHaveBeenCalled(); }); it('should not do callback if uploaded file does not match provided accept prop', async () => { @@ -59,7 +57,7 @@ describe('StudioFileUploader', () => { renderFileUploader({ accept }); const file = new File(['test'], 'fileNameMock.someOtherExtension', { type: 'image/png' }); await user.upload(getFileInputElement(), file); - expect(onUploadFile).not.toHaveBeenCalled(); + expect(onSubmit).not.toHaveBeenCalled(); }); it('should do callback if uploaded file does match provided accept prop', async () => { @@ -68,86 +66,14 @@ describe('StudioFileUploader', () => { renderFileUploader({ accept }); const file = new File(['test'], `fileNameMock${accept}`, { type: 'image/png' }); await user.upload(getFileInputElement(), file); - expect(onUploadFile).toHaveBeenCalledTimes(1); + expect(onSubmit).toHaveBeenCalledTimes(1); }); - it('should validate file as valid if customFileNameValidation is not defined', async () => { - const user = userEvent.setup(); - renderFileUploader(); - const file = new File(['test'], 'fileNameMock', { type: 'image/png' }); - await user.upload(getFileInputElement(), file); - expect(onUploadFile).toHaveBeenCalledTimes(1); - }); - - it('should call onInvalidFileName and not upload callback when validateFileName returns false', async () => { - const user = userEvent.setup(); - const onInvalidFileName = jest.fn(); - const customFileValidation: FileValidation = { - validateFileName: () => false, - onInvalidFileName, - }; - renderFileUploader({ customFileValidation }); - const file = new File(['test'], 'fileNameMock', { type: 'image/png' }); - await user.upload(getFileInputElement(), file); - expect(onUploadFile).not.toHaveBeenCalled(); - expect(onInvalidFileName).toHaveBeenCalledTimes(1); - }); - - it('should not call onInvalidFileName and upload callback when validateFileName returns true', async () => { - const user = userEvent.setup(); - const onInvalidFileName = jest.fn(); - const customFileValidation: FileValidation = { - validateFileName: () => true, - onInvalidFileName, - }; - renderFileUploader({ customFileValidation }); - const file = new File(['test'], 'fileNameMock', { type: 'image/png' }); - await user.upload(getFileInputElement(), file); - expect(onUploadFile).toHaveBeenCalledTimes(1); - expect(onInvalidFileName).not.toHaveBeenCalled(); - }); - - it('should call onInvalidFileSize and not upload callback when fileSize is larger than fileSizeLimit', async () => { - const user = userEvent.setup(); - const onInvalidFileSize = jest.fn(); - const fileSizeLimitMb = 1; - const customFileValidation: FileValidation = { - onInvalidFileSize, - fileSizeLimitMb, - }; - renderFileUploader({ customFileValidation }); - const file = new File( - [new Blob([new Uint8Array(fileSizeLimitMb * BITS_IN_A_MEGA_BYTE + 1)])], - 'fileNameMock', - { type: 'image/png' }, - ); - await user.upload(getFileInputElement(), file); - expect(onUploadFile).not.toHaveBeenCalled(); - expect(onInvalidFileSize).toHaveBeenCalledTimes(1); - }); - - it('should not call onInvalidFileSize and upload callback when fileSize is smaller than fileSizeLimit', async () => { - const user = userEvent.setup(); - const onInvalidFileSize = jest.fn(); - const fileSizeLimitMb = 1; - const customFileValidation: FileValidation = { - onInvalidFileSize, - fileSizeLimitMb, - }; - renderFileUploader({ customFileValidation }); - const file = new File([new Uint8Array(fileSizeLimitMb * BITS_IN_A_MEGA_BYTE)], 'fileNameMock', { - type: 'image/png', - }); - await user.upload(getFileInputElement(), file); - expect(onUploadFile).toHaveBeenCalledTimes(1); - expect(onInvalidFileSize).not.toHaveBeenCalled(); - }); - - it('should not call upload callback when no file is uploaded', async () => { + it('should not call submit callback when no file is uploaded', async () => { const user = userEvent.setup(); renderFileUploader(); await user.upload(getFileInputElement(), undefined); - expect(onUploadFile).not.toHaveBeenCalled(); + expect(onSubmit).not.toHaveBeenCalled(); }); it('Applies given class name to the root element', () => { diff --git a/frontend/libs/studio-components/src/components/StudioFileUploader/StudioFileUploader.tsx b/frontend/libs/studio-components/src/components/StudioFileUploader/StudioFileUploader.tsx index 40013ecda0e..2cc85211447 100644 --- a/frontend/libs/studio-components/src/components/StudioFileUploader/StudioFileUploader.tsx +++ b/frontend/libs/studio-components/src/components/StudioFileUploader/StudioFileUploader.tsx @@ -6,21 +6,10 @@ import type { StudioButtonProps } from '../StudioButton'; import { StudioButton } from '../StudioButton'; import { useForwardedRef } from '@studio/hooks'; -const NUMBER_BITS_IN_A_BYTE = 1024; -export const BITS_IN_A_MEGA_BYTE = NUMBER_BITS_IN_A_BYTE * NUMBER_BITS_IN_A_BYTE; - -export type FileValidation = { - validateFileName?: (fileName: string) => boolean; - fileSizeLimitMb?: number; - onInvalidFileName?: (file?: FormData, fileName?: string) => void; - onInvalidFileSize?: () => void; -}; - export type StudioFileUploaderProps = { - onUploadFile: (file: FormData, fileName: string) => void; uploaderButtonText?: string; - customFileValidation?: FileValidation; -} & Omit, 'type' | 'size'> & + onSubmit?: (file: File) => void; +} & Omit, 'type' | 'size' | 'onSubmit'> & Pick; export const StudioFileUploader = forwardRef( @@ -28,9 +17,8 @@ export const StudioFileUploader = forwardRef) => { event?.preventDefault(); const file = getFile(internalRef); - if (isFileValid(file, internalRef, customFileValidation)) { - const formData = new FormData(); - formData.append('file', file); - onUploadFile(formData, file.name); + if (file) { + onSubmit?.(file); } }; @@ -84,28 +70,3 @@ export const StudioFileUploader = forwardRef): File => fileRef?.current?.files?.item(0); - -const isFileValid = ( - file: File, - fileRef: RefObject, - customFileValidation: FileValidation, -): boolean => { - if (!file) return false; - if (!customFileValidation) return true; - if (customFileValidation.validateFileName && !customFileValidation.validateFileName(file.name)) { - const formData = new FormData(); - formData.append('file', file); - customFileValidation.onInvalidFileName(formData, file.name); - fileRef.current.value = ''; - return false; - } - if ( - customFileValidation.fileSizeLimitMb && - file.size > customFileValidation.fileSizeLimitMb * BITS_IN_A_MEGA_BYTE - ) { - customFileValidation.onInvalidFileSize(); - fileRef.current.value = ''; - return false; - } - return true; -}; diff --git a/frontend/libs/studio-components/src/components/StudioFileUploader/StudioFileUploaderWrapper.tsx b/frontend/libs/studio-components/src/components/StudioFileUploader/StudioFileUploaderWrapper.tsx deleted file mode 100644 index 0a6f33b20c6..00000000000 --- a/frontend/libs/studio-components/src/components/StudioFileUploader/StudioFileUploaderWrapper.tsx +++ /dev/null @@ -1,56 +0,0 @@ -import React, { useState } from 'react'; -import type { StudioFileUploaderProps } from './StudioFileUploader'; -import { StudioFileUploader } from './StudioFileUploader'; -import { Alert } from '@digdir/designsystemet-react'; - -type StudioFileUploaderWrapperProps = Exclude & { - validateFileName: boolean; - onInvalidFileName: boolean; - fileSizeLimitMb: number; - onInvalidFileSize: boolean; -}; - -export const StudioFileUploadWrapper = ( - props: StudioFileUploaderWrapperProps, -): React.ReactElement => { - const [showFileNameError, setShowFileNameError] = useState(false); - const [showFileSizeError, setShowFileSizError] = useState(false); - - const handleFileNameValidation = () => { - props.onInvalidFileName && setShowFileNameError(!props.validateFileName); - }; - - const handleFileSizeValidation = () => { - props.onInvalidFileSize && setShowFileSizError(props.onInvalidFileSize); - }; - - const handleSuccessfulUpload = () => { - setShowFileNameError(false); - setShowFileSizError(false); - }; - - return ( - <> - props.validateFileName, - onInvalidFileName: handleFileNameValidation, - fileSizeLimitMb: props.fileSizeLimitMb, - onInvalidFileSize: handleFileSizeValidation, - }} - /> - {showFileNameError && ( - - {'File name invalidation was handled outside of the component'} - - )} - {showFileSizeError && ( - - {'File size invalidation was handled outside of the component'} - - )} - - ); -}; diff --git a/frontend/libs/studio-components/src/components/StudioFileUploader/index.ts b/frontend/libs/studio-components/src/components/StudioFileUploader/index.ts index 2904715f0da..13c9d5e4036 100644 --- a/frontend/libs/studio-components/src/components/StudioFileUploader/index.ts +++ b/frontend/libs/studio-components/src/components/StudioFileUploader/index.ts @@ -1 +1,2 @@ export { StudioFileUploader } from './StudioFileUploader'; +export type { StudioFileUploaderProps } from './StudioFileUploader'; diff --git a/frontend/libs/studio-components/src/test-utils/testCustomAttributes.ts b/frontend/libs/studio-components/src/test-utils/testCustomAttributes.ts index 773dcce4f59..5f882835b9f 100644 --- a/frontend/libs/studio-components/src/test-utils/testCustomAttributes.ts +++ b/frontend/libs/studio-components/src/test-utils/testCustomAttributes.ts @@ -8,7 +8,7 @@ type CustomAttributes = { export function testCustomAttributes< Element extends HTMLElement = HTMLElement, - Props extends HTMLAttributes = HTMLAttributes, + Props extends {} = HTMLAttributes, >( renderComponent: (customAttributes: Props) => RenderResult, getTargetElement: (container: RenderResult['container']) => Element = getRootElementFromContainer, diff --git a/frontend/packages/ux-editor/src/components/config/editModal/EditImage/LocalImage/ImportImage/UploadImage/FileUploaderWithValidation/FileUploaderWithValidation.test.tsx b/frontend/packages/ux-editor/src/components/config/editModal/EditImage/LocalImage/ImportImage/UploadImage/FileUploaderWithValidation/FileUploaderWithValidation.test.tsx new file mode 100644 index 00000000000..6d4fd614903 --- /dev/null +++ b/frontend/packages/ux-editor/src/components/config/editModal/EditImage/LocalImage/ImportImage/UploadImage/FileUploaderWithValidation/FileUploaderWithValidation.test.tsx @@ -0,0 +1,164 @@ +import React from 'react'; +import type { RenderResult } from '@testing-library/react'; +import { render, screen } from '@testing-library/react'; +import type { FileValidation, FileUploaderWithValidationProps } from './FileUploaderWithValidation'; +import { BYTES_IN_A_MEGA_BYTE, FileUploaderWithValidation } from './FileUploaderWithValidation'; +import userEvent from '@testing-library/user-event'; + +// Test data: +const uploaderButtonText = 'Upload file'; +const onUploadFile = jest.fn(); +const defaultProps: FileUploaderWithValidationProps = { + onUploadFile, + uploaderButtonText, +}; + +describe('FileUploaderWithValidation', () => { + afterEach(jest.clearAllMocks); + + it('should render only studioButton by default ', () => { + renderFileUploader({ uploaderButtonText: undefined }); + const uploadButton = screen.getByRole('button', { name: '' }); + expect(uploadButton).toBeInTheDocument(); + }); + + it('should render studioButton with buttonText when provided', () => { + renderFileUploader(); + expect(getUploadButton()).toBeInTheDocument(); + }); + + it('should send uploaded file in callback', async () => { + const user = userEvent.setup(); + const fileNameMock = 'fileNameMock'; + renderFileUploader(); + const file = new File(['test'], fileNameMock, { type: 'image/png' }); + await user.upload(getFileInputElement(), file); + const formDataMock = new FormData(); + formDataMock.append('file', file); + expect(onUploadFile).toHaveBeenCalledTimes(1); + expect(onUploadFile).toHaveBeenCalledWith(formDataMock, fileNameMock); + }); + + it('should render uploadButton as disabled and not trigger callback on upload when disabled prop is set', async () => { + const user = userEvent.setup(); + renderFileUploader({ disabled: true }); + expect(getUploadButton()).toBeDisabled(); + + const file = new File(['test'], 'fileNameMock', { type: 'image/png' }); + await user.upload(getFileInputElement(), file); + expect(onUploadFile).not.toHaveBeenCalled(); + }); + + it('should not do callback if uploaded file does not match provided accept prop', async () => { + const user = userEvent.setup(); + const accept = '.fileExtension'; + renderFileUploader({ accept }); + const file = new File(['test'], 'fileNameMock.someOtherExtension', { type: 'image/png' }); + await user.upload(getFileInputElement(), file); + expect(onUploadFile).not.toHaveBeenCalled(); + }); + + it('should do callback if uploaded file does match provided accept prop', async () => { + const user = userEvent.setup(); + const accept = '.fileExtension'; + renderFileUploader({ accept }); + const file = new File(['test'], `fileNameMock${accept}`, { type: 'image/png' }); + await user.upload(getFileInputElement(), file); + expect(onUploadFile).toHaveBeenCalledTimes(1); + }); + + it('should validate file as valid if customFileNameValidation is not defined', async () => { + const user = userEvent.setup(); + renderFileUploader(); + const file = new File(['test'], 'fileNameMock', { type: 'image/png' }); + await user.upload(getFileInputElement(), file); + expect(onUploadFile).toHaveBeenCalledTimes(1); + }); + + it('should call onInvalidFileName and not upload callback when validateFileName returns false', async () => { + const user = userEvent.setup(); + const onInvalidFileName = jest.fn(); + const customFileValidation: FileValidation = { + validateFileName: () => false, + onInvalidFileName, + }; + renderFileUploader({ customFileValidation }); + const file = new File(['test'], 'fileNameMock', { type: 'image/png' }); + await user.upload(getFileInputElement(), file); + expect(onUploadFile).not.toHaveBeenCalled(); + expect(onInvalidFileName).toHaveBeenCalledTimes(1); + }); + + it('should not call onInvalidFileName and upload callback when validateFileName returns true', async () => { + const user = userEvent.setup(); + const onInvalidFileName = jest.fn(); + const customFileValidation: FileValidation = { + validateFileName: () => true, + onInvalidFileName, + }; + renderFileUploader({ customFileValidation }); + const file = new File(['test'], 'fileNameMock', { type: 'image/png' }); + await user.upload(getFileInputElement(), file); + expect(onUploadFile).toHaveBeenCalledTimes(1); + expect(onInvalidFileName).not.toHaveBeenCalled(); + }); + + it('should call onInvalidFileSize and not upload callback when fileSize is larger than fileSizeLimit', async () => { + const user = userEvent.setup(); + const onInvalidFileSize = jest.fn(); + const fileSizeLimitMb = 1; + const customFileValidation: FileValidation = { + onInvalidFileSize, + fileSizeLimitMb, + }; + renderFileUploader({ customFileValidation }); + const file = new File( + [new Blob([new Uint8Array(fileSizeLimitMb * BYTES_IN_A_MEGA_BYTE + 1)])], + 'fileNameMock', + { type: 'image/png' }, + ); + await user.upload(getFileInputElement(), file); + expect(onUploadFile).not.toHaveBeenCalled(); + expect(onInvalidFileSize).toHaveBeenCalledTimes(1); + }); + + it('should not call onInvalidFileSize and upload callback when fileSize is smaller than fileSizeLimit', async () => { + const user = userEvent.setup(); + const onInvalidFileSize = jest.fn(); + const fileSizeLimitMb = 1; + const customFileValidation: FileValidation = { + onInvalidFileSize, + fileSizeLimitMb, + }; + renderFileUploader({ customFileValidation }); + const file = new File( + [new Uint8Array(fileSizeLimitMb * BYTES_IN_A_MEGA_BYTE)], + 'fileNameMock', + { + type: 'image/png', + }, + ); + await user.upload(getFileInputElement(), file); + expect(onUploadFile).toHaveBeenCalledTimes(1); + expect(onInvalidFileSize).not.toHaveBeenCalled(); + }); + + it('should not call upload callback when no file is uploaded', async () => { + const user = userEvent.setup(); + renderFileUploader(); + await user.upload(getFileInputElement(), undefined); + expect(onUploadFile).not.toHaveBeenCalled(); + }); +}); + +function renderFileUploader(props: Partial = {}): RenderResult { + return render(); +} + +function getFileInputElement(): HTMLInputElement { + return screen.getByLabelText(uploaderButtonText) as HTMLInputElement; +} + +function getUploadButton(): HTMLButtonElement { + return screen.getByRole('button', { name: uploaderButtonText }) as HTMLButtonElement; +} diff --git a/frontend/packages/ux-editor/src/components/config/editModal/EditImage/LocalImage/ImportImage/UploadImage/FileUploaderWithValidation/FileUploaderWithValidation.tsx b/frontend/packages/ux-editor/src/components/config/editModal/EditImage/LocalImage/ImportImage/UploadImage/FileUploaderWithValidation/FileUploaderWithValidation.tsx new file mode 100644 index 00000000000..af1d0e993a4 --- /dev/null +++ b/frontend/packages/ux-editor/src/components/config/editModal/EditImage/LocalImage/ImportImage/UploadImage/FileUploaderWithValidation/FileUploaderWithValidation.tsx @@ -0,0 +1,61 @@ +import React, { type RefObject, useRef } from 'react'; +import { StudioFileUploader } from '@studio/components'; +import type { StudioFileUploaderProps } from '@studio/components'; + +export type FileValidation = { + validateFileName?: (fileName: string) => boolean; + fileSizeLimitMb?: number; + onInvalidFileName?: (file?: FormData, fileName?: string) => void; + onInvalidFileSize?: () => void; +}; + +export type FileUploaderWithValidationProps = StudioFileUploaderProps & { + onUploadFile: (file: FormData, fileName: string) => void; + customFileValidation?: FileValidation; +}; + +export function FileUploaderWithValidation({ + customFileValidation, + onUploadFile, + ...rest +}: FileUploaderWithValidationProps) { + const ref = useRef(null); + + const handleSubmit = (file: File) => { + if (isFileValid(file, ref, customFileValidation)) { + const formData = new FormData(); + formData.append('file', file); + onUploadFile(formData, file.name); + } + }; + + return ; +} + +const isFileValid = ( + file: File, + fileRef: RefObject, + customFileValidation: FileValidation, +): boolean => { + if (!customFileValidation) return true; + if (customFileValidation.validateFileName && !customFileValidation.validateFileName(file.name)) { + const formData = new FormData(); + formData.append('file', file); + customFileValidation.onInvalidFileName(formData, file.name); + fileRef.current.value = ''; + return false; + } + if ( + customFileValidation.fileSizeLimitMb && + file.size > customFileValidation.fileSizeLimitMb * BYTES_IN_A_MEGA_BYTE + ) { + customFileValidation.onInvalidFileSize(); + fileRef.current.value = ''; + return false; + } + return true; +}; + +const BYTES_IN_A_KILO_BYTE = 1024; +const KILO_BYTES_IN_A_MEGA_BYTE = 1024; +export const BYTES_IN_A_MEGA_BYTE = KILO_BYTES_IN_A_MEGA_BYTE * BYTES_IN_A_KILO_BYTE; diff --git a/frontend/packages/ux-editor/src/components/config/editModal/EditImage/LocalImage/ImportImage/UploadImage/FileUploaderWithValidation/index.ts b/frontend/packages/ux-editor/src/components/config/editModal/EditImage/LocalImage/ImportImage/UploadImage/FileUploaderWithValidation/index.ts new file mode 100644 index 00000000000..8d3a1c4cdf1 --- /dev/null +++ b/frontend/packages/ux-editor/src/components/config/editModal/EditImage/LocalImage/ImportImage/UploadImage/FileUploaderWithValidation/index.ts @@ -0,0 +1,2 @@ +export { FileUploaderWithValidation } from './FileUploaderWithValidation'; +export type { FileUploaderWithValidationProps } from './FileUploaderWithValidation'; diff --git a/frontend/packages/ux-editor/src/components/config/editModal/EditImage/LocalImage/ImportImage/UploadImage/UploadImage.tsx b/frontend/packages/ux-editor/src/components/config/editModal/EditImage/LocalImage/ImportImage/UploadImage/UploadImage.tsx index 77bc2d37250..1acee435064 100644 --- a/frontend/packages/ux-editor/src/components/config/editModal/EditImage/LocalImage/ImportImage/UploadImage/UploadImage.tsx +++ b/frontend/packages/ux-editor/src/components/config/editModal/EditImage/LocalImage/ImportImage/UploadImage/UploadImage.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { StudioFileUploader, StudioParagraph, StudioSpinner } from '@studio/components'; +import { StudioParagraph, StudioSpinner } from '@studio/components'; import { useStudioEnvironmentParams } from 'app-shared/hooks/useStudioEnvironmentParams'; import { useAddImageMutation } from 'app-shared/hooks/mutations/useAddImageMutation'; import { useGetAllImageFileNamesQuery } from 'app-shared/hooks/queries/useGetAllImageFileNamesQuery'; @@ -7,6 +7,7 @@ import { useTranslation } from 'react-i18next'; import { MAX_FILE_SIZE_MB, WWWROOT_FILE_PATH } from '../../../../EditImage/constants'; import classes from './UploadImage.module.css'; import { toast } from 'react-toastify'; +import { FileUploaderWithValidation } from './FileUploaderWithValidation'; // This list should be fetched from backend to ensure we use equal validation // ISSUE: https://github.com/Altinn/altinn-studio/issues/13649 @@ -55,7 +56,7 @@ export const UploadImage = ({ onImageChange }: UploadImageProps) => { ) : (
-