Skip to content

Commit

Permalink
chore: Update InputFileAvatar component to better align with usage in…
Browse files Browse the repository at this point in the history
… forms including adding value and onchange
  • Loading branch information
shan8851 committed Jan 10, 2025
1 parent f5d31a9 commit 6fe658c
Show file tree
Hide file tree
Showing 8 changed files with 95 additions and 73 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
### Changed

- Update `InputFileAvatar` core component to accept optional `initialValue` and `onCancel` properties
- Update `IProposalActionUpdateMetadataDaoMetadata` interface logo property to `avatar` to better align with actions.

## [1.0.62] - 2025-01-08

Expand Down
2 changes: 1 addition & 1 deletion src/core/components/forms/inputFileAvatar/index.tsx
Original file line number Diff line number Diff line change
@@ -1,2 +1,2 @@
export { InputFileAvatar } from './inputFileAvatar';
export { InputFileAvatarError, type IInputFileAvatarProps } from './inputFileAvatar.api';
export { InputFileAvatarError, type IInputFileAvatarProps, type IInputFileAvatarValue } from './inputFileAvatar.api';
29 changes: 18 additions & 11 deletions src/core/components/forms/inputFileAvatar/inputFileAvatar.api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,14 +10,29 @@ export enum InputFileAvatarError {
FILE_TOO_LARGE = 'file-too-large',
}

export interface IInputFileAvatarValue {
/**
* URL of the image for the preview.
*/
url?: string;
/**
* File object of the image.
*/
file?: File;
}

export interface IInputFileAvatarProps
extends Pick<IInputContainerBaseProps, 'alert' | 'label' | 'helpText' | 'isOptional' | 'variant' | 'disabled'> {
/**
* Function that is called when a file is selected. Passes the file to the parent component.
* Function that is called when a file is selected.
* 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.
* If the file is accepted, the function is called with the file as an argument and a url string for generating the preview.
*/
onFileSelect?: (file: File) => void;
onChange: (value?: IInputFileAvatarValue) => void;
/**
* The current value of the input.
*/
value?: IInputFileAvatarValue;
/**
* Function that is called when a file is rejected. Passes the error message to the parent component.
*/
Expand Down Expand Up @@ -48,12 +63,4 @@ export interface IInputFileAvatarProps
* Optional ID for the file avatar input.
*/
id?: string;
/**
* Optional initial value for the image preview.
*/
initialValue?: string;
/**
* callback to be fired when cancel is clicked
*/
onCancel?: () => void;
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import type { Meta, StoryObj } from '@storybook/react';
import { useState } from 'react';
import { InputFileAvatar } from './inputFileAvatar';
import type { IInputFileAvatarValue } from './inputFileAvatar.api';

const meta: Meta<typeof InputFileAvatar> = {
title: 'Core/Components/Forms/InputFileAvatar',
Expand All @@ -18,23 +20,27 @@ type Story = StoryObj<typeof InputFileAvatar>;
/**
* Default usage example of the InputFileAvatar component.
*/
export const Default: Story = {};
export const Default: Story = {
render: ({ ...props }) => {
const [value, setValue] = useState<IInputFileAvatarValue>();

return <InputFileAvatar {...props} value={value} onChange={setValue} />;
},
};

/**
* Usage example of the InputFileAvatar component with an initial value.
*/
export const InitialValue: Story = {
args: {
initialValue: 'https://assets.coingecko.com/coins/images/279/large/ethereum.png?1696501628',
value: {
url: 'https://assets.coingecko.com/coins/images/279/large/ethereum.png?1696501628',
},
},
};
render: (props) => {
const [value, setValue] = useState<IInputFileAvatarValue | undefined>(props.value);

/**
* Usage example with onCancel callback
*/
export const WithOnCancel: Story = {
args: {
onCancel: () => alert('Cancel clicked'),
return <InputFileAvatar {...props} value={value} onChange={setValue} />;
},
};

Expand Down
82 changes: 47 additions & 35 deletions src/core/components/forms/inputFileAvatar/inputFileAvatar.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,10 @@ describe('<InputFileAvatar /> component', () => {
});

const createTestComponent = (props?: Partial<IInputFileAvatarProps>) => {
const completeProps = { ...props };
const completeProps = {
onChange: jest.fn(),
...props,
};

return <InputFileAvatar {...completeProps} />;
};
Expand All @@ -59,33 +62,62 @@ describe('<InputFileAvatar /> component', () => {
const user = userEvent.setup();
const label = 'test-label';
const fileSrc = 'https://chucknorris.com/image.png';
const file = new File(['(⌐□_□)'], fileSrc, { type: 'image/png' });
const onFileSelect = jest.fn();
const file = new File(['(⌐□_□)'], 'image.png', { type: 'image/png' });
const onChange = jest.fn();
createObjectURLMock.mockReturnValue(fileSrc);

render(createTestComponent({ label, onFileSelect }));
await user.upload(screen.getByLabelText(label), file);
const previewImg = await screen.findByRole<HTMLImageElement>('img');
const { rerender } = render(createTestComponent({ label, onChange }));

const fileInput = screen.getByLabelText<HTMLInputElement>(label);
await user.upload(fileInput, file);

await waitFor(() => {
expect(onChange).toHaveBeenCalledWith({ url: fileSrc, file });
});

rerender(createTestComponent({ label, onChange, value: { url: fileSrc, file } }));

const previewImg = await screen.findByTestId('avatar');
expect(previewImg).toBeInTheDocument();
expect(previewImg.src).toEqual(fileSrc);
expect(onFileSelect).toHaveBeenCalledWith(file);
expect(previewImg).toHaveAttribute('src', fileSrc);
});

it('clears the current file selection on close button click after an image has been selected', async () => {
const user = userEvent.setup();
const label = 'test-label';
const file = new File(['something'], 'test.png', { type: 'image/png' });
createObjectURLMock.mockReturnValue('file-src');
const fileSrc = 'file-src';
const onChange = jest.fn();
createObjectURLMock.mockReturnValue(fileSrc);

const { rerender } = render(createTestComponent({ label, onChange }));

const fileInput = screen.getByLabelText(label);
await user.upload(fileInput, file);

await waitFor(() => {
expect(onChange).toHaveBeenCalledWith({ url: fileSrc, file });
});

rerender(createTestComponent({ label, onChange, value: { url: fileSrc, file } }));

const previewImg = await screen.findByTestId('avatar');
expect(previewImg).toBeInTheDocument();

render(createTestComponent({ label }));
await user.upload(screen.getByLabelText(label), file);
const cancelButton = await screen.findByRole('button');
expect(cancelButton).toBeInTheDocument();

await user.click(cancelButton);

await waitFor(() => {
expect(onChange).toHaveBeenCalled();
});

rerender(createTestComponent({ label, onChange }));

expect(screen.getByTestId(IconType.PLUS)).toBeInTheDocument();
expect(screen.queryByRole('img')).not.toBeInTheDocument();
expect(screen.queryByTestId('avatar')).not.toBeInTheDocument();
expect(revokeObjectURLMock).toHaveBeenCalledWith(fileSrc);
});

it('calls onFileError when file has incorrect dimensions', async () => {
Expand All @@ -103,31 +135,11 @@ describe('<InputFileAvatar /> component', () => {
});

it('displays the initialValue image preview when provided', async () => {
const initialValue = 'https://example.com/avatar.png';
render(createTestComponent({ initialValue }));
const value = { url: 'https://example.com/avatar.png' };
render(createTestComponent({ value }));
const previewImg = await screen.findByRole<HTMLImageElement>('img');

expect(previewImg).toBeInTheDocument();
expect(previewImg.src).toEqual(initialValue);
});

it('fires the onCancel callback when cancel button is clicked', async () => {
const user = userEvent.setup();
const label = 'test-label';
const onCancel = jest.fn();

const file = new File(['something'], 'test.png', { type: 'image/png' });
createObjectURLMock.mockReturnValue('file-src');

render(createTestComponent({ label, onCancel }));
await user.upload(screen.getByLabelText(label), file);
const cancelButton = await screen.findByRole('button');
expect(cancelButton).toBeInTheDocument();

await user.click(cancelButton);

expect(onCancel).toHaveBeenCalled();
expect(screen.getByTestId(IconType.PLUS)).toBeInTheDocument();
expect(screen.queryByRole('img')).not.toBeInTheDocument();
expect(previewImg.src).toEqual(value.url);
});
});
24 changes: 10 additions & 14 deletions src/core/components/forms/inputFileAvatar/inputFileAvatar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,6 @@ const dropzoneErrorToError: Record<string, InputFileAvatarError | undefined> = {

export const InputFileAvatar: React.FC<IInputFileAvatarProps> = (props) => {
const {
onFileSelect,
onFileError,
maxFileSize,
minDimension,
Expand All @@ -50,15 +49,14 @@ export const InputFileAvatar: React.FC<IInputFileAvatarProps> = (props) => {
onlySquare,
variant = 'default',
disabled,
initialValue,
onCancel,
value,
onChange,
...otherProps
} = props;

const { id, ...containerProps } = otherProps;
const randomId = useRandomId(id);

const [imagePreview, setImagePreview] = useState<string | null>(initialValue ?? null);
const [isLoading, setIsLoading] = useState(false);

const onDrop = useCallback(
Expand All @@ -84,8 +82,7 @@ export const InputFileAvatar: React.FC<IInputFileAvatarProps> = (props) => {
} else if (isBelowMinDimension ?? isAboveMaxDimension) {
onFileError?.(InputFileAvatarError.WRONG_DIMENSION);
} else {
setImagePreview(image.src);
onFileSelect?.(file);
onChange({ url: image.src, file });
}

setIsLoading(false);
Expand All @@ -99,7 +96,7 @@ export const InputFileAvatar: React.FC<IInputFileAvatarProps> = (props) => {

image.src = URL.createObjectURL(file);
},
[maxDimension, minDimension, onFileError, onFileSelect, onlySquare],
[maxDimension, minDimension, onChange, onFileError, onlySquare],
);

const { getRootProps, getInputProps } = useDropzone({
Expand All @@ -112,10 +109,9 @@ export const InputFileAvatar: React.FC<IInputFileAvatarProps> = (props) => {

const handleCancel = (event: React.MouseEvent<HTMLButtonElement>) => {
event.stopPropagation();
onCancel?.();
setImagePreview(null);
if (imagePreview) {
URL.revokeObjectURL(imagePreview);
onChange();
if (value?.url) {
URL.revokeObjectURL(value.url);
}
};

Expand All @@ -132,9 +128,9 @@ export const InputFileAvatar: React.FC<IInputFileAvatarProps> = (props) => {
<InputContainer id={randomId} useCustomWrapper={true} {...containerProps}>
<div {...getRootProps()} className={inputAvatarClassNames}>
<input {...getInputProps()} id={randomId} />
{imagePreview ? (
{value?.url ? (
<div className="relative">
<Avatar src={imagePreview} size="lg" className="cursor-pointer" data-testid="avatar" />
<Avatar src={value.url} size="lg" className="cursor-pointer" data-testid="avatar" />
<button
onClick={handleCancel}
className={classNames(
Expand All @@ -149,7 +145,7 @@ export const InputFileAvatar: React.FC<IInputFileAvatarProps> = (props) => {
) : (
<>
{isLoading && <Spinner size="lg" variant="neutral" />}
{!imagePreview && !isLoading && (
{!value?.url && !isLoading && (
<Icon icon={IconType.PLUS} size="lg" className={classNames(addIconClasses)} />
)}
</>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,9 @@ export interface IProposalActionUpdateMetadataDaoMetadataLink {

export interface IProposalActionUpdateMetadataDaoMetadata {
/**
* URL of the logo, only set for DAO metadata.
* URL of the avatar, only set for DAO metadata.
*/
logo?: string;
avatar?: string;
/**
* Name of the DAO or Plugin.
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ export const ProposalActionUpdateMetadata: React.FC<IProposalActionUpdateMetadat
className="md:items-center"
term={modulesCopy.proposalActionsUpdateMetadata.logoTerm}
>
<Avatar alt="dao-logo" src={metadataToDisplay.logo} responsiveSize={{ md: 'md', sm: 'sm' }} />
<Avatar alt="dao-logo" src={metadataToDisplay.avatar} responsiveSize={{ md: 'md', sm: 'sm' }} />
</DefinitionList.Item>
)}
<DefinitionList.Item term={modulesCopy.proposalActionsUpdateMetadata.nameTerm}>
Expand Down

0 comments on commit 6fe658c

Please sign in to comment.