mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 13:01:00 +00:00
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:
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>");
|
||||
});
|
||||
});
|
||||
@@ -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={() => {}}
|
||||
|
||||
@@ -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} />
|
||||
|
||||
Reference in new issue
Block a user