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>