feat(ask): add question and option notes
This commit is contained in:
@@ -151,10 +151,36 @@ The current prototype supports:
|
|||||||
- single-select questions
|
- single-select questions
|
||||||
- multi-select questions
|
- multi-select questions
|
||||||
- preview questions with a dedicated preview pane
|
- 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
|
- number-key quick selection
|
||||||
- final submit/review page
|
- final submit/review page
|
||||||
- transcript rendering for call/result rows
|
- 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<string, string> // 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
|
## Example agent instruction
|
||||||
|
|
||||||
You can give the agent an instruction like this to encourage proper `ask_user` usage:
|
You can give the agent an instruction like this to encourage proper `ask_user` usage:
|
||||||
|
|||||||
+256
-34
@@ -14,14 +14,19 @@ import {
|
|||||||
confirmCurrentSelection,
|
confirmCurrentSelection,
|
||||||
createInitialState,
|
createInitialState,
|
||||||
enterInputMode,
|
enterInputMode,
|
||||||
|
enterOptionNoteMode,
|
||||||
|
enterQuestionNoteMode,
|
||||||
getAnswer,
|
getAnswer,
|
||||||
getCurrentQuestion,
|
getCurrentQuestion,
|
||||||
|
getOptionNote,
|
||||||
|
getQuestionNote,
|
||||||
getRenderableOptions,
|
getRenderableOptions,
|
||||||
isQuestionAnswered,
|
isQuestionAnswered,
|
||||||
isSubmitTab,
|
isSubmitTab,
|
||||||
moveOption,
|
moveOption,
|
||||||
moveTab,
|
moveTab,
|
||||||
saveCustomAnswer,
|
saveCustomAnswer,
|
||||||
|
saveNote,
|
||||||
submitCustomAnswer,
|
submitCustomAnswer,
|
||||||
toAskResult,
|
toAskResult,
|
||||||
toggleCurrentMultiOption,
|
toggleCurrentMultiOption,
|
||||||
@@ -54,7 +59,11 @@ export async function runAskFlow(
|
|||||||
|
|
||||||
editor.onSubmit = (value) => {
|
editor.onSubmit = (value) => {
|
||||||
suppressAutoInputForSelection = false;
|
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("");
|
editor.setText("");
|
||||||
refresh();
|
refresh();
|
||||||
maybeFinish();
|
maybeFinish();
|
||||||
@@ -71,17 +80,25 @@ export async function runAskFlow(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function hydrateEditorForInputMode() {
|
function hydrateEditorForCurrentMode() {
|
||||||
if (state.mode !== "input" || !state.inputQuestionId) {
|
if (state.mode === "input" && state.inputQuestionId) {
|
||||||
|
const existingText =
|
||||||
|
getAnswer(state, state.inputQuestionId)?.customText ?? "";
|
||||||
|
editor.setText(existingText);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const existingText =
|
if (state.mode !== "note" || !state.noteQuestionId) {
|
||||||
getAnswer(state, state.inputQuestionId)?.customText ?? "";
|
return;
|
||||||
|
}
|
||||||
|
const existingText = state.noteOptionValue
|
||||||
|
? (getOptionNote(state, state.noteQuestionId, state.noteOptionValue) ??
|
||||||
|
"")
|
||||||
|
: (getQuestionNote(state, state.noteQuestionId) ?? "");
|
||||||
editor.setText(existingText);
|
editor.setText(existingText);
|
||||||
}
|
}
|
||||||
|
|
||||||
function syncInputModeWithSelection() {
|
function syncInputModeWithSelection() {
|
||||||
if (isSubmitTab(state)) {
|
if (state.mode === "note" || isSubmitTab(state)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -100,32 +117,28 @@ export async function runAskFlow(
|
|||||||
}
|
}
|
||||||
|
|
||||||
state = enterInputMode(state, question.id);
|
state = enterInputMode(state, question.id);
|
||||||
hydrateEditorForInputMode();
|
hydrateEditorForCurrentMode();
|
||||||
}
|
}
|
||||||
|
|
||||||
syncInputModeWithSelection();
|
syncInputModeWithSelection();
|
||||||
|
|
||||||
function handleInput(data: string) {
|
function handleInput(data: string) {
|
||||||
if (state.mode === "input") {
|
if (state.mode === "input" || state.mode === "note") {
|
||||||
if (matchesKey(data, Key.tab) || matchesKey(data, Key.right)) {
|
if (matchesKey(data, Key.tab) || matchesKey(data, Key.right)) {
|
||||||
state = saveCustomAnswer(state, editor.getText());
|
state = saveEditorAndMoveTab(1);
|
||||||
suppressAutoInputForSelection = false;
|
|
||||||
state = moveTab(state, 1);
|
|
||||||
syncInputModeWithSelection();
|
syncInputModeWithSelection();
|
||||||
refresh();
|
refresh();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (matchesKey(data, Key.shift("tab")) || matchesKey(data, Key.left)) {
|
if (matchesKey(data, Key.shift("tab")) || matchesKey(data, Key.left)) {
|
||||||
state = saveCustomAnswer(state, editor.getText());
|
state = saveEditorAndMoveTab(-1);
|
||||||
suppressAutoInputForSelection = false;
|
|
||||||
state = moveTab(state, -1);
|
|
||||||
syncInputModeWithSelection();
|
syncInputModeWithSelection();
|
||||||
refresh();
|
refresh();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (matchesKey(data, Key.escape)) {
|
if (matchesKey(data, Key.escape)) {
|
||||||
state = saveCustomAnswer(state, editor.getText());
|
state = saveEditorWithoutAdvancing();
|
||||||
suppressAutoInputForSelection = true;
|
suppressAutoInputForSelection = state.mode !== "input";
|
||||||
refresh();
|
refresh();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -168,17 +181,38 @@ export async function runAskFlow(
|
|||||||
suppressAutoInputForSelection = false;
|
suppressAutoInputForSelection = false;
|
||||||
state = toggleCurrentMultiOption(state);
|
state = toggleCurrentMultiOption(state);
|
||||||
if (state.mode === "input") {
|
if (state.mode === "input") {
|
||||||
hydrateEditorForInputMode();
|
hydrateEditorForCurrentMode();
|
||||||
}
|
}
|
||||||
refresh();
|
refresh();
|
||||||
return;
|
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)) {
|
if (matchesKey(data, Key.enter)) {
|
||||||
suppressAutoInputForSelection = false;
|
suppressAutoInputForSelection = false;
|
||||||
state = confirmCurrentSelection(state);
|
state = confirmCurrentSelection(state);
|
||||||
if (state.mode === "input") {
|
if (state.mode === "input") {
|
||||||
hydrateEditorForInputMode();
|
hydrateEditorForCurrentMode();
|
||||||
}
|
}
|
||||||
refresh();
|
refresh();
|
||||||
maybeFinish();
|
maybeFinish();
|
||||||
@@ -196,13 +230,28 @@ export async function runAskFlow(
|
|||||||
suppressAutoInputForSelection = false;
|
suppressAutoInputForSelection = false;
|
||||||
state = applyNumberShortcut(state, digit);
|
state = applyNumberShortcut(state, digit);
|
||||||
if (state.mode === "input") {
|
if (state.mode === "input") {
|
||||||
hydrateEditorForInputMode();
|
hydrateEditorForCurrentMode();
|
||||||
}
|
}
|
||||||
refresh();
|
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[] {
|
function render(width: number): string[] {
|
||||||
if (cachedLines) return cachedLines;
|
if (cachedLines) return cachedLines;
|
||||||
|
|
||||||
@@ -305,8 +354,9 @@ function renderSubmit(
|
|||||||
);
|
);
|
||||||
lines.push("");
|
lines.push("");
|
||||||
|
|
||||||
|
const submittedAnswers = toAskResult(state).answers;
|
||||||
for (const question of state.questions) {
|
for (const question of state.questions) {
|
||||||
const answer = getAnswer(state, question.id);
|
const answer = submittedAnswers[question.id];
|
||||||
pushWrappedText(
|
pushWrappedText(
|
||||||
lines,
|
lines,
|
||||||
question.prompt,
|
question.prompt,
|
||||||
@@ -323,15 +373,45 @@ function renderSubmit(
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const answerText = answer.customText ?? answer.labels.join(", ");
|
const answerText = answer.customText ?? answer.labels.join(", ");
|
||||||
pushWrappedText(
|
if (answerText) {
|
||||||
lines,
|
pushWrappedText(
|
||||||
`→ ${answerText}`,
|
lines,
|
||||||
width,
|
`→ ${answerText}`,
|
||||||
theme,
|
width,
|
||||||
answer.customText ? "text" : "success",
|
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("");
|
lines.push("");
|
||||||
@@ -376,6 +456,15 @@ function renderQuestionContent(args: {
|
|||||||
args;
|
args;
|
||||||
|
|
||||||
renderQuestionPrompt(lines, question.prompt, theme, width);
|
renderQuestionPrompt(lines, question.prompt, theme, width);
|
||||||
|
renderQuestionNote(
|
||||||
|
lines,
|
||||||
|
state,
|
||||||
|
question.id,
|
||||||
|
theme,
|
||||||
|
width,
|
||||||
|
editor,
|
||||||
|
newLineHint,
|
||||||
|
);
|
||||||
|
|
||||||
if (question.type === "preview") {
|
if (question.type === "preview") {
|
||||||
renderPreviewQuestion(
|
renderPreviewQuestion(
|
||||||
@@ -413,6 +502,55 @@ function renderQuestionPrompt(
|
|||||||
lines.push("");
|
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(
|
function renderStandardQuestion(
|
||||||
lines: string[],
|
lines: string[],
|
||||||
state: AskState,
|
state: AskState,
|
||||||
@@ -428,6 +566,9 @@ function renderStandardQuestion(
|
|||||||
for (let i = 0; i < options.length; i++) {
|
for (let i = 0; i < options.length; i++) {
|
||||||
const option = options[i];
|
const option = options[i];
|
||||||
const selected = i === state.optionIndex;
|
const selected = i === state.optionIndex;
|
||||||
|
const optionNote = option.isOther
|
||||||
|
? undefined
|
||||||
|
: answer?.optionNotes?.[option.value];
|
||||||
const isAnsweredOption = option.isOther
|
const isAnsweredOption = option.isOther
|
||||||
? !!answer?.customText
|
? !!answer?.customText
|
||||||
: !!answer?.values.includes(option.value);
|
: !!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))) {
|
for (const editorLine of editor.render(Math.max(8, width - 7))) {
|
||||||
lines.push(
|
lines.push(
|
||||||
truncateToWidth(
|
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);
|
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") {
|
if (selectedOption?.isOther && state.mode === "input") {
|
||||||
add("");
|
add("");
|
||||||
for (const editorLine of editor.render(Math.max(8, width - 7))) {
|
for (const editorLine of editor.render(Math.max(8, width - 7))) {
|
||||||
@@ -729,7 +931,16 @@ function renderFooter(
|
|||||||
theme: ExtensionContext["ui"]["theme"],
|
theme: ExtensionContext["ui"]["theme"],
|
||||||
): string {
|
): string {
|
||||||
if (state.mode === "input") {
|
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);
|
const question = getCurrentQuestion(state);
|
||||||
@@ -739,10 +950,13 @@ function renderFooter(
|
|||||||
if (question?.type === "multi") {
|
if (question?.type === "multi") {
|
||||||
return theme.fg(
|
return theme.fg(
|
||||||
"dim",
|
"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 {
|
function parseDigit(data: string): number | null {
|
||||||
@@ -771,3 +985,11 @@ function formatKeybindingLabel(key: string): string {
|
|||||||
})
|
})
|
||||||
.join("+");
|
.join("+");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isOptionNoteShortcut(data: string): boolean {
|
||||||
|
return data === "n" || data === "N";
|
||||||
|
}
|
||||||
|
|
||||||
|
function isQuestionNoteShortcut(data: string): boolean {
|
||||||
|
return matchesKey(data, Key.ctrl("n"));
|
||||||
|
}
|
||||||
|
|||||||
+15
-4
@@ -9,10 +9,21 @@ export function renderResultText(result: AskResult): string {
|
|||||||
for (const question of result.questions) {
|
for (const question of result.questions) {
|
||||||
const answer = result.answers[question.id];
|
const answer = result.answers[question.id];
|
||||||
if (!answer) continue;
|
if (!answer) continue;
|
||||||
const value = answer.customText
|
if (answer.customText) {
|
||||||
? `(wrote) ${answer.customText}`
|
lines.push(`✓ ${question.label}: (wrote) ${answer.customText}`);
|
||||||
: answer.labels.join(", ");
|
} else if (answer.labels.length > 0) {
|
||||||
lines.push(`✓ ${question.label}: ${value}`);
|
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";
|
return lines.join("\n") || "Submitted";
|
||||||
}
|
}
|
||||||
|
|||||||
+188
-15
@@ -37,6 +37,8 @@ export function createInitialState(params: AskParams): AskState {
|
|||||||
submitIndex: 0,
|
submitIndex: 0,
|
||||||
mode: "navigate",
|
mode: "navigate",
|
||||||
inputQuestionId: null,
|
inputQuestionId: null,
|
||||||
|
noteQuestionId: null,
|
||||||
|
noteOptionValue: null,
|
||||||
answers: {},
|
answers: {},
|
||||||
completed: false,
|
completed: false,
|
||||||
cancelled: false,
|
cancelled: false,
|
||||||
@@ -73,6 +75,21 @@ export function getAnswer(
|
|||||||
return state.answers[questionId];
|
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(
|
export function isQuestionAnswered(
|
||||||
state: AskState,
|
state: AskState,
|
||||||
questionId: string,
|
questionId: string,
|
||||||
@@ -91,6 +108,8 @@ export function moveTab(state: AskState, delta: number): AskState {
|
|||||||
submitIndex: 0,
|
submitIndex: 0,
|
||||||
mode: isSubmitTab({ ...state, currentTab }) ? "submit" : "navigate",
|
mode: isSubmitTab({ ...state, currentTab }) ? "submit" : "navigate",
|
||||||
inputQuestionId: null,
|
inputQuestionId: null,
|
||||||
|
noteQuestionId: null,
|
||||||
|
noteOptionValue: null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -118,6 +137,8 @@ export function enterInputMode(state: AskState, questionId: string): AskState {
|
|||||||
...state,
|
...state,
|
||||||
mode: "input",
|
mode: "input",
|
||||||
inputQuestionId: questionId,
|
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 {
|
export function toggleCurrentMultiOption(state: AskState): AskState {
|
||||||
const question = getCurrentQuestion(state);
|
const question = getCurrentQuestion(state);
|
||||||
if (!question || question.type === "single") return 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);
|
if (!question) return exitInputMode(state);
|
||||||
|
|
||||||
const trimmed = rawValue.trim();
|
|
||||||
const nextState = exitInputMode(state);
|
const nextState = exitInputMode(state);
|
||||||
|
const trimmed = rawValue.trim();
|
||||||
|
const existing = nextState.answers[question.id];
|
||||||
if (!trimmed) {
|
if (!trimmed) {
|
||||||
const answers = { ...nextState.answers };
|
|
||||||
delete answers[question.id];
|
|
||||||
return {
|
return {
|
||||||
...nextState,
|
...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 {
|
return {
|
||||||
...nextState,
|
...nextState,
|
||||||
answers: {
|
answers: {
|
||||||
...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 {
|
): AskState {
|
||||||
const questionId = state.inputQuestionId;
|
const questionId = state.inputQuestionId;
|
||||||
const nextState = saveCustomAnswer(state, rawValue);
|
const nextState = saveCustomAnswer(state, rawValue);
|
||||||
if (!questionId || !getAnswer(nextState, questionId)) {
|
if (!questionId || !isQuestionAnswered(nextState, questionId)) {
|
||||||
return nextState;
|
return nextState;
|
||||||
}
|
}
|
||||||
return advanceToNextTab(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 {
|
export function cancelFlow(state: AskState): AskState {
|
||||||
if (state.mode === "input") {
|
if (state.mode === "input") {
|
||||||
return exitInputMode(state);
|
return exitInputMode(state);
|
||||||
}
|
}
|
||||||
|
if (state.mode === "note") {
|
||||||
|
return exitNoteMode(state);
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
...state,
|
...state,
|
||||||
cancelled: true,
|
cancelled: true,
|
||||||
@@ -262,7 +360,7 @@ export function toAskResult(state: AskState): AskResult {
|
|||||||
prompt: question.prompt,
|
prompt: question.prompt,
|
||||||
type: question.type,
|
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 lines = result.questions.flatMap((question) => {
|
||||||
const answer = result.answers[question.id];
|
const answer = result.answers[question.id];
|
||||||
if (!answer) return [];
|
if (!answer) return [];
|
||||||
|
|
||||||
|
const questionLines: string[] = [];
|
||||||
if (answer.customText) {
|
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";
|
return lines.join("\n") || "User submitted the ask flow";
|
||||||
@@ -291,6 +403,7 @@ function toggleAnswer(
|
|||||||
): AskState {
|
): AskState {
|
||||||
const existing = state.answers[questionId] ?? emptyAnswer();
|
const existing = state.answers[questionId] ?? emptyAnswer();
|
||||||
const next = cloneAnswer(existing);
|
const next = cloneAnswer(existing);
|
||||||
|
delete next.customText;
|
||||||
const foundIndex = next.values.indexOf(option.value);
|
const foundIndex = next.values.indexOf(option.value);
|
||||||
|
|
||||||
if (foundIndex >= 0) {
|
if (foundIndex >= 0) {
|
||||||
@@ -318,14 +431,18 @@ function setSingleAnswer(
|
|||||||
option: RenderOption,
|
option: RenderOption,
|
||||||
index: number,
|
index: number,
|
||||||
): AskState {
|
): AskState {
|
||||||
|
const existing = state.answers[questionId] ?? emptyAnswer();
|
||||||
return {
|
return {
|
||||||
...state,
|
...state,
|
||||||
answers: {
|
answers: {
|
||||||
...state.answers,
|
...state.answers,
|
||||||
[questionId]: {
|
[questionId]: {
|
||||||
|
...emptyAnswer(),
|
||||||
|
...existing,
|
||||||
values: [option.value],
|
values: [option.value],
|
||||||
labels: [option.label],
|
labels: [option.label],
|
||||||
indices: [index + 1],
|
indices: [index + 1],
|
||||||
|
customText: undefined,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -341,6 +458,8 @@ function advanceToNextTab(state: AskState): AskState {
|
|||||||
optionIndex: 0,
|
optionIndex: 0,
|
||||||
submitIndex: 0,
|
submitIndex: 0,
|
||||||
inputQuestionId: null,
|
inputQuestionId: null,
|
||||||
|
noteQuestionId: null,
|
||||||
|
noteOptionValue: null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -352,6 +471,8 @@ function advanceToNextTab(state: AskState): AskState {
|
|||||||
optionIndex: 0,
|
optionIndex: 0,
|
||||||
submitIndex: 0,
|
submitIndex: 0,
|
||||||
inputQuestionId: null,
|
inputQuestionId: null,
|
||||||
|
noteQuestionId: null,
|
||||||
|
noteOptionValue: null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -366,9 +487,61 @@ function cloneAnswer(answer: AskAnswer): AskAnswer {
|
|||||||
indices: [...answer.indices],
|
indices: [...answer.indices],
|
||||||
customText: answer.customText,
|
customText: answer.customText,
|
||||||
note: answer.note,
|
note: answer.note,
|
||||||
|
optionNotes: answer.optionNotes ? { ...answer.optionNotes } : undefined,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function clamp(value: number, min: number, max: number): number {
|
function clamp(value: number, min: number, max: number): number {
|
||||||
return Math.min(max, Math.max(min, value));
|
return Math.min(max, Math.max(min, value));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function updateAnswer(
|
||||||
|
answers: Record<string, AskAnswer>,
|
||||||
|
questionId: string,
|
||||||
|
mutate: (answer: AskAnswer) => void,
|
||||||
|
): Record<string, AskAnswer> {
|
||||||
|
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<string, AskAnswer>,
|
||||||
|
): Record<string, AskAnswer> {
|
||||||
|
const result: Record<string, AskAnswer> = {};
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|||||||
+4
-1
@@ -34,6 +34,7 @@ export interface AskAnswer {
|
|||||||
indices: number[];
|
indices: number[];
|
||||||
customText?: string;
|
customText?: string;
|
||||||
note?: string;
|
note?: string;
|
||||||
|
optionNotes?: Record<string, string>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AskResult {
|
export interface AskResult {
|
||||||
@@ -48,7 +49,7 @@ export interface AskResult {
|
|||||||
answers: Record<string, AskAnswer>;
|
answers: Record<string, AskAnswer>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ViewMode = "navigate" | "input" | "submit";
|
export type ViewMode = "navigate" | "input" | "note" | "submit";
|
||||||
|
|
||||||
export interface AskState {
|
export interface AskState {
|
||||||
title?: string;
|
title?: string;
|
||||||
@@ -58,6 +59,8 @@ export interface AskState {
|
|||||||
submitIndex: number;
|
submitIndex: number;
|
||||||
mode: ViewMode;
|
mode: ViewMode;
|
||||||
inputQuestionId: string | null;
|
inputQuestionId: string | null;
|
||||||
|
noteQuestionId: string | null;
|
||||||
|
noteOptionValue: string | null;
|
||||||
answers: Record<string, AskAnswer>;
|
answers: Record<string, AskAnswer>;
|
||||||
completed: boolean;
|
completed: boolean;
|
||||||
cancelled: boolean;
|
cancelled: boolean;
|
||||||
|
|||||||
@@ -4,6 +4,9 @@ import { renderResultText } from "../src/result.ts";
|
|||||||
import {
|
import {
|
||||||
applyNumberShortcut,
|
applyNumberShortcut,
|
||||||
createInitialState,
|
createInitialState,
|
||||||
|
enterOptionNoteMode,
|
||||||
|
enterQuestionNoteMode,
|
||||||
|
saveNote,
|
||||||
submitCustomAnswer,
|
submitCustomAnswer,
|
||||||
summarizeResult,
|
summarizeResult,
|
||||||
toAskResult,
|
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", () => {
|
test("cancelled result renders as cancelled", () => {
|
||||||
const result = {
|
const result = {
|
||||||
title: "Interview",
|
title: "Interview",
|
||||||
|
|||||||
+137
-3
@@ -5,11 +5,15 @@ import {
|
|||||||
cancelFlow,
|
cancelFlow,
|
||||||
confirmCurrentSelection,
|
confirmCurrentSelection,
|
||||||
createInitialState,
|
createInitialState,
|
||||||
|
enterOptionNoteMode,
|
||||||
|
enterQuestionNoteMode,
|
||||||
saveCustomAnswer,
|
saveCustomAnswer,
|
||||||
|
saveNote,
|
||||||
getRenderableOptions,
|
getRenderableOptions,
|
||||||
moveOption,
|
moveOption,
|
||||||
moveTab,
|
moveTab,
|
||||||
submitCustomAnswer,
|
submitCustomAnswer,
|
||||||
|
toAskResult,
|
||||||
toggleCurrentMultiOption,
|
toggleCurrentMultiOption,
|
||||||
} from "../src/state.ts";
|
} from "../src/state.ts";
|
||||||
import type { AskParams } from "../src/types.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");
|
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({
|
let state = createInitialState({
|
||||||
questions: [
|
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 = applyNumberShortcut(state, 2);
|
||||||
state = saveCustomAnswer(state, "my answer");
|
state = saveCustomAnswer(state, "my answer");
|
||||||
state = applyNumberShortcut(state, 2);
|
state = applyNumberShortcut(state, 2);
|
||||||
state = saveCustomAnswer(state, " ");
|
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");
|
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", () => {
|
test("tab navigation wraps including submit tab", () => {
|
||||||
let state = createInitialState(sampleParams());
|
let state = createInitialState(sampleParams());
|
||||||
state = moveTab(state, -1);
|
state = moveTab(state, -1);
|
||||||
@@ -205,7 +333,7 @@ test("tab navigation works with a single question", () => {
|
|||||||
assert.equal(state.mode, "navigate");
|
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());
|
let state = createInitialState(sampleParams());
|
||||||
state = cancelFlow(state);
|
state = cancelFlow(state);
|
||||||
assert.equal(state.cancelled, true);
|
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);
|
state = cancelFlow(state);
|
||||||
assert.equal(state.mode, "navigate");
|
assert.equal(state.mode, "navigate");
|
||||||
assert.equal(state.cancelled, false);
|
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);
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user