release: v1.32.0 — hardening and scalability (#580)

* release: prepare v1.32.0

* test: stabilize water capture re-entry
This commit is contained in:
Marc-André Bombeck authored and GitHub committed 2026-07-22 04:14:15 +02:00
1 parent b01822be48
commit 62eb431d66
372 files changed
+25178 -8561

No files matched your search

@@ -0,0 +1,140 @@
import type { ReactNode } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { afterEach, describe, expect, it, vi } from "vitest";
import { I18nProvider } from "@/lib/i18n/context";
import { handleFilePickerChange, OcrReviewDialog } from "../ocr-review-dialog";
const hookState = vi.hoisted(() => ({ extractPending: false }));
vi.mock("@/components/ui/responsive-sheet", () => ({
ResponsiveSheet: ({
children,
title,
description,
}: {
children: ReactNode;
title: string;
description: string;
}) => (
<section aria-label={title}>
<p>{description}</p>
{children}
</section>
),
}));
vi.mock("../use-ocr-extract", () => ({
useOcrExtract: () => ({
isPending: hookState.extractPending,
mutate: vi.fn(),
reset: vi.fn(),
}),
useOcrTextExtract: () => ({
isPending: hookState.extractPending,
mutate: vi.fn(),
reset: vi.fn(),
}),
useOcrCommit: () => ({
isPending: false,
mutate: vi.fn(),
reset: vi.fn(),
}),
}));
vi.mock("sonner", () => ({
toast: { success: vi.fn(), error: vi.fn() },
}));
function render(
locale: "en" | "de" = "en",
options: { mode?: "vision" | "text"; pdfSupported?: boolean } = {},
): string {
return renderToStaticMarkup(
<I18nProvider initialLocale={locale}>
<OcrReviewDialog
open
onOpenChange={() => {}}
mode={options.mode ?? "vision"}
pdfSupported={options.pdfSupported ?? true}
onCommitted={() => {}}
/>
</I18nProvider>,
);
}
function pickerMarkup(html: string): { input: string; label: string } {
const input = html.match(/<input[^>]*type="file"[^>]*>/)?.[0];
const label = html.match(/<label[^>]*>[\s\S]*?<\/label>/)?.[0];
expect(input).toBeDefined();
expect(label).toBeDefined();
return { input: input!, label: label! };
}
afterEach(() => {
hookState.extractPending = false;
});
describe("<OcrReviewDialog> file picker", () => {
it.each([
["en", "Scan a report"],
["de", "Befund scannen"],
] as const)(
"renders one natively labelled, localized picker in %s",
(locale, localizedName) => {
const html = render(locale);
const { input, label } = pickerMarkup(html);
const inputId = input.match(/id="([^"]+)"/)?.[1];
expect(html.match(/<input[^>]*type="file"/g)).toHaveLength(1);
expect(html).not.toContain("<button");
expect(inputId).toBeTruthy();
expect(label).toContain(`for="${inputId}"`);
expect(label).toContain(localizedName);
},
);
it("uses the native file input as the sole keyboard focus target and paints its focus on the visible label", () => {
const html = render();
const { input, label } = pickerMarkup(html);
expect(input).not.toMatch(/\sdisabled(?:[=\s/>])/);
expect(input).not.toContain('tabindex="-1"');
expect(input).not.toContain("aria-hidden");
expect(input).toContain("peer");
expect(label).not.toContain("tabindex=");
expect(label).toContain("peer-focus-visible:ring-2");
expect(label).toContain("peer-focus-visible:ring-ring");
});
it("keeps the mode-specific MIME restrictions and disables the native picker while extracting", () => {
const visionInput = pickerMarkup(render()).input;
const textInput = pickerMarkup(
render("en", { mode: "text", pdfSupported: true }),
).input;
expect(visionInput).toContain(
'accept="image/jpeg,image/png,image/webp,application/pdf"',
);
expect(textInput).toContain('accept="image/jpeg,image/png,image/webp"');
hookState.extractPending = true;
const pending = pickerMarkup(render());
expect(pending.input).toMatch(/\sdisabled(?:[=\s/>])/);
expect(pending.label).toContain("peer-disabled:cursor-not-allowed");
expect(pending.label).toContain("Reading your report");
});
it("passes the first selected file to the callback and clears the native value", () => {
const selected = { name: "report.pdf" } as File;
const input = { files: [selected], value: "C:\\fakepath\\report.pdf" };
const onFilePicked = vi.fn();
handleFilePickerChange(input, onFilePicked);
expect(onFilePicked).toHaveBeenCalledOnce();
expect(onFilePicked).toHaveBeenCalledWith(selected);
expect(input.value).toBe("");
});
});
+3 -6
View File
@@ -18,7 +18,8 @@ import {
SelectValue,
} from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";
import { ApiError, apiPost, apiPut } from "@/lib/api/api-fetch";
import { apiPost, apiPut } from "@/lib/api/api-fetch";
import { localizedApiError } from "@/lib/api/localized-error";
import {
BIOMARKER_CATALOG,
BIOMARKER_PANELS,
@@ -131,11 +132,7 @@ export function BiomarkerForm({
);
onSuccess?.(saved);
} catch (err) {
const message =
err instanceof ApiError
? err.message
: t("labs.biomarker.form.saveError");
setError(message);
setError(localizedApiError(err, t, "labs.biomarker.form.saveError"));
} finally {
setSubmitting(false);
}
+3 -4
View File
@@ -19,7 +19,8 @@ import {
SelectValue,
} from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";
import { ApiError, apiGet, apiPost } from "@/lib/api/api-fetch";
import { apiGet, apiPost } from "@/lib/api/api-fetch";
import { localizedApiError } from "@/lib/api/localized-error";
import { formatReferenceRange } from "@/lib/labs/reference-range";
import { formatLabValue } from "@/lib/labs/format-value";
import { useTranslations } from "@/lib/i18n/context";
@@ -226,9 +227,7 @@ export function LabForm({
onSuccess?.(created);
}
} catch (err) {
const message =
err instanceof ApiError ? err.message : t("labs.form.saveError");
setError(message);
setError(localizedApiError(err, t, "labs.form.saveError"));
} finally {
setPendingAction(null);
}
+52 -15
View File
@@ -11,6 +11,10 @@ import { DateTimeField } from "@/components/ui/date-time-field";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
import {
SeededFormDiscardDialog,
useSeededFormDismissal,
} from "@/components/forms/use-seeded-form-dismissal";
import {
Table,
TableBody,
@@ -21,13 +25,8 @@ import {
} from "@/components/ui/table";
import { Textarea } from "@/components/ui/textarea";
import { useTableSort } from "@/hooks/use-table-sort";
import {
ApiError,
apiDelete,
apiGet,
apiPost,
apiPut,
} from "@/lib/api/api-fetch";
import { apiDelete, apiGet, apiPost, apiPut } from "@/lib/api/api-fetch";
import { localizedApiError } from "@/lib/api/localized-error";
import { formatDateShort } from "@/lib/format";
import { formatLabReading } from "@/lib/labs/format-value";
import { resolveNoteForUpdate } from "@/lib/labs/note-update";
@@ -92,6 +91,12 @@ export function LabHistoryList({ readings }: { readings: LabResultDto[] }) {
const [editValueText, setEditValueText] = useState("");
const [editTakenAt, setEditTakenAt] = useState("");
const [editNote, setEditNote] = useState("");
const [editSeed, setEditSeed] = useState({
value: "",
valueText: "",
takenAt: "",
note: "",
});
const [editLoading, setEditLoading] = useState(false);
const [editError, setEditError] = useState<string | null>(null);
// True when the decrypted-note GET failed to load for the row being edited.
@@ -149,21 +154,44 @@ export function LabHistoryList({ readings }: { readings: LabResultDto[] }) {
onSuccess: () => {
invalidate();
toast.success(t("labs.editedToast"));
closeEdit();
setEditSeed({
value: editValue,
valueText: editValueText,
takenAt: editTakenAt,
note: editNote,
});
dismissEdit();
},
onError: (err) => {
setEditError(err instanceof ApiError ? err.message : t("labs.editError"));
setEditError(localizedApiError(err, t, "labs.editError"));
},
});
const editDismissal = useSeededFormDismissal({
seed: editSeed,
value: {
value: editValue,
valueText: editValueText,
takenAt: editTakenAt,
note: editNote,
},
blocked: updateMutation.isPending,
});
async function openEdit(reading: LabResultDto) {
const seed = {
value: reading.value !== null ? String(reading.value) : "",
valueText: reading.valueText ?? "",
takenAt: toDateTimeLocal(reading.takenAt),
note: "",
};
setEditingId(reading.id);
const qualitative = reading.value === null;
setEditIsQualitative(qualitative);
setEditValue(reading.value !== null ? String(reading.value) : "");
setEditValueText(reading.valueText ?? "");
setEditTakenAt(toDateTimeLocal(reading.takenAt));
setEditNote("");
setEditValue(seed.value);
setEditValueText(seed.valueText);
setEditTakenAt(seed.takenAt);
setEditNote(seed.note);
setEditSeed(seed);
setEditError(null);
setNoteLoadFailed(false);
setHasNote(reading.hasNote);
@@ -174,6 +202,7 @@ export function LabHistoryList({ readings }: { readings: LabResultDto[] }) {
`/api/labs/${reading.id}`,
);
setEditNote(detail.note ?? "");
setEditSeed({ ...seed, note: detail.note ?? "" });
} catch {
// The note couldn't be loaded. Flag it so the submit path omits `note`
// (preserving the stored ciphertext) rather than sending `null` and
@@ -184,12 +213,15 @@ export function LabHistoryList({ readings }: { readings: LabResultDto[] }) {
}
}
function closeEdit() {
if (updateMutation.isPending) return;
function dismissEdit() {
setEditingId(null);
setEditError(null);
}
function closeEdit() {
editDismissal.requestClose(dismissEdit);
}
function submitEdit(e: React.FormEvent) {
e.preventDefault();
if (!editingId) return;
@@ -431,6 +463,11 @@ export function LabHistoryList({ readings }: { readings: LabResultDto[] }) {
</div>
</form>
</ResponsiveSheet>
<SeededFormDiscardDialog
open={editDismissal.discardDialogOpen}
onConfirm={editDismissal.confirmDiscard}
onCancel={editDismissal.cancelDiscard}
/>
</>
);
}
+27 -18
View File
@@ -9,7 +9,7 @@
* confirms. Reuses the labs design language via `ResponsiveSheet` + the labs
* row editor.
*/
import { useMemo, useRef, useState } from "react";
import { useId, useMemo, useState } from "react";
import { Loader2, ScanLine, Upload } from "lucide-react";
import { toast } from "sonner";
@@ -35,6 +35,20 @@ export type OcrMode = "vision" | "text";
const ACCEPT = "image/jpeg,image/png,image/webp,application/pdf";
const ACCEPT_IMAGE_ONLY = "image/jpeg,image/png,image/webp";
type FilePickerInput = {
files: ArrayLike<File> | null;
value: string;
};
export function handleFilePickerChange(
input: FilePickerInput,
onFilePicked: (file: File) => void,
) {
const file = input.files?.[0];
// Reset so re-picking the same file fires `change` again.
input.value = "";
if (file) onFilePicked(file);
}
/** Map a confirmed review row to the commit payload, or null when invalid. */
function toCommitRow(row: OcrReviewRow): OcrCommitRowInput | null {
@@ -105,7 +119,7 @@ export function OcrReviewDialog({
onCommitted: () => void;
}) {
const { t } = useTranslations();
const inputRef = useRef<HTMLInputElement | null>(null);
const pickerId = useId();
const [stage, setStage] = useState<Stage>("pick");
const [rows, setRows] = useState<OcrReviewRow[]>([]);
// S9 — in vision mode the picked file is retained so, on commit, it can be
@@ -217,25 +231,20 @@ export function OcrReviewDialog({
}
>
{stage === "pick" ? (
<div className="space-y-4 py-2">
<div className="relative py-2">
<input
ref={inputRef}
id={pickerId}
type="file"
accept={allowPdf ? ACCEPT : ACCEPT_IMAGE_ONLY}
className="sr-only"
onChange={(e) => {
const file = e.target.files?.[0];
// Reset so re-picking the same file fires `change` again.
e.target.value = "";
if (file) onFilePicked(file);
}}
/>
<Button
type="button"
variant="outline"
onClick={() => inputRef.current?.click()}
disabled={extract.isPending}
className="border-muted-foreground/25 hover:bg-muted/50 h-auto min-h-44 w-full flex-col gap-3 rounded-lg border-dashed p-6 text-center whitespace-normal"
className="peer absolute inset-0 z-10 h-full w-full cursor-pointer rounded-lg opacity-0 disabled:cursor-not-allowed"
onChange={(event) =>
handleFilePickerChange(event.currentTarget, onFilePicked)
}
/>
<label
htmlFor={pickerId}
className="border-muted-foreground/25 hover:bg-muted/50 peer-focus-visible:ring-ring flex min-h-44 w-full flex-col items-center justify-center gap-3 rounded-lg border border-dashed p-6 text-center whitespace-normal peer-focus-visible:ring-2 peer-focus-visible:ring-offset-2 peer-disabled:cursor-not-allowed peer-disabled:opacity-50"
>
{extract.isPending ? (
<>
@@ -272,7 +281,7 @@ export function OcrReviewDialog({
) : null}
</>
)}
</Button>
</label>
</div>
) : (
<div className="space-y-3 py-2">