Skip to content
Open
Show file tree
Hide file tree
Changes from 5 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
1 change: 1 addition & 0 deletions packages/ui/src/elements/Avatar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,7 @@ export const Avatar = (props: AvatarProps) => {
}),
sx,
]}
data-rounded={rounded}
>
{ImgOrFallback}

Expand Down
69 changes: 61 additions & 8 deletions packages/ui/src/elements/AvatarUploader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,18 +39,14 @@ const validSize = (f: File) => f.size <= MAX_SIZE_BYTES;

export const AvatarUploader = (props: AvatarUploaderProps) => {
const { t } = useLocalizations();
const [showUpload, setShowUpload] = React.useState(false);
const [objectUrl, setObjectUrl] = React.useState<string>();
const [isDraggingOver, setIsDraggingOver] = React.useState(false);
const card = useCardState();
const inputRef = React.useRef<HTMLInputElement | null>(null);
const openDialog = () => inputRef.current?.click();

const { onAvatarChange, onAvatarRemove, title, avatarPreview, avatarPreviewPlaceholder, ...rest } = props;

const toggle = () => {
setShowUpload(!showUpload);
};

const handleFileDrop = (file: File | null) => {
if (file === null) {
return setObjectUrl('');
Expand All @@ -60,7 +56,6 @@ export const AvatarUploader = (props: AvatarUploaderProps) => {
card.setLoading();
return onAvatarChange(file)
.then(() => {
toggle();
card.setIdle();
})
.catch(err => handleError(err, [], card.setError));
Expand Down Expand Up @@ -90,6 +85,44 @@ export const AvatarUploader = (props: AvatarUploaderProps) => {
await handleFileDrop(f);
};

const isFileDrag = (e: React.DragEvent) => e.dataTransfer?.types?.includes('Files') ?? false;

const handleDragEnter = (e: React.DragEvent<HTMLDivElement>) => {
if (card.isLoading || !isFileDrag(e)) {
return;
}
e.preventDefault();
setIsDraggingOver(true);
};

const handleDragOver = (e: React.DragEvent<HTMLDivElement>) => {
if (card.isLoading || !isFileDrag(e)) {
return;
}
e.preventDefault();
e.dataTransfer.dropEffect = 'copy';
};

const handleDragLeave = (e: React.DragEvent<HTMLDivElement>) => {
// Only reset when leaving the container entirely, not when moving between children.
if (e.currentTarget.contains(e.relatedTarget as Node | null)) {
return;
}
setIsDraggingOver(false);
};

const handleDrop = (e: React.DragEvent<HTMLDivElement>) => {
if (!isFileDrag(e)) {
return;
}
e.preventDefault();
setIsDraggingOver(false);
if (card.isLoading) {
return;
}
void upload(e.dataTransfer.files?.[0]);
};

const hasExistingImage = !!(avatarPreview.props as { imageUrl?: string })?.imageUrl;
const previewElement = objectUrl
? React.cloneElement(avatarPreview, { imageUrl: objectUrl })
Expand All @@ -110,9 +143,29 @@ export const AvatarUploader = (props: AvatarUploaderProps) => {
<Flex
gap={4}
align='center'
onDragEnter={handleDragEnter}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
{...rest}
>
{previewElement}
<Flex
sx={t => ({
borderRadius: t.radii.$md,
transitionProperty: t.transitionProperty.$common,
transitionDuration: t.transitionDuration.$controls,
transitionTimingFunction: t.transitionTiming.$common,
...(isDraggingOver && {
outline: `${t.borderWidths.$normal} dashed ${t.colors.$primary500}`,
outlineOffset: t.space.$0x5,
'&:has([data-rounded="true"])': {
borderRadius: t.radii.$circle,
},
}),
})}
>
{previewElement}
</Flex>
<Col gap={1}>
<Flex
elementDescriptor={descriptors.avatarImageActions}
Expand All @@ -127,7 +180,7 @@ export const AvatarUploader = (props: AvatarUploaderProps) => {
onClick={openDialog}
/>

{!!onAvatarRemove && !showUpload && (
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fixed a bug where the remove button was showing inconsistently

{!!onAvatarRemove && (
<Button
elementDescriptor={descriptors.avatarImageActionsRemove}
localizationKey={localizationKeys('userProfile.profilePage.imageFormDestructiveActionSubtitle')}
Expand Down
174 changes: 174 additions & 0 deletions packages/ui/src/elements/__tests__/AvatarUploader.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
import { fireEvent, render, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { describe, expect, it, vi } from 'vitest';

import { bindCreateFixtures } from '@/test/create-fixtures';

import { localizationKeys } from '../../customizables';
import { AvatarUploader, type AvatarUploaderProps } from '../AvatarUploader';
import { useCardState, withCardStateProvider } from '../contexts';

const { createFixtures } = bindCreateFixtures('UserProfile');

const StubPreview = (_props: { imageUrl?: string }) => <span data-testid='avatar-preview' />;

type HarnessProps = Omit<AvatarUploaderProps, 'title' | 'avatarPreview'>;

const Harness = withCardStateProvider((props: HarnessProps) => {
const card = useCardState();
return (
<>
<AvatarUploader
{...props}
title={localizationKeys('userProfile.profilePage.imageFormTitle')}
avatarPreview={<StubPreview />}
/>
{card.error ? <div data-testid='card-error'>{card.error}</div> : null}
</>
);
});

const makeImageFile = (size = 1024, type = 'image/png') => {
const file = new File([new Uint8Array(size)], 'logo.png', { type });
Object.defineProperty(file, 'size', { value: size });
return file;
};

const makeDataTransfer = (files: File[] = [], types: string[] = ['Files']) =>
({
files,
types,
items: files.map(f => ({ kind: 'file', type: f.type, getAsFile: () => f })),
dropEffect: 'none',
effectAllowed: 'all',
}) as unknown as DataTransfer;

const findFileInput = (container: HTMLElement) => {
const input = container.querySelector<HTMLInputElement>('input[type="file"]');
if (!input) throw new Error('Could not find hidden file input');
return input;
};

const findDropZone = (container: HTMLElement) => {
// The outer Flex registered with the drop handlers is the file input's next sibling.
const sibling = findFileInput(container).nextElementSibling;
if (!sibling) throw new Error('Could not find drop zone element');
return sibling as HTMLElement;
};

describe('AvatarUploader', () => {
describe('click-upload', () => {
it('calls onAvatarChange with the selected file', async () => {
const { wrapper } = await createFixtures();
const onAvatarChange = vi.fn().mockResolvedValue(undefined);
const file = makeImageFile();
const { container } = render(<Harness onAvatarChange={onAvatarChange} />, { wrapper });

fireEvent.change(findFileInput(container), { target: { files: [file] } });

await waitFor(() => expect(onAvatarChange).toHaveBeenCalledWith(file));
});
});

describe('drag-and-drop', () => {
it('calls onAvatarChange when a valid image file is dropped', async () => {
const { wrapper } = await createFixtures();
const onAvatarChange = vi.fn().mockResolvedValue(undefined);
const file = makeImageFile();
const { container } = render(<Harness onAvatarChange={onAvatarChange} />, { wrapper });

fireEvent.drop(findDropZone(container), { dataTransfer: makeDataTransfer([file]) });

await waitFor(() => expect(onAvatarChange).toHaveBeenCalledWith(file));
});

it('rejects unsupported file types', async () => {
const { wrapper } = await createFixtures();
const onAvatarChange = vi.fn();
const pdf = makeImageFile(1024, 'application/pdf');
const { container, findByTestId } = render(<Harness onAvatarChange={onAvatarChange} />, { wrapper });

fireEvent.drop(findDropZone(container), { dataTransfer: makeDataTransfer([pdf]) });

const error = await findByTestId('card-error');
expect(error).toHaveTextContent(/file type not supported/i);
expect(onAvatarChange).not.toHaveBeenCalled();
});

it('rejects files exceeding the max size', async () => {
const { wrapper } = await createFixtures();
const onAvatarChange = vi.fn();
const oversized = makeImageFile(11 * 1000 * 1000);
const { container, findByTestId } = render(<Harness onAvatarChange={onAvatarChange} />, { wrapper });

fireEvent.drop(findDropZone(container), { dataTransfer: makeDataTransfer([oversized]) });

const error = await findByTestId('card-error');
expect(error).toHaveTextContent(/file size exceeds/i);
expect(onAvatarChange).not.toHaveBeenCalled();
});

it('ignores drops that do not contain files (e.g. text drags)', async () => {
const { wrapper } = await createFixtures();
const onAvatarChange = vi.fn();
const { container } = render(<Harness onAvatarChange={onAvatarChange} />, { wrapper });

fireEvent.drop(findDropZone(container), {
dataTransfer: makeDataTransfer([], ['text/plain']),
});

expect(onAvatarChange).not.toHaveBeenCalled();
});
});

describe('remove button', () => {
it('is hidden when onAvatarRemove is not provided', async () => {
const { wrapper } = await createFixtures();
const { queryByRole } = render(<Harness onAvatarChange={vi.fn().mockResolvedValue(undefined)} />, { wrapper });

expect(queryByRole('button', { name: /^remove$/i })).not.toBeInTheDocument();
});

it('stays visible after a successful upload', async () => {
// Regression: previously `showUpload` was toggled inside handleFileDrop and the remove
// button was gated on `!showUpload`, so it disappeared after each successful upload.
const { wrapper } = await createFixtures();
const onAvatarChange = vi.fn().mockResolvedValue(undefined);
const onAvatarRemove = vi.fn();
const { container, getByRole } = render(
<Harness
onAvatarChange={onAvatarChange}
onAvatarRemove={onAvatarRemove}
/>,
{ wrapper },
);

expect(getByRole('button', { name: /^remove$/i })).toBeInTheDocument();

fireEvent.change(findFileInput(container), { target: { files: [makeImageFile()] } });

await waitFor(() => expect(onAvatarChange).toHaveBeenCalledTimes(1));
await waitFor(() => {
expect(getByRole('button', { name: /^remove$/i })).not.toBeDisabled();
});
expect(getByRole('button', { name: /^remove$/i })).toBeInTheDocument();
});

it('invokes onAvatarRemove when clicked', async () => {
const user = userEvent.setup();
const { wrapper } = await createFixtures();
const onAvatarRemove = vi.fn();
const { getByRole } = render(
<Harness
onAvatarChange={vi.fn().mockResolvedValue(undefined)}
onAvatarRemove={onAvatarRemove}
/>,
{ wrapper },
);

await user.click(getByRole('button', { name: /^remove$/i }));

await waitFor(() => expect(onAvatarRemove).toHaveBeenCalledTimes(1));
});
});
});
Loading