diff --git a/CHANGELOG.md b/CHANGELOG.md index cdedaf1ed..2d3725909 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 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 a95c875b3..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. + */ + 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. */ diff --git a/src/core/components/forms/inputFileAvatar/inputFileAvatar.stories.tsx b/src/core/components/forms/inputFileAvatar/inputFileAvatar.stories.tsx index f54b8305d..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,6 +20,28 @@ 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: { + value: { + url: 'https://assets.coingecko.com/coins/images/279/large/ethereum.png?1696501628', + }, + }, + render: (props) => { + const [value, setValue] = useState(props.value); + + return ; + }, +}; export default meta; diff --git a/src/core/components/forms/inputFileAvatar/inputFileAvatar.test.tsx b/src/core/components/forms/inputFileAvatar/inputFileAvatar.test.tsx index ca7543ae8..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 () => { @@ -101,4 +133,13 @@ describe(' 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('img'); + + expect(previewImg).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 692245fb7..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,13 +49,14 @@ export const InputFileAvatar: React.FC = (props) => { onlySquare, variant = 'default', disabled, + value, + onChange, ...otherProps } = props; const { id, ...containerProps } = otherProps; const randomId = useRandomId(id); - const [imagePreview, setImagePreview] = useState(null); const [isLoading, setIsLoading] = useState(false); const onDrop = useCallback( @@ -82,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); @@ -97,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({ @@ -110,9 +109,9 @@ export const InputFileAvatar: React.FC = (props) => { const handleCancel = (event: React.MouseEvent) => { event.stopPropagation(); - setImagePreview(null); - if (imagePreview) { - URL.revokeObjectURL(imagePreview); + onChange(); + if (value?.url) { + URL.revokeObjectURL(value.url); } }; @@ -129,9 +128,9 @@ export const InputFileAvatar: React.FC = (props) => {
- {imagePreview ? ( + {value?.url ? (
- +