fix(studio): an ask card with several questions walks them in order

With more than one question, the only footer action was Confirm, disabled
until every question had an answer, so after the first pick the next
question could only be reached through its tab.

A single-choice pick now moves to the next open question after a short
pause, so the mark is seen first. While another question is still open,
the primary button is Next (i/n); it is enabled once this question is
answered. Previous steps back. Confirm appears only on the last open
question. Multi-choice and free-text answers never advance by
themselves.
This commit is contained in:
yhh committed 2026-09-24 02:57:30 -07:00
1 parent 96ed3492f0
commit 2b504d43f6
3 files changed
+78 -4

No files matched your search

+2
View File
@@ -992,6 +992,8 @@ export const EN: Record<string, string> = {
"无法解析该供应商余额接口返回的内容": "The provider's balance endpoint answered in a shape this build cannot read",
"{n} 个问题": "{n} questions",
"上一题": "Previous",
"下一题({i}/{n})": "Next ({i}/{n})",
"提交(还差 {n} 项)": "Submit ({n} still to fill)",
"提交": "Submit",
+36 -4
View File
@@ -4,6 +4,8 @@ import { useState } from "react";
import { t } from "../../i18n";
import type { Item } from "../../state/session";
const ADVANCE_MS = 220;
interface Props {
item: Extract<Item, { t: "ask" }>;
onAnswer: (itemId: string, id: string, answers: { questionId: string; selected: string[] }[]) => Promise<void>;
@@ -45,6 +47,16 @@ export function AskCard({ item, onAnswer }: Props) {
const at = <T,>(list: T[], i: number, v: T) => list.map((x, k) => (k === i ? v : x));
// The next question still waiting for an answer after `from`, wrapping, or -1.
// `done` counts one more question as answered than state yet shows.
const nextOpen = (from: number, done = -1) => {
for (let k = 1; k < qs.length; k++) {
const j = (from + k) % qs.length;
if (j !== done && !answered(j)) return j;
}
return -1;
};
const toggle = (qi: number, label: string) => {
if (sealed) return;
setPicks((prev) => {
@@ -52,7 +64,13 @@ export function AskCard({ item, onAnswer }: Props) {
const has = prev[qi].includes(label);
return at(prev, qi, has ? prev[qi].filter((l) => l !== label) : [...prev[qi], label]);
});
if (!qs[qi].multi) setOtherOn((prev) => at(prev, qi, false));
if (!qs[qi].multi) {
setOtherOn((prev) => at(prev, qi, false));
// One pick answers a single-choice question, so the card moves on by
// itself; the pause lets the mark land before the pane changes.
const next = nextOpen(qi, qi);
if (next >= 0) window.setTimeout(() => setTab((cur) => (cur === qi ? next : cur)), ADVANCE_MS);
}
};
const toggleOther = (qi: number) => {
@@ -176,9 +194,23 @@ export function AskCard({ item, onAnswer }: Props) {
<button className="dismiss" data-action="ask.answer" data-value="none" disabled={submitting} onClick={() => void send(qs.map(() => []))}>
{t("先不选择,直接回复")}
</button>
<button className="btn" data-primary data-action="ask.answer" data-value="chosen" disabled={left > 0 || submitting} onClick={() => void send(qs.map((_, i) => selected(i)))}>
{submitting ? t("正在提交…") : t("确认")}
</button>
{qs.length > 1 && tab > 0 && (
<button className="dismiss" data-action="ask.step" data-value="prev" disabled={submitting} onClick={() => setTab(tab - 1)}>
{t("上一题")}
</button>
)}
{nextOpen(tab, tab) >= 0 ? (
// While another question is still open the primary step is to
// it; it waits for this one to be answered first.
<button className="btn" data-primary data-action="ask.step" data-value="next"
disabled={!answered(tab) || submitting} onClick={() => setTab(nextOpen(tab))}>
{t("下一题({i}/{n})", { i: tab + 1, n: qs.length })}
</button>
) : (
<button className="btn" data-primary data-action="ask.answer" data-value="chosen" disabled={left > 0 || submitting} onClick={() => void send(qs.map((_, i) => selected(i)))}>
{submitting ? t("正在提交…") : t("确认")}
</button>
)}
</div>
)}
</div>
@@ -106,4 +106,44 @@ describe("decision cards", () => {
expect(answer).toHaveBeenCalledWith("row", "ask", [{ questionId: "city", selected: ["深圳"] }]);
});
// Several questions are walked, not hunted for: a single-choice pick moves on
// by itself, a multi-choice one waits for Next, and Confirm appears only once
// every question has an answer.
it("steps through several questions without reaching for the tabs", async () => {
const answer = vi.fn(pending);
const item = {
t: "ask", id: "row", ask: { id: "ask", questions: [
{ id: "mode", header: "方式", prompt: "怎么做", multi: false, options: [{ label: "直接改" }, { label: "先出计划" }] },
{ id: "scope", header: "范围", prompt: "改哪些", multi: true, options: [{ label: "calc.go" }, { label: "calc_test.go" }] },
{ id: "style", header: "风格", prompt: "注释", multi: false, options: [{ label: "简短" }, { label: "详细" }] },
] },
} as Extract<Item, { t: "ask" }>;
render(<AskCard item={item} onAnswer={answer} />);
const tab = (name: string) => screen.getByRole("tab", { name: new RegExp(name) });
expect((screen.getByRole("button", { name: "下一题(1/3)" }) as HTMLButtonElement).disabled).toBe(true);
await userEvent.click(screen.getByRole("button", { name: /直接改/ }));
await waitFor(() => expect(tab("范围").getAttribute("aria-selected")).toBe("true"));
await userEvent.click(screen.getByRole("button", { name: /calc_test\.go/ }));
expect(tab("范围").getAttribute("aria-selected")).toBe("true");
await userEvent.click(screen.getByRole("button", { name: "下一题(2/3)" }));
expect(tab("风格").getAttribute("aria-selected")).toBe("true");
// The last open question offers Confirm, waiting for its answer, not a Next
// that leads nowhere.
expect((screen.getByRole("button", { name: "确认" }) as HTMLButtonElement).disabled).toBe(true);
await userEvent.click(screen.getByRole("button", { name: "上一题" }));
expect(tab("范围").getAttribute("aria-selected")).toBe("true");
await userEvent.click(tab("风格"));
await userEvent.click(screen.getByRole("button", { name: /简短/ }));
await userEvent.click(screen.getByRole("button", { name: "确认" }));
expect(answer).toHaveBeenCalledWith("row", "ask", [
{ questionId: "mode", selected: ["直接改"] },
{ questionId: "scope", selected: ["calc_test.go"] },
{ questionId: "style", selected: ["简短"] },
]);
});
});