mirror of
https://github.com/Tria-plc/emaui.git
synced 2026-08-30 11:08:13 +00:00
feat: implement XHR-based file upload with progress tracking to replace RTK-based mutations for personal documents
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -1334,6 +1334,7 @@ export const am: Translations = {
|
||||
fileCountUnlimited_one: '{{count}} ፋይል',
|
||||
fileCountUnlimited_other: '{{count}} ፋይሎች',
|
||||
slotFull: 'ይህ ሰነድ ተሟልቷል። ለመቀየር አንዱን ፋይል ይተኩ ወይም ያስወግዱ።',
|
||||
uploading: 'በመስቀል ላይ… {{percent}}%',
|
||||
delete: 'ፋይል አስወግድ',
|
||||
confirmDelete: {
|
||||
title: 'ይህን ፋይል ያስወግዱ?',
|
||||
|
||||
@@ -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?',
|
||||
|
||||
Reference in New Issue
Block a user