fix: preserve multi-select choices with custom answers
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
+13
-9
@@ -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);
|
||||
next.selected = [];
|
||||
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(
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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: [
|
||||
|
||||
Reference in New Issue
Block a user