fix(symptoms): show the load error in the quick entry instead of the empty hint

This commit is contained in:
Marc-André Bombeck committed 2026-10-03 23:07:16 +02:00
1 parent d644db1975
commit 1eab6145a9
2 files changed
+119 -77

No files matched your search

@@ -7,13 +7,16 @@
* - drop the `resolvedAt === null` filter on the episodes → "offers no
* episode selector when every episode is resolved" goes red;
* - render the add chip unconditionally → "a delegate who may log but not
* manage gets no add chip" goes red.
* manage gets no add chip" goes red;
* - drop the `definitions.isError` branch → "a failed read shows the error
* row, not the empty hint" goes red (the empty hint renders instead).
*/
import { describe, expect, it, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
const state = vi.hoisted(() => ({
canManage: true,
definitionsFailed: false,
episodes: [] as Array<{
id: string;
label: string;
@@ -34,30 +37,42 @@ vi.mock("@/components/illness/use-illness", () => ({
}));
vi.mock("../use-symptoms", () => ({
useSymptomDefinitions: () => ({
isLoading: false,
data: {
limit: 8,
definitions: [
{
id: "d1",
label: "Aura",
icon: "Zap",
sortOrder: 0,
isActive: true,
recent: { count30d: 0, maxIntensity30d: null, lastOccurredAt: null },
useSymptomDefinitions: () =>
state.definitionsFailed
? { isLoading: false, isError: true, data: undefined, refetch: vi.fn() }
: {
isLoading: false,
isError: false,
data: {
limit: 8,
definitions: [
{
id: "d1",
label: "Aura",
icon: "Zap",
sortOrder: 0,
isActive: true,
recent: {
count30d: 0,
maxIntensity30d: null,
lastOccurredAt: null,
},
},
{
id: "d2",
label: "Headache",
icon: "Brain",
sortOrder: 1,
isActive: true,
recent: {
count30d: 0,
maxIntensity30d: null,
lastOccurredAt: null,
},
},
],
},
},
{
id: "d2",
label: "Headache",
icon: "Brain",
sortOrder: 1,
isActive: true,
recent: { count30d: 0, maxIntensity30d: null, lastOccurredAt: null },
},
],
},
}),
useLogSymptomEvent: () => ({ mutateAsync: vi.fn(), isPending: false }),
useCreateSymptomDefinition: () => ({
mutateAsync: vi.fn(),
@@ -111,4 +126,19 @@ describe("<SymptomEntryForm>", () => {
];
expect(render()).not.toContain("-episode");
});
it("a failed read shows the error row, not the empty hint", () => {
state.canManage = true;
state.episodes = [];
state.definitionsFailed = true;
try {
const html = render();
expect(html).toContain('data-slot="query-error-row"');
expect(html).toContain("load your symptoms");
expect(html).not.toContain("first symptom");
expect(html).not.toContain('data-testid="symptom-add-chip"');
} finally {
state.definitionsFailed = false;
}
});
});
+65 -53
View File
@@ -27,6 +27,7 @@ import {
import { FieldGroup } from "@/components/ui/field-group";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { QueryErrorRow } from "@/components/ui/query-error-row";
import {
Select,
SelectContent,
@@ -210,59 +211,70 @@ export function SymptomEntryForm({
>
<div className="space-y-2">
<Label id={symptomGroupId}>{t("symptoms.entry.symptom")}</Label>
<div
role="radiogroup"
aria-labelledby={symptomGroupId}
aria-describedby={
symptomError ? `${symptomGroupId}-error` : undefined
}
className="flex flex-wrap gap-2"
>
{list.map((definition) => {
const selected = definitionId === definition.id;
return (
<button
key={definition.id}
type="button"
role="radio"
aria-checked={selected}
data-testid="symptom-chip"
onClick={() => {
setDefinitionId(selected ? null : definition.id);
setSymptomError(null);
}}
className={cn(
"focus-visible:ring-ring/50 inline-flex min-h-11 items-center gap-2 rounded-full border px-3 py-1.5 text-sm transition-colors focus-visible:ring-2 focus-visible:outline-none sm:min-h-8",
selected
? "border-primary bg-primary/10 text-foreground"
: "border-border text-foreground hover:bg-accent",
)}
>
<SymptomIcon name={definition.icon} className="size-4" />
{definition.label ?? t("symptoms.unreadableLabel")}
</button>
);
})}
{canDefine && !atLimit ? (
<AddSymptomChip
onCreated={(id) => {
setDefinitionId(id);
setSymptomError(null);
}}
/>
) : null}
</div>
{list.length === 0 && !definitions.isLoading ? (
<p className="text-muted-foreground text-xs">
{canDefine
? t("symptoms.entry.emptyHint")
: t("symptoms.entry.emptyHintNoManage")}
</p>
) : atLimit && canDefine ? (
<p className="text-muted-foreground text-xs">
{t("symptoms.entry.limitHint", { limit })}
</p>
) : null}
{definitions.isError ? (
// A failed read is not an empty list: the empty hint would invite
// the person to define a symptom they may already have.
<QueryErrorRow
message={t("symptoms.section.loadError")}
onRetry={() => void definitions.refetch()}
/>
) : (
<>
<div
role="radiogroup"
aria-labelledby={symptomGroupId}
aria-describedby={
symptomError ? `${symptomGroupId}-error` : undefined
}
className="flex flex-wrap gap-2"
>
{list.map((definition) => {
const selected = definitionId === definition.id;
return (
<button
key={definition.id}
type="button"
role="radio"
aria-checked={selected}
data-testid="symptom-chip"
onClick={() => {
setDefinitionId(selected ? null : definition.id);
setSymptomError(null);
}}
className={cn(
"focus-visible:ring-ring/50 inline-flex min-h-11 items-center gap-2 rounded-full border px-3 py-1.5 text-sm transition-colors focus-visible:ring-2 focus-visible:outline-none sm:min-h-8",
selected
? "border-primary bg-primary/10 text-foreground"
: "border-border text-foreground hover:bg-accent",
)}
>
<SymptomIcon name={definition.icon} className="size-4" />
{definition.label ?? t("symptoms.unreadableLabel")}
</button>
);
})}
{canDefine && !atLimit ? (
<AddSymptomChip
onCreated={(id) => {
setDefinitionId(id);
setSymptomError(null);
}}
/>
) : null}
</div>
{list.length === 0 && !definitions.isLoading ? (
<p className="text-muted-foreground text-xs">
{canDefine
? t("symptoms.entry.emptyHint")
: t("symptoms.entry.emptyHintNoManage")}
</p>
) : atLimit && canDefine ? (
<p className="text-muted-foreground text-xs">
{t("symptoms.entry.limitHint", { limit })}
</p>
) : null}
</>
)}
{symptomError ? (
<p
id={`${symptomGroupId}-error`}