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?',

View File

@@ -1,2 +1,3 @@
export * from './personal-document.types';
export * from './personal-document.upload';
export * from './personal-document-api';

View File

@@ -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;

View File

@@ -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,
);
}