mirror of
https://github.com/Tria-plc/emaui.git
synced 2026-08-28 20:40:56 +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?',
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
export * from './personal-document.types';
|
||||
export * from './personal-document.upload';
|
||||
export * from './personal-document-api';
|
||||
|
||||
@@ -9,10 +9,10 @@ const LIST = { type: TAG, id: 'LIST' } as const;
|
||||
* kept against their profile rather than any one application, so they survive
|
||||
* having no seafarer registration yet.
|
||||
*
|
||||
* Bodies are `FormData`: `fetchBaseQuery` passes one through untouched and
|
||||
* never sets `Content-Type`, so the browser's multipart boundary survives.
|
||||
* Going through RTK rather than the raw `uploadDocument` helper is what buys
|
||||
* the automatic refetch after every write.
|
||||
* Reads and deletes live here; the two uploads do not. `fetch` — what
|
||||
* `fetchBaseQuery` runs on — cannot report how much of a request body has gone
|
||||
* up, so they use XHR instead (`personal-document.upload.ts`) and the page
|
||||
* refetches this query when one finishes.
|
||||
*/
|
||||
export const personalDocumentApi = baseApi
|
||||
.enhanceEndpoints({ addTagTypes: [TAG] })
|
||||
@@ -23,33 +23,6 @@ export const personalDocumentApi = baseApi
|
||||
providesTags: () => [LIST],
|
||||
}),
|
||||
|
||||
/** One file per call — front and back of an ID are two uploads. */
|
||||
uploadPersonalDocument: builder.mutation<
|
||||
PersonalDocumentSlot,
|
||||
{ documentKey: string; file: File }
|
||||
>({
|
||||
query: ({ documentKey, file }) => {
|
||||
const body = new FormData();
|
||||
body.append('documentKey', documentKey);
|
||||
body.append('file', file, file.name);
|
||||
return { url: '/profiles/me/documents', method: 'POST', body };
|
||||
},
|
||||
invalidatesTags: (_r, error) => (error ? [] : [LIST]),
|
||||
}),
|
||||
|
||||
/** Swaps one file in place; the slot comes from the file, not the caller. */
|
||||
replacePersonalDocumentFile: builder.mutation<
|
||||
PersonalDocumentSlot,
|
||||
{ fileId: string; file: File }
|
||||
>({
|
||||
query: ({ fileId, file }) => {
|
||||
const body = new FormData();
|
||||
body.append('file', file, file.name);
|
||||
return { url: `/profiles/me/documents/files/${fileId}`, method: 'PUT', body };
|
||||
},
|
||||
invalidatesTags: (_r, error) => (error ? [] : [LIST]),
|
||||
}),
|
||||
|
||||
deletePersonalDocumentFile: builder.mutation<{ deleted: boolean }, string>({
|
||||
query: (fileId) => ({
|
||||
url: `/profiles/me/documents/files/${fileId}`,
|
||||
@@ -63,7 +36,5 @@ export const personalDocumentApi = baseApi
|
||||
|
||||
export const {
|
||||
useGetMyPersonalDocumentsQuery,
|
||||
useUploadPersonalDocumentMutation,
|
||||
useReplacePersonalDocumentFileMutation,
|
||||
useDeletePersonalDocumentFileMutation,
|
||||
} = personalDocumentApi;
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
import { resolveTokenFromStorage } from '../../session';
|
||||
import type { PersonalDocumentSlot } from './personal-document.types';
|
||||
|
||||
const BASE_API_URL =
|
||||
(import.meta as { env?: Record<string, string> }).env?.['VITE_BASE_API_URL'] ??
|
||||
'http://localhost:3000/api';
|
||||
|
||||
/** What the server says when it refuses a file — see ProfileDocumentsService. */
|
||||
export interface PersonalDocumentError {
|
||||
message?: string;
|
||||
[detail: string]: unknown;
|
||||
}
|
||||
|
||||
export type PersonalDocumentUploadResult =
|
||||
| { ok: true; slot: PersonalDocumentSlot }
|
||||
| { ok: false; error: PersonalDocumentError };
|
||||
|
||||
/**
|
||||
* Uploads one file and reports how far it has got.
|
||||
*
|
||||
* XHR rather than `fetch`, and therefore outside RTK Query: `fetch` has no
|
||||
* upload progress event, so a request body of any size is a spinner with
|
||||
* nothing behind it. That is tolerable for a 5 MB scan and not for the video a
|
||||
* slot can now be opened to, where the difference between "uploading" and
|
||||
* "uploading, 12%" is the difference between waiting and reloading the page.
|
||||
*
|
||||
* The caller refetches the vault afterwards; nothing here touches the cache.
|
||||
*/
|
||||
function upload(
|
||||
path: string,
|
||||
method: 'POST' | 'PUT',
|
||||
body: FormData,
|
||||
onProgress?: (percent: number) => void,
|
||||
): Promise<PersonalDocumentUploadResult> {
|
||||
return new Promise((resolve) => {
|
||||
const request = new XMLHttpRequest();
|
||||
request.open(method, `${BASE_API_URL}${path}`);
|
||||
|
||||
const token = resolveTokenFromStorage();
|
||||
if (token) request.setRequestHeader('Authorization', `Bearer ${token}`);
|
||||
|
||||
request.upload.onprogress = (event) => {
|
||||
// Not every browser knows the total for a streamed body; without it a
|
||||
// percentage would be invented, so the caller keeps its spinner.
|
||||
if (!event.lengthComputable || !onProgress) return;
|
||||
onProgress(Math.round((event.loaded / event.total) * 100));
|
||||
};
|
||||
|
||||
request.onload = () => {
|
||||
const parsed = parseBody(request.responseText);
|
||||
if (request.status >= 200 && request.status < 300) {
|
||||
resolve({ ok: true, slot: parsed as PersonalDocumentSlot });
|
||||
return;
|
||||
}
|
||||
resolve({ ok: false, error: toError(parsed, request.status) });
|
||||
};
|
||||
|
||||
// A dropped connection and a cancelled request both land here; neither
|
||||
// carries a server message, so the caller falls back to its own wording.
|
||||
request.onerror = () => resolve({ ok: false, error: {} });
|
||||
request.onabort = () => resolve({ ok: false, error: {} });
|
||||
|
||||
request.send(body);
|
||||
});
|
||||
}
|
||||
|
||||
function parseBody(text: string): unknown {
|
||||
try {
|
||||
return JSON.parse(text);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Nest wraps a thrown `BadRequestException({ message, ... })` as
|
||||
* `{ message: { message, ... } }`, and a plain string message as
|
||||
* `{ message: "slot_full" }`. Both are flattened to the object the UI
|
||||
* translates by its `message` key.
|
||||
*/
|
||||
function toError(parsed: unknown, status: number): PersonalDocumentError {
|
||||
const message = (parsed as { message?: unknown } | null)?.message;
|
||||
if (typeof message === 'object' && message !== null) {
|
||||
return message as PersonalDocumentError;
|
||||
}
|
||||
if (typeof message === 'string') return { message };
|
||||
return { message: `http_${status}` };
|
||||
}
|
||||
|
||||
/** Adds one file to a personal document slot. */
|
||||
export function uploadPersonalDocumentFile(params: {
|
||||
documentKey: string;
|
||||
file: File;
|
||||
onProgress?: (percent: number) => void;
|
||||
}): Promise<PersonalDocumentUploadResult> {
|
||||
const body = new FormData();
|
||||
body.append('documentKey', params.documentKey);
|
||||
body.append('file', params.file, params.file.name);
|
||||
return upload('/profiles/me/documents', 'POST', body, params.onProgress);
|
||||
}
|
||||
|
||||
/** Swaps one file for another in the same slot. */
|
||||
export function replacePersonalDocumentFile(params: {
|
||||
fileId: string;
|
||||
file: File;
|
||||
onProgress?: (percent: number) => void;
|
||||
}): Promise<PersonalDocumentUploadResult> {
|
||||
const body = new FormData();
|
||||
body.append('file', params.file, params.file.name);
|
||||
return upload(
|
||||
`/profiles/me/documents/files/${params.fileId}`,
|
||||
'PUT',
|
||||
body,
|
||||
params.onProgress,
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user