mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 14:16:12 +00:00
release: v1.32.0 — hardening and scalability (#580)
* release: prepare v1.32.0 * test: stabilize water capture re-entry
This commit is contained in:
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("");
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
Reference in new issue
Block a user