-
Notifications
You must be signed in to change notification settings - Fork 7
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat: APP-2721 - Implement InputFileAvatar Component (#89)
Co-authored-by: Ruggero Cino <[email protected]>
- Loading branch information
1 parent
f87e165
commit 539a760
Showing
11 changed files
with
378 additions
and
2 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,2 @@ | ||
export { InputFileAvatar } from './inputFileAvatar'; | ||
export { InputFileAvatarError, type IInputFileAvatarProps } from './inputFileAvatar.api'; |
56 changes: 56 additions & 0 deletions
56
src/components/input/inputFileAvatar/inputFileAvatar.api.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,56 @@ | ||
import { type Accept } from 'react-dropzone'; | ||
import { type IInputComponentProps } from '../inputContainer'; | ||
|
||
export enum InputFileAvatarError { | ||
SQUARE_ONLY = 'square-only', | ||
WRONG_DIMENSION = 'wrong-dimension', | ||
UNKNOWN_ERROR = 'unknown-file-error', | ||
FILE_INVALID_TYPE = 'file-invalid-type', | ||
TOO_MANY_FILES = 'too-many-files', | ||
FILE_TOO_LARGE = 'file-too-large', | ||
} | ||
|
||
export interface IInputFileAvatarProps | ||
extends Omit< | ||
IInputComponentProps, | ||
| 'maxLength' | ||
| 'onChange' | ||
| 'inputLength' | ||
| 'wrapperClassName' | ||
| 'useCustomWrapper' | ||
| 'inputClassName' | ||
| 'multiple' | ||
> { | ||
/** | ||
* Function that is called when a file is selected. Passes the file to the parent component. | ||
* If the file is rejected, the function is not called. | ||
* If the file is accepted, the function is called with the file as an argument. | ||
*/ | ||
onFileSelect?: (file: File) => void; | ||
/** | ||
* Function that is called when a file is rejected. Passes the error message to the parent component. | ||
*/ | ||
onFileError?: (error: InputFileAvatarError) => void; | ||
/** | ||
* Allowed file extensions, it must be an object with the keys set to the MIME type | ||
* and the values an array of file extensions (see https://developer.mozilla.org/en-US/docs/Web/API/window/showOpenFilePicker#accept) | ||
* @default { 'image/png': [], 'image/gif': [], 'image/jpeg': ['.jpg', '.jpeg'] } | ||
*/ | ||
acceptedFileTypes?: Accept; | ||
/** | ||
* Maximum file size in bytes (e.g. 2097152 bytes | 2 * 1024 ** 2 = 2MiB). | ||
*/ | ||
maxFileSize?: number; | ||
/** | ||
* Minimum dimension of the image in pixels. | ||
*/ | ||
minDimension?: number; | ||
/** | ||
* Maximum dimension of the image in pixels. | ||
*/ | ||
maxDimension?: number; | ||
/** | ||
* If true, only square images are accepted. | ||
*/ | ||
onlySquare?: boolean; | ||
} |
24 changes: 24 additions & 0 deletions
24
src/components/input/inputFileAvatar/inputFileAvatar.stories.tsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,24 @@ | ||
import type { Meta, StoryObj } from '@storybook/react'; | ||
import { InputFileAvatar } from './inputFileAvatar'; | ||
|
||
const meta: Meta<typeof InputFileAvatar> = { | ||
title: 'components/Input/InputFileAvatar', | ||
component: InputFileAvatar, | ||
tags: ['autodocs'], | ||
|
||
parameters: { | ||
design: { | ||
type: 'figma', | ||
url: 'https://www.figma.com/file/ISSDryshtEpB7SUSdNqAcw/branch/jfKRr1V9evJUp1uBeyP3Zz/Aragon-ODS?type=design&node-id=11970%3A18464&mode=design&t=vme2iG22v3jenK5f-1', | ||
}, | ||
}, | ||
}; | ||
|
||
type Story = StoryObj<typeof InputFileAvatar>; | ||
|
||
/** | ||
* Default usage example of the InputFileAvatar component. | ||
*/ | ||
export const Default: Story = {}; | ||
|
||
export default meta; |
104 changes: 104 additions & 0 deletions
104
src/components/input/inputFileAvatar/inputFileAvatar.test.tsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,104 @@ | ||
import { render, screen, waitFor } from '@testing-library/react'; | ||
import { userEvent } from '@testing-library/user-event'; | ||
import { IconType } from '../../icon'; | ||
import { InputFileAvatar } from './inputFileAvatar'; | ||
import { InputFileAvatarError, type IInputFileAvatarProps } from './inputFileAvatar.api'; | ||
|
||
Object.defineProperty(URL, 'createObjectURL', { value: jest.fn(), configurable: true }); | ||
Object.defineProperty(URL, 'revokeObjectURL', { value: jest.fn(), configurable: true }); | ||
|
||
describe('<InputFileAvatar /> component', () => { | ||
const createObjectURLMock = jest.spyOn(URL, 'createObjectURL'); | ||
const revokeObjectURLMock = jest.spyOn(URL, 'revokeObjectURL'); | ||
|
||
const originalGlobalImage = window.Image; | ||
|
||
beforeEach(() => { | ||
(window.Image as unknown) = class MockImage { | ||
onload: () => void = () => {}; | ||
onerror: () => void = () => {}; | ||
src: string = 'test'; | ||
|
||
removeEventListener = jest.fn(); | ||
addEventListener = (event: string, callback: () => void) => { | ||
if (event === 'load') { | ||
this.onload = callback; | ||
} else if (event === 'error') { | ||
this.onerror = callback; | ||
} | ||
}; | ||
|
||
constructor() { | ||
setTimeout(() => this.onload(), 100); | ||
} | ||
}; | ||
}); | ||
|
||
afterEach(() => { | ||
window.Image = originalGlobalImage; | ||
createObjectURLMock.mockReset(); | ||
revokeObjectURLMock.mockReset(); | ||
}); | ||
|
||
const createTestComponent = (props?: Partial<IInputFileAvatarProps>) => { | ||
const completeProps = { ...props }; | ||
|
||
return <InputFileAvatar {...completeProps} />; | ||
}; | ||
|
||
it('renders a file input and an add icon', () => { | ||
const label = 'input-label'; | ||
render(createTestComponent({ label })); | ||
const fileInput = screen.getByLabelText<HTMLInputElement>(label); | ||
expect(fileInput).toBeInTheDocument(); | ||
expect(fileInput.type).toEqual('file'); | ||
expect(screen.getByTestId(IconType.ADD)).toBeInTheDocument(); | ||
}); | ||
|
||
it('displays a preview and calls the onFileSelect callback when a valid file is selected', async () => { | ||
const label = 'test-label'; | ||
const fileSrc = 'https://chucknorris.com/image.png'; | ||
const file = new File(['(⌐□_□)'], fileSrc, { type: 'image/png' }); | ||
const onFileSelect = jest.fn(); | ||
createObjectURLMock.mockReturnValue(fileSrc); | ||
|
||
render(createTestComponent({ label, onFileSelect })); | ||
await userEvent.upload(screen.getByLabelText(label), file); | ||
const previewImg = await screen.findByRole<HTMLImageElement>('img'); | ||
|
||
expect(previewImg).toBeInTheDocument(); | ||
expect(previewImg.src).toEqual(fileSrc); | ||
expect(onFileSelect).toHaveBeenCalledWith(file); | ||
}); | ||
|
||
it('clears the current file selection on close button click after an image has been selected', async () => { | ||
const label = 'test-label'; | ||
const file = new File(['something'], 'test.png', { type: 'image/png' }); | ||
createObjectURLMock.mockReturnValue('file-src'); | ||
|
||
render(createTestComponent({ label })); | ||
await userEvent.upload(screen.getByLabelText(label), file); | ||
const cancelButton = await screen.findByRole('button'); | ||
expect(cancelButton).toBeInTheDocument(); | ||
|
||
await userEvent.click(cancelButton); | ||
expect(screen.getByTestId(IconType.ADD)).toBeInTheDocument(); | ||
expect(screen.queryByRole('img')).not.toBeInTheDocument(); | ||
}); | ||
|
||
it('calls onFileError when file has incorrect dimensions', async () => { | ||
const originalWidth = global.Image.prototype.width; | ||
global.Image.prototype.width = 800; | ||
|
||
const label = 'test-label'; | ||
const file = new File(['test'], 'test.png', { type: 'image/png' }); | ||
const onFileError = jest.fn(); | ||
const minDimension = 1000; | ||
|
||
render(createTestComponent({ label, onFileError, minDimension })); | ||
await userEvent.upload(screen.getByLabelText(label), file); | ||
await waitFor(() => expect(onFileError).toHaveBeenCalledWith(InputFileAvatarError.WRONG_DIMENSION)); | ||
|
||
global.Image.prototype.width = originalWidth; | ||
}); | ||
}); |
Oops, something went wrong.