Skip to content
Merged
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
1 change: 1 addition & 0 deletions packages/account/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@
"react": "^18.3.1",
"react-device-detect": "^2.2.3",
"react-dom": "^18.3.1",
"react-easy-crop": "^5.5.7",
"react-helmet": "^6.1.0",
"react-i18next": "^12.3.1",
"react-modal": "^3.15.1",
Expand Down
169 changes: 169 additions & 0 deletions packages/account/src/components/AvatarUploadField/index.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
import { fireEvent, render, waitFor } from '@testing-library/react';

import { uploadAccountAvatar } from '@ac/apis/avatar';

import AvatarUploadField from '.';

const mockGetAccessToken = jest.fn();

jest.mock('@logto/react', () => ({
useLogto: () => ({
getAccessToken: mockGetAccessToken,
}),
}));

jest.mock('@ac/apis/avatar', () => ({
uploadAccountAvatar: jest.fn(),
}));

jest.mock('@experience/utils/image-crop', () => ({
getCroppedImageBlob: jest.fn(async () => new Blob([new Uint8Array([1])], { type: 'image/jpeg' })),
}));

// Render the crop modal inline and immediately report a crop area so confirming works in jsdom.
jest.mock('react-modal', () => ({
__esModule: true,
default: ({ isOpen, children }: { isOpen: boolean; children: React.ReactNode }) =>
isOpen ? <div data-testid="crop-modal">{children}</div> : null,
}));

jest.mock('react-easy-crop', () => ({
__esModule: true,
// The cropper reports the crop area synchronously so confirming has a value to export.
default: ({
onCropComplete,
}: {
onCropComplete: (area: unknown, areaPixels: unknown) => void;
}) => {
onCropComplete({}, { x: 0, y: 0, width: 100, height: 100 });
return <div data-testid="cropper" />;
},
}));

jest.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string, options?: Record<string, string>) => {
if (options?.extensions) {
return `${key}:${options.extensions}`;
}
if (options?.limit) {
return `${key}:${options.limit}`;
}
return key;
},
i18n: { dir: () => 'ltr' },
}),
}));

const selectFile = (container: HTMLElement, file: File) => {
const input = container.querySelector('input[type="file"]');
if (!(input instanceof HTMLInputElement)) {
throw new TypeError('file input not found');
}
fireEvent.change(input, { target: { files: [file] } });
};

const validImageFile = () =>
new File([new Uint8Array([0xff, 0xd8, 0xff])], 'avatar.jpg', { type: 'image/jpeg' });

const urlObjectHelpersSnapshot = {
createObjectURL: undefined as typeof URL.createObjectURL | undefined,
revokeObjectURL: undefined as typeof URL.revokeObjectURL | undefined,
};

describe('AvatarUploadField (account center)', () => {
beforeAll(() => {
// eslint-disable-next-line @silverhand/fp/no-mutation
urlObjectHelpersSnapshot.createObjectURL = globalThis.URL.createObjectURL;
// eslint-disable-next-line @silverhand/fp/no-mutation
urlObjectHelpersSnapshot.revokeObjectURL = globalThis.URL.revokeObjectURL;
});

beforeEach(() => {
jest.clearAllMocks();
mockGetAccessToken.mockResolvedValue('access-token');
// Jsdom does not implement object URL helpers used by the crop flow.
// eslint-disable-next-line @silverhand/fp/no-mutation
globalThis.URL.createObjectURL = jest.fn(() => 'blob:mock-avatar');
// eslint-disable-next-line @silverhand/fp/no-mutation
globalThis.URL.revokeObjectURL = jest.fn();
});

afterAll(() => {
// eslint-disable-next-line @silverhand/fp/no-mutation
globalThis.URL.createObjectURL =
urlObjectHelpersSnapshot.createObjectURL ?? (() => 'blob:restored');
// eslint-disable-next-line @silverhand/fp/no-mutation
globalThis.URL.revokeObjectURL = urlObjectHelpersSnapshot.revokeObjectURL ?? jest.fn();
});

it('opens the crop modal on selection and uploads the cropped image after confirming', async () => {
jest.mocked(uploadAccountAvatar).mockResolvedValue({ url: 'https://example.com/avatar.png' });
const onChange = jest.fn();

const { container, getByTestId, getByText } = render(
<AvatarUploadField label="Avatar" onChange={onChange} />
);

selectFile(container, validImageFile());

// The crop modal opens and no upload happens yet.
await waitFor(() => {
expect(getByTestId('crop-modal')).toBeTruthy();
});
expect(uploadAccountAvatar).not.toHaveBeenCalled();

fireEvent.click(getByText('action.save'));

await waitFor(() => {
expect(uploadAccountAvatar).toHaveBeenCalledTimes(1);
});

const [accessToken, uploadedFile, options] =
jest.mocked(uploadAccountAvatar).mock.calls[0] ?? [];
expect(accessToken).toBe('access-token');
expect(uploadedFile).toBeInstanceOf(File);
expect(uploadedFile?.type).toBe('image/jpeg');
expect(options?.signal).toBeInstanceOf(AbortSignal);
expect(onChange).toHaveBeenCalledWith('https://example.com/avatar.png');
});

it('shows a client-side error for unsupported file types and does not open the modal', async () => {
const onChange = jest.fn();

const { container, getByRole, queryByTestId } = render(
<AvatarUploadField label="Avatar" onChange={onChange} />
);

selectFile(container, new File(['text'], 'notes.txt', { type: 'text/plain' }));

await waitFor(() => {
expect(getByRole('alert').textContent).toBe('error_file_type:JPEG, PNG, GIF, WebP, BMP');
});
expect(queryByTestId('crop-modal')).toBeNull();
expect(uploadAccountAvatar).not.toHaveBeenCalled();
expect(onChange).not.toHaveBeenCalled();
});

it('surfaces a generic error and does not update the value when upload fails', async () => {
jest.mocked(uploadAccountAvatar).mockRejectedValue(new Error('network error'));
const onChange = jest.fn();

const { container, getByTestId, getByText, getByRole } = render(
<AvatarUploadField label="Avatar" onChange={onChange} />
);

selectFile(container, validImageFile());

await waitFor(() => {
expect(getByTestId('crop-modal')).toBeTruthy();
});

fireEvent.click(getByText('action.save'));

await waitFor(() => {
expect(getByRole('alert').textContent).toBe('error_upload');
});
expect(onChange).not.toHaveBeenCalled();
});
});
137 changes: 34 additions & 103 deletions packages/account/src/components/AvatarUploadField/index.tsx
Original file line number Diff line number Diff line change
@@ -1,17 +1,11 @@
import UserAvatar from '@experience/assets/icons/default-user-avatar.svg?react';
import AvatarCropModal from '@experience/components/AvatarCropModal';
import useAvatarCropUpload from '@experience/hooks/use-avatar-crop-upload';
import RotatingRingIcon from '@experience/shared/components/Button/RotatingRingIcon';
import {
avatarFileAccept,
avatarFileExtensions,
formatFileSizeLimit,
getAvatarUploadErrorMessage,
validateAvatarFile,
} from '@experience/utils/avatar-upload';
import { avatarFileAccept } from '@experience/utils/avatar-upload';
import { useLogto } from '@logto/react';
import { maxUploadFileSize, type RequestErrorBody } from '@logto/schemas';
import classNames from 'classnames';
import { HTTPError } from 'ky';
import { useCallback, useEffect, useId, useRef, useState } from 'react';
import { useCallback, useId, useRef } from 'react';
import { useTranslation } from 'react-i18next';

import { uploadAccountAvatar } from '@ac/apis/avatar';
Expand All @@ -21,9 +15,6 @@ import profileStyles from '../../pages/Profile/index.module.scss';

import styles from './index.module.scss';

const isAbortError = (error: unknown) =>
(error instanceof DOMException || error instanceof Error) && error.name === 'AbortError';

type Props = {
readonly className?: string;
readonly label: string;
Expand All @@ -37,104 +28,37 @@ const AvatarUploadField = ({ className, label, value = '', onChange }: Props) =>
const { getAccessToken } = useLogto();
const inputId = useId();
const inputRef = useRef<HTMLInputElement>(null);
const abortControllerRef = useRef<AbortController>();
const [isUploading, setIsUploading] = useState(false);
const [uploadError, setUploadError] = useState<string>();
const [fileInputKey, setFileInputKey] = useState(0);

useEffect(() => {
return () => {
abortControllerRef.current?.abort();
};
}, []);

const openFilePicker = useCallback(() => {
if (isUploading) {
return;
}

inputRef.current?.click();
}, [isUploading]);

const resetFileInput = useCallback(() => {
setFileInputKey((key) => key + 1);
}, []);
const upload = useCallback(
async (file: File, options: { signal: AbortSignal }) => {
const accessToken = await getAccessToken();

const handleUploadError = useCallback(
async (error: unknown) => {
if (error instanceof HTTPError) {
try {
const errorBody = await error.response.json<RequestErrorBody>();
setUploadError(getAvatarUploadErrorMessage(errorBody, tAvatar));
return;
} catch {
// Fall through to generic error message.
}
if (!accessToken) {
throw new Error('Session expired');
}

setUploadError(tAvatar('error_upload'));
return uploadAccountAvatar(accessToken, file, options);
},
[tAvatar]
[getAccessToken]
);

const handleFileChange = useCallback(
async (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];

if (!file) {
return;
}

const validationError = validateAvatarFile(file);

if (validationError === 'file_size_exceeded') {
setUploadError(
tAvatar('error_file_size', { limit: formatFileSizeLimit(maxUploadFileSize) })
);
resetFileInput();
return;
}
const {
cropImageSource,
isUploading,
uploadError,
fileInputKey,
handleFileChange,
handleCropCancel,
handleCropConfirm,
} = useAvatarCropUpload({ upload, onChange });

if (validationError === 'file_type') {
setUploadError(tAvatar('error_file_type', { extensions: avatarFileExtensions }));
resetFileInput();
return;
}

abortControllerRef.current?.abort();
const abortController = new AbortController();
// eslint-disable-next-line @silverhand/fp/no-mutation
abortControllerRef.current = abortController;

setUploadError(undefined);
setIsUploading(true);

try {
const accessToken = await getAccessToken();

if (!accessToken) {
throw new Error('Session expired');
}

const { url } = await uploadAccountAvatar(accessToken, file, {
signal: abortController.signal,
});
await onChange(url);
} catch (error: unknown) {
if (isAbortError(error) || abortController.signal.aborted) {
return;
}
const openFilePicker = useCallback(() => {
if (isUploading) {
return;
}

await handleUploadError(error);
} finally {
if (!abortController.signal.aborted) {
setIsUploading(false);
setFileInputKey((key) => key + 1);
}
}
},
[getAccessToken, handleUploadError, onChange, resetFileInput, tAvatar]
);
inputRef.current?.click();
}, [isUploading]);

const actionLabel = isUploading
? tAvatar('uploading')
Expand Down Expand Up @@ -175,7 +99,7 @@ const AvatarUploadField = ({ className, label, value = '', onChange }: Props) =>
) : (
<UserAvatar className={styles.placeholder} />
)}
{uploadError && (
{uploadError && !cropImageSource && (
<span className={styles.errorText} role="alert">
{uploadError}
</span>
Expand All @@ -191,6 +115,13 @@ const AvatarUploadField = ({ className, label, value = '', onChange }: Props) =>
accept={avatarFileAccept}
onChange={handleFileChange}
/>
<AvatarCropModal
imageSource={cropImageSource}
isUploading={isUploading}
uploadError={uploadError}
onCancel={handleCropCancel}
onConfirm={handleCropConfirm}
/>
</div>
);
};
Expand Down
Loading
Loading