From 6fe658cf5e562afdfeedd5d05b7963a25917cbbb Mon Sep 17 00:00:00 2001 From: shan8851 Date: Fri, 10 Jan 2025 11:37:20 +0000 Subject: [PATCH] chore: Update InputFileAvatar component to better align with usage in forms including adding value and onchange --- CHANGELOG.md | 1 + .../forms/inputFileAvatar/index.tsx | 2 +- .../inputFileAvatar/inputFileAvatar.api.ts | 29 ++++--- .../inputFileAvatar.stories.tsx | 24 ++++-- .../inputFileAvatar/inputFileAvatar.test.tsx | 82 +++++++++++-------- .../forms/inputFileAvatar/inputFileAvatar.tsx | 24 +++--- .../proposalActionUpdateMetadata.api.ts | 4 +- .../proposalActionUpdateMetadata.tsx | 2 +- 8 files changed, 95 insertions(+), 73 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0ce9a5e16..2d3725909 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/src/core/components/forms/inputFileAvatar/index.tsx b/src/core/components/forms/inputFileAvatar/index.tsx index a2189aec4..e80cee58a 100644 --- a/src/core/components/forms/inputFileAvatar/index.tsx +++ b/src/core/components/forms/inputFileAvatar/index.tsx @@ -1,2 +1,2 @@ export { InputFileAvatar } from './inputFileAvatar'; -export { InputFileAvatarError, type IInputFileAvatarProps } from './inputFileAvatar.api'; +export { InputFileAvatarError, type IInputFileAvatarProps, type IInputFileAvatarValue } from './inputFileAvatar.api'; diff --git a/src/core/components/forms/inputFileAvatar/inputFileAvatar.api.ts b/src/core/components/forms/inputFileAvatar/inputFileAvatar.api.ts index 08689a3e2..45a595f79 100644 --- a/src/core/components/forms/inputFileAvatar/inputFileAvatar.api.ts +++ b/src/core/components/forms/inputFileAvatar/inputFileAvatar.api.ts @@ -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 { /** - * 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. */ @@ -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; } diff --git a/src/core/components/forms/inputFileAvatar/inputFileAvatar.stories.tsx b/src/core/components/forms/inputFileAvatar/inputFileAvatar.stories.tsx index 443efca34..3632ef0d2 100644 --- a/src/core/components/forms/inputFileAvatar/inputFileAvatar.stories.tsx +++ b/src/core/components/forms/inputFileAvatar/inputFileAvatar.stories.tsx @@ -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 = { title: 'Core/Components/Forms/InputFileAvatar', @@ -18,23 +20,27 @@ type Story = StoryObj; /** * Default usage example of the InputFileAvatar component. */ -export const Default: Story = {}; +export const Default: Story = { + render: ({ ...props }) => { + const [value, setValue] = useState(); + + return ; + }, +}; /** * 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(props.value); -/** - * Usage example with onCancel callback - */ -export const WithOnCancel: Story = { - args: { - onCancel: () => alert('Cancel clicked'), + return ; }, }; diff --git a/src/core/components/forms/inputFileAvatar/inputFileAvatar.test.tsx b/src/core/components/forms/inputFileAvatar/inputFileAvatar.test.tsx index e265cdcf3..5f3acb932 100644 --- a/src/core/components/forms/inputFileAvatar/inputFileAvatar.test.tsx +++ b/src/core/components/forms/inputFileAvatar/inputFileAvatar.test.tsx @@ -41,7 +41,10 @@ describe(' component', () => { }); const createTestComponent = (props?: Partial) => { - const completeProps = { ...props }; + const completeProps = { + onChange: jest.fn(), + ...props, + }; return ; }; @@ -59,33 +62,62 @@ describe(' 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('img'); + 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(); - 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 () => { @@ -103,31 +135,11 @@ describe(' 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('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); }); }); diff --git a/src/core/components/forms/inputFileAvatar/inputFileAvatar.tsx b/src/core/components/forms/inputFileAvatar/inputFileAvatar.tsx index f505c52ae..6cf19ff36 100644 --- a/src/core/components/forms/inputFileAvatar/inputFileAvatar.tsx +++ b/src/core/components/forms/inputFileAvatar/inputFileAvatar.tsx @@ -41,7 +41,6 @@ const dropzoneErrorToError: Record = { export const InputFileAvatar: React.FC = (props) => { const { - onFileSelect, onFileError, maxFileSize, minDimension, @@ -50,15 +49,14 @@ export const InputFileAvatar: React.FC = (props) => { onlySquare, variant = 'default', disabled, - initialValue, - onCancel, + value, + onChange, ...otherProps } = props; const { id, ...containerProps } = otherProps; const randomId = useRandomId(id); - const [imagePreview, setImagePreview] = useState(initialValue ?? null); const [isLoading, setIsLoading] = useState(false); const onDrop = useCallback( @@ -84,8 +82,7 @@ export const InputFileAvatar: React.FC = (props) => { } else if (isBelowMinDimension ?? isAboveMaxDimension) { onFileError?.(InputFileAvatarError.WRONG_DIMENSION); } else { - setImagePreview(image.src); - onFileSelect?.(file); + onChange({ url: image.src, file }); } setIsLoading(false); @@ -99,7 +96,7 @@ export const InputFileAvatar: React.FC = (props) => { image.src = URL.createObjectURL(file); }, - [maxDimension, minDimension, onFileError, onFileSelect, onlySquare], + [maxDimension, minDimension, onChange, onFileError, onlySquare], ); const { getRootProps, getInputProps } = useDropzone({ @@ -112,10 +109,9 @@ export const InputFileAvatar: React.FC = (props) => { const handleCancel = (event: React.MouseEvent) => { event.stopPropagation(); - onCancel?.(); - setImagePreview(null); - if (imagePreview) { - URL.revokeObjectURL(imagePreview); + onChange(); + if (value?.url) { + URL.revokeObjectURL(value.url); } }; @@ -132,9 +128,9 @@ export const InputFileAvatar: React.FC = (props) => {
- {imagePreview ? ( + {value?.url ? (
- +