fix(settings): end the allergy and family-history cards in one action row

The allergy card had two action rows, the list's add button and the
free-text note's save, and the family-history card on a shared record
put its sentence about relatives after the list's actions. The add
button now rides the note's row before save, and the sentence moves
above the list.
This commit is contained in:
Marc-André Bombeck committed 2026-10-08 22:57:47 +02:00
1 parent 04084dd6e2
commit 7525d2ed00
6 files changed
+130 -31

No files matched your search

@@ -21,7 +21,7 @@
* Plain text only — the value renders exclusively through the input; no
* markdown anywhere.
*/
import { useState } from "react";
import { useState, type ReactNode } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Loader2 } from "lucide-react";
import { toast } from "sonner";
@@ -50,7 +50,15 @@ interface AboutMeData {
const FIELD_CLASSES =
"border-input bg-background placeholder:text-muted-foreground focus-visible:border-input-focus w-full rounded-md border px-3 py-2 text-sm focus-visible:outline-none";
export function AllergyFreeTextNote() {
export function AllergyFreeTextNote({
leadingAction,
}: {
/**
* The card's other action (the allergy list's add button), placed before
* Save so the card ends in one action row (design standards §12).
*/
leadingAction?: ReactNode;
} = {}) {
const { t } = useTranslations();
const queryClient = useQueryClient();
const [draft, setDraft] = useState<string | null>(null);
@@ -121,12 +129,18 @@ export function AllergyFreeTextNote() {
onChange={(e) => setDraft(e.target.value)}
/>
{query.isError ? (
<QueryErrorRow
message={t("settings.ai.aboutMe.loadError")}
onRetry={() => query.refetch()}
/>
<>
<QueryErrorRow
message={t("settings.ai.aboutMe.loadError")}
onRetry={() => query.refetch()}
/>
{leadingAction ? (
<SettingsCardActions>{leadingAction}</SettingsCardActions>
) : null}
</>
) : (
<SettingsCardActions>
{leadingAction}
<Button
type="button"
size="sm"
+31 -17
View File
@@ -1,6 +1,6 @@
"use client";
import { useState } from "react";
import { useState, type ReactNode } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Pencil, Plus, ShieldAlert } from "lucide-react";
import { toast } from "sonner";
@@ -24,8 +24,18 @@ import type { AllergyDTO } from "@/lib/records/dto";
* v1.25 (W-RECORDS) — the structured allergy/intolerance manager. Renders in
* the Settings → Anamnese section: a list of records with add / edit / delete.
* Patient-reported reference data, not a clinical diagnosis.
*
* `afterList` lets the card put something between the list and the action
* row (the free-text note in the Anamnesis card) while keeping ONE action
* row as the card's last element (design standards §12): it receives the
* add button (null while the empty state offers its own) and renders the
* row itself. Without it the manager closes with its own row.
*/
export function AllergyManager() {
export function AllergyManager({
afterList,
}: {
afterList?: (addAction: ReactNode | null) => ReactNode;
} = {}) {
const { t } = useTranslations();
const queryClient = useQueryClient();
const [formOpen, setFormOpen] = useState(false);
@@ -119,6 +129,19 @@ export function AllergyManager() {
);
}
const addAction =
!isLoading && !isError && rows.length > 0 ? (
<Button
size="sm"
variant="outline"
onClick={openNew}
className="min-h-11 sm:min-h-9"
>
<Plus className="h-4 w-4" />
{t("records.allergies.add")}
</Button>
) : null;
return (
<div className="space-y-4">
{isLoading ? (
@@ -146,21 +169,12 @@ export function AllergyManager() {
)}
{/* The add action closes the list (design standards §12); an empty
list leaves it to the empty state's own button. Outline here,
because the card's primary is the free-text note's Save below. */}
{!isLoading && !isError && rows.length > 0 ? (
<SettingsCardActions>
<Button
size="sm"
variant="outline"
onClick={openNew}
className="min-h-11 sm:min-h-9"
>
<Plus className="h-4 w-4" />
{t("records.allergies.add")}
</Button>
</SettingsCardActions>
) : null}
list leaves it to the empty state's own button. Outline, because
where the card carries the free-text note its Save is the primary
in the same row. */}
{afterList
? afterList(addAction)
: addAction && <SettingsCardActions>{addAction}</SettingsCardActions>}
<ResponsiveSheet
open={formOpen}
@@ -1,3 +1,4 @@
import type React from "react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { readFileSync } from "node:fs";
@@ -38,10 +39,30 @@ vi.mock("@/components/records/health-profile-facts-manager", () => ({
HealthProfileFactsManager: () => null,
}));
vi.mock("@/components/records/allergy-manager", () => ({
AllergyManager: () => <div data-slot="allergy-manager-probe" />,
AllergyManager: ({
afterList,
}: {
afterList?: (add: React.ReactNode) => React.ReactNode;
}) => (
<div data-slot="allergy-manager-probe">
{afterList ? (
afterList(<button data-slot="allergy-add-probe" />)
) : (
<div data-slot="allergy-manager-own-actions" />
)}
</div>
),
}));
vi.mock("@/components/records/allergy-free-text-note", () => ({
AllergyFreeTextNote: () => <div data-slot="allergy-free-text-probe" />,
AllergyFreeTextNote: ({
leadingAction,
}: {
leadingAction?: React.ReactNode;
}) => (
<div data-slot="allergy-free-text-probe">
<div data-slot="settings-card-actions">{leadingAction}</div>
</div>
),
}));
vi.mock("@/components/records/about-me-note-manager", () => ({
AboutMeNoteManager: () => <div data-slot="about-me-note-probe" />,
@@ -88,6 +109,19 @@ describe("Anamnese — the About-me note card (#159)", () => {
expect(between).not.toContain("<h2");
});
it("ends the allergies card in one action row carrying the add action", () => {
// The list's add button rides the note's row (design standards §12):
// the manager renders no row of its own, and the note's row holds add.
const html = renderSection();
expect(html).not.toContain('data-slot="allergy-manager-own-actions"');
const note = html.slice(
html.indexOf('data-slot="allergy-free-text-probe"'),
);
expect(note).toMatch(
/data-slot="settings-card-actions"><button data-slot="allergy-add-probe"/,
);
});
it("keeps the same AI-surface gate as the inclusion card", () => {
moduleState.coach = false;
moduleState.insights = false;
@@ -0,0 +1,33 @@
/**
* A card's action row is its last element (design standards §12). The
* family-history card on a shared record carries a sentence about relatives;
* it sits above the list, whose manager closes with the card's actions.
*/
import { describe, expect, it, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
vi.mock("@/components/records/allergy-manager", () => ({
AllergyManager: () => null,
}));
vi.mock("@/components/records/family-history-manager", () => ({
FamilyHistoryManager: () => (
<div data-slot="settings-card-actions">family-actions</div>
),
}));
import { I18nProvider } from "@/lib/i18n/context";
import { RecordAnamnesisSection } from "../record-anamnesis-section";
describe("<RecordAnamnesisSection> card order", () => {
it("puts the relatives sentence above the family list and its actions", () => {
const html = renderToStaticMarkup(
<I18nProvider initialLocale="en">
<RecordAnamnesisSection />
</I18nProvider>,
);
const note = html.indexOf('data-slot="record-anamnesis-relative-note"');
const actions = html.indexOf("family-actions");
expect(note).toBeGreaterThan(-1);
expect(actions).toBeGreaterThan(note);
});
});
@@ -111,11 +111,13 @@ export function AnamnesisSection() {
title={t("records.allergies.cardTitle")}
description={t("records.allergies.cardDescription")}
/>
<AllergyManager />
{/* #159 — the free-text supplement to the structured list above,
moved here from the account-settings "About me" panel so both
allergy inputs live in one card. */}
<AllergyFreeTextNote />
allergy inputs live in one card. The list's add button rides the
note's action row, so the card ends in one row. */}
<AllergyManager
afterList={(add) => <AllergyFreeTextNote leadingAction={add} />}
/>
</SettingsCard>
<SettingsCard>
@@ -36,8 +36,8 @@ import { useTranslations } from "@/lib/i18n/context";
* relatives, and often enough the delegate IS that relative, asserting a
* condition about themselves into somebody else's record. The route's own
* comment named that discomfort and left it for the surface to say out loud,
* because no route comment can. The sentence under the card is where it gets
* said.
* because no route comment can. The sentence at the top of the card body,
* above the list, is where it gets said.
*
* The cache is not a concern here and the reason is worth naming: switching
* records clears the whole query client, so the allergy and family-history
@@ -63,13 +63,15 @@ export function RecordAnamnesisSection() {
title={t("records.family.cardTitle")}
description={t("records.family.cardDescription")}
/>
<FamilyHistoryManager />
{/* Above the list: the manager closes with its action row, and
nothing follows a card's actions (design standards §12). */}
<p
data-slot="record-anamnesis-relative-note"
className="text-foreground text-sm"
>
{t("recordSharing.anamnesis.relativeNote")}
</p>
<FamilyHistoryManager />
</SettingsCard>
<p className="text-muted-foreground text-xs">{t("records.disclaimer")}</p>