fix(onboarding): an empty multi-select is not an answer

Q2, Q4 and Q6 let "Weiter" write an answer with nothing ticked. On Q2
that answered "no areas", which is what switched every optional module
off for somebody whose click meant "no preference"; on Q6 it marked the
step done while recording no unit at all.

An empty selection now yields no body, so the primary button is dead
until something is chosen. Skip stays the way past, and Skip is the
conservative derivation the completion route already applies.
This commit is contained in:
Marc-André Bombeck committed 2026-09-11 09:14:34 +02:00
1 parent 0e79b195b0
commit 77a09a5080
4 files changed
+134 -20

No files matched your search

@@ -0,0 +1,82 @@
/**
* v1.39 (Wave C, C3) — Q6 cannot be answered with nothing chosen (M-k).
*
* "Weiter" used to be live on an untouched units screen: it wrote
* `{ units: {} }`, which marked the step DONE without recording a preference,
* so the account carried "asked and answered" for a question nobody answered.
* Skip is the way past a question with no opinion, and Skip says so on the
* ledger.
*
* Rendered server-side, this repo's component-test convention.
*/
import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it, vi } from "vitest";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { I18nProvider } from "@/lib/i18n/context";
import {
defaultOnboardingSteps,
emptyOnboardingNeeds,
type OnboardingStateDto,
} from "@/lib/onboarding/needs";
import { UnitsScreen } from "../units-screen";
vi.mock("next/navigation", () => ({
useRouter: () => ({ push: vi.fn(), replace: vi.fn() }),
}));
vi.mock("@/hooks/use-auth", () => ({
useAuth: () => ({ user: null }),
}));
function state(
needs: Partial<OnboardingStateDto["needs"]>,
): OnboardingStateDto {
return {
steps: defaultOnboardingSteps(),
needs: { ...emptyOnboardingNeeds(), recordTarget: "me", ...needs },
completedAt: null,
firstResult: null,
};
}
function render(needs: Partial<OnboardingStateDto["needs"]>): string {
return renderToStaticMarkup(
<QueryClientProvider client={new QueryClient()}>
<I18nProvider initialLocale="en">
<UnitsScreen state={state(needs)} />
</I18nProvider>
</QueryClientProvider>,
);
}
/**
* Is the primary button's own tag disabled? Read off the tag rather than the
* whole page, and off the attribute rather than the string "disabled", which
* also occurs in the button's `disabled:` utility classes.
*/
function nextIsDisabled(html: string): boolean {
const at = html.indexOf('data-slot="onboarding-next"');
expect(at).toBeGreaterThan(-1);
const open = html.lastIndexOf("<button", at);
return html.slice(open, html.indexOf(">", at) + 1).includes('disabled=""');
}
describe("<UnitsScreen>", () => {
it("does not let the person continue without choosing a unit", () => {
const html = render({ areas: ["glucose"] });
expect(nextIsDisabled(html)).toBe(true);
// Skip is still there: it is the honest way past a question with no
// opinion, and it says "skipped" on the ledger.
expect(html).toContain('data-slot="onboarding-skip"');
});
it("lets the person continue once a unit is chosen", () => {
const html = render({
areas: ["glucose"],
units: { glucoseUnit: "mg/dL", unitPreference: null },
});
expect(nextIsDisabled(html)).toBe(false);
});
});
+11 -4
View File
@@ -54,9 +54,15 @@ export function UnitsScreen({ state }: { state: OnboardingStateDto }) {
const back = previousScreen(state, "units");
const answerBody = unitsAnswerBody({ asked, glucoseUnit, unitPreference });
async function persist(
input:
ReturnType<typeof unitsAnswerBody> | { step: "units"; status: "skipped" },
| NonNullable<ReturnType<typeof unitsAnswerBody>>
| {
step: "units";
status: "skipped";
},
) {
try {
const written = await answer.mutateAsync(input);
@@ -121,9 +127,10 @@ export function UnitsScreen({ state }: { state: OnboardingStateDto }) {
<StepActions
backHref={back ? screenHref(back) : undefined}
onSkip={() => void persist({ step: "units", status: "skipped" })}
onNext={() =>
void persist(unitsAnswerBody({ asked, glucoseUnit, unitPreference }))
}
onNext={() => {
if (answerBody) void persist(answerBody);
}}
nextDisabled={answerBody === null}
pending={answer.isPending}
/>
</section>
@@ -80,11 +80,15 @@ describe("questionAnswerBody", () => {
expect(questionAnswerBody("visit", [])).toBeNull();
});
it("sends an empty list for a many-answer question with nothing chosen", () => {
expect(questionAnswerBody("areas", [])).toEqual({
step: "areas",
areas: [],
});
it("is null for a many-answer question with nothing chosen (I4)", () => {
// An empty tick list is not an opinion. It used to be sent as an
// ANSWER, and an answered Q2 with no areas is what switched every
// optional module off for somebody who meant "no preference" — Skip is
// the way past, and Skip is the conservative derivation.
expect(questionAnswerBody("areas", [])).toBeNull();
expect(questionAnswerBody("sources", [])).toBeNull();
// A list of values none of which the step offers is just as empty.
expect(questionAnswerBody("areas", ["no-such-area"])).toBeNull();
});
it("drops a value the step's vocabulary does not contain", () => {
@@ -125,13 +129,22 @@ describe("units", () => {
expect(onboardingAnswerSchema.safeParse(body).success).toBe(true);
});
it("sends an empty answer when nothing was chosen, which leaves the columns alone", () => {
it("is null when nothing was chosen, so Skip is the way past (M-k)", () => {
// An empty units answer marked Q6 done without recording a preference.
expect(
unitsAnswerBody({
asked: { glucose: true, weight: true },
glucoseUnit: null,
unitPreference: null,
}),
).toEqual({ step: "units", units: {} });
).toBeNull();
// One of two asked questions answered is still an answer.
expect(
unitsAnswerBody({
asked: { glucose: true, weight: true },
glucoseUnit: "mg/dL",
unitPreference: null,
}),
).toEqual({ step: "units", units: { glucoseUnit: "mg/dL" } });
});
});
+21 -9
View File
@@ -110,9 +110,15 @@ function only<T extends string>(
/**
* The body for one answered step, or null when the selection cannot be an
* answer (Q1 with nothing chosen). Every arm names its field; a value that is
* not in the step's vocabulary is dropped rather than sent, so the strict
* server schema never sees a chip this file did not offer.
* answer. Every arm names its field; a value that is not in the step's
* vocabulary is dropped rather than sent, so the strict server schema never
* sees a chip this file did not offer.
*
* v1.39 (Wave C, C3) — an empty tick list is not an answer either (research
* I4). The many-answer arms used to send `[]` and mark the step DONE, and an
* answered Q2 with no areas is what switched every optional module off for
* somebody whose click meant "no preference". Skip is the way past a question
* nobody wants to answer, and Skip is the conservative derivation.
*/
export function questionAnswerBody(
step: QuestionStepId,
@@ -123,14 +129,18 @@ export function questionAnswerBody(
const [recordTarget] = only(ONBOARDING_RECORD_TARGETS, selected);
return recordTarget ? { step, recordTarget } : null;
}
case "areas":
return { step, areas: only(ONBOARDING_AREA_KEYS, selected) };
case "areas": {
const areas = only(ONBOARDING_AREA_KEYS, selected);
return areas.length > 0 ? { step, areas } : null;
}
case "medication": {
const [medication] = only(ONBOARDING_MEDICATION_ANSWERS, selected);
return medication ? { step, medication } : null;
}
case "sources":
return { step, sources: only(ONBOARDING_SOURCE_KEYS, selected) };
case "sources": {
const sources = only(ONBOARDING_SOURCE_KEYS, selected);
return sources.length > 0 ? { step, sources } : null;
}
case "visit": {
const [visit] = only(ONBOARDING_VISIT_ANSWERS, selected);
return visit ? { step, visit } : null;
@@ -171,7 +181,7 @@ export function unitsAnswerBody(input: {
asked: { glucose: boolean; weight: boolean };
glucoseUnit: "mg/dL" | "mmol/L" | null;
unitPreference: "metric" | "imperial" | null;
}): OnboardingAnswerInput {
}): OnboardingAnswerInput | null {
const units: {
glucoseUnit?: "mg/dL" | "mmol/L";
unitPreference?: "metric" | "imperial";
@@ -182,5 +192,7 @@ export function unitsAnswerBody(input: {
if (input.asked.weight && input.unitPreference) {
units.unitPreference = input.unitPreference;
}
return { step: "units", units };
// v1.39 (Wave C, C3) — nothing chosen is not an answer (M-k): an empty
// units body marked Q6 done while recording no preference at all.
return Object.keys(units).length > 0 ? { step: "units", units } : null;
}