diff --git a/README.md b/README.md index a8261fe..2c90bf7 100644 --- a/README.md +++ b/README.md @@ -151,10 +151,36 @@ The current prototype supports: - single-select questions - multi-select questions - preview questions with a dedicated preview pane +- free-form answers via `Type your own` +- per-question notes via `Ctrl+N` +- per-option notes via `N` +- full inline rendering of saved notes - number-key quick selection - final submit/review page - transcript rendering for call/result rows +### Result shape notes + +The returned `details.answers[questionId]` object may include: + +```ts +{ + values: string[] + labels: string[] + customText?: string + note?: string // question-level note + optionNotes?: Record // selected options only +} +``` + +Behavior: + +- question-level notes are submitted whenever authored +- option notes can be authored for any active option during the UI flow +- 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 + ## Example agent instruction You can give the agent an instruction like this to encourage proper `ask_user` usage: diff --git a/src/ask-component.ts b/src/ask-component.ts index a622392..8a3221e 100644 --- a/src/ask-component.ts +++ b/src/ask-component.ts @@ -14,14 +14,19 @@ import { confirmCurrentSelection, createInitialState, enterInputMode, + enterOptionNoteMode, + enterQuestionNoteMode, getAnswer, getCurrentQuestion, + getOptionNote, + getQuestionNote, getRenderableOptions, isQuestionAnswered, isSubmitTab, moveOption, moveTab, saveCustomAnswer, + saveNote, submitCustomAnswer, toAskResult, toggleCurrentMultiOption, @@ -54,7 +59,11 @@ export async function runAskFlow( editor.onSubmit = (value) => { suppressAutoInputForSelection = false; - state = submitCustomAnswer(state, value); + if (state.mode === "input") { + state = submitCustomAnswer(state, value); + } else if (state.mode === "note") { + state = saveNote(state, value); + } editor.setText(""); refresh(); maybeFinish(); @@ -71,17 +80,25 @@ export async function runAskFlow( } } - function hydrateEditorForInputMode() { - if (state.mode !== "input" || !state.inputQuestionId) { + function hydrateEditorForCurrentMode() { + if (state.mode === "input" && state.inputQuestionId) { + const existingText = + getAnswer(state, state.inputQuestionId)?.customText ?? ""; + editor.setText(existingText); return; } - const existingText = - getAnswer(state, state.inputQuestionId)?.customText ?? ""; + if (state.mode !== "note" || !state.noteQuestionId) { + return; + } + const existingText = state.noteOptionValue + ? (getOptionNote(state, state.noteQuestionId, state.noteOptionValue) ?? + "") + : (getQuestionNote(state, state.noteQuestionId) ?? ""); editor.setText(existingText); } function syncInputModeWithSelection() { - if (isSubmitTab(state)) { + if (state.mode === "note" || isSubmitTab(state)) { return; } @@ -100,32 +117,28 @@ export async function runAskFlow( } state = enterInputMode(state, question.id); - hydrateEditorForInputMode(); + hydrateEditorForCurrentMode(); } syncInputModeWithSelection(); function handleInput(data: string) { - if (state.mode === "input") { + if (state.mode === "input" || state.mode === "note") { if (matchesKey(data, Key.tab) || matchesKey(data, Key.right)) { - state = saveCustomAnswer(state, editor.getText()); - suppressAutoInputForSelection = false; - state = moveTab(state, 1); + state = saveEditorAndMoveTab(1); syncInputModeWithSelection(); refresh(); return; } if (matchesKey(data, Key.shift("tab")) || matchesKey(data, Key.left)) { - state = saveCustomAnswer(state, editor.getText()); - suppressAutoInputForSelection = false; - state = moveTab(state, -1); + state = saveEditorAndMoveTab(-1); syncInputModeWithSelection(); refresh(); return; } if (matchesKey(data, Key.escape)) { - state = saveCustomAnswer(state, editor.getText()); - suppressAutoInputForSelection = true; + state = saveEditorWithoutAdvancing(); + suppressAutoInputForSelection = state.mode !== "input"; refresh(); return; } @@ -168,17 +181,38 @@ export async function runAskFlow( suppressAutoInputForSelection = false; state = toggleCurrentMultiOption(state); if (state.mode === "input") { - hydrateEditorForInputMode(); + hydrateEditorForCurrentMode(); } refresh(); return; } } + if (isQuestionNoteShortcut(data)) { + const question = getCurrentQuestion(state); + if (question && !isSubmitTab(state)) { + state = enterQuestionNoteMode(state, question.id); + hydrateEditorForCurrentMode(); + refresh(); + } + return; + } + if (isOptionNoteShortcut(data)) { + const question = getCurrentQuestion(state); + const option = question + ? getRenderableOptions(question)[state.optionIndex] + : undefined; + if (question && option && !option.isOther && !isSubmitTab(state)) { + state = enterOptionNoteMode(state, question.id, option.value); + hydrateEditorForCurrentMode(); + refresh(); + } + return; + } if (matchesKey(data, Key.enter)) { suppressAutoInputForSelection = false; state = confirmCurrentSelection(state); if (state.mode === "input") { - hydrateEditorForInputMode(); + hydrateEditorForCurrentMode(); } refresh(); maybeFinish(); @@ -196,13 +230,28 @@ export async function runAskFlow( suppressAutoInputForSelection = false; state = applyNumberShortcut(state, digit); if (state.mode === "input") { - hydrateEditorForInputMode(); + hydrateEditorForCurrentMode(); } refresh(); - return; } } + function saveEditorWithoutAdvancing(): AskState { + const text = editor.getText(); + editor.setText(""); + if (state.mode === "input") { + suppressAutoInputForSelection = true; + return saveCustomAnswer(state, text); + } + return saveNote(state, text); + } + + function saveEditorAndMoveTab(delta: number): AskState { + const nextState = saveEditorWithoutAdvancing(); + suppressAutoInputForSelection = false; + return moveTab(nextState, delta); + } + function render(width: number): string[] { if (cachedLines) return cachedLines; @@ -305,8 +354,9 @@ function renderSubmit( ); lines.push(""); + const submittedAnswers = toAskResult(state).answers; for (const question of state.questions) { - const answer = getAnswer(state, question.id); + const answer = submittedAnswers[question.id]; pushWrappedText( lines, question.prompt, @@ -323,15 +373,45 @@ function renderSubmit( continue; } const answerText = answer.customText ?? answer.labels.join(", "); - pushWrappedText( - lines, - `→ ${answerText}`, - width, - theme, - answer.customText ? "text" : "success", - " ", - " ", - ); + if (answerText) { + pushWrappedText( + lines, + `→ ${answerText}`, + width, + theme, + answer.customText ? "text" : "success", + " ", + " ", + ); + } + if (answer.note) { + pushWrappedText( + lines, + `Question note: ${answer.note}`, + width, + theme, + "muted", + " ", + " ", + ); + } + for (let index = 0; index < answer.values.length; index++) { + const value = answer.values[index]; + const label = answer.labels[index] ?? value; + const note = answer.optionNotes?.[value]; + if (!note) { + continue; + } + pushWrappedText( + lines, + `${label} note: ${note}`, + width, + theme, + "muted", + " ", + " ", + ); + } } lines.push(""); @@ -376,6 +456,15 @@ function renderQuestionContent(args: { args; renderQuestionPrompt(lines, question.prompt, theme, width); + renderQuestionNote( + lines, + state, + question.id, + theme, + width, + editor, + newLineHint, + ); if (question.type === "preview") { renderPreviewQuestion( @@ -413,6 +502,55 @@ function renderQuestionPrompt( lines.push(""); } +function renderQuestionNote( + lines: string[], + state: AskState, + questionId: string, + theme: ExtensionContext["ui"]["theme"], + width: number, + editor: Editor, + newLineHint: string, +) { + const existingNote = getQuestionNote(state, questionId); + if ( + state.mode === "note" && + state.noteQuestionId === questionId && + !state.noteOptionValue + ) { + pushWrappedText(lines, "Question note", width, theme, "accent", " ", " "); + for (const editorLine of editor.render(Math.max(8, width - 5))) { + lines.push( + truncateToWidth( + ` ${renderInputLine(editorLine, width - 3, theme)}`, + width, + ), + ); + } + lines.push( + truncateToWidth( + ` ${theme.fg("dim", `${newLineHint} newline · Enter save · Esc save and close`)}`, + width, + ), + ); + lines.push(""); + return; + } + + if (!existingNote) { + return; + } + pushWrappedText( + lines, + `Question note: ${existingNote}`, + width, + theme, + "muted", + " ", + " ", + ); + lines.push(""); +} + function renderStandardQuestion( lines: string[], state: AskState, @@ -428,6 +566,9 @@ function renderStandardQuestion( for (let i = 0; i < options.length; i++) { const option = options[i]; const selected = i === state.optionIndex; + const optionNote = option.isOther + ? undefined + : answer?.optionNotes?.[option.value]; const isAnsweredOption = option.isOther ? !!answer?.customText : !!answer?.values.includes(option.value); @@ -459,7 +600,27 @@ function renderStandardQuestion( " ", ); } - if (option.isOther && selected && state.mode === "input") { + if ( + !option.isOther && + state.mode === "note" && + state.noteQuestionId === question.id && + state.noteOptionValue === option.value + ) { + for (const editorLine of editor.render(Math.max(8, width - 7))) { + lines.push( + truncateToWidth( + ` ${renderInputLine(editorLine, width - 5, theme)}`, + width, + ), + ); + } + lines.push( + truncateToWidth( + ` ${theme.fg("dim", `${newLineHint} newline · Enter save · Esc save and close`)}`, + width, + ), + ); + } else if (option.isOther && selected && state.mode === "input") { for (const editorLine of editor.render(Math.max(8, width - 7))) { lines.push( truncateToWidth( @@ -489,6 +650,20 @@ function renderStandardQuestion( ); } } + if ( + optionNote && + !(state.mode === "note" && state.noteOptionValue === option.value) + ) { + pushWrappedText( + lines, + `Note: ${optionNote}`, + width, + theme, + "muted", + " ", + " ", + ); + } } } @@ -529,6 +704,33 @@ function renderPreviewQuestion( renderPreviewPane(selectedOption, theme, width).forEach(add); } + if ( + selectedOption && + !selectedOption.isOther && + state.mode === "note" && + state.noteQuestionId === question.id && + state.noteOptionValue === selectedOption.value + ) { + add(""); + for (const editorLine of editor.render(Math.max(8, width - 7))) { + add(` ${renderInputLine(editorLine, width - 5, theme)}`); + } + add( + ` ${theme.fg("dim", `${newLineHint} newline · Enter save · Esc save and close`)}`, + ); + } else if (selectedOption && !selectedOption.isOther) { + const optionNote = answer?.optionNotes?.[selectedOption.value]; + if (optionNote) { + add(""); + for (const line of wrapText( + `Note: ${optionNote}`, + Math.max(1, width - 5), + )) { + add(` ${theme.fg("muted", line)}`); + } + } + } + if (selectedOption?.isOther && state.mode === "input") { add(""); for (const editorLine of editor.render(Math.max(8, width - 7))) { @@ -729,7 +931,16 @@ function renderFooter( theme: ExtensionContext["ui"]["theme"], ): string { if (state.mode === "input") { - return ""; + return theme.fg( + "dim", + " Enter submit · Esc save and close · Tab/←→ save and navigate", + ); + } + if (state.mode === "note") { + return theme.fg( + "dim", + " Enter save · Esc save and close · Tab/←→ save and navigate", + ); } const question = getCurrentQuestion(state); @@ -739,10 +950,13 @@ function renderFooter( if (question?.type === "multi") { return theme.fg( "dim", - " ⇆ tab · ↑↓ select · space toggle · enter continue · esc dismiss · 1-9 quick toggle", + " ⇆ tab · ↑↓ select · Space toggle · Enter continue · N option note · Ctrl+N question note · Esc dismiss · 1-9 quick toggle", ); } - return theme.fg("dim", " ⇆ tab · ↑↓ select · enter confirm · esc dismiss"); + return theme.fg( + "dim", + " ⇆ tab · ↑↓ select · Enter confirm · N option note · Ctrl+N question note · Esc dismiss", + ); } function parseDigit(data: string): number | null { @@ -771,3 +985,11 @@ function formatKeybindingLabel(key: string): string { }) .join("+"); } + +function isOptionNoteShortcut(data: string): boolean { + return data === "n" || data === "N"; +} + +function isQuestionNoteShortcut(data: string): boolean { + return matchesKey(data, Key.ctrl("n")); +} diff --git a/src/result.ts b/src/result.ts index 84c23b1..68d756e 100644 --- a/src/result.ts +++ b/src/result.ts @@ -9,10 +9,21 @@ export function renderResultText(result: AskResult): string { for (const question of result.questions) { const answer = result.answers[question.id]; if (!answer) continue; - const value = answer.customText - ? `(wrote) ${answer.customText}` - : answer.labels.join(", "); - lines.push(`✓ ${question.label}: ${value}`); + if (answer.customText) { + lines.push(`✓ ${question.label}: (wrote) ${answer.customText}`); + } else if (answer.labels.length > 0) { + lines.push(`✓ ${question.label}: ${answer.labels.join(", ")}`); + } + if (answer.note) { + lines.push(` note: ${answer.note}`); + } + for (let index = 0; index < answer.values.length; index++) { + const value = answer.values[index]; + const label = answer.labels[index] ?? value; + const note = answer.optionNotes?.[value]; + if (!note) continue; + lines.push(` ${label} note: ${note}`); + } } return lines.join("\n") || "Submitted"; } diff --git a/src/state.ts b/src/state.ts index d080052..3e5aa00 100644 --- a/src/state.ts +++ b/src/state.ts @@ -37,6 +37,8 @@ export function createInitialState(params: AskParams): AskState { submitIndex: 0, mode: "navigate", inputQuestionId: null, + noteQuestionId: null, + noteOptionValue: null, answers: {}, completed: false, cancelled: false, @@ -73,6 +75,21 @@ export function getAnswer( return state.answers[questionId]; } +export function getQuestionNote( + state: AskState, + questionId: string, +): string | undefined { + return state.answers[questionId]?.note; +} + +export function getOptionNote( + state: AskState, + questionId: string, + optionValue: string, +): string | undefined { + return state.answers[questionId]?.optionNotes?.[optionValue]; +} + export function isQuestionAnswered( state: AskState, questionId: string, @@ -91,6 +108,8 @@ export function moveTab(state: AskState, delta: number): AskState { submitIndex: 0, mode: isSubmitTab({ ...state, currentTab }) ? "submit" : "navigate", inputQuestionId: null, + noteQuestionId: null, + noteOptionValue: null, }; } @@ -118,6 +137,8 @@ export function enterInputMode(state: AskState, questionId: string): AskState { ...state, mode: "input", inputQuestionId: questionId, + noteQuestionId: null, + noteOptionValue: null, }; } @@ -129,6 +150,42 @@ export function exitInputMode(state: AskState): AskState { }; } +export function enterQuestionNoteMode( + state: AskState, + questionId: string, +): AskState { + return { + ...state, + mode: "note", + inputQuestionId: null, + noteQuestionId: questionId, + noteOptionValue: null, + }; +} + +export function enterOptionNoteMode( + state: AskState, + questionId: string, + optionValue: string, +): AskState { + return { + ...state, + mode: "note", + inputQuestionId: null, + noteQuestionId: questionId, + noteOptionValue: optionValue, + }; +} + +export function exitNoteMode(state: AskState): AskState { + return { + ...state, + mode: isSubmitTab(state) ? "submit" : "navigate", + noteQuestionId: null, + noteOptionValue: null, + }; +} + export function toggleCurrentMultiOption(state: AskState): AskState { const question = getCurrentQuestion(state); if (!question || question.type === "single") return state; @@ -203,28 +260,33 @@ export function saveCustomAnswer(state: AskState, rawValue: string): AskState { ); if (!question) return exitInputMode(state); - const trimmed = rawValue.trim(); const nextState = exitInputMode(state); + const trimmed = rawValue.trim(); + const existing = nextState.answers[question.id]; if (!trimmed) { - const answers = { ...nextState.answers }; - delete answers[question.id]; return { ...nextState, - answers, + answers: updateAnswer(nextState.answers, question.id, (answer) => { + delete answer.customText; + answer.values = []; + answer.labels = []; + answer.indices = []; + }), }; } - const answer: AskAnswer = { - values: [rawValue], - labels: [rawValue], - indices: [], - customText: rawValue, - }; return { ...nextState, answers: { ...nextState.answers, - [question.id]: answer, + [question.id]: { + ...emptyAnswer(), + ...existing, + values: [rawValue], + labels: [rawValue], + indices: [], + customText: rawValue, + }, }, }; } @@ -235,16 +297,52 @@ export function submitCustomAnswer( ): AskState { const questionId = state.inputQuestionId; const nextState = saveCustomAnswer(state, rawValue); - if (!questionId || !getAnswer(nextState, questionId)) { + if (!questionId || !isQuestionAnswered(nextState, questionId)) { return nextState; } return advanceToNextTab(nextState); } +export function saveNote(state: AskState, rawValue: string): AskState { + const questionId = state.noteQuestionId; + if (!questionId) { + return exitNoteMode(state); + } + + const optionValue = state.noteOptionValue; + const trimmed = rawValue.trim(); + const nextState = exitNoteMode(state); + return { + ...nextState, + answers: updateAnswer(nextState.answers, questionId, (answer) => { + if (optionValue) { + const optionNotes = { ...(answer.optionNotes ?? {}) }; + if (trimmed) { + optionNotes[optionValue] = rawValue; + } else { + delete optionNotes[optionValue]; + } + answer.optionNotes = + Object.keys(optionNotes).length > 0 ? optionNotes : undefined; + return; + } + + if (trimmed) { + answer.note = rawValue; + } else { + delete answer.note; + } + }), + }; +} + export function cancelFlow(state: AskState): AskState { if (state.mode === "input") { return exitInputMode(state); } + if (state.mode === "note") { + return exitNoteMode(state); + } return { ...state, cancelled: true, @@ -262,7 +360,7 @@ export function toAskResult(state: AskState): AskResult { prompt: question.prompt, type: question.type, })), - answers: state.answers, + answers: filterAnswersForSubmission(state.answers), }; } @@ -274,10 +372,24 @@ export function summarizeResult(result: AskResult): string { const lines = result.questions.flatMap((question) => { const answer = result.answers[question.id]; if (!answer) return []; + + const questionLines: string[] = []; if (answer.customText) { - return [`${question.label}: ${answer.customText}`]; + questionLines.push(`${question.label}: ${answer.customText}`); + } else if (answer.labels.length > 0) { + questionLines.push(`${question.label}: ${answer.labels.join(", ")}`); } - return [`${question.label}: ${answer.labels.join(", ")}`]; + if (answer.note) { + questionLines.push(`${question.label} note: ${answer.note}`); + } + for (const label of answer.labels) { + const optionValue = answer.values[answer.labels.indexOf(label)]; + const note = optionValue ? answer.optionNotes?.[optionValue] : undefined; + if (note) { + questionLines.push(`${question.label} / ${label} note: ${note}`); + } + } + return questionLines; }); return lines.join("\n") || "User submitted the ask flow"; @@ -291,6 +403,7 @@ function toggleAnswer( ): AskState { const existing = state.answers[questionId] ?? emptyAnswer(); const next = cloneAnswer(existing); + delete next.customText; const foundIndex = next.values.indexOf(option.value); if (foundIndex >= 0) { @@ -318,14 +431,18 @@ function setSingleAnswer( option: RenderOption, index: number, ): AskState { + const existing = state.answers[questionId] ?? emptyAnswer(); return { ...state, answers: { ...state.answers, [questionId]: { + ...emptyAnswer(), + ...existing, values: [option.value], labels: [option.label], indices: [index + 1], + customText: undefined, }, }, }; @@ -341,6 +458,8 @@ function advanceToNextTab(state: AskState): AskState { optionIndex: 0, submitIndex: 0, inputQuestionId: null, + noteQuestionId: null, + noteOptionValue: null, }; } @@ -352,6 +471,8 @@ function advanceToNextTab(state: AskState): AskState { optionIndex: 0, submitIndex: 0, inputQuestionId: null, + noteQuestionId: null, + noteOptionValue: null, }; } @@ -366,9 +487,61 @@ function cloneAnswer(answer: AskAnswer): AskAnswer { indices: [...answer.indices], customText: answer.customText, note: answer.note, + optionNotes: answer.optionNotes ? { ...answer.optionNotes } : undefined, }; } function clamp(value: number, min: number, max: number): number { return Math.min(max, Math.max(min, value)); } + +function updateAnswer( + answers: Record, + questionId: string, + mutate: (answer: AskAnswer) => void, +): Record { + const nextAnswer = cloneAnswer(answers[questionId] ?? emptyAnswer()); + mutate(nextAnswer); + if (isEmptyAnswer(nextAnswer)) { + const nextAnswers = { ...answers }; + delete nextAnswers[questionId]; + return nextAnswers; + } + return { + ...answers, + [questionId]: nextAnswer, + }; +} + +function isEmptyAnswer(answer: AskAnswer): boolean { + return ( + answer.values.length === 0 && + answer.labels.length === 0 && + answer.indices.length === 0 && + !answer.customText && + !answer.note && + (!answer.optionNotes || Object.keys(answer.optionNotes).length === 0) + ); +} + +function filterAnswersForSubmission( + answers: Record, +): Record { + const result: Record = {}; + for (const [questionId, answer] of Object.entries(answers)) { + const next = cloneAnswer(answer); + if (next.optionNotes) { + const selectedNotes = Object.fromEntries( + next.values + .map((value) => [value, next.optionNotes?.[value]]) + .filter((entry): entry is [string, string] => !!entry[1]), + ); + next.optionNotes = + Object.keys(selectedNotes).length > 0 ? selectedNotes : undefined; + } + if (!isEmptyAnswer(next)) { + result[questionId] = next; + } + } + return result; +} diff --git a/src/types.ts b/src/types.ts index fae8b74..d3ab7a1 100644 --- a/src/types.ts +++ b/src/types.ts @@ -34,6 +34,7 @@ export interface AskAnswer { indices: number[]; customText?: string; note?: string; + optionNotes?: Record; } export interface AskResult { @@ -48,7 +49,7 @@ export interface AskResult { answers: Record; } -export type ViewMode = "navigate" | "input" | "submit"; +export type ViewMode = "navigate" | "input" | "note" | "submit"; export interface AskState { title?: string; @@ -58,6 +59,8 @@ export interface AskState { submitIndex: number; mode: ViewMode; inputQuestionId: string | null; + noteQuestionId: string | null; + noteOptionValue: string | null; answers: Record; completed: boolean; cancelled: boolean; diff --git a/tests/result.test.ts b/tests/result.test.ts index 6e908a0..24d2df5 100644 --- a/tests/result.test.ts +++ b/tests/result.test.ts @@ -4,6 +4,9 @@ import { renderResultText } from "../src/result.ts"; import { applyNumberShortcut, createInitialState, + enterOptionNoteMode, + enterQuestionNoteMode, + saveNote, submitCustomAnswer, summarizeResult, toAskResult, @@ -54,6 +57,95 @@ test("summaries include custom text answers", () => { ); }); +test("summaries and result text include question and selected option notes", () => { + let state = createInitialState({ + questions: [ + { + id: "framework", + label: "Framework", + prompt: "Pick a framework", + options: [ + { value: "react", label: "React" }, + { value: "vue", label: "Vue" }, + ], + }, + ], + }); + + state = enterQuestionNoteMode(state, "framework"); + state = saveNote(state, "Need SSR support"); + state = enterOptionNoteMode(state, "framework", "vue"); + state = saveNote(state, "Team has prior experience"); + state = applyNumberShortcut(state, 2); + const result = toAskResult(state); + + assert.equal( + summarizeResult(result), + [ + "Framework: Vue", + "Framework note: Need SSR support", + "Framework / Vue note: Team has prior experience", + ].join("\n"), + ); + assert.equal( + renderResultText(result), + [ + "✓ Framework: Vue", + " note: Need SSR support", + " Vue note: Team has prior experience", + ].join("\n"), + ); +}); + +test("unselected option notes are omitted from the submitted result", () => { + let state = createInitialState({ + questions: [ + { + id: "framework", + label: "Framework", + prompt: "Pick a framework", + options: [ + { value: "react", label: "React" }, + { value: "vue", label: "Vue" }, + ], + }, + ], + }); + + state = enterOptionNoteMode(state, "framework", "react"); + state = saveNote(state, "Only for legacy projects"); + state = applyNumberShortcut(state, 2); + const result = toAskResult(state); + + assert.equal(result.answers.framework.optionNotes, undefined); + assert.equal(renderResultText(result), "✓ Framework: Vue"); +}); + +test("question-note-only results still render note content", () => { + const result = { + title: "Interview", + cancelled: false, + questions: [ + { + id: "scope", + label: "Scope", + prompt: "What should we focus on?", + type: "single" as const, + }, + ], + answers: { + scope: { + values: [], + labels: [], + indices: [], + note: "Prefer incremental changes", + }, + }, + }; + + assert.equal(renderResultText(result), " note: Prefer incremental changes"); +}); + test("cancelled result renders as cancelled", () => { const result = { title: "Interview", diff --git a/tests/state.test.ts b/tests/state.test.ts index ec43d8b..cc7c99a 100644 --- a/tests/state.test.ts +++ b/tests/state.test.ts @@ -5,11 +5,15 @@ import { cancelFlow, confirmCurrentSelection, createInitialState, + enterOptionNoteMode, + enterQuestionNoteMode, saveCustomAnswer, + saveNote, getRenderableOptions, moveOption, moveTab, submitCustomAnswer, + toAskResult, toggleCurrentMultiOption, } from "../src/state.ts"; import type { AskParams } from "../src/types.ts"; @@ -158,7 +162,7 @@ test("escape saves typed custom answer without advancing", () => { assert.equal(state.answers.q1.customText, "my draft answer"); }); -test("empty custom draft clears the stored answer", () => { +test("empty custom draft clears the stored answer but preserves notes", () => { let state = createInitialState({ questions: [ { @@ -169,15 +173,139 @@ test("empty custom draft clears the stored answer", () => { ], }); + state = enterQuestionNoteMode(state, "q1"); + state = saveNote(state, "question note"); state = applyNumberShortcut(state, 2); state = saveCustomAnswer(state, "my answer"); state = applyNumberShortcut(state, 2); state = saveCustomAnswer(state, " "); - assert.equal(state.answers.q1, undefined); + assert.equal(state.answers.q1.customText, undefined); + assert.equal(state.answers.q1.note, "question note"); assert.equal(state.mode, "navigate"); }); +test("question note can be saved without selecting an answer and is submitted", () => { + let state = createInitialState({ + questions: [ + { id: "q1", prompt: "Question?", options: [{ value: "a", label: "A" }] }, + ], + }); + + state = enterQuestionNoteMode(state, "q1"); + assert.equal(state.mode, "note"); + state = saveNote(state, "needs examples"); + + assert.equal(state.mode, "navigate"); + assert.equal(state.answers.q1.note, "needs examples"); + assert.deepEqual(toAskResult(state).answers.q1, { + values: [], + labels: [], + indices: [], + customText: undefined, + note: "needs examples", + optionNotes: undefined, + }); +}); + +test("option notes can exist before selection but only selected option notes are submitted", () => { + let state = createInitialState({ + questions: [ + { + id: "q1", + prompt: "Pick frameworks", + type: "multi", + options: [ + { value: "react", label: "React" }, + { value: "vue", label: "Vue" }, + ], + }, + ], + }); + + state = enterOptionNoteMode(state, "q1", "vue"); + state = saveNote(state, "maybe later"); + assert.equal(state.answers.q1.optionNotes?.vue, "maybe later"); + assert.equal(toAskResult(state).answers.q1, undefined); + + state = toggleCurrentMultiOption(state); + assert.deepEqual(state.answers.q1.values, ["react"]); + state = moveOption(state, 1); + state = toggleCurrentMultiOption(state); + assert.deepEqual(state.answers.q1.values, ["react", "vue"]); + assert.deepEqual(toAskResult(state).answers.q1.optionNotes, { + vue: "maybe later", + }); +}); + +test("deselecting an option keeps its note in state but omits it from submission", () => { + let state = createInitialState({ + questions: [ + { + id: "q1", + prompt: "Pick frameworks", + type: "multi", + options: [ + { value: "react", label: "React" }, + { value: "vue", label: "Vue" }, + ], + }, + ], + }); + + state = moveOption(state, 1); + state = toggleCurrentMultiOption(state); + state = enterOptionNoteMode(state, "q1", "vue"); + state = saveNote(state, "migration risk"); + state = toggleCurrentMultiOption(state); + + assert.equal(state.answers.q1.optionNotes?.vue, "migration risk"); + assert.equal(toAskResult(state).answers.q1, undefined); + + state = toggleCurrentMultiOption(state); + assert.deepEqual(toAskResult(state).answers.q1, { + values: ["vue"], + labels: ["Vue"], + indices: [2], + customText: undefined, + note: undefined, + optionNotes: { + vue: "migration risk", + }, + }); +}); + +test("preview questions can store selected option notes for submission", () => { + let state = createInitialState({ + questions: [ + { + id: "preview", + prompt: "Pick layout", + type: "preview", + options: [ + { value: "compact", label: "Compact", preview: "A" }, + { value: "spacious", label: "Spacious", preview: "B" }, + ], + }, + ], + }); + + state = enterOptionNoteMode(state, "preview", "spacious"); + state = saveNote(state, "better scanability"); + state = applyNumberShortcut(state, 2); + + assert.deepEqual(toAskResult(state).answers.preview, { + values: ["spacious"], + labels: ["Spacious"], + indices: [2], + customText: undefined, + note: undefined, + optionNotes: { + spacious: "better scanability", + }, + }); +}); + test("tab navigation wraps including submit tab", () => { let state = createInitialState(sampleParams()); state = moveTab(state, -1); @@ -205,7 +333,7 @@ test("tab navigation works with a single question", () => { assert.equal(state.mode, "navigate"); }); -test("escape cancels from main flow but only exits input mode when typing", () => { +test("escape cancels from main flow but only exits input and note modes when editing", () => { let state = createInitialState(sampleParams()); state = cancelFlow(state); assert.equal(state.cancelled, true); @@ -221,4 +349,10 @@ test("escape cancels from main flow but only exits input mode when typing", () = state = cancelFlow(state); assert.equal(state.mode, "navigate"); assert.equal(state.cancelled, false); + + state = enterQuestionNoteMode(state, "q"); + assert.equal(state.mode, "note"); + state = cancelFlow(state); + assert.equal(state.mode, "navigate"); + assert.equal(state.cancelled, false); });