feat: implement XHR-based file upload with progress tracking to replace RTK-based mutations for personal documents

This commit is contained in:
estifanos
2026-08-28 10:42:04 +00:00
parent bd220a8fb2
commit 3368c9bd3f
6 changed files with 183 additions and 53 deletions

View File

@@ -10,6 +10,7 @@ import {
Group,
Loader,
Modal,
Progress,
SimpleGrid,
Stack,
Text,
@@ -26,24 +27,25 @@ import { useTranslation } from 'react-i18next';
import { ModalFooter } from '@ema-platform/ui';
import { useLocalized } from '@ema-platform/api';
import {
replacePersonalDocumentFile,
uploadPersonalDocumentFile,
useDeletePersonalDocumentFileMutation,
useGetMyPersonalDocumentsQuery,
useReplacePersonalDocumentFileMutation,
useUploadPersonalDocumentMutation,
type AttachmentFile,
type PersonalDocumentError,
type PersonalDocumentSlot,
type PersonalDocumentUploadResult,
} from '@ema-platform/api';
import { PORTAL_PERMISSIONS, RequirePermission } from '@ema-platform/auth';
/**
* A refused upload comes back as a structured payload — which slot, what was
* allowed, how big — so the applicant reads the real reason rather than
* "upload failed".
* A refused delete comes back through RTK, which nests the server's payload
* under `data`. Uploads report theirs directly — see the XHR helper.
*/
function errorBody(err: unknown): { message?: string } & Record<string, unknown> {
function errorBody(err: unknown): PersonalDocumentError {
const payload = (err as { data?: { message?: unknown } })?.data?.message;
return typeof payload === 'object' && payload !== null
? (payload as { message?: string } & Record<string, unknown>)
? (payload as PersonalDocumentError)
: { message: typeof payload === 'string' ? payload : undefined };
}
@@ -63,12 +65,13 @@ export function PersonalDocumentSlots({
const { t } = useTranslation();
const localized = useLocalized();
const { data, isLoading } = useGetMyPersonalDocumentsQuery();
const [uploadDocument] = useUploadPersonalDocumentMutation();
const [replaceFile] = useReplacePersonalDocumentFileMutation();
const { data, isLoading, refetch } = useGetMyPersonalDocumentsQuery();
const [deleteFile] = useDeletePersonalDocumentFileMutation();
const [busy, setBusy] = useState<string | null>(null);
// Percent for the upload in flight. A video is minutes of waiting, so the
// bar is the difference between waiting and reloading the page.
const [progress, setProgress] = useState<number | null>(null);
const [error, setError] = useState<string | null>(null);
const [deleteTarget, setDeleteTarget] = useState<AttachmentFile | null>(null);
// Mantine's FileButton clears its input through a ref object, and there is
@@ -100,25 +103,46 @@ export function PersonalDocumentSlots({
return null;
}
function describe(body: PersonalDocumentError): string {
return t(`documents.personal.errors.${body.message ?? 'unknown'}`, {
...body,
defaultValue: t('documents.personal.errors.unknown'),
});
}
/** Deletes, which still go through RTK and refetch themselves. */
async function run(busyKey: string, action: () => Promise<unknown>) {
setBusy(busyKey);
setError(null);
try {
await action();
} catch (err) {
const body = errorBody(err);
setError(
t(`documents.personal.errors.${body.message ?? 'unknown'}`, {
...body,
defaultValue: t('documents.personal.errors.unknown'),
}),
);
setError(describe(errorBody(err)));
} finally {
setBusy(null);
clearInput(busyKey);
}
}
/**
* Uploads, which report progress and so bypass RTK — the vault is refetched
* by hand once the file has landed.
*/
async function send(
busyKey: string,
action: (onProgress: (percent: number) => void) => Promise<PersonalDocumentUploadResult>,
) {
setBusy(busyKey);
setProgress(0);
setError(null);
const result = await action(setProgress);
if (result.ok) await refetch();
else setError(describe(result.error));
setBusy(null);
setProgress(null);
clearInput(busyKey);
}
function handleUpload(slot: PersonalDocumentSlot, file: File | null) {
if (!file) return;
const rejected = rejectFile(slot, file);
@@ -127,8 +151,8 @@ export function PersonalDocumentSlots({
clearInput(slot.key);
return;
}
return run(slot.key, () =>
uploadDocument({ documentKey: slot.key, file }).unwrap(),
return send(slot.key, (onProgress) =>
uploadPersonalDocumentFile({ documentKey: slot.key, file, onProgress }),
);
}
@@ -140,7 +164,9 @@ export function PersonalDocumentSlots({
clearInput(fileId);
return;
}
return run(fileId, () => replaceFile({ fileId, file }).unwrap());
return send(fileId, (onProgress) =>
replacePersonalDocumentFile({ fileId, file, onProgress }),
);
}
async function confirmDelete() {
@@ -149,6 +175,11 @@ export function PersonalDocumentSlots({
setDeleteTarget(null);
}
/** The bar belongs to the card whose slot, or whose file, is uploading. */
function isThisSlot(slot: PersonalDocumentSlot, busyKey: string) {
return busyKey === slot.key || slot.files.some((f) => f.id === busyKey);
}
if (isLoading) return <Loader size="sm" type="oval" />;
const slots = data?.slots ?? [];
@@ -274,6 +305,15 @@ export function PersonalDocumentSlots({
</Stack>
)}
{busy !== null && progress !== null && isThisSlot(slot, busy) && (
<Stack gap={2} mt="sm">
<Progress value={progress} size="sm" radius="xl" animated />
<Text fz="xs" c="dimmed" ta="right">
{t('documents.personal.uploading', { percent: progress })}
</Text>
</Stack>
)}
<RequirePermission anyOf={[PORTAL_PERMISSIONS.UPLOAD_DOCUMENTS]} hideOnly>
<Tooltip label={t('documents.personal.slotFull')} disabled={!full}>
<div>

View File

@@ -1334,6 +1334,7 @@ export const am: Translations = {
fileCountUnlimited_one: '{{count}} ፋይል',
fileCountUnlimited_other: '{{count}} ፋይሎች',
slotFull: 'ይህ ሰነድ ተሟልቷል። ለመቀየር አንዱን ፋይል ይተኩ ወይም ያስወግዱ።',
uploading: 'በመስቀል ላይ… {{percent}}%',
delete: 'ፋይል አስወግድ',
confirmDelete: {
title: 'ይህን ፋይል ያስወግዱ?',

View File

@@ -1338,6 +1338,7 @@ export const en = {
fileCountUnlimited_one: '{{count}} file',
fileCountUnlimited_other: '{{count}} files',
slotFull: 'This document is complete. Replace or remove a file to change it.',
uploading: 'Uploading… {{percent}}%',
delete: 'Remove file',
confirmDelete: {
title: 'Remove this file?',