Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(APP-3693): Update InputFileAvatar component to handle initial preview and add onCancel callback #384

Open
wants to merge 6 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,11 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),

## [Unreleased]

### 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

### Fixed
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';
21 changes: 18 additions & 3 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.
*/
onChange: (value?: IInputFileAvatarValue) => void;
/**
* The current value of the input.
*/
onFileSelect?: (file: File) => void;
value?: IInputFileAvatarValue;
/**
* Function that is called when a file is rejected. Passes the error message to the parent component.
*/
Expand Down
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,6 +20,28 @@ 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: {
value: {
url: 'https://assets.coingecko.com/coins/images/279/large/ethereum.png?1696501628',
},
},
render: (props) => {
const [value, setValue] = useState<IInputFileAvatarValue | undefined>(props.value);

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

export default meta;
65 changes: 53 additions & 12 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 @@ -101,4 +133,13 @@ describe('<InputFileAvatar /> component', () => {
await user.upload(screen.getByLabelText(label), file);
await waitFor(() => expect(onFileError).toHaveBeenCalledWith(InputFileAvatarError.WRONG_DIMENSION));
});

it('displays the initialValue image preview when provided', async () => {
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(value.url);
});
});
21 changes: 10 additions & 11 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,13 +49,14 @@ export const InputFileAvatar: React.FC<IInputFileAvatarProps> = (props) => {
onlySquare,
variant = 'default',
disabled,
value,
onChange,
...otherProps
} = props;

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

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

const onDrop = useCallback(
Expand All @@ -82,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 @@ -97,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 @@ -110,9 +109,9 @@ export const InputFileAvatar: React.FC<IInputFileAvatarProps> = (props) => {

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

Expand All @@ -129,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 @@ -146,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 @@ -23,13 +23,13 @@ export const Default: Story = {
args: {
action: generateProposalActionUpdateMetadata({
existingMetadata: {
logo: 'https://cdn.prod.website-files.com/5e997428d0f2eb13a90aec8c/635283b535e03c60d5aafe64_logo_aragon_isotype.png',
avatar: 'https://cdn.prod.website-files.com/5e997428d0f2eb13a90aec8c/635283b535e03c60d5aafe64_logo_aragon_isotype.png',
name: 'Aragon DAO',
description: 'A description for the Aragon DAO',
links: [{ label: 'Aragon DAO', href: 'https://aragon.org/' }],
},
proposedMetadata: {
logo: 'https://cdn.prod.website-files.com/5e997428d0f2eb13a90aec8c/635283b535e03c60d5aafe64_logo_aragon_isotype.png',
avatar: 'https://cdn.prod.website-files.com/5e997428d0f2eb13a90aec8c/635283b535e03c60d5aafe64_logo_aragon_isotype.png',
name: 'Aragon X',
description: 'Updated description for the AragonX DAO',
links: [
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ describe('<ProposalActionUpdateMetadata /> component', () => {

it('renders the correct existing metadata for UPDATE_METADATA', async () => {
const proposedMetadata = {
logo: 'proposed-logo.png',
avatar: 'proposed-logo.png',
name: 'Proposed Name',
description: 'Proposed DAO description',
links: [
Expand All @@ -44,7 +44,7 @@ describe('<ProposalActionUpdateMetadata /> component', () => {
};

const existingMetadata = {
logo: 'existing-logo.png',
avatar: 'existing-logo.png',
name: 'Existing Name',
description: 'Existing DAO description',
links: [
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,13 @@ export const generateProposalActionUpdateMetadata = (
...generateProposalAction(),
type: ProposalActionType.UPDATE_METADATA,
existingMetadata: {
logo: 'https://i.pravatar.cc/300',
avatar: 'https://i.pravatar.cc/300',
name: 'Old name',
description: 'Existing DAO description.',
links: [],
},
proposedMetadata: {
logo: 'https://i.pravatar.cc/300',
avatar: 'https://i.pravatar.cc/300',
name: 'New name',
description: 'Proposed DAO description.',
links: [],
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
Loading