fix: multi-select custom answer toggling

This commit is contained in:
Erik Kotsiuba
2026-05-03 22:13:30 +02:00
parent f86f3a578e
commit 780aac24cf
7 changed files with 112 additions and 11 deletions
+2
View File
@@ -172,6 +172,8 @@ This document defines the stable external behavior. It does not explain internal
- on single-select questions, saving free-form text clears selected options for that question - on single-select questions, saving free-form text clears selected options for that question
- on multi-select questions, `values` and `labels` include both selected options and `customText` when both are present - on multi-select questions, `values` and `labels` include both selected options and `customText` when both are present
- on multi-select questions, selected options keep their original order and `customText` is appended last - on multi-select questions, selected options keep their original order and `customText` is appended last
- submitting free-form text on a multi-select question stays on the same question tab and marks the custom row selected
- on multi-select questions, toggling an empty custom row opens the free-form editor, while toggling a custom row with saved free-form text selects or deselects it without opening the editor or clearing the text
- saving or clearing free-form text on a multi-select question does not clear other selected options - saving or clearing free-form text on a multi-select question does not clear other selected options
- `note` stores a question-level note - `note` stores a question-level note
- `optionNotes` includes only notes for selected options - `optionNotes` includes only notes for selected options
+22 -4
View File
@@ -17,6 +17,7 @@ export function emptyAnswer(): AskStateAnswer {
export function cloneAnswer(answer: AskStateAnswer): AskStateAnswer { export function cloneAnswer(answer: AskStateAnswer): AskStateAnswer {
return { return {
selected: answer.selected.map(cloneSelection), selected: answer.selected.map(cloneSelection),
customSelected: answer.customSelected,
customText: answer.customText, customText: answer.customText,
note: answer.note, note: answer.note,
optionNotes: answer.optionNotes ? { ...answer.optionNotes } : undefined, optionNotes: answer.optionNotes ? { ...answer.optionNotes } : undefined,
@@ -87,13 +88,24 @@ export function saveCustomText(
next.selected = []; next.selected = [];
} }
if (!trimmed) { if (!trimmed) {
next.customSelected = undefined;
next.customText = undefined; next.customText = undefined;
return next; return next;
} }
next.customSelected = true;
next.customText = rawValue; next.customText = rawValue;
return next; return next;
} }
export function setCustomSelected(
answer: AskStateAnswer,
selected: boolean
): AskStateAnswer {
const next = cloneAnswer(answer);
next.customSelected = selected || undefined;
return next;
}
export function saveQuestionNote( export function saveQuestionNote(
answer: AskStateAnswer, answer: AskStateAnswer,
rawValue: string rawValue: string
@@ -137,7 +149,10 @@ export function isAnswerAnswered(answer?: AskStateAnswer): boolean {
if (!answer) { if (!answer) {
return false; return false;
} }
return answer.selected.length > 0 || !!answer.customText?.trim(); return (
answer.selected.length > 0 ||
!!(answer.customSelected && answer.customText?.trim())
);
} }
export function hasAnswerNotes(answer?: AskStateAnswer): boolean { export function hasAnswerNotes(answer?: AskStateAnswer): boolean {
@@ -178,13 +193,16 @@ export function isOptionSelected(
} }
export function serializeAnswer(answer: AskStateAnswer): AskResultAnswer { export function serializeAnswer(answer: AskStateAnswer): AskResultAnswer {
const selectedCustomText = answer.customSelected
? answer.customText
: undefined;
const values = [ const values = [
...answer.selected.map((selection) => selection.value), ...answer.selected.map((selection) => selection.value),
...(answer.customText ? [answer.customText] : []), ...(selectedCustomText ? [selectedCustomText] : []),
]; ];
const labels = [ const labels = [
...answer.selected.map((selection) => selection.label), ...answer.selected.map((selection) => selection.label),
...(answer.customText ? [answer.customText] : []), ...(selectedCustomText ? [selectedCustomText] : []),
]; ];
const indices = answer.selected.map((selection) => selection.index); const indices = answer.selected.map((selection) => selection.index);
const selectedNotes = answer.optionNotes const selectedNotes = answer.optionNotes
@@ -202,7 +220,7 @@ export function serializeAnswer(answer: AskStateAnswer): AskResultAnswer {
values, values,
labels, labels,
indices, indices,
customText: answer.customText, customText: selectedCustomText,
note: answer.note, note: answer.note,
optionNotes: optionNotes:
selectedNotes && Object.keys(selectedNotes).length > 0 selectedNotes && Object.keys(selectedNotes).length > 0
+23 -2
View File
@@ -6,6 +6,7 @@ import {
saveCustomText, saveCustomText,
saveOptionNote, saveOptionNote,
saveQuestionNote, saveQuestionNote,
setCustomSelected,
setSingleSelection, setSingleSelection,
toggleSelection, toggleSelection,
} from "./answers.ts"; } from "./answers.ts";
@@ -187,7 +188,7 @@ function activateCurrentOption(
return state; return state;
} }
if (option.isCustomOption) { if (option.isCustomOption) {
return setView(state, inputView(question.id)); return activateCustomOption(state, question.id, question.type, trigger);
} }
if (question.type === "multi") { if (question.type === "multi") {
if (trigger === "confirm") { if (trigger === "confirm") {
@@ -215,6 +216,23 @@ function activateCurrentOption(
return trigger === "toggle" ? nextState : advanceToNextTab(nextState); return trigger === "toggle" ? nextState : advanceToNextTab(nextState);
} }
function activateCustomOption(
state: AskState,
questionId: string,
questionType: AskState["questions"][number]["type"],
trigger: "toggle" | "confirm" | "digit"
): AskState {
if (questionType === "multi" && trigger !== "confirm") {
const answer = getAnswer(state, questionId);
if (answer?.customText?.trim()) {
return updateAnswer(state, questionId, (currentAnswer) =>
setCustomSelected(currentAnswer, !answer.customSelected)
);
}
}
return setView(state, inputView(questionId));
}
function saveInputValue( function saveInputValue(
state: AskState, state: AskState,
rawValue: string, rawValue: string,
@@ -239,7 +257,10 @@ function saveInputValue(
question.type === "multi" ? "multi" : "single" question.type === "multi" ? "multi" : "single"
) )
); );
if (!(submit && isAnswerAnswered(nextState.answers[question.id]))) { if (
question.type === "multi" ||
!(submit && isAnswerAnswered(nextState.answers[question.id]))
) {
return nextState; return nextState;
} }
return advanceToNextTab(nextState); return advanceToNextTab(nextState);
+1
View File
@@ -46,6 +46,7 @@ export interface AskSelectedOption {
} }
export interface AskStateAnswer { export interface AskStateAnswer {
customSelected?: boolean;
customText?: string; customText?: string;
note?: string; note?: string;
optionNotes?: Record<string, string>; optionNotes?: Record<string, string>;
+3 -3
View File
@@ -88,7 +88,7 @@ function buildOptionRowModel(
const answer = getAnswer(state, question.id); const answer = getAnswer(state, question.id);
const selected = index === state.activeOptionIndex; const selected = index === state.activeOptionIndex;
const answered = option.isCustomOption const answered = option.isCustomOption
? !!answer?.customText ? !!(answer?.customSelected && answer.customText?.trim())
: isOptionSelected(answer, option.value); : isOptionSelected(answer, option.value);
const pointer = getOptionPointer(option, selected); const pointer = getOptionPointer(option, selected);
return { return {
@@ -180,10 +180,10 @@ function getPreviewLayout(
function getOptionPrefix( function getOptionPrefix(
questionType: QuestionRenderContext["question"]["type"], questionType: QuestionRenderContext["question"]["type"],
option: AskDisplayOption, _option: AskDisplayOption,
answered: boolean answered: boolean
): string { ): string {
if (questionType !== "multi" || option.isCustomOption) { if (questionType !== "multi") {
return ""; return "";
} }
return `[${answered ? "x" : " "}] `; return `[${answered ? "x" : " "}] `;
+21
View File
@@ -5,6 +5,7 @@ import { getRenderableOptions } from "../src/state/selectors.ts";
import { import {
applyNumberShortcut, applyNumberShortcut,
enterQuestionNoteMode, enterQuestionNoteMode,
submitCustomAnswer,
} from "../src/state/transitions.ts"; } from "../src/state/transitions.ts";
import { buildQuestionScreenModel } from "../src/ui/view-models/question.ts"; import { buildQuestionScreenModel } from "../src/ui/view-models/question.ts";
@@ -55,6 +56,26 @@ test("question view model marks active custom option as inline editor", () => {
assert.equal(model.rows[1]?.detail?.placeholder, "Type answer..."); assert.equal(model.rows[1]?.detail?.placeholder, "Type answer...");
}); });
test("question view model marks multi custom answer as checked", () => {
let state = createInitialState({
questions: [
{
id: "q1",
prompt: "Pick many",
type: "multi",
options: [{ value: "a", label: "A" }],
},
],
});
state = applyNumberShortcut(state, 2);
state = submitCustomAnswer(state, "Other answer");
const model = buildQuestionScreenModel(buildContext(state));
assert.equal(model.rows[1]?.prefix, "[x] ");
assert.equal(model.rows[1]?.color, "success");
assert.equal(model.rows[1]?.detail?.kind, "custom-text");
});
test("question view model exposes saved question notes", () => { test("question view model exposes saved question notes", () => {
let state = enterQuestionNoteMode( let state = enterQuestionNoteMode(
createInitialState({ createInitialState({
+40 -2
View File
@@ -390,7 +390,7 @@ test("multi-select keeps existing selections when leaving custom input empty", (
assert.equal(state.answers.q1.customText, undefined); assert.equal(state.answers.q1.customText, undefined);
}); });
test("multi-select keeps existing selections when saving custom text", () => { test("multi-select keeps existing selections and stays on tab when submitting custom text", () => {
let state = createInitialState({ let state = createInitialState({
questions: [ questions: [
{ {
@@ -414,7 +414,8 @@ test("multi-select keeps existing selections when saving custom text", () => {
state = applyNumberShortcut(state, 3); state = applyNumberShortcut(state, 3);
state = submitCustomAnswer(state, "SolidStart"); state = submitCustomAnswer(state, "SolidStart");
assert.equal(state.activeTabIndex, 1); assert.equal(state.activeTabIndex, 0);
assert.equal(state.view.kind, "navigate");
assert.deepEqual( assert.deepEqual(
state.answers.q1.selected.map((selection) => selection.value), state.answers.q1.selected.map((selection) => selection.value),
["react"] ["react"]
@@ -541,6 +542,43 @@ test("deselecting an option keeps its note in state but omits it from submission
}); });
}); });
test("multi-select toggles saved custom text selection with space or number shortcuts", () => {
let state = createInitialState({
questions: [
{
id: "q1",
prompt: "Pick frameworks",
type: "multi",
options: [{ value: "react", label: "React" }],
},
],
});
state = applyNumberShortcut(state, 2);
state = submitCustomAnswer(state, "SolidStart");
assert.equal(state.answers.q1.customText, "SolidStart");
assert.equal(state.activeTabIndex, 0);
state = toggleCurrentMultiOption(state);
assert.equal(state.answers.q1.customText, "SolidStart");
assert.equal(state.answers.q1.customSelected, undefined);
assert.equal(toAskResult(state).answers.q1, undefined);
assert.equal(state.view.kind, "navigate");
assert.equal(state.activeTabIndex, 0);
state = applyNumberShortcut(state, 2);
assert.equal(state.answers.q1.customText, "SolidStart");
assert.equal(state.answers.q1.customSelected, true);
assert.equal(state.view.kind, "navigate");
assert.deepEqual(toAskResult(state).answers.q1?.values, ["SolidStart"]);
state = applyNumberShortcut(state, 2);
assert.equal(state.answers.q1.customText, "SolidStart");
assert.equal(state.answers.q1.customSelected, undefined);
assert.equal(state.view.kind, "navigate");
assert.equal(toAskResult(state).answers.q1, undefined);
});
test("multi-select keeps custom text when toggling additional options", () => { test("multi-select keeps custom text when toggling additional options", () => {
let state = createInitialState({ let state = createInitialState({
questions: [ questions: [