feat(ask): add question and option notes
This commit is contained in:
+256
-34
@@ -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"));
|
||||
}
|
||||
|
||||
+15
-4
@@ -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";
|
||||
}
|
||||
|
||||
+188
-15
@@ -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<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[];
|
||||
customText?: string;
|
||||
note?: string;
|
||||
optionNotes?: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface AskResult {
|
||||
@@ -48,7 +49,7 @@ export interface AskResult {
|
||||
answers: Record<string, AskAnswer>;
|
||||
}
|
||||
|
||||
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<string, AskAnswer>;
|
||||
completed: boolean;
|
||||
cancelled: boolean;
|
||||
|
||||
Reference in New Issue
Block a user