Align layout settings cards and the insights arrange editor

The insights arrange list becomes a Settings card with its own header and
a Save that waits for a change; the self-link back to the page it sits on
and the focus grab on load are gone. The medications view card takes the
header shape the labs and documents cards use, the order card shares
their title and loads with row placeholders, and the documents view card
uses their icon.
This commit is contained in:
Marc-André Bombeck committed 2026-10-08 20:20:03 +02:00
1 parent 991764b0c8
commit 2dda933d77
6 files changed
+188 -89

No files matched your search

@@ -60,4 +60,26 @@ describe("<InsightsEditMode> — detail-page manager retired (v1.15.20)", () =>
expect(html).toContain('data-slot="insights-edit-section-row"');
expect(html).toContain('data-slot="insights-edit-section-eye"');
});
it("as a Settings card: settings header, one action row last, no self-link", () => {
// On /settings/layout/insights the editor is a standing card. The
// pointer "Manage chips in Settings" would link to the page it is on,
// and Reset + Done beside the title broke the one-action-row rule.
const html = render(
<InsightsEditMode
variant="settings"
layout={DEFAULT_INSIGHTS_LAYOUT}
gatedOffSectionIds={noneGated}
onClose={() => {}}
/>,
);
expect(html).not.toContain('data-slot="insights-edit-manage-link"');
expect(html).toContain('id="insights-overview-arrange-title"');
const row = html.indexOf('data-slot="settings-card-actions"');
expect(row).toBeGreaterThan(
html.lastIndexOf('data-slot="insights-edit-section-row"'),
);
expect(html.slice(row)).toContain('data-slot="insights-edit-done"');
expect(html.slice(row)).toContain(">Save</button>");
});
});
+123 -53
View File
@@ -4,7 +4,14 @@ import { useEffect, useMemo, useRef, useState } from "react";
import Link from "next/link";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { Eye, EyeOff, GripVertical, Loader2, RotateCcw } from "lucide-react";
import {
Eye,
EyeOff,
GripVertical,
LayoutGrid,
Loader2,
RotateCcw,
} from "lucide-react";
import {
DndContext,
KeyboardSensor,
@@ -24,6 +31,8 @@ import { CSS } from "@dnd-kit/utilities";
import { Button } from "@/components/ui/button";
import { SettingsCard } from "@/components/settings/settings-card";
import { SettingsCardActions } from "@/components/settings/_card-actions";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { ConfirmButton } from "@/components/ui/confirm-button";
import { cn } from "@/lib/utils";
import { prefersReducedMotion } from "@/lib/charts/reduced-motion";
@@ -88,13 +97,24 @@ interface InsightsEditModeProps {
gatedOffSectionIds: ReadonlySet<InsightsSectionId>;
/** Close edit mode (the "Fertig" / save-success path calls this). */
onClose: () => void;
/**
* `page` (default) is the inline editor the Insights overview opens: its own
* title, Reset + Done beside it, and a pointer to the Settings pill manager.
* `settings` is the same editor as a standing Settings card: a settings
* header, the actions in one row at the bottom (design standards §12), no
* pointer back to the page it already sits on, and no focus grab on mount —
* the card is part of the page, not a surface the user just opened.
*/
variant?: "page" | "settings";
}
export function InsightsEditMode({
layout,
gatedOffSectionIds,
onClose,
variant = "page",
}: InsightsEditModeProps) {
const inSettings = variant === "settings";
const { t } = useTranslations();
const queryClient = useQueryClient();
@@ -115,8 +135,9 @@ export function InsightsEditMode({
// "Anpassen" toggle on close via the page's onClose handler.
const headingRef = useRef<HTMLHeadingElement | null>(null);
useEffect(() => {
if (inSettings) return;
headingRef.current?.focus();
}, []);
}, [inSettings]);
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 6 } }),
@@ -186,6 +207,15 @@ export function InsightsEditMode({
});
const busy = saveMutation.isPending || resetMutation.isPending;
// The Settings card's Save waits for a change, like the pill-order Save
// beside it; the overview's inline editor keeps "Done" live because it
// also closes the editor.
const arrangement = (rows: readonly InsightsSectionConfig[]) =>
[...rows]
.sort((a, b) => a.order - b.order)
.map((r) => `${r.id}:${r.visible ? 1 : 0}`)
.join(",");
const dirty = arrangement(draft.sections) !== arrangement(layout.sections);
const sections = useMemo(
() => [...draft.sections].sort((a, b) => a.order - b.order),
@@ -215,46 +245,55 @@ export function InsightsEditMode({
return (
<SettingsCard data-slot="insights-edit-mode">
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-3">
<div>
<h2
ref={headingRef}
tabIndex={-1}
className="text-lg font-semibold focus-visible:outline-none"
>
{t("insights.editMode.title")}
</h2>
<p className="text-muted-foreground text-sm">
{t("insights.editMode.description")}
</p>
{inSettings ? (
<SettingsCardHeader
icon={LayoutGrid}
title={t("insights.settings.overviewTitle")}
titleId="insights-overview-arrange-title"
description={t("insights.settings.overviewDescription")}
/>
) : (
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-3">
<div>
<h2
ref={headingRef}
tabIndex={-1}
className="text-lg font-semibold focus-visible:outline-none"
>
{t("insights.editMode.title")}
</h2>
<p className="text-muted-foreground text-sm">
{t("insights.editMode.description")}
</p>
</div>
<div className="flex items-center gap-2 self-end sm:self-auto">
<ConfirmButton
slot="insights-edit-reset"
variant="ghost"
size="sm"
icon={<RotateCcw className="h-3.5 w-3.5" />}
label={t("insights.editMode.reset")}
title={t("insights.editMode.resetTitle")}
body={t("insights.editMode.resetBody")}
confirmLabel={t("insights.editMode.resetConfirm")}
disabled={busy && !resetMutation.isPending}
pending={resetMutation.isPending}
onConfirm={() => resetMutation.mutate()}
/>
<Button
size="sm"
onClick={() => saveMutation.mutate(draft)}
disabled={busy}
data-slot="insights-edit-done"
>
{saveMutation.isPending && (
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
)}
{t("insights.editMode.done")}
</Button>
</div>
</div>
<div className="flex items-center gap-2 self-end sm:self-auto">
<ConfirmButton
slot="insights-edit-reset"
variant="ghost"
size="sm"
icon={<RotateCcw className="h-3.5 w-3.5" />}
label={t("insights.editMode.reset")}
title={t("insights.editMode.resetTitle")}
body={t("insights.editMode.resetBody")}
confirmLabel={t("insights.editMode.resetConfirm")}
disabled={busy && !resetMutation.isPending}
pending={resetMutation.isPending}
onConfirm={() => resetMutation.mutate()}
/>
<Button
size="sm"
onClick={() => saveMutation.mutate(draft)}
disabled={busy}
data-slot="insights-edit-done"
>
{saveMutation.isPending && (
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
)}
{t("insights.editMode.done")}
</Button>
</div>
</div>
)}
{allHidden && (
<p
@@ -298,18 +337,49 @@ export function InsightsEditMode({
</SortableContext>
</DndContext>
{/* v1.15.20 — the per-detail-page manager (sort + show/hide) lives on
Settings → Insights; the disclosure this card used to nest under
the Vitals row duplicated it. Keep a quiet pointer instead. */}
<p className="text-muted-foreground text-xs">
<Link
href="/settings/layout/insights#insights-pill-order"
data-slot="insights-edit-manage-link"
className="hover:text-foreground focus-visible:ring-ring rounded underline underline-offset-2 focus-visible:ring-2 focus-visible:outline-none"
>
{t("insights.editMode.manageInSettings")}
</Link>
</p>
{inSettings ? (
<SettingsCardActions>
<ConfirmButton
slot="insights-edit-reset"
variant="outline"
size="sm"
className="min-h-11 sm:min-h-9"
icon={<RotateCcw className="h-3.5 w-3.5" />}
label={t("insights.editMode.reset")}
title={t("insights.editMode.resetTitle")}
body={t("insights.editMode.resetBody")}
confirmLabel={t("insights.editMode.resetConfirm")}
disabled={busy && !resetMutation.isPending}
pending={resetMutation.isPending}
onConfirm={() => resetMutation.mutate()}
/>
<Button
size="sm"
className="min-h-11 sm:min-h-9"
onClick={() => saveMutation.mutate(draft)}
disabled={busy || !dirty}
data-slot="insights-edit-done"
>
{saveMutation.isPending && (
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
)}
{t("common.save")}
</Button>
</SettingsCardActions>
) : (
// v1.15.20 — the per-detail-page manager (sort + show/hide) lives on
// Settings → Insights; the disclosure this card used to nest under
// the Vitals row duplicated it. Keep a quiet pointer instead.
<p className="text-muted-foreground text-xs">
<Link
href="/settings/layout/insights#insights-pill-order"
data-slot="insights-edit-manage-link"
className="hover:text-foreground focus-visible:ring-ring rounded underline underline-offset-2 focus-visible:ring-2 focus-visible:outline-none"
>
{t("insights.editMode.manageInSettings")}
</Link>
</p>
)}
</SettingsCard>
);
}
@@ -97,7 +97,8 @@ describe("<MedicationsSection> — SSR smoke", () => {
it("renders the German copy end-to-end", () => {
const html = render("de");
expect(html).toContain("Reihenfolge anpassen");
// The order card shares its title with the Labs order card.
expect(html).toContain(">Reihenfolge<");
expect(html).toContain(
"Wähle, ob deine Medikamente als Karten oder als Tabelle angezeigt werden.",
);
@@ -9,7 +9,7 @@
* shape): the card/list view and the month arrangement. Both persist per user
* through `/api/documents/inbound/layout`.
*/
import { Eye, Rows3, WrapText } from "lucide-react";
import { LayoutGrid, Rows3, WrapText } from "lucide-react";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { SettingsCard } from "@/components/settings/settings-card";
@@ -26,7 +26,7 @@ export function DocumentsSection() {
<div className="space-y-6" data-slot="documents-layout-settings">
<SettingsCard id="documents-view" className="scroll-mt-28">
<SettingsCardHeader
icon={Eye}
icon={LayoutGrid}
title={t("moduleList.viewHeading")}
description={t("documents.layout.viewDescription")}
status={<ModuleViewToggle view={layout.view} onChange={setView} />}
@@ -9,8 +9,8 @@ import { useAuth } from "@/hooks/use-auth";
import { useAiCapability } from "@/hooks/use-ai-capability";
import { useInsightsLayoutQuery } from "@/hooks/use-insights-layout";
import { InsightsEditMode } from "@/components/insights/insights-edit-mode";
import { SectionHeading } from "@/components/ui/section-heading";
import { SettingsCard } from "@/components/settings/settings-card";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import {
INSIGHTS_SECTION_IDS,
type InsightsSectionId,
@@ -59,29 +59,28 @@ export function InsightsOverviewArrangeSection({ id }: { id?: string }) {
id={id}
data-slot="insights-overview-arrange-section"
aria-labelledby="insights-overview-arrange-title"
className="space-y-3"
className="scroll-mt-28"
>
<SectionHeading
icon={LayoutGrid}
id="insights-overview-arrange-title"
title={t("insights.settings.overviewTitle")}
subtitle={t("insights.settings.overviewDescription")}
/>
{/* Gate the editor mount until the layout GET settles. The editor seeds
its draft once from `layout` on mount, which is the canonical default
while in flight; mounting it early would let a "Fertig" save flush
defaults over the user's real saved layout (the same QA-L1 gate the
mother page applies to its "Anpassen" toggle). */}
while in flight; mounting it early would let a Save flush defaults
over the user's real saved layout (the same QA-L1 gate the mother
page applies to its "Anpassen" toggle). The loading card paints the
same header the loaded card carries (design standards §13), so the
page does not reflow under the cursor when the list arrives. */}
{isLoading ? (
<SettingsCard
className="text-muted-foreground flex items-center gap-2 text-sm"
data-slot="insights-overview-arrange-loading"
>
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
<SettingsCard data-slot="insights-overview-arrange-loading">
<SettingsCardHeader
icon={LayoutGrid}
title={t("insights.settings.overviewTitle")}
titleId="insights-overview-arrange-title"
description={t("insights.settings.overviewDescription")}
/>
<Loader2 className="text-muted-foreground h-4 w-4 animate-spin motion-reduce:animate-none" />
</SettingsCard>
) : (
<InsightsEditMode
variant="settings"
layout={layout}
gatedOffSectionIds={gatedOffSectionIds}
onClose={() => {}}
+23 -16
View File
@@ -1,7 +1,7 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { LayoutGrid, Loader2, Pill } from "lucide-react";
import { LayoutGrid, ListOrdered } from "lucide-react";
import {
MedicationOrderEditor,
@@ -9,6 +9,7 @@ import {
} from "@/components/medications/medication-order-editor";
import { MedicationViewToggle } from "@/components/medications/medication-view-toggle";
import { SettingsCard } from "@/components/settings/settings-card";
import { Skeleton } from "@/components/ui/skeleton";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { InjectionSitesCard } from "@/components/settings/injection-sites-card";
import { useAuth } from "@/hooks/use-auth";
@@ -81,33 +82,39 @@ export function MedicationsSection() {
{/* View preference — cards vs table. The shared header toggle
writes optimistically, so there is no Save button here. */}
<SettingsCard id="medications-view" className="scroll-mt-28">
{/* Same shape as the Labs and Documents view cards: the hint is the
header description, the toggle sits in the status slot. */}
<SettingsCardHeader
icon={LayoutGrid}
title={t("medications.viewToggleLabel")}
/>
<div className="border-border bg-background/30 flex min-h-12 items-center justify-between gap-3 rounded-md border px-3 py-2">
<p className="text-muted-foreground min-w-0 text-xs">
{t("medications.viewToggleHint")}
</p>
{isLayoutLoading ? (
<Loader2 className="text-muted-foreground h-4 w-4 animate-spin motion-reduce:animate-none" />
) : (
description={t("medications.viewToggleHint")}
status={
// Painted from the first frame, like the Labs and Documents
// toggles: a spinner the width of one icon swapped for a
// two-button toggle moved the cards below it.
<MedicationViewToggle view={layout.view} onChange={setView} />
)}
</div>
}
/>
</SettingsCard>
{/* Manual order — applies to both list views. */}
<SettingsCard id="medications-order" className="scroll-mt-28">
<SettingsCardHeader
icon={Pill}
title={t("medications.reorderTitle")}
icon={ListOrdered}
title={t("moduleList.reorder.heading")}
description={t("medications.reorderDescription")}
/>
{isLoading || isLayoutLoading ? (
<div className="text-muted-foreground flex items-center gap-2 text-sm">
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
{t("common.loading")}
// Row-shaped placeholders instead of a one-line spinner, so the
// card below moves less when the list arrives.
<div
className="space-y-2"
role="status"
aria-label={t("common.loading")}
>
{Array.from({ length: 3 }, (_, i) => (
<Skeleton key={i} className="h-14 w-full rounded-md" />
))}
</div>
) : (
<MedicationOrderEditor medications={ordered} />