fix: preserve multi-select choices with custom answers

This commit is contained in:
Erik Kotsiuba
2026-04-19 16:32:27 +02:00
parent 4a131f7752
commit 8d8e7b67c9
8 changed files with 157 additions and 14 deletions
+3
View File
@@ -119,6 +119,7 @@ After answers are returned, continue the task using those answers explicitly.
- multi-select questions
- preview questions with a dedicated preview pane
- free-form answers via an inline `Type your own` option that turns into an embedded editor when selected
- on multi-select questions, free-form answers are additive and do not clear other selected options
- per-question notes via `Ctrl+N`
- per-option notes via `N`
- full inline rendering of saved notes
@@ -148,6 +149,8 @@ Behavior:
- only notes for currently selected options are included in the submitted result
- deselecting an option keeps its note in UI state, so re-selecting it restores the note
- empty note text clears the note
- on multi-select questions, saving a free-form answer keeps other selected options intact
- on multi-select questions, submitted `values` and `labels` include both selected options and the free-form answer when both exist
- when the free-form option is selected, it becomes an inline input row with the selected-tab background style spanning the full width
- while editing a note or free-form answer, `Up` / `Down` save the draft and move navigation instead of being trapped by the editor
- `Space` toggles the active option on single-select questions too, but does not auto-advance
+2
View File
@@ -68,6 +68,8 @@ This document defines the stable external behavior. It does not explain internal
- single-select answers still use arrays
- `indices` are 1-based rendered option positions
- `customText` stores the free-form answer
- on multi-select questions, `values` and `labels` include both selected options and `customText` when both are present
- saving or clearing free-form text on a multi-select question does not clear other selected options
- `note` stores a question-level note
- `optionNotes` includes only notes for selected options
- question notes may exist without a selected answer
+6 -2
View File
@@ -37,19 +37,23 @@ function formatAnswerLine(
answer: AskResult["answers"][string],
mode: "summary" | "render"
): string | undefined {
const answerText = answer.customText ?? answer.labels.join(", ");
const answerText = answer.labels.join(", ");
if (!answerText) {
return;
}
if (mode === "summary") {
return `${questionLabel}: ${answerText}`;
}
if (answer.customText) {
if (isCustomOnlyAnswer(answer)) {
return `✓ ${questionLabel}: (wrote) ${answerText}`;
}
return `✓ ${questionLabel}: ${answerText}`;
}
function isCustomOnlyAnswer(answer: AskResult["answers"][string]): boolean {
return answer.indices.length === 0 && !!answer.customText;
}
function formatQuestionNoteLine(
questionLabel: string,
note: string | undefined,
+12 -8
View File
@@ -35,7 +35,6 @@ export function toggleSelection(
index: number
): AskStateAnswer {
const next = cloneAnswer(answer);
next.customText = undefined;
const selectedIndex = next.selected.findIndex(
(selection) => selection.value === option.value
);
@@ -74,11 +73,14 @@ export function setSingleSelection(
export function saveCustomText(
answer: AskStateAnswer,
rawValue: string
rawValue: string,
mode: "single" | "multi"
): AskStateAnswer {
const trimmed = rawValue.trim();
const next = cloneAnswer(answer);
if (mode === "single") {
next.selected = [];
}
if (!trimmed) {
next.customText = undefined;
return next;
@@ -143,12 +145,14 @@ export function isOptionSelected(
}
export function serializeAnswer(answer: AskStateAnswer): AskResultAnswer {
const values = answer.customText
? [answer.customText]
: answer.selected.map((selection) => selection.value);
const labels = answer.customText
? [answer.customText]
: answer.selected.map((selection) => selection.label);
const values = [
...answer.selected.map((selection) => selection.value),
...(answer.customText ? [answer.customText] : []),
];
const labels = [
...answer.selected.map((selection) => selection.label),
...(answer.customText ? [answer.customText] : []),
];
const indices = answer.selected.map((selection) => selection.index);
const selectedNotes = answer.optionNotes
? Object.fromEntries(
+6 -1
View File
@@ -258,7 +258,12 @@ function saveInputValue(
const nextState = updateAnswer(
exitEditingView(state),
question.id,
(answer) => saveCustomText(answer, rawValue)
(answer) =>
saveCustomText(
answer,
rawValue,
question.type === "multi" ? "multi" : "single"
)
);
if (!(submit && isAnswerAnswered(nextState.answers[question.id]))) {
return nextState;
+6 -2
View File
@@ -64,14 +64,14 @@ function renderSubmittedAnswer(
theme: Theme,
width: number
) {
const answerText = answer.customText ?? answer.labels.join(", ");
const answerText = answer.labels.join(", ");
if (answerText) {
pushWrappedText(
lines,
`→ ${answerText}`,
width,
theme,
answer.customText ? "text" : "success",
isCustomOnlyAnswer(answer) ? "text" : "success",
" ",
" "
);
@@ -106,6 +106,10 @@ function renderSubmittedAnswer(
}
}
function isCustomOnlyAnswer(answer: AskResult["answers"][string]): boolean {
return answer.indices.length === 0 && !!answer.customText;
}
function renderSubmitActions(
lines: string[],
state: AskState,
+33
View File
@@ -56,6 +56,39 @@ test("summaries include custom text answers", () => {
);
});
test("multi-select summaries include selected options and custom text", () => {
let state = createInitialState({
questions: [
{
id: "frameworks",
label: "Frameworks",
prompt: "Which frameworks?",
type: "multi",
options: [
{ value: "react", label: "React" },
{ value: "vue", label: "Vue" },
],
},
],
});
state = applyNumberShortcut(state, 1);
state = applyNumberShortcut(state, 3);
state = submitCustomAnswer(state, "SolidStart");
const result = toAskResult(state);
assert.deepEqual(result.answers.frameworks, {
values: ["react", "SolidStart"],
labels: ["React", "SolidStart"],
indices: [1],
customText: "SolidStart",
note: undefined,
optionNotes: undefined,
});
assert.equal(summarizeResult(result), "Frameworks: React, SolidStart");
assert.equal(renderResultText(result), "✓ Frameworks: React, SolidStart");
});
test("summaries and result text include question and selected option notes", () => {
let state = createInitialState({
questions: [
+88
View File
@@ -185,6 +185,68 @@ test("escape saves typed custom answer without advancing", () => {
assert.equal(state.answers.q1.customText, "my draft answer");
});
test("multi-select keeps existing selections when leaving custom input empty", () => {
let state = createInitialState({
questions: [
{
id: "q1",
prompt: "Pick frameworks",
type: "multi",
options: [
{ value: "react", label: "React" },
{ value: "vue", label: "Vue" },
{ value: "svelte", label: "Svelte" },
],
},
],
});
state = moveOption(state, 1);
state = toggleCurrentMultiOption(state);
state = moveOption(state, 1);
state = toggleCurrentMultiOption(state);
state = applyNumberShortcut(state, 4);
state = saveCustomAnswer(state, " ");
assert.deepEqual(
state.answers.q1.selected.map((selection) => selection.value),
["vue", "svelte"]
);
assert.equal(state.answers.q1.customText, undefined);
});
test("multi-select keeps existing selections when saving custom text", () => {
let state = createInitialState({
questions: [
{
id: "q1",
prompt: "Pick frameworks",
type: "multi",
options: [
{ value: "react", label: "React" },
{ value: "vue", label: "Vue" },
],
},
{
id: "q2",
prompt: "Anything else?",
options: [{ value: "a", label: "A" }],
},
],
});
state = toggleCurrentMultiOption(state);
state = applyNumberShortcut(state, 3);
state = submitCustomAnswer(state, "SolidStart");
assert.equal(state.activeTabIndex, 1);
assert.deepEqual(
state.answers.q1.selected.map((selection) => selection.value),
["react"]
);
assert.equal(state.answers.q1.customText, "SolidStart");
});
test("empty custom draft clears the stored answer but preserves notes", () => {
let state = createInitialState({
questions: [
@@ -304,6 +366,32 @@ test("deselecting an option keeps its note in state but omits it from submission
});
});
test("multi-select keeps custom text when toggling additional options", () => {
let state = createInitialState({
questions: [
{
id: "q1",
prompt: "Pick frameworks",
type: "multi",
options: [
{ value: "react", label: "React" },
{ value: "vue", label: "Vue" },
],
},
],
});
state = applyNumberShortcut(state, 3);
state = saveCustomAnswer(state, "SolidStart");
state = applyNumberShortcut(state, 1);
assert.equal(state.answers.q1.customText, "SolidStart");
assert.deepEqual(
state.answers.q1.selected.map((selection) => selection.value),
["react"]
);
});
test("preview questions can store selected option notes for submission", () => {
let state = createInitialState({
questions: [