diff --git a/src/api/api.ts b/src/api/api.ts index 2a6181fb..19c29d81 100644 --- a/src/api/api.ts +++ b/src/api/api.ts @@ -21,6 +21,11 @@ function isAuthLoginRequest(request: Request): boolean { 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({ prefix: resolveApiPrefixUrl(env.apiUrl), headers: { @@ -29,11 +34,15 @@ export const api: KyInstance = ky.create({ }, hooks: { beforeRequest: [ - ({ request }) => { + ({ request, options }) => { const token = getAccessToken(); if (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)], @@ -58,10 +67,18 @@ export async function apiPost(url: string, body?: unknown, options?: Options) return api.post(url, { ...options, json: body }).json(); } +export async function apiPostForm(url: string, body: FormData, options?: Options): Promise { + return api.post(url, { ...options, body }).json(); +} + export async function apiPut(url: string, body?: unknown, options?: Options): Promise { return api.put(url, { ...options, json: body }).json(); } +export async function apiPatch(url: string, body?: unknown, options?: Options): Promise { + return api.patch(url, { ...options, json: body }).json(); +} + export async function apiDelete(url: string, options?: Options): Promise { return api.delete(url, options).json(); } diff --git a/src/app/(protected)/workorders/_components/loading/detail-page-skeleton.tsx b/src/app/(protected)/workorders/_components/loading/detail-page-skeleton.tsx new file mode 100644 index 00000000..c9dbd36e --- /dev/null +++ b/src/app/(protected)/workorders/_components/loading/detail-page-skeleton.tsx @@ -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 ( + + + + + {variant === "view" ? ( + + {Array.from({ length: 3 }, (_, i) => ( + + + + + + + + + ))} + + ) : ( + + {Array.from({ length: 4 }, (_, i) => ( + + + + + + + + ))} + + + + + + )} + + ); +} diff --git a/src/app/(protected)/workorders/_components/loading/dispatch-detail-skeleton.tsx b/src/app/(protected)/workorders/_components/loading/dispatch-detail-skeleton.tsx new file mode 100644 index 00000000..8a1d81c5 --- /dev/null +++ b/src/app/(protected)/workorders/_components/loading/dispatch-detail-skeleton.tsx @@ -0,0 +1,49 @@ +import { Box, DialogContent, DialogTitle, Skeleton, Stack } from "@mui/material"; +import { LoadingRegion } from "@/components/ui/loading-region"; + +export function DispatchDetailSkeleton() { + return ( + + +
+ + +
+
+ + + + {Array.from({ length: 4 }, (_, i) => ( +
+ + +
+ ))} +
+
+ + + + + {Array.from({ length: 3 }, (_, i) => ( +
+ + +
+ ))} +
+
+ + + + + + + + + + +
+
+ ); +} diff --git a/src/app/(protected)/workorders/_components/loading/slide-over-tab-skeleton.tsx b/src/app/(protected)/workorders/_components/loading/slide-over-tab-skeleton.tsx new file mode 100644 index 00000000..b7df1d07 --- /dev/null +++ b/src/app/(protected)/workorders/_components/loading/slide-over-tab-skeleton.tsx @@ -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 ( + + {variant === "info" && } + {variant === "comments" && } + {variant === "audit" && } + {(variant === "completion" || variant === "extras") && } + + ); +} + +function InfoSkeleton() { + return ( + + {Array.from({ length: 6 }, (_, i) => ( +
+ + +
+ ))} +
+ ); +} + +function CommentsSkeleton() { + return ( + + {Array.from({ length: 3 }, (_, i) => ( +
+
+ + +
+ +
+ ))} + +
+ ); +} + +function AuditSkeleton() { + return ( + + {Array.from({ length: 4 }, (_, i) => ( +
+ +
+ + +
+
+ ))} +
+ ); +} + +function DocsSkeleton() { + return ( + + + + + + ); +} diff --git a/src/app/(protected)/workorders/_components/loading/wo-table-skeleton.tsx b/src/app/(protected)/workorders/_components/loading/wo-table-skeleton.tsx new file mode 100644 index 00000000..85cee325 --- /dev/null +++ b/src/app/(protected)/workorders/_components/loading/wo-table-skeleton.tsx @@ -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 ( + + + + + {days.map((day) => ( + + ))} + +
+
+ ); +} + +function DaySkeletonSection({ label, rows }: { label: string; rows: number }) { + return ( + <> + + +
+ + {label} + + +
+ + + + + ); +} diff --git a/src/components/ui/button.tsx b/src/components/ui/button.tsx index 9813b3f7..83c7e12d 100644 --- a/src/components/ui/button.tsx +++ b/src/components/ui/button.tsx @@ -51,13 +51,15 @@ export function Button({ {loading ? ( <> - + {loadingText} ) : ( diff --git a/src/components/ui/loading-region.tsx b/src/components/ui/loading-region.tsx new file mode 100644 index 00000000..6a6db559 --- /dev/null +++ b/src/components/ui/loading-region.tsx @@ -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 ( +
+ {children} +
+ ); +} diff --git a/src/components/ui/query-error-panel.tsx b/src/components/ui/query-error-panel.tsx new file mode 100644 index 00000000..8d9740bb --- /dev/null +++ b/src/components/ui/query-error-panel.tsx @@ -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 ( +
+ {message} + {Boolean(onRetry) && ( + + )} +
+ ); +} diff --git a/src/components/ui/side-sheet.tsx b/src/components/ui/side-sheet.tsx index d49532eb..e0813a20 100644 --- a/src/components/ui/side-sheet.tsx +++ b/src/components/ui/side-sheet.tsx @@ -1,4 +1,4 @@ -import type { ReactNode } from "react"; +import type { MouseEvent, ReactNode } from "react"; import { Drawer } from "@mui/material"; import { DIALOG_BLUR_BACKDROP_SX } from "@/components/ui/dialog-blur-backdrop"; import { cn } from "@/lib/utils"; @@ -8,6 +8,8 @@ type SideSheetProps = { onClose: () => void; children: ReactNode; widthClass?: string; + /** When true, backdrop click and ESC do not close the sheet. */ + closeDisabled?: boolean; "aria-label"?: string; }; @@ -16,16 +18,23 @@ export function SideSheet({ onClose, children, widthClass = "w-full max-w-sm", + closeDisabled = false, "aria-label": ariaLabel = "Side panel", }: SideSheetProps) { return ( { + if (closeDisabled) return; + if (reason === "backdropClick" || reason === "escapeKeyDown") { + onClose(); + } + }} slotProps={{ backdrop: { sx: DIALOG_BLUR_BACKDROP_SX, + ...(closeDisabled ? { onClick: (e: MouseEvent) => e.stopPropagation() } : {}), }, paper: { className: cn( diff --git a/src/components/ui/table-skeleton-rows.tsx b/src/components/ui/table-skeleton-rows.tsx new file mode 100644 index 00000000..3ae11b7c --- /dev/null +++ b/src/components/ui/table-skeleton-rows.tsx @@ -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) => ( + + {columns.map((col) => ( + + + + ))} + + ))} + + ); +} diff --git a/src/lib/dialog-guard.ts b/src/lib/dialog-guard.ts new file mode 100644 index 00000000..c193be62 --- /dev/null +++ b/src/lib/dialog-guard.ts @@ -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; +} diff --git a/src/test/api/api-formdata-content-type.test.ts b/src/test/api/api-formdata-content-type.test.ts new file mode 100644 index 00000000..b914b9f0 --- /dev/null +++ b/src/test/api/api-formdata-content-type.test.ts @@ -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"); + } + }); +});