Skip to content

Commit

Permalink
feat: APP-2721 - Implement InputFileAvatar Component (#89)
Browse files Browse the repository at this point in the history
Co-authored-by: Ruggero Cino <[email protected]>
  • Loading branch information
thekidnamedkd and cgero-eth authored Feb 14, 2024
1 parent f87e165 commit 539a760
Show file tree
Hide file tree
Showing 11 changed files with 378 additions and 2 deletions.
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),

## [Unreleased]

### Added

- Implement `InputFileAvatar` component

## [1.0.13] - 2024-02-14

### Added
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@
"classnames": "^2.0.0",
"react": "^18.0.0",
"react-dom": "^18.0.0",
"react-dropzone": "^14.2.0",
"react-imask": "^7.4.0"
},
"peerDependencies": {
Expand Down
1 change: 1 addition & 0 deletions src/components/input/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
export * from './inputContainer';
export * from './inputDate';
export * from './inputFileAvatar';
export * from './inputNumber';
export * from './inputNumberMax';
export * from './inputSearch';
Expand Down
4 changes: 4 additions & 0 deletions src/components/input/inputContainer/inputContainer.api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,10 @@ export interface IInputContainerBaseProps {
* Classes for the input wrapper.
*/
wrapperClassName?: string;
/**
* Shortcircuits all the input wrapper classes to pass control to the child component.
*/
useCustomWrapper?: boolean;
}

export interface IInputContainerProps extends IInputContainerBaseProps, Omit<ComponentPropsWithRef<'div'>, 'id'> {}
Expand Down
8 changes: 6 additions & 2 deletions src/components/input/inputContainer/inputContainer.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import classNames from 'classnames';
import { forwardRef } from 'react';
import React, { forwardRef } from 'react';
import { AlertInline } from '../../alerts';
import { Tag } from '../../tag';
import type { IInputContainerProps, InputVariant } from './inputContainer.api';
Expand Down Expand Up @@ -45,6 +45,7 @@ export const InputContainer = forwardRef<HTMLDivElement, IInputContainerProps>((
className,
wrapperClassName,
id,
useCustomWrapper,
...otherProps
} = props;

Expand All @@ -62,6 +63,9 @@ export const InputContainer = forwardRef<HTMLDivElement, IInputContainerProps>((
'animate-shake': inputLength === maxLength,
});

const InputWrapper = useCustomWrapper ? React.Fragment : 'div';
const containerProps = useCustomWrapper ? {} : { className: containerClasses };

return (
<div className={classNames('flex grow flex-col gap-2 md:gap-3', className)} ref={ref} {...otherProps}>
{(label != null || helpText != null) && (
Expand All @@ -78,7 +82,7 @@ export const InputContainer = forwardRef<HTMLDivElement, IInputContainerProps>((
)}
</label>
)}
<div className={containerClasses}>{children}</div>
<InputWrapper {...containerProps}>{children}</InputWrapper>
{maxLength != null && !alert && (
<p className={counterClasses}>
{inputLength}/{maxLength}
Expand Down
2 changes: 2 additions & 0 deletions src/components/input/inputFileAvatar/index.tsx
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 src/components/input/inputFileAvatar/inputFileAvatar.api.ts
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 src/components/input/inputFileAvatar/inputFileAvatar.stories.tsx
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 src/components/input/inputFileAvatar/inputFileAvatar.test.tsx
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;
});
});
Loading

0 comments on commit 539a760

Please sign in to comment.