mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
refactor(medications): route card actions to detail tabs, retire the advanced sheet
The medications-list card kebab no longer opens a modal editor or the stacked advanced-settings sheet. Edit navigates to the detail page's Zeitplan tab, History to Verlauf, Advanced to Erweitert. The list-page wizard becomes create-only; editing an existing medication goes through the detail hero's "Vollständig bearbeiten". Dissolve the advanced-settings sheet entirely: its five stacked groups (Lifecycle / External API / Data / Danger zone) now live as inset-grouped sections in the Erweitert tab. Drop the orphaned modal component, the standalone SettingsSection wrapper, and the lazy intake-count query the sheet's danger zone needed (the tab fetches it on demand). Remove the two tests that only exercised the retired surfaces.
This commit is contained in:
1 parent
ac39b9ca34
commit
8201cd2d8c
5 files changed
+20
-684
No files matched your search
+14
-117
@@ -3,13 +3,10 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { AdvancedSettingsSheet } from "@/components/medications/advanced-settings-sheet";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { parseScheduleRecurrence } from "@/lib/medication-schedule";
|
||||
import { MedicationWizardDialog } from "@/components/medications/wizard/MedicationWizardDialog";
|
||||
import type { MedicationPayload } from "@/components/medications/wizard/wizard-payload";
|
||||
import { MedicationCard } from "@/components/medications/medication-card";
|
||||
import { Glp1MedicationCard } from "@/components/medications/glp1-medication-card";
|
||||
import { LogIntakeDialog } from "@/components/medications/log-intake-dialog";
|
||||
@@ -130,12 +127,6 @@ export default function MedicationsPage() {
|
||||
// the param from the URL so a manual close + refresh stays closed.
|
||||
const shouldOpenFromUrl = searchParams?.get("new") === "1";
|
||||
const [dialogOpen, setDialogOpen] = useState(shouldOpenFromUrl);
|
||||
const [editingMed, setEditingMed] = useState<Medication | null>(null);
|
||||
// v1.7.1 — the medications-card History + Advanced actions. History
|
||||
// navigates to the full intake-history view; Advanced opens one shared
|
||||
// `<AdvancedSettingsSheet>` mounted below, keyed to the selected
|
||||
// medication (reused, not duplicated per card).
|
||||
const [advancedMed, setAdvancedMed] = useState<Medication | null>(null);
|
||||
// v1.14.0 — the medications-page "Add" choice. The top button now offers
|
||||
// two paths: log an intake (incl. a backdated one) against an existing
|
||||
// medication, or create a new medication (the existing wizard).
|
||||
@@ -165,61 +156,29 @@ export default function MedicationsPage() {
|
||||
enabled: isAuthenticated,
|
||||
});
|
||||
|
||||
// v1.7.1 — the advanced sheet's danger zone needs `intakeCount` to
|
||||
// gate the purge button. Fetch it lazily once a medication is selected
|
||||
// (the list view never pays for it otherwise), mirroring the
|
||||
// detail-page query shape.
|
||||
const advancedIntakeParams = {
|
||||
sortBy: "takenAt",
|
||||
sortDir: "desc" as const,
|
||||
limit: 1,
|
||||
offset: 0,
|
||||
status: "completed",
|
||||
};
|
||||
const { data: advancedIntakeCount } = useQuery({
|
||||
queryKey: advancedMed
|
||||
? queryKeys.medicationIntakeList(advancedMed.id, advancedIntakeParams)
|
||||
: ["medications", "intake-count", "none"],
|
||||
queryFn: async () => {
|
||||
if (!advancedMed) return 0;
|
||||
const params = new URLSearchParams({
|
||||
sortBy: advancedIntakeParams.sortBy,
|
||||
sortDir: advancedIntakeParams.sortDir,
|
||||
limit: String(advancedIntakeParams.limit),
|
||||
offset: String(advancedIntakeParams.offset),
|
||||
status: advancedIntakeParams.status,
|
||||
});
|
||||
const res = await fetch(
|
||||
`/api/medications/${advancedMed.id}/intake?${params.toString()}`,
|
||||
);
|
||||
if (!res.ok) return 0;
|
||||
const json = await res.json();
|
||||
return (json.data?.meta?.total ?? 0) as number;
|
||||
},
|
||||
enabled: isAuthenticated && !!advancedMed,
|
||||
});
|
||||
|
||||
function openCreate() {
|
||||
setEditingMed(null);
|
||||
setDialogOpen(true);
|
||||
}
|
||||
|
||||
// v1.15.18 — the card kebab actions now navigate to the full-page
|
||||
// tabbed detail surface on the right tab rather than opening a modal
|
||||
// editor / sheet. Edit lands on Zeitplan (the everyday schedule view +
|
||||
// the hero's "Vollständig bearbeiten" jump into the wizard), History on
|
||||
// Verlauf, Advanced on Erweitert (the dissolved advanced-settings).
|
||||
function openEdit(med: Medication) {
|
||||
setEditingMed(med);
|
||||
setDialogOpen(true);
|
||||
router.push(`/medications/${med.id}?tab=zeitplan`);
|
||||
}
|
||||
|
||||
function openHistory(med: Medication) {
|
||||
router.push(`/medications/${med.id}/history`);
|
||||
router.push(`/medications/${med.id}?tab=verlauf`);
|
||||
}
|
||||
|
||||
function openAdvanced(med: Medication) {
|
||||
setAdvancedMed(med);
|
||||
router.push(`/medications/${med.id}?tab=erweitert`);
|
||||
}
|
||||
|
||||
function closeDialog() {
|
||||
setDialogOpen(false);
|
||||
setEditingMed(null);
|
||||
}
|
||||
|
||||
if (authLoading) {
|
||||
@@ -412,15 +371,15 @@ export default function MedicationsPage() {
|
||||
the API endpoint moves to the detail-page Settings → Externe
|
||||
Integration sub-row. */}
|
||||
|
||||
{/* v1.5.4 — modal-wizard mount. The same component drives both
|
||||
create (no initial) and edit (hydrates from the medication's
|
||||
payload). The wizard owns its own ResponsiveSheet shell with
|
||||
the dialog/sheet split and the sticky footer. */}
|
||||
{/* v1.15.18 — create-only wizard mount. Editing an existing
|
||||
medication now navigates to the detail page's tabs (the hero's
|
||||
"Vollständig bearbeiten" reopens the wizard in edit mode from
|
||||
there); the list page wizard only ever creates. The wizard owns
|
||||
its own ResponsiveSheet shell with the sticky footer. */}
|
||||
<MedicationWizardDialog
|
||||
open={dialogOpen}
|
||||
onOpenChange={setDialogOpen}
|
||||
mode={editingMed ? "edit" : "create"}
|
||||
initial={editingMed ? medicationToPayload(editingMed) : undefined}
|
||||
mode="create"
|
||||
onSuccess={closeDialog}
|
||||
/>
|
||||
|
||||
@@ -446,68 +405,6 @@ export default function MedicationsPage() {
|
||||
}))}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* v1.7.1 — one shared advanced-settings sheet for every card.
|
||||
Reuses the exact component the detail page mounts (Data /
|
||||
Reminders / Lifecycle / Danger zone); the card's sliders button
|
||||
selects the medication and opens it. Not duplicated per card. */}
|
||||
{advancedMed && (
|
||||
<AdvancedSettingsSheet
|
||||
open={!!advancedMed}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setAdvancedMed(null);
|
||||
}}
|
||||
medicationId={advancedMed.id}
|
||||
medicationName={advancedMed.name}
|
||||
treatmentClass={advancedMed.treatmentClass}
|
||||
active={advancedMed.active}
|
||||
startsOn={advancedMed.startsOn}
|
||||
endsOn={advancedMed.endsOn}
|
||||
notificationsEnabled={advancedMed.notificationsEnabled}
|
||||
reminderGraceMinutes={
|
||||
advancedMed.schedules[0]?.reminderGraceMinutes ?? null
|
||||
}
|
||||
intakeCount={advancedIntakeCount ?? 0}
|
||||
atcCode={advancedMed.atcCode}
|
||||
rxNormCode={advancedMed.rxNormCode}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Map a `Medication` row from `GET /api/medications` onto the
|
||||
* `MedicationPayload` shape the wizard's edit-path hydrator consumes.
|
||||
* Mirrors the schedule pass-through the v1.5.3 flat form relied on so
|
||||
* legacy cadences round-trip through the bridge cleanly.
|
||||
*/
|
||||
function medicationToPayload(med: Medication): MedicationPayload {
|
||||
return {
|
||||
id: med.id,
|
||||
name: med.name,
|
||||
dose: med.dose,
|
||||
category: med.category,
|
||||
treatmentClass: med.treatmentClass,
|
||||
deliveryForm: med.deliveryForm,
|
||||
dosesPerUnit: med.dosesPerUnit ?? null,
|
||||
trackInjectionSites: med.trackInjectionSites ?? false,
|
||||
allowedInjectionSites: med.allowedInjectionSites ?? [],
|
||||
notificationsEnabled: med.notificationsEnabled,
|
||||
startsOn: med.startsOn ? new Date(med.startsOn) : null,
|
||||
endsOn: med.endsOn ? new Date(med.endsOn) : null,
|
||||
oneShot: med.oneShot ?? false,
|
||||
schedules: med.schedules.map((s) => ({
|
||||
id: s.id,
|
||||
windowStart: s.windowStart,
|
||||
windowEnd: s.windowEnd,
|
||||
label: s.label ?? null,
|
||||
dose: s.dose ?? null,
|
||||
...parseScheduleRecurrence(s.daysOfWeek),
|
||||
timesOfDay: s.timesOfDay,
|
||||
rrule: s.rrule ?? null,
|
||||
rollingIntervalDays: s.rollingIntervalDays ?? null,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,210 +0,0 @@
|
||||
/**
|
||||
* v1.7.0 — AdvancedSettingsSheet composition contract.
|
||||
*
|
||||
* The sheet hosts five blocks — Reminders → Lifecycle → Data →
|
||||
* Externe API → Danger zone — composed from bare section bodies.
|
||||
* `<ResponsiveSheet>` wraps Radix portals that `renderToStaticMarkup`
|
||||
* does not materialise, so we mock it to a passthrough that renders
|
||||
* children only when `open` and echoes `contentWidth`. The bodies are
|
||||
* mocked to ordered markers. The test pins:
|
||||
* - the five blocks render in the documented order;
|
||||
* - the external-API row lives in its own group, not in Data;
|
||||
* - the sheet opens at the `2xl` width token;
|
||||
* - `onRequestPhaseSheet` threads through to the phases row;
|
||||
* - `onOpenImport` wires the import button (co-located with export);
|
||||
* - the export control sits beside the import control;
|
||||
* - the lifecycle + danger bodies receive an `onAfterAction` closer;
|
||||
* - the sheet renders nothing when `open` is false.
|
||||
*/
|
||||
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
vi.mock("@/lib/i18n/context", () => ({
|
||||
useTranslations: () => ({ t: (key: string) => key }),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/ui/responsive-sheet", () => ({
|
||||
ResponsiveSheet: ({
|
||||
open,
|
||||
children,
|
||||
title,
|
||||
contentWidth,
|
||||
}: {
|
||||
open: boolean;
|
||||
children: React.ReactNode;
|
||||
title: React.ReactNode;
|
||||
contentWidth?: string;
|
||||
}) =>
|
||||
open ? (
|
||||
<div data-slot="mock-responsive-sheet" data-content-width={contentWidth}>
|
||||
<div data-slot="mock-title">{title}</div>
|
||||
{children}
|
||||
</div>
|
||||
) : null,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/medications/sections/api-tokens-row", () => ({
|
||||
ApiTokensRow: () => <div data-slot="mock-api-tokens" />,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/medications/sections/notifications-section", () => ({
|
||||
NotificationsBody: () => <div data-slot="mock-notifications" />,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/medications/sections/settings-section", () => ({
|
||||
GraceRow: () => <div data-slot="mock-grace" />,
|
||||
DrugCodingRow: () => <div data-slot="mock-drug-coding" />,
|
||||
PhasesRow: ({
|
||||
onRequestPhaseSheet,
|
||||
}: {
|
||||
onRequestPhaseSheet?: () => void;
|
||||
}) => (
|
||||
<div
|
||||
data-slot="mock-phases"
|
||||
data-has-phase-swap={onRequestPhaseSheet ? "yes" : "no"}
|
||||
/>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/medications/sections/destructive-zone-section", () => ({
|
||||
LifecycleManageBody: ({ onAfterAction }: { onAfterAction?: () => void }) => (
|
||||
<div
|
||||
data-slot="mock-lifecycle"
|
||||
data-has-after-action={onAfterAction ? "yes" : "no"}
|
||||
/>
|
||||
),
|
||||
DangerZoneBody: ({ onAfterAction }: { onAfterAction?: () => void }) => (
|
||||
<button
|
||||
data-slot="mock-danger"
|
||||
data-has-after-action={onAfterAction ? "yes" : "no"}
|
||||
onClick={onAfterAction}
|
||||
/>
|
||||
),
|
||||
}));
|
||||
|
||||
import { AdvancedSettingsSheet } from "@/components/medications/advanced-settings-sheet";
|
||||
|
||||
function baseProps(
|
||||
open: boolean,
|
||||
extra: Partial<{
|
||||
onRequestPhaseSheet: () => void;
|
||||
onOpenImport: () => void;
|
||||
}> = {},
|
||||
) {
|
||||
return {
|
||||
open,
|
||||
onOpenChange: () => {},
|
||||
medicationId: "med-1",
|
||||
medicationName: "Test Drug",
|
||||
treatmentClass: "GLP1",
|
||||
active: true,
|
||||
startsOn: "2026-01-01",
|
||||
endsOn: "2026-06-01",
|
||||
notificationsEnabled: true,
|
||||
reminderGraceMinutes: 30,
|
||||
intakeCount: 5,
|
||||
...extra,
|
||||
};
|
||||
}
|
||||
|
||||
describe("AdvancedSettingsSheet (v1.7.0)", () => {
|
||||
it("renders the five blocks in Reminders → Lifecycle → Data → Externe API → Danger order", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<AdvancedSettingsSheet
|
||||
{...baseProps(true, { onRequestPhaseSheet: () => {} })}
|
||||
/>,
|
||||
);
|
||||
const reminders = html.indexOf("advanced-group-reminders");
|
||||
const lifecycle = html.indexOf("advanced-group-lifecycle");
|
||||
const data = html.indexOf("advanced-group-data");
|
||||
const externalApi = html.indexOf("advanced-group-external-api");
|
||||
const danger = html.indexOf("mock-danger");
|
||||
expect(reminders).toBeGreaterThan(-1);
|
||||
expect(lifecycle).toBeGreaterThan(reminders);
|
||||
expect(data).toBeGreaterThan(lifecycle);
|
||||
expect(externalApi).toBeGreaterThan(data);
|
||||
expect(danger).toBeGreaterThan(externalApi);
|
||||
});
|
||||
|
||||
it("homes the external-API row in its own group, not in the Data group", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<AdvancedSettingsSheet {...baseProps(true)} />,
|
||||
);
|
||||
// The API row marker must sit after the external-API group opens,
|
||||
// not inside the Data group it used to share.
|
||||
const dataGroup = html.indexOf("advanced-group-data");
|
||||
const externalApiGroup = html.indexOf("advanced-group-external-api");
|
||||
const apiRow = html.indexOf("mock-api-tokens");
|
||||
expect(apiRow).toBeGreaterThan(externalApiGroup);
|
||||
expect(externalApiGroup).toBeGreaterThan(dataGroup);
|
||||
});
|
||||
|
||||
it("opens at the 2xl width token", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<AdvancedSettingsSheet {...baseProps(true)} />,
|
||||
);
|
||||
expect(html).toContain('data-content-width="2xl"');
|
||||
});
|
||||
|
||||
it("threads onRequestPhaseSheet through to the phases row", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<AdvancedSettingsSheet
|
||||
{...baseProps(true, { onRequestPhaseSheet: () => {} })}
|
||||
/>,
|
||||
);
|
||||
expect(html).toContain('data-has-phase-swap="yes"');
|
||||
});
|
||||
|
||||
it("wires the import button", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<AdvancedSettingsSheet
|
||||
{...baseProps(true, { onOpenImport: () => {} })}
|
||||
/>,
|
||||
);
|
||||
expect(html).toContain("advanced-import-button");
|
||||
expect(html).toContain(
|
||||
"medications.detail.advanced.dataPortability.import.button",
|
||||
);
|
||||
});
|
||||
|
||||
it("co-locates an export control beside the import control", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<AdvancedSettingsSheet {...baseProps(true)} />,
|
||||
);
|
||||
expect(html).toContain("advanced-export-button");
|
||||
expect(html).toContain(
|
||||
"medications.detail.advanced.dataPortability.export.button",
|
||||
);
|
||||
const importIdx = html.indexOf("advanced-import-block");
|
||||
const exportIdx = html.indexOf("advanced-export-block");
|
||||
expect(importIdx).toBeGreaterThan(-1);
|
||||
expect(exportIdx).toBeGreaterThan(importIdx);
|
||||
});
|
||||
|
||||
it("passes an onAfterAction to the lifecycle and danger bodies so they can close the sheet", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<AdvancedSettingsSheet {...baseProps(true)} />,
|
||||
);
|
||||
const lifecycle = html.match(
|
||||
/mock-lifecycle"[^>]*data-has-after-action="yes"/,
|
||||
);
|
||||
const danger = html.match(/mock-danger"[^>]*data-has-after-action="yes"/);
|
||||
expect(lifecycle).not.toBeNull();
|
||||
expect(danger).not.toBeNull();
|
||||
});
|
||||
|
||||
it("renders nothing when closed", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<AdvancedSettingsSheet {...baseProps(false)} />,
|
||||
);
|
||||
expect(html).toBe("");
|
||||
});
|
||||
|
||||
it("titles the sheet with the advanced-settings key", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<AdvancedSettingsSheet {...baseProps(true)} />,
|
||||
);
|
||||
expect(html).toContain("medications.detail.advanced.title");
|
||||
});
|
||||
});
|
||||
@@ -1,208 +0,0 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* v1.7.0 — Advanced settings sheet.
|
||||
* v1.7.2 — widened to `4xl` and re-laid into a responsive two-column
|
||||
* grid.
|
||||
* v1.8.6 — rebuilt as a single column. The two-column data|reminders
|
||||
* grid read as overloaded; the sheet now narrows to `2xl` (~672px) and
|
||||
* stacks bordered section cards in most-used-first order. One spacing
|
||||
* rule throughout — `space-y-4` between cards, `divide-y` + `py-3` rows
|
||||
* inside each card (the `<SettingsGroup>` card owns the dividers).
|
||||
*
|
||||
* v1.9.0 — the Data group split. It had carried both operator data
|
||||
* portability and the developer-facing external API in one frame; the
|
||||
* API endpoint now sits in its own `Externe API` group so the two
|
||||
* audiences no longer share a card. Order: Reminders → Lifecycle →
|
||||
* Data → Externe API → Danger.
|
||||
*
|
||||
* - Reminders: notifications switch + reminder window (grace).
|
||||
* - Lifecycle: pause/resume + end course + phases.
|
||||
* - Data: intake Import + medications CSV Export (co-located).
|
||||
* - Externe API: the per-medication ingest endpoint + token, with the
|
||||
* request examples collapsed by default.
|
||||
* - Danger zone: purge intake history + delete medication, set off by
|
||||
* its own destructive-tinted card so an irreversible action never
|
||||
* sits next to a routine toggle.
|
||||
*
|
||||
* Button styling is consistent: reversible / neutral actions are
|
||||
* `outline`, destructive actions are `destructive font-semibold`,
|
||||
* toggles are switches wrapped in labels.
|
||||
*
|
||||
* The Phasen button is sibling-swapped, not nested: `onRequestPhaseSheet`
|
||||
* closes this sheet first and lets the parent open the phase sheet so
|
||||
* the modal stack never exceeds two (D-3 §10 invariant 7). Each control
|
||||
* self-saves, so the footer slot stays empty.
|
||||
*/
|
||||
|
||||
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
|
||||
import { SettingsGroup } from "@/components/medications/settings-group";
|
||||
import { ApiTokensRow } from "@/components/medications/sections/api-tokens-row";
|
||||
import { DataPortabilityRow } from "@/components/medications/sections/data-portability-row";
|
||||
import { NotificationsBody } from "@/components/medications/sections/notifications-section";
|
||||
import {
|
||||
DrugCodingRow,
|
||||
GraceRow,
|
||||
PhasesRow,
|
||||
} from "@/components/medications/sections/settings-section";
|
||||
import {
|
||||
DangerZoneBody,
|
||||
LifecycleManageBody,
|
||||
} from "@/components/medications/sections/destructive-zone-section";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
|
||||
export interface AdvancedSettingsSheetProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
medicationId: string;
|
||||
medicationName: string;
|
||||
treatmentClass?: string;
|
||||
active: boolean;
|
||||
startsOn?: string | null;
|
||||
endsOn?: string | null;
|
||||
notificationsEnabled: boolean;
|
||||
reminderGraceMinutes: number | null;
|
||||
intakeCount: number;
|
||||
/** v1.9.0 — optional drug-classification codes for the FHIR export. */
|
||||
atcCode?: string | null;
|
||||
rxNormCode?: string | null;
|
||||
/**
|
||||
* Sibling-swap the phase sheet: the parent closes this sheet and
|
||||
* opens `<PhaseConfigSheet>` so the two never stack (G-1 §5).
|
||||
*/
|
||||
onRequestPhaseSheet?: () => void;
|
||||
/**
|
||||
* v1.7.0 — opens the page-owned `<IntakeImportDialog>` from the Data
|
||||
* group's import button.
|
||||
*/
|
||||
onOpenImport?: () => void;
|
||||
}
|
||||
|
||||
export function AdvancedSettingsSheet({
|
||||
open,
|
||||
onOpenChange,
|
||||
medicationId,
|
||||
medicationName,
|
||||
treatmentClass,
|
||||
active,
|
||||
startsOn,
|
||||
endsOn,
|
||||
notificationsEnabled,
|
||||
reminderGraceMinutes,
|
||||
intakeCount,
|
||||
atcCode,
|
||||
rxNormCode,
|
||||
onRequestPhaseSheet,
|
||||
onOpenImport,
|
||||
}: AdvancedSettingsSheetProps) {
|
||||
const { t } = useTranslations();
|
||||
const isGlp1 = treatmentClass === "GLP1";
|
||||
|
||||
return (
|
||||
<ResponsiveSheet
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
title={t("medications.detail.advanced.title")}
|
||||
contentWidth="2xl"
|
||||
bodyClassName="gap-4"
|
||||
>
|
||||
<div className="space-y-4" data-slot="advanced-settings-sheet-body">
|
||||
{/* REMINDERS — notifications + reminder window. Most-used group
|
||||
first; each row is a padded `<div>` so the group card's
|
||||
`divide-y` draws the hairline between them. */}
|
||||
<SettingsGroup
|
||||
label={t("medications.detail.advanced.group.reminders")}
|
||||
dataSlot="advanced-group-reminders"
|
||||
>
|
||||
<div className="py-3">
|
||||
<NotificationsBody
|
||||
medicationId={medicationId}
|
||||
notificationsEnabled={notificationsEnabled}
|
||||
/>
|
||||
</div>
|
||||
<div className="py-3">
|
||||
<GraceRow
|
||||
medicationId={medicationId}
|
||||
reminderGraceMinutes={reminderGraceMinutes}
|
||||
/>
|
||||
</div>
|
||||
</SettingsGroup>
|
||||
|
||||
{/* LIFECYCLE — pause/resume + end course + phases */}
|
||||
<SettingsGroup
|
||||
label={t("medications.detail.advanced.group.lifecycle")}
|
||||
dataSlot="advanced-group-lifecycle"
|
||||
>
|
||||
<div className="py-3">
|
||||
<LifecycleManageBody
|
||||
medicationId={medicationId}
|
||||
medicationName={medicationName}
|
||||
active={active}
|
||||
onAfterAction={() => onOpenChange(false)}
|
||||
/>
|
||||
</div>
|
||||
{isGlp1 && (
|
||||
<div className="py-3">
|
||||
<PhasesRow
|
||||
medicationId={medicationId}
|
||||
treatmentClass={treatmentClass}
|
||||
startsOn={startsOn}
|
||||
endsOn={endsOn}
|
||||
onRequestPhaseSheet={onRequestPhaseSheet}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</SettingsGroup>
|
||||
|
||||
{/* DATA — import + export. The external API lives in its own
|
||||
group below; this group stays scoped to operator data
|
||||
portability so the two audiences don't share one frame. */}
|
||||
<SettingsGroup
|
||||
label={t("medications.detail.advanced.group.data")}
|
||||
dataSlot="advanced-group-data"
|
||||
>
|
||||
<div className="py-3">
|
||||
<DataPortabilityRow
|
||||
medicationId={medicationId}
|
||||
onOpenImport={onOpenImport}
|
||||
/>
|
||||
</div>
|
||||
</SettingsGroup>
|
||||
|
||||
{/* EXTERNAL API — the per-medication ingest endpoint + token.
|
||||
Pulled out of the Data group into its own frame so the
|
||||
developer/integration surface reads as a distinct, lowest-
|
||||
frequency block right before the danger zone. */}
|
||||
<SettingsGroup
|
||||
label={t("medications.detail.advanced.group.externalApi")}
|
||||
dataSlot="advanced-group-external-api"
|
||||
>
|
||||
<div className="py-3">
|
||||
<ApiTokensRow
|
||||
medicationId={medicationId}
|
||||
medicationName={medicationName}
|
||||
/>
|
||||
</div>
|
||||
<div className="py-3">
|
||||
<DrugCodingRow
|
||||
medicationId={medicationId}
|
||||
atcCode={atcCode}
|
||||
rxNormCode={rxNormCode}
|
||||
/>
|
||||
</div>
|
||||
</SettingsGroup>
|
||||
|
||||
{/* DANGER ZONE — purge + delete. Set off by the destructive-
|
||||
tinted card so an irreversible action never sits next to a
|
||||
routine toggle. The body supplies its own matched-pair card,
|
||||
so it slots in without the neutral group frame. */}
|
||||
<DangerZoneBody
|
||||
medicationId={medicationId}
|
||||
medicationName={medicationName}
|
||||
intakeCount={intakeCount}
|
||||
onAfterAction={() => onOpenChange(false)}
|
||||
/>
|
||||
</div>
|
||||
</ResponsiveSheet>
|
||||
);
|
||||
}
|
||||
@@ -1,71 +0,0 @@
|
||||
/**
|
||||
* v1.5.6 G-1 §5 — phase sibling-swap wiring on `<SettingsSection>`.
|
||||
*
|
||||
* When the hosting `<AdvancedSettingsSheet>` passes
|
||||
* `onRequestPhaseSheet`, the section must NOT mount its own
|
||||
* `<PhaseConfigSheet>` — the parent orchestrates the swap (close the
|
||||
* advanced sheet, then open the phase sheet) so the two never stack.
|
||||
* Without the callback the section keeps owning its own phase sheet,
|
||||
* preserving the standalone-surface fallback.
|
||||
*
|
||||
* `<PhaseConfigSheet>` is mocked to a marker so its presence /
|
||||
* absence is observable in static markup regardless of open-state.
|
||||
*/
|
||||
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
|
||||
vi.mock("@/components/medications/sections/api-tokens-row", () => ({
|
||||
ApiTokensRow: () => <div data-slot="mock-api-tokens" />,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/medications/sections/phase-config-sheet", () => ({
|
||||
PhaseConfigSheet: () => <div data-slot="mock-phase-config-sheet" />,
|
||||
}));
|
||||
|
||||
import { SettingsSection } from "@/components/medications/sections/settings-section";
|
||||
|
||||
function makeClient() {
|
||||
return new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false, staleTime: Number.POSITIVE_INFINITY },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function render(node: React.ReactNode) {
|
||||
return renderToStaticMarkup(
|
||||
<QueryClientProvider client={makeClient()}>
|
||||
<I18nProvider initialLocale="en">{node}</I18nProvider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
// GLP-1 + a set course window so the Phasen row + button mount.
|
||||
const glp1Props = {
|
||||
medicationId: "med-1",
|
||||
medicationName: "Test Drug",
|
||||
treatmentClass: "GLP1",
|
||||
startsOn: "2026-01-01",
|
||||
endsOn: "2026-06-01",
|
||||
reminderGraceMinutes: 30,
|
||||
};
|
||||
|
||||
describe("SettingsSection phase sibling-swap (G-1 §5)", () => {
|
||||
it("does NOT self-mount the phase sheet when onRequestPhaseSheet is provided", () => {
|
||||
const html = render(
|
||||
<SettingsSection {...glp1Props} onRequestPhaseSheet={() => {}} />,
|
||||
);
|
||||
expect(html).toContain("medication-detail-phase-management-button");
|
||||
expect(html).not.toContain("mock-phase-config-sheet");
|
||||
});
|
||||
|
||||
it("self-mounts the phase sheet on the standalone surface (no callback)", () => {
|
||||
const html = render(<SettingsSection {...glp1Props} />);
|
||||
expect(html).toContain("medication-detail-phase-management-button");
|
||||
expect(html).toContain("mock-phase-config-sheet");
|
||||
});
|
||||
});
|
||||
@@ -4,12 +4,12 @@
|
||||
* v1.5.5 D-3 §9.7 — Settings rows.
|
||||
*
|
||||
* v1.7.0 — the monolithic "Settings" card is decomposed into reusable
|
||||
* rows so the redesigned `<AdvancedSettingsSheet>` can slot each one
|
||||
* under the right group: API tokens → Data, Grace → Reminders,
|
||||
* Phasen → Lifecycle. The standalone `<SettingsSection>` wrapper stays
|
||||
* (it composes the rows under one section card) so existing callers +
|
||||
* tests keep working. The CSV-import stub is gone — the sheet's Data
|
||||
* group carries a real import button.
|
||||
* rows so each can slot under the right group: API tokens → Data, Grace →
|
||||
* Reminders, Phasen → Lifecycle.
|
||||
*
|
||||
* v1.15.18 — the standalone `<SettingsSection>` wrapper is retired with
|
||||
* the modal advanced-settings sheet; the detail page's tabs consume the
|
||||
* individual rows (`GraceRow` / `DrugCodingRow` / `PhasesRow`) directly.
|
||||
*
|
||||
* Per H-4-UX: the Grace row's label flags it as primary-schedule
|
||||
* scoped so the user knows a multi-schedule medication does not get a
|
||||
@@ -23,31 +23,10 @@ import { toast } from "sonner";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { MedicationDetailSection } from "@/components/medications/medication-detail-section";
|
||||
import { ApiTokensRow } from "@/components/medications/sections/api-tokens-row";
|
||||
import { PhaseConfigSheet } from "@/components/medications/sections/phase-config-sheet";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { invalidateKeys, medicationDependentKeys } from "@/lib/query-keys";
|
||||
|
||||
export interface SettingsSectionProps {
|
||||
medicationId: string;
|
||||
medicationName: string;
|
||||
treatmentClass?: string;
|
||||
startsOn?: string | null;
|
||||
endsOn?: string | null;
|
||||
reminderGraceMinutes?: number | null;
|
||||
/**
|
||||
* v1.5.6 G-1 §5 — sibling-swap the phase sheet. When provided, the
|
||||
* Phasen button hands control to the parent (which closes the
|
||||
* hosting sheet before opening `<PhaseConfigSheet>`) instead of the
|
||||
* self-managed `phaseSheetOpen` state, so the two sheets never
|
||||
* stack. Absent on the standalone surface, where the section owns
|
||||
* its own phase sheet.
|
||||
*/
|
||||
onRequestPhaseSheet?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.7.0 — primary-schedule reminder-window (grace) row. Self-saves via
|
||||
* `PUT /api/medications/[id]` with a top-level `reminderGraceMinutes`.
|
||||
@@ -313,54 +292,3 @@ export function PhasesRow({
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Standalone section wrapper — composes the rows under one section
|
||||
* card. Retained for callers / tests that expect the bundled shape.
|
||||
*/
|
||||
export function SettingsSection({
|
||||
medicationId,
|
||||
medicationName,
|
||||
treatmentClass,
|
||||
startsOn,
|
||||
endsOn,
|
||||
reminderGraceMinutes,
|
||||
onRequestPhaseSheet,
|
||||
}: SettingsSectionProps) {
|
||||
const { t } = useTranslations();
|
||||
const isGlp1 = treatmentClass === "GLP1";
|
||||
|
||||
return (
|
||||
<MedicationDetailSection
|
||||
titleId="medication-detail-settings-heading"
|
||||
title={t("medications.detail.settings.title")}
|
||||
dataSlot="medication-detail-settings-section"
|
||||
>
|
||||
<div className="space-y-4" data-slot="medication-detail-settings-body">
|
||||
<ApiTokensRow
|
||||
medicationId={medicationId}
|
||||
medicationName={medicationName}
|
||||
/>
|
||||
|
||||
{isGlp1 && (
|
||||
<>
|
||||
<Separator />
|
||||
<PhasesRow
|
||||
medicationId={medicationId}
|
||||
treatmentClass={treatmentClass}
|
||||
startsOn={startsOn}
|
||||
endsOn={endsOn}
|
||||
onRequestPhaseSheet={onRequestPhaseSheet}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Separator />
|
||||
|
||||
<GraceRow
|
||||
medicationId={medicationId}
|
||||
reminderGraceMinutes={reminderGraceMinutes}
|
||||
/>
|
||||
</div>
|
||||
</MedicationDetailSection>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user