diff --git a/README.md b/README.md index 39e4f36..4a455c8 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/docs/contract.md b/docs/contract.md index febbd1c..953b5eb 100644 --- a/docs/contract.md +++ b/docs/contract.md @@ -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 diff --git a/src/result-format.ts b/src/result-format.ts index 5b4b0cf..609ac8d 100644 --- a/src/result-format.ts +++ b/src/result-format.ts @@ -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, diff --git a/src/state/answers.ts b/src/state/answers.ts index 32da749..d4555de 100644 --- a/src/state/answers.ts +++ b/src/state/answers.ts @@ -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( diff --git a/src/state/transitions.ts b/src/state/transitions.ts index c791614..f9450be 100644 --- a/src/state/transitions.ts +++ b/src/state/transitions.ts @@ -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; diff --git a/src/ui/render-submit.ts b/src/ui/render-submit.ts index fc606f8..4d605ca 100644 --- a/src/ui/render-submit.ts +++ b/src/ui/render-submit.ts @@ -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, diff --git a/tests/result.test.ts b/tests/result.test.ts index 4459d88..9454530 100644 --- a/tests/result.test.ts +++ b/tests/result.test.ts @@ -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: [ diff --git a/tests/state.test.ts b/tests/state.test.ts index 3c0c957..94e02d8 100644 --- a/tests/state.test.ts +++ b/tests/state.test.ts @@ -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: [