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
|
- multi-select questions
|
||||||
- preview questions with a dedicated preview pane
|
- 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
|
- 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-question notes via `Ctrl+N`
|
||||||
- per-option notes via `N`
|
- per-option notes via `N`
|
||||||
- full inline rendering of saved notes
|
- full inline rendering of saved notes
|
||||||
@@ -148,6 +149,8 @@ Behavior:
|
|||||||
- only notes for currently selected options are included in the submitted result
|
- 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
|
- deselecting an option keeps its note in UI state, so re-selecting it restores the note
|
||||||
- empty note text clears 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
|
- 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
|
- 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
|
- `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
|
- single-select answers still use arrays
|
||||||
- `indices` are 1-based rendered option positions
|
- `indices` are 1-based rendered option positions
|
||||||
- `customText` stores the free-form answer
|
- `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
|
- `note` stores a question-level note
|
||||||
- `optionNotes` includes only notes for selected options
|
- `optionNotes` includes only notes for selected options
|
||||||
- question notes may exist without a selected answer
|
- question notes may exist without a selected answer
|
||||||
|
|||||||
@@ -37,19 +37,23 @@ function formatAnswerLine(
|
|||||||
answer: AskResult["answers"][string],
|
answer: AskResult["answers"][string],
|
||||||
mode: "summary" | "render"
|
mode: "summary" | "render"
|
||||||
): string | undefined {
|
): string | undefined {
|
||||||
const answerText = answer.customText ?? answer.labels.join(", ");
|
const answerText = answer.labels.join(", ");
|
||||||
if (!answerText) {
|
if (!answerText) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (mode === "summary") {
|
if (mode === "summary") {
|
||||||
return `${questionLabel}: ${answerText}`;
|
return `${questionLabel}: ${answerText}`;
|
||||||
}
|
}
|
||||||
if (answer.customText) {
|
if (isCustomOnlyAnswer(answer)) {
|
||||||
return `✓ ${questionLabel}: (wrote) ${answerText}`;
|
return `✓ ${questionLabel}: (wrote) ${answerText}`;
|
||||||
}
|
}
|
||||||
return `✓ ${questionLabel}: ${answerText}`;
|
return `✓ ${questionLabel}: ${answerText}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isCustomOnlyAnswer(answer: AskResult["answers"][string]): boolean {
|
||||||
|
return answer.indices.length === 0 && !!answer.customText;
|
||||||
|
}
|
||||||
|
|
||||||
function formatQuestionNoteLine(
|
function formatQuestionNoteLine(
|
||||||
questionLabel: string,
|
questionLabel: string,
|
||||||
note: string | undefined,
|
note: string | undefined,
|
||||||
|
|||||||
+13
-9
@@ -35,7 +35,6 @@ export function toggleSelection(
|
|||||||
index: number
|
index: number
|
||||||
): AskStateAnswer {
|
): AskStateAnswer {
|
||||||
const next = cloneAnswer(answer);
|
const next = cloneAnswer(answer);
|
||||||
next.customText = undefined;
|
|
||||||
const selectedIndex = next.selected.findIndex(
|
const selectedIndex = next.selected.findIndex(
|
||||||
(selection) => selection.value === option.value
|
(selection) => selection.value === option.value
|
||||||
);
|
);
|
||||||
@@ -74,11 +73,14 @@ export function setSingleSelection(
|
|||||||
|
|
||||||
export function saveCustomText(
|
export function saveCustomText(
|
||||||
answer: AskStateAnswer,
|
answer: AskStateAnswer,
|
||||||
rawValue: string
|
rawValue: string,
|
||||||
|
mode: "single" | "multi"
|
||||||
): AskStateAnswer {
|
): AskStateAnswer {
|
||||||
const trimmed = rawValue.trim();
|
const trimmed = rawValue.trim();
|
||||||
const next = cloneAnswer(answer);
|
const next = cloneAnswer(answer);
|
||||||
next.selected = [];
|
if (mode === "single") {
|
||||||
|
next.selected = [];
|
||||||
|
}
|
||||||
if (!trimmed) {
|
if (!trimmed) {
|
||||||
next.customText = undefined;
|
next.customText = undefined;
|
||||||
return next;
|
return next;
|
||||||
@@ -143,12 +145,14 @@ export function isOptionSelected(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function serializeAnswer(answer: AskStateAnswer): AskResultAnswer {
|
export function serializeAnswer(answer: AskStateAnswer): AskResultAnswer {
|
||||||
const values = answer.customText
|
const values = [
|
||||||
? [answer.customText]
|
...answer.selected.map((selection) => selection.value),
|
||||||
: answer.selected.map((selection) => selection.value);
|
...(answer.customText ? [answer.customText] : []),
|
||||||
const labels = answer.customText
|
];
|
||||||
? [answer.customText]
|
const labels = [
|
||||||
: answer.selected.map((selection) => selection.label);
|
...answer.selected.map((selection) => selection.label),
|
||||||
|
...(answer.customText ? [answer.customText] : []),
|
||||||
|
];
|
||||||
const indices = answer.selected.map((selection) => selection.index);
|
const indices = answer.selected.map((selection) => selection.index);
|
||||||
const selectedNotes = answer.optionNotes
|
const selectedNotes = answer.optionNotes
|
||||||
? Object.fromEntries(
|
? Object.fromEntries(
|
||||||
|
|||||||
@@ -258,7 +258,12 @@ function saveInputValue(
|
|||||||
const nextState = updateAnswer(
|
const nextState = updateAnswer(
|
||||||
exitEditingView(state),
|
exitEditingView(state),
|
||||||
question.id,
|
question.id,
|
||||||
(answer) => saveCustomText(answer, rawValue)
|
(answer) =>
|
||||||
|
saveCustomText(
|
||||||
|
answer,
|
||||||
|
rawValue,
|
||||||
|
question.type === "multi" ? "multi" : "single"
|
||||||
|
)
|
||||||
);
|
);
|
||||||
if (!(submit && isAnswerAnswered(nextState.answers[question.id]))) {
|
if (!(submit && isAnswerAnswered(nextState.answers[question.id]))) {
|
||||||
return nextState;
|
return nextState;
|
||||||
|
|||||||
@@ -64,14 +64,14 @@ function renderSubmittedAnswer(
|
|||||||
theme: Theme,
|
theme: Theme,
|
||||||
width: number
|
width: number
|
||||||
) {
|
) {
|
||||||
const answerText = answer.customText ?? answer.labels.join(", ");
|
const answerText = answer.labels.join(", ");
|
||||||
if (answerText) {
|
if (answerText) {
|
||||||
pushWrappedText(
|
pushWrappedText(
|
||||||
lines,
|
lines,
|
||||||
`→ ${answerText}`,
|
`→ ${answerText}`,
|
||||||
width,
|
width,
|
||||||
theme,
|
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(
|
function renderSubmitActions(
|
||||||
lines: string[],
|
lines: string[],
|
||||||
state: AskState,
|
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", () => {
|
test("summaries and result text include question and selected option notes", () => {
|
||||||
let state = createInitialState({
|
let state = createInitialState({
|
||||||
questions: [
|
questions: [
|
||||||
|
|||||||
@@ -185,6 +185,68 @@ test("escape saves typed custom answer without advancing", () => {
|
|||||||
assert.equal(state.answers.q1.customText, "my draft answer");
|
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", () => {
|
test("empty custom draft clears the stored answer but preserves notes", () => {
|
||||||
let state = createInitialState({
|
let state = createInitialState({
|
||||||
questions: [
|
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", () => {
|
test("preview questions can store selected option notes for submission", () => {
|
||||||
let state = createInitialState({
|
let state = createInitialState({
|
||||||
questions: [
|
questions: [
|
||||||
|
|||||||
Reference in New Issue
Block a user