mirror of
https://github.com/Sea-Haven-Industries/shoc-frontend-new.git
synced 2026-10-06 13:32:06 +00:00
Merge pull request #30 from Sea-Haven-Industries/feature/wo-board-foundation
feat(ui): add loading/error primitives and FormData upload fix
This commit is contained in:
commit
4845494af5
12 changed files with 444 additions and 4 deletions
|
|
@ -21,6 +21,11 @@ function isAuthLoginRequest(request: Request): boolean {
|
||||||
return request.url.includes(API_PATHS.authentication.login);
|
return request.url.includes(API_PATHS.authentication.login);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Ky's beforeRequest sees request.body as a stream; check options.body for FormData. */
|
||||||
|
export function shouldStripJsonContentType(body: unknown): boolean {
|
||||||
|
return body instanceof FormData;
|
||||||
|
}
|
||||||
|
|
||||||
export const api: KyInstance = ky.create({
|
export const api: KyInstance = ky.create({
|
||||||
prefix: resolveApiPrefixUrl(env.apiUrl),
|
prefix: resolveApiPrefixUrl(env.apiUrl),
|
||||||
headers: {
|
headers: {
|
||||||
|
|
@ -29,11 +34,15 @@ export const api: KyInstance = ky.create({
|
||||||
},
|
},
|
||||||
hooks: {
|
hooks: {
|
||||||
beforeRequest: [
|
beforeRequest: [
|
||||||
({ request }) => {
|
({ request, options }) => {
|
||||||
const token = getAccessToken();
|
const token = getAccessToken();
|
||||||
if (token) {
|
if (token) {
|
||||||
request.headers.set("Authorization", token);
|
request.headers.set("Authorization", token);
|
||||||
}
|
}
|
||||||
|
// Let the runtime set multipart boundary; default JSON Content-Type breaks FormData.
|
||||||
|
if (shouldStripJsonContentType(options.body)) {
|
||||||
|
request.headers.delete("Content-Type");
|
||||||
|
}
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
beforeError: [({ error }) => normalizeApiRequestError(error)],
|
beforeError: [({ error }) => normalizeApiRequestError(error)],
|
||||||
|
|
@ -58,10 +67,18 @@ export async function apiPost<T>(url: string, body?: unknown, options?: Options)
|
||||||
return api.post(url, { ...options, json: body }).json<T>();
|
return api.post(url, { ...options, json: body }).json<T>();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function apiPostForm<T>(url: string, body: FormData, options?: Options): Promise<T> {
|
||||||
|
return api.post(url, { ...options, body }).json<T>();
|
||||||
|
}
|
||||||
|
|
||||||
export async function apiPut<T>(url: string, body?: unknown, options?: Options): Promise<T> {
|
export async function apiPut<T>(url: string, body?: unknown, options?: Options): Promise<T> {
|
||||||
return api.put(url, { ...options, json: body }).json<T>();
|
return api.put(url, { ...options, json: body }).json<T>();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function apiPatch<T>(url: string, body?: unknown, options?: Options): Promise<T> {
|
||||||
|
return api.patch(url, { ...options, json: body }).json<T>();
|
||||||
|
}
|
||||||
|
|
||||||
export async function apiDelete<T>(url: string, options?: Options): Promise<T> {
|
export async function apiDelete<T>(url: string, options?: Options): Promise<T> {
|
||||||
return api.delete(url, options).json<T>();
|
return api.delete(url, options).json<T>();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,50 @@
|
||||||
|
import { Box, Skeleton, Stack } from "@mui/material";
|
||||||
|
import { LoadingRegion } from "@/components/ui/loading-region";
|
||||||
|
|
||||||
|
type DetailPageSkeletonProps = {
|
||||||
|
/** Form layout (create/edit) vs view layout. */
|
||||||
|
variant?: "view" | "form";
|
||||||
|
};
|
||||||
|
|
||||||
|
export function DetailPageSkeleton({ variant = "view" }: DetailPageSkeletonProps) {
|
||||||
|
return (
|
||||||
|
<LoadingRegion
|
||||||
|
label={variant === "form" ? "Loading work order form" : "Loading work order"}
|
||||||
|
className="flex flex-col gap-4 p-4"
|
||||||
|
>
|
||||||
|
<Skeleton variant="text" width={200} height={20} animation="wave" />
|
||||||
|
<Skeleton variant="text" width={280} height={32} animation="wave" />
|
||||||
|
|
||||||
|
{variant === "view" ? (
|
||||||
|
<Stack spacing={3} className="mt-2">
|
||||||
|
{Array.from({ length: 3 }, (_, i) => (
|
||||||
|
<Box key={i} className="rounded-lg border border-border p-4">
|
||||||
|
<Skeleton variant="text" width={120} height={18} className="mb-3" animation="wave" />
|
||||||
|
<Stack direction={{ xs: "column", md: "row" }} spacing={2}>
|
||||||
|
<Skeleton variant="rounded" height={40} className="flex-1" animation="wave" />
|
||||||
|
<Skeleton variant="rounded" height={40} className="flex-1" animation="wave" />
|
||||||
|
<Skeleton variant="rounded" height={40} className="flex-1" animation="wave" />
|
||||||
|
</Stack>
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
) : (
|
||||||
|
<Stack spacing={3} className="mt-2">
|
||||||
|
{Array.from({ length: 4 }, (_, i) => (
|
||||||
|
<Box key={i}>
|
||||||
|
<Skeleton variant="text" width={100} height={16} className="mb-2" animation="wave" />
|
||||||
|
<Stack direction={{ xs: "column", md: "row" }} spacing={2}>
|
||||||
|
<Skeleton variant="rounded" height={56} className="flex-1" animation="wave" />
|
||||||
|
<Skeleton variant="rounded" height={56} className="flex-1" animation="wave" />
|
||||||
|
</Stack>
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
|
<Stack direction="row" spacing={2} sx={{ justifyContent: "flex-end" }} className="mt-4">
|
||||||
|
<Skeleton variant="rounded" width={96} height={36} animation="wave" />
|
||||||
|
<Skeleton variant="rounded" width={96} height={36} animation="wave" />
|
||||||
|
</Stack>
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
</LoadingRegion>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,49 @@
|
||||||
|
import { Box, DialogContent, DialogTitle, Skeleton, Stack } from "@mui/material";
|
||||||
|
import { LoadingRegion } from "@/components/ui/loading-region";
|
||||||
|
|
||||||
|
export function DispatchDetailSkeleton() {
|
||||||
|
return (
|
||||||
|
<LoadingRegion label="Loading dispatch details">
|
||||||
|
<DialogTitle>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Skeleton variant="text" width={200} height={28} animation="wave" />
|
||||||
|
<Skeleton variant="circular" width={28} height={28} animation="wave" />
|
||||||
|
</div>
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogContent className="flex flex-col gap-6">
|
||||||
|
<Box className="rounded border border-border p-3">
|
||||||
|
<Stack direction={{ xs: "column", sm: "row" }} spacing={2}>
|
||||||
|
{Array.from({ length: 4 }, (_, i) => (
|
||||||
|
<div key={i} className="flex-1 space-y-1">
|
||||||
|
<Skeleton variant="text" width={64} height={12} animation="wave" />
|
||||||
|
<Skeleton variant="rounded" height={24} animation="wave" />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box>
|
||||||
|
<Skeleton variant="text" width={120} height={20} className="mb-2" animation="wave" />
|
||||||
|
<Stack spacing={1}>
|
||||||
|
{Array.from({ length: 3 }, (_, i) => (
|
||||||
|
<div key={i} className="flex items-center gap-2">
|
||||||
|
<Skeleton variant="rounded" width={20} height={20} animation="wave" />
|
||||||
|
<Skeleton variant="text" width="50%" height={16} animation="wave" />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box>
|
||||||
|
<Skeleton variant="text" width={80} height={20} className="mb-2" animation="wave" />
|
||||||
|
<Stack direction={{ xs: "column", sm: "row" }} spacing={2}>
|
||||||
|
<Skeleton variant="rounded" height={100} className="flex-1" animation="wave" />
|
||||||
|
<Skeleton variant="rounded" height={100} className="flex-1" animation="wave" />
|
||||||
|
</Stack>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Skeleton variant="rounded" height={80} animation="wave" />
|
||||||
|
</DialogContent>
|
||||||
|
</LoadingRegion>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,75 @@
|
||||||
|
import { Skeleton, Stack } from "@mui/material";
|
||||||
|
import { LoadingRegion } from "@/components/ui/loading-region";
|
||||||
|
|
||||||
|
export type SlideOverTabSkeletonVariant = "info" | "comments" | "audit" | "completion" | "extras";
|
||||||
|
|
||||||
|
type SlideOverTabSkeletonProps = {
|
||||||
|
variant?: SlideOverTabSkeletonVariant;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function SlideOverTabSkeleton({ variant = "info" }: SlideOverTabSkeletonProps) {
|
||||||
|
return (
|
||||||
|
<LoadingRegion label="Loading details" className="w-full">
|
||||||
|
{variant === "info" && <InfoSkeleton />}
|
||||||
|
{variant === "comments" && <CommentsSkeleton />}
|
||||||
|
{variant === "audit" && <AuditSkeleton />}
|
||||||
|
{(variant === "completion" || variant === "extras") && <DocsSkeleton />}
|
||||||
|
</LoadingRegion>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function InfoSkeleton() {
|
||||||
|
return (
|
||||||
|
<Stack spacing={2.5}>
|
||||||
|
{Array.from({ length: 6 }, (_, i) => (
|
||||||
|
<div key={i} className="space-y-1.5">
|
||||||
|
<Skeleton variant="text" width={72} height={12} animation="wave" />
|
||||||
|
<Skeleton variant="rounded" height={36} animation="wave" />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CommentsSkeleton() {
|
||||||
|
return (
|
||||||
|
<Stack spacing={2}>
|
||||||
|
{Array.from({ length: 3 }, (_, i) => (
|
||||||
|
<div key={i} className="space-y-1.5 rounded-lg border border-border p-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Skeleton variant="circular" width={28} height={28} animation="wave" />
|
||||||
|
<Skeleton variant="text" width={120} height={14} animation="wave" />
|
||||||
|
</div>
|
||||||
|
<Skeleton variant="rounded" height={40} animation="wave" />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<Skeleton variant="rounded" height={80} animation="wave" />
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AuditSkeleton() {
|
||||||
|
return (
|
||||||
|
<Stack spacing={2}>
|
||||||
|
{Array.from({ length: 4 }, (_, i) => (
|
||||||
|
<div key={i} className="flex gap-3">
|
||||||
|
<Skeleton variant="circular" width={10} height={10} className="mt-1.5 shrink-0" />
|
||||||
|
<div className="flex-1 space-y-1">
|
||||||
|
<Skeleton variant="text" width="60%" height={14} animation="wave" />
|
||||||
|
<Skeleton variant="text" width="40%" height={12} animation="wave" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DocsSkeleton() {
|
||||||
|
return (
|
||||||
|
<Stack spacing={2}>
|
||||||
|
<Skeleton variant="text" width={140} height={16} animation="wave" />
|
||||||
|
<Skeleton variant="rounded" height={48} animation="wave" />
|
||||||
|
<Skeleton variant="rounded" height={120} animation="wave" />
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,72 @@
|
||||||
|
import { Skeleton } from "@mui/material";
|
||||||
|
import { LoadingRegion } from "@/components/ui/loading-region";
|
||||||
|
import { TableSkeletonRows } from "@/components/ui/table-skeleton-rows";
|
||||||
|
import { buildWeekSkeleton } from "@/domain/work-orders/utils/table-day-utils";
|
||||||
|
import { WoTableHead } from "@/app/(protected)/workorders/_components/list/table/wo-table-head";
|
||||||
|
import {
|
||||||
|
WO_TABLE_COLS,
|
||||||
|
WO_TABLE_MIN_WIDTH,
|
||||||
|
WO_TABLE_ROW_HEIGHT,
|
||||||
|
} from "@/app/(protected)/workorders/_components/list/table/wo-table-cols";
|
||||||
|
|
||||||
|
type WoTableSkeletonProps = {
|
||||||
|
weekMonday: string;
|
||||||
|
rowsPerDay?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const SKELETON_COLS = WO_TABLE_COLS.map((col) => ({ key: col.key, width: col.w }));
|
||||||
|
|
||||||
|
export function WoTableSkeleton({ weekMonday, rowsPerDay = 2 }: WoTableSkeletonProps) {
|
||||||
|
const days = buildWeekSkeleton(weekMonday);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<LoadingRegion label="Loading work orders" className="h-full">
|
||||||
|
<table
|
||||||
|
style={{
|
||||||
|
width: "100%",
|
||||||
|
minWidth: WO_TABLE_MIN_WIDTH,
|
||||||
|
borderCollapse: "collapse",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<WoTableHead />
|
||||||
|
<tbody>
|
||||||
|
{days.map((day) => (
|
||||||
|
<DaySkeletonSection key={day.key} label={day.label} rows={rowsPerDay} />
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</LoadingRegion>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DaySkeletonSection({ label, rows }: { label: string; rows: number }) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<tr>
|
||||||
|
<td
|
||||||
|
colSpan={WO_TABLE_COLS.length}
|
||||||
|
style={{
|
||||||
|
padding: "10px 14px",
|
||||||
|
background: "var(--color-bg-muted)",
|
||||||
|
borderBottom: "1px solid var(--border)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: 700,
|
||||||
|
letterSpacing: "0.04em",
|
||||||
|
color: "var(--color-text-muted)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
<Skeleton variant="rounded" width={28} height={16} animation="wave" />
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<TableSkeletonRows rows={rows} columns={SKELETON_COLS} rowHeight={WO_TABLE_ROW_HEIGHT} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -51,13 +51,15 @@ export function Button({
|
||||||
<MuiButton
|
<MuiButton
|
||||||
{...mapped}
|
{...mapped}
|
||||||
disabled={disabled || loading}
|
disabled={disabled || loading}
|
||||||
|
aria-busy={loading || undefined}
|
||||||
|
aria-disabled={disabled || loading || undefined}
|
||||||
sx={[sizeSx[size], ...(Array.isArray(sx) ? sx : sx ? [sx] : [])]}
|
sx={[sizeSx[size], ...(Array.isArray(sx) ? sx : sx ? [sx] : [])]}
|
||||||
className={cn(mapped.className, className)}
|
className={cn(mapped.className, className)}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<>
|
<>
|
||||||
<CircularProgress size={16} color="inherit" sx={{ mr: 1 }} />
|
<CircularProgress size={16} color="inherit" sx={{ mr: 1 }} aria-hidden />
|
||||||
{loadingText}
|
{loadingText}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
|
|
|
||||||
28
src/components/ui/loading-region.tsx
Normal file
28
src/components/ui/loading-region.tsx
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
type LoadingRegionProps = {
|
||||||
|
busy?: boolean;
|
||||||
|
label?: string;
|
||||||
|
className?: string;
|
||||||
|
children: ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Accessible wrapper for loading/skeleton regions. */
|
||||||
|
export function LoadingRegion({
|
||||||
|
busy = true,
|
||||||
|
label = "Loading",
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
}: LoadingRegionProps) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(className)}
|
||||||
|
aria-busy={busy || undefined}
|
||||||
|
role={busy ? "status" : undefined}
|
||||||
|
aria-label={busy ? label : undefined}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
39
src/components/ui/query-error-panel.tsx
Normal file
39
src/components/ui/query-error-panel.tsx
Normal file
|
|
@ -0,0 +1,39 @@
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Text } from "@/components/ui/text";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
type QueryErrorPanelProps = {
|
||||||
|
message?: string;
|
||||||
|
onRetry?: () => void;
|
||||||
|
className?: string;
|
||||||
|
compact?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function QueryErrorPanel({
|
||||||
|
message = "Something went wrong. Please try again.",
|
||||||
|
onRetry,
|
||||||
|
className,
|
||||||
|
compact = false,
|
||||||
|
}: QueryErrorPanelProps) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex flex-col items-start gap-2 rounded-lg border border-border bg-background",
|
||||||
|
compact ? "p-3" : "p-4",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Text variant="error">{message}</Text>
|
||||||
|
{Boolean(onRetry) && (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="default"
|
||||||
|
onClick={onRetry}
|
||||||
|
sx={{ minHeight: 32, height: 32 }}
|
||||||
|
>
|
||||||
|
Retry
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import type { ReactNode } from "react";
|
import type { MouseEvent, ReactNode } from "react";
|
||||||
import { Drawer } from "@mui/material";
|
import { Drawer } from "@mui/material";
|
||||||
import { DIALOG_BLUR_BACKDROP_SX } from "@/components/ui/dialog-blur-backdrop";
|
import { DIALOG_BLUR_BACKDROP_SX } from "@/components/ui/dialog-blur-backdrop";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
@ -8,6 +8,8 @@ type SideSheetProps = {
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
widthClass?: string;
|
widthClass?: string;
|
||||||
|
/** When true, backdrop click and ESC do not close the sheet. */
|
||||||
|
closeDisabled?: boolean;
|
||||||
"aria-label"?: string;
|
"aria-label"?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -16,16 +18,23 @@ export function SideSheet({
|
||||||
onClose,
|
onClose,
|
||||||
children,
|
children,
|
||||||
widthClass = "w-full max-w-sm",
|
widthClass = "w-full max-w-sm",
|
||||||
|
closeDisabled = false,
|
||||||
"aria-label": ariaLabel = "Side panel",
|
"aria-label": ariaLabel = "Side panel",
|
||||||
}: SideSheetProps) {
|
}: SideSheetProps) {
|
||||||
return (
|
return (
|
||||||
<Drawer
|
<Drawer
|
||||||
anchor="right"
|
anchor="right"
|
||||||
open={open}
|
open={open}
|
||||||
onClose={onClose}
|
onClose={(_event, reason) => {
|
||||||
|
if (closeDisabled) return;
|
||||||
|
if (reason === "backdropClick" || reason === "escapeKeyDown") {
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
}}
|
||||||
slotProps={{
|
slotProps={{
|
||||||
backdrop: {
|
backdrop: {
|
||||||
sx: DIALOG_BLUR_BACKDROP_SX,
|
sx: DIALOG_BLUR_BACKDROP_SX,
|
||||||
|
...(closeDisabled ? { onClick: (e: MouseEvent) => e.stopPropagation() } : {}),
|
||||||
},
|
},
|
||||||
paper: {
|
paper: {
|
||||||
className: cn(
|
className: cn(
|
||||||
|
|
|
||||||
29
src/components/ui/table-skeleton-rows.tsx
Normal file
29
src/components/ui/table-skeleton-rows.tsx
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
import { Skeleton } from "@mui/material";
|
||||||
|
|
||||||
|
type TableSkeletonRowsProps = {
|
||||||
|
rows?: number;
|
||||||
|
columns: { key: string; width: number }[];
|
||||||
|
rowHeight?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function TableSkeletonRows({ rows = 3, columns, rowHeight = 52 }: TableSkeletonRowsProps) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{Array.from({ length: rows }, (_, rowIndex) => (
|
||||||
|
<tr key={rowIndex} style={{ height: rowHeight }}>
|
||||||
|
{columns.map((col) => (
|
||||||
|
<td key={col.key} style={{ padding: "0 14px", width: col.width, minWidth: col.width }}>
|
||||||
|
<Skeleton
|
||||||
|
variant="rounded"
|
||||||
|
height={14}
|
||||||
|
width="72%"
|
||||||
|
animation="wave"
|
||||||
|
sx={{ bgcolor: "action.hover" }}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
10
src/lib/dialog-guard.ts
Normal file
10
src/lib/dialog-guard.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
/**
|
||||||
|
* Returns `undefined` as the dialog close handler while a mutation is pending,
|
||||||
|
* preventing backdrop click / ESC from dismissing the dialog.
|
||||||
|
*/
|
||||||
|
export function guardDialogClose(
|
||||||
|
isPending: boolean,
|
||||||
|
onClose: () => void,
|
||||||
|
): (() => void) | undefined {
|
||||||
|
return isPending ? undefined : onClose;
|
||||||
|
}
|
||||||
60
src/test/api/api-formdata-content-type.test.ts
Normal file
60
src/test/api/api-formdata-content-type.test.ts
Normal file
|
|
@ -0,0 +1,60 @@
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { shouldStripJsonContentType } from "@/api/api";
|
||||||
|
|
||||||
|
describe("shouldStripJsonContentType", () => {
|
||||||
|
it("returns true for FormData (multipart uploads)", () => {
|
||||||
|
const form = new FormData();
|
||||||
|
form.append("file", new File(["x"], "a.png", { type: "image/png" }));
|
||||||
|
expect(shouldStripJsonContentType(form)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns false for JSON body / undefined", () => {
|
||||||
|
expect(shouldStripJsonContentType(undefined)).toBe(false);
|
||||||
|
expect(shouldStripJsonContentType(JSON.stringify({ a: 1 }))).toBe(false);
|
||||||
|
expect(shouldStripJsonContentType({ a: 1 })).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("api.post FormData Content-Type", () => {
|
||||||
|
const originalFetch = globalThis.fetch;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.resetModules();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
globalThis.fetch = originalFetch;
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not send Content-Type: application/json when body is FormData", async () => {
|
||||||
|
let capturedContentType: string | null = null;
|
||||||
|
|
||||||
|
globalThis.fetch = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||||
|
const headers = new Headers(
|
||||||
|
init?.headers ?? (input instanceof Request ? input.headers : undefined),
|
||||||
|
);
|
||||||
|
capturedContentType = headers.get("Content-Type");
|
||||||
|
return new Response(JSON.stringify({ ok: true }), {
|
||||||
|
status: 200,
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
});
|
||||||
|
}) as typeof fetch;
|
||||||
|
|
||||||
|
const { api } = await import("@/api/api");
|
||||||
|
const form = new FormData();
|
||||||
|
form.append("category", "Before");
|
||||||
|
form.append("file", new File(["png"], "image.png", { type: "image/png" }));
|
||||||
|
|
||||||
|
await api.post("workorders/9/media", { body: form, throwHttpErrors: false });
|
||||||
|
|
||||||
|
expect(capturedContentType).not.toBe("application/json");
|
||||||
|
// Browser sets multipart/form-data; boundary=... when Content-Type was stripped.
|
||||||
|
// In jsdom/fetch mock the runtime may leave it null or set multipart - both are OK
|
||||||
|
// as long as it is not forced JSON.
|
||||||
|
// Cast after expect(): Vitest assertion narrowing collapses string|null to never.
|
||||||
|
const contentType = capturedContentType as string | null;
|
||||||
|
if (contentType !== null) {
|
||||||
|
expect(contentType.toLowerCase()).toContain("multipart/form-data");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Add table
Reference in a new issue