diff --git a/docs/contract.md b/docs/contract.md index 0446c60..2733699 100644 --- a/docs/contract.md +++ b/docs/contract.md @@ -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 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 +- 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 - `note` stores a question-level note - `optionNotes` includes only notes for selected options diff --git a/src/state/answers.ts b/src/state/answers.ts index 46d683f..4a67bd3 100644 --- a/src/state/answers.ts +++ b/src/state/answers.ts @@ -17,6 +17,7 @@ export function emptyAnswer(): AskStateAnswer { export function cloneAnswer(answer: AskStateAnswer): AskStateAnswer { return { selected: answer.selected.map(cloneSelection), + customSelected: answer.customSelected, customText: answer.customText, note: answer.note, optionNotes: answer.optionNotes ? { ...answer.optionNotes } : undefined, @@ -87,13 +88,24 @@ export function saveCustomText( next.selected = []; } if (!trimmed) { + next.customSelected = undefined; next.customText = undefined; return next; } + next.customSelected = true; next.customText = rawValue; return next; } +export function setCustomSelected( + answer: AskStateAnswer, + selected: boolean +): AskStateAnswer { + const next = cloneAnswer(answer); + next.customSelected = selected || undefined; + return next; +} + export function saveQuestionNote( answer: AskStateAnswer, rawValue: string @@ -137,7 +149,10 @@ export function isAnswerAnswered(answer?: AskStateAnswer): boolean { if (!answer) { 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 { @@ -178,13 +193,16 @@ export function isOptionSelected( } export function serializeAnswer(answer: AskStateAnswer): AskResultAnswer { + const selectedCustomText = answer.customSelected + ? answer.customText + : undefined; const values = [ ...answer.selected.map((selection) => selection.value), - ...(answer.customText ? [answer.customText] : []), + ...(selectedCustomText ? [selectedCustomText] : []), ]; const labels = [ ...answer.selected.map((selection) => selection.label), - ...(answer.customText ? [answer.customText] : []), + ...(selectedCustomText ? [selectedCustomText] : []), ]; const indices = answer.selected.map((selection) => selection.index); const selectedNotes = answer.optionNotes @@ -202,7 +220,7 @@ export function serializeAnswer(answer: AskStateAnswer): AskResultAnswer { values, labels, indices, - customText: answer.customText, + customText: selectedCustomText, note: answer.note, optionNotes: selectedNotes && Object.keys(selectedNotes).length > 0 diff --git a/src/state/transitions.ts b/src/state/transitions.ts index e22e19f..b7d35ad 100644 --- a/src/state/transitions.ts +++ b/src/state/transitions.ts @@ -6,6 +6,7 @@ import { saveCustomText, saveOptionNote, saveQuestionNote, + setCustomSelected, setSingleSelection, toggleSelection, } from "./answers.ts"; @@ -187,7 +188,7 @@ function activateCurrentOption( return state; } if (option.isCustomOption) { - return setView(state, inputView(question.id)); + return activateCustomOption(state, question.id, question.type, trigger); } if (question.type === "multi") { if (trigger === "confirm") { @@ -215,6 +216,23 @@ function activateCurrentOption( 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( state: AskState, rawValue: string, @@ -239,7 +257,10 @@ function saveInputValue( 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 advanceToNextTab(nextState); diff --git a/src/types.ts b/src/types.ts index b21500c..ca70dbd 100644 --- a/src/types.ts +++ b/src/types.ts @@ -46,6 +46,7 @@ export interface AskSelectedOption { } export interface AskStateAnswer { + customSelected?: boolean; customText?: string; note?: string; optionNotes?: Record; diff --git a/src/ui/view-models/question.ts b/src/ui/view-models/question.ts index cfd7cf4..a4a9e9b 100644 --- a/src/ui/view-models/question.ts +++ b/src/ui/view-models/question.ts @@ -88,7 +88,7 @@ function buildOptionRowModel( const answer = getAnswer(state, question.id); const selected = index === state.activeOptionIndex; const answered = option.isCustomOption - ? !!answer?.customText + ? !!(answer?.customSelected && answer.customText?.trim()) : isOptionSelected(answer, option.value); const pointer = getOptionPointer(option, selected); return { @@ -180,10 +180,10 @@ function getPreviewLayout( function getOptionPrefix( questionType: QuestionRenderContext["question"]["type"], - option: AskDisplayOption, + _option: AskDisplayOption, answered: boolean ): string { - if (questionType !== "multi" || option.isCustomOption) { + if (questionType !== "multi") { return ""; } return `[${answered ? "x" : " "}] `; diff --git a/tests/question-view-model.test.ts b/tests/question-view-model.test.ts index 966b6cc..d44d919 100644 --- a/tests/question-view-model.test.ts +++ b/tests/question-view-model.test.ts @@ -5,6 +5,7 @@ import { getRenderableOptions } from "../src/state/selectors.ts"; import { applyNumberShortcut, enterQuestionNoteMode, + submitCustomAnswer, } from "../src/state/transitions.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..."); }); +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", () => { let state = enterQuestionNoteMode( createInitialState({ diff --git a/tests/state.test.ts b/tests/state.test.ts index 7a55841..50dfff7 100644 --- a/tests/state.test.ts +++ b/tests/state.test.ts @@ -390,7 +390,7 @@ test("multi-select keeps existing selections when leaving custom input empty", ( 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({ questions: [ { @@ -414,7 +414,8 @@ test("multi-select keeps existing selections when saving custom text", () => { state = applyNumberShortcut(state, 3); state = submitCustomAnswer(state, "SolidStart"); - assert.equal(state.activeTabIndex, 1); + assert.equal(state.activeTabIndex, 0); + assert.equal(state.view.kind, "navigate"); assert.deepEqual( state.answers.q1.selected.map((selection) => selection.value), ["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", () => { let state = createInitialState({ questions: [