refactor(ask): reorganize ask flow internals

This commit is contained in:
Erik Kotsiuba
2026-04-17 19:58:58 +02:00
parent c1ac241655
commit 87cbfa546e
25 changed files with 2448 additions and 1639 deletions
+179
View File
@@ -0,0 +1,179 @@
import type {
AskDisplayOption,
AskResultAnswer,
AskSelectedOption,
AskStateAnswer,
} from "../types.ts";
export function emptyAnswer(): AskStateAnswer {
return { selected: [] };
}
export function cloneAnswer(answer: AskStateAnswer): AskStateAnswer {
return {
selected: answer.selected.map(cloneSelection),
customText: answer.customText,
note: answer.note,
optionNotes: answer.optionNotes ? { ...answer.optionNotes } : undefined,
};
}
export function cloneResultAnswer(answer: AskResultAnswer): AskResultAnswer {
return {
values: [...answer.values],
labels: [...answer.labels],
indices: [...answer.indices],
customText: answer.customText,
note: answer.note,
optionNotes: answer.optionNotes ? { ...answer.optionNotes } : undefined,
};
}
export function toggleSelection(
answer: AskStateAnswer,
option: AskDisplayOption,
index: number,
): AskStateAnswer {
const next = cloneAnswer(answer);
delete next.customText;
const selectedIndex = next.selected.findIndex(
(selection) => selection.value === option.value,
);
if (selectedIndex >= 0) {
next.selected.splice(selectedIndex, 1);
return next;
}
next.selected.push({
value: option.value,
label: option.label,
index: index + 1,
});
return next;
}
export function setSingleSelection(
answer: AskStateAnswer,
option: AskDisplayOption,
index: number,
): AskStateAnswer {
return {
...emptyAnswer(),
note: answer.note,
optionNotes: answer.optionNotes ? { ...answer.optionNotes } : undefined,
selected: [
{
value: option.value,
label: option.label,
index: index + 1,
},
],
};
}
export function saveCustomText(
answer: AskStateAnswer,
rawValue: string,
): AskStateAnswer {
const trimmed = rawValue.trim();
const next = cloneAnswer(answer);
next.selected = [];
if (!trimmed) {
delete next.customText;
return next;
}
next.customText = rawValue;
return next;
}
export function saveQuestionNote(
answer: AskStateAnswer,
rawValue: string,
): AskStateAnswer {
const next = cloneAnswer(answer);
if (rawValue.trim()) {
next.note = rawValue;
return next;
}
delete next.note;
return next;
}
export function saveOptionNote(
answer: AskStateAnswer,
optionValue: string,
rawValue: string,
): AskStateAnswer {
const next = cloneAnswer(answer);
const optionNotes = { ...(next.optionNotes ?? {}) };
if (rawValue.trim()) {
optionNotes[optionValue] = rawValue;
} else {
delete optionNotes[optionValue];
}
next.optionNotes =
Object.keys(optionNotes).length > 0 ? optionNotes : undefined;
return next;
}
export function isAnswerEmpty(answer: AskStateAnswer): boolean {
return (
answer.selected.length === 0 &&
!answer.customText &&
!answer.note &&
(!answer.optionNotes || Object.keys(answer.optionNotes).length === 0)
);
}
export function isAnswerAnswered(answer?: AskStateAnswer): boolean {
if (!answer) {
return false;
}
return answer.selected.length > 0 || !!answer.customText?.trim();
}
export function isOptionSelected(
answer: AskStateAnswer | undefined,
optionValue: string,
): boolean {
return !!answer?.selected.some(
(selection) => selection.value === optionValue,
);
}
export function serializeAnswer(answer: AskStateAnswer): AskResultAnswer {
const values = answer.customText
? [answer.customText]
: answer.selected.map((selection) => selection.value);
const labels = answer.customText
? [answer.customText]
: answer.selected.map((selection) => selection.label);
const indices = answer.selected.map((selection) => selection.index);
const selectedNotes = answer.optionNotes
? Object.fromEntries(
answer.selected
.map((selection) => [
selection.value,
answer.optionNotes?.[selection.value],
])
.filter((entry): entry is [string, string] => !!entry[1]),
)
: undefined;
return {
values,
labels,
indices,
customText: answer.customText,
note: answer.note,
optionNotes:
selectedNotes && Object.keys(selectedNotes).length > 0
? selectedNotes
: undefined,
};
}
function cloneSelection(selection: AskSelectedOption): AskSelectedOption {
return { ...selection };
}
+19
View File
@@ -0,0 +1,19 @@
import type { AskParams, AskQuestion, AskQuestionInput } from "../types.ts";
export function normalizeQuestions(params: AskParams): AskQuestion[] {
return params.questions.map((question, index) =>
normalizeQuestion(question, index),
);
}
function normalizeQuestion(
question: AskQuestionInput,
index: number,
): AskQuestion {
return {
...question,
label: question.label?.trim() || `Q${index + 1}`,
type: question.type ?? "single",
required: question.required !== false,
};
}
+54
View File
@@ -0,0 +1,54 @@
import { CANCELLED_SUMMARY, SUBMITTED_SUMMARY } from "../constants.ts";
import { formatResultLines } from "../result-format.ts";
import type { AskResult, AskState } from "../types.ts";
import { isAnswerEmpty, serializeAnswer } from "./answers.ts";
export function toAskResult(state: AskState): AskResult {
const answers = Object.fromEntries(
Object.entries(state.answers)
.map(
([questionId, answer]) =>
[questionId, serializeAnswer(answer)] as const,
)
.filter(([, answer]) => !isSerializedAnswerEmpty(answer)),
);
return {
title: state.title,
cancelled: state.cancelled,
questions: state.questions.map((question) => ({
id: question.id,
label: question.label,
prompt: question.prompt,
type: question.type,
})),
answers,
};
}
export function summarizeResult(result: AskResult): string {
if (result.cancelled) {
return CANCELLED_SUMMARY;
}
const lines = formatResultLines(result, { mode: "summary" });
return lines.join("\n") || SUBMITTED_SUMMARY;
}
export function hasAnswerContent(state: AskState, questionId: string): boolean {
const answer = state.answers[questionId];
return !!answer && !isAnswerEmpty(answer);
}
function isSerializedAnswerEmpty(
answer: AskResult["answers"][string],
): 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)
);
}
+110
View File
@@ -0,0 +1,110 @@
import { OTHER_OPTION_LABEL, OTHER_OPTION_VALUE } from "../constants.ts";
import type {
AskDisplayOption,
AskQuestion,
AskState,
AskStateAnswer,
} from "../types.ts";
import { isAnswerAnswered } from "./answers.ts";
export function getCurrentQuestion(state: AskState): AskQuestion | undefined {
return state.questions[state.activeTabIndex];
}
export function getQuestionById(
state: AskState,
questionId: string,
): AskQuestion | undefined {
return state.questions.find((question) => question.id === questionId);
}
export function isSubmitTab(state: AskState): boolean {
return state.activeTabIndex >= state.questions.length;
}
export function getRenderableOptions(
question?: AskQuestion,
): AskDisplayOption[] {
if (!question) {
return [];
}
const options: AskDisplayOption[] = question.options.map((option) => ({
...option,
}));
if (question.type !== "preview") {
options.push({
value: OTHER_OPTION_VALUE,
label: OTHER_OPTION_LABEL,
isCustomOption: true,
});
}
return options;
}
export function getCurrentOption(
state: AskState,
): AskDisplayOption | undefined {
return getRenderableOptions(getCurrentQuestion(state))[
state.activeOptionIndex
];
}
export function getAnswer(
state: AskState,
questionId: string,
): AskStateAnswer | undefined {
return state.answers[questionId];
}
export function getQuestionNote(
state: AskState,
questionId: string,
): string | undefined {
return getAnswer(state, questionId)?.note;
}
export function getOptionNote(
state: AskState,
questionId: string,
optionValue: string,
): string | undefined {
return getAnswer(state, questionId)?.optionNotes?.[optionValue];
}
export function isQuestionAnswered(
state: AskState,
questionId: string,
): boolean {
return isAnswerAnswered(getAnswer(state, questionId));
}
export function isQuestionNoteOpen(
state: AskState,
questionId: string,
): boolean {
return (
state.view.kind === "note" &&
state.view.questionId === questionId &&
state.view.optionValue === undefined
);
}
export function isOptionNoteOpen(
state: AskState,
questionId: string,
optionValue: string,
): boolean {
return (
state.view.kind === "note" &&
state.view.questionId === questionId &&
state.view.optionValue === optionValue
);
}
export function isInputOpenForQuestion(
state: AskState,
questionId: string,
): boolean {
return state.view.kind === "input" && state.view.questionId === questionId;
}
+351
View File
@@ -0,0 +1,351 @@
import type { AskAction, AskState } from "../types.ts";
import {
emptyAnswer,
isAnswerAnswered,
isAnswerEmpty,
saveCustomText,
saveOptionNote,
saveQuestionNote,
setSingleSelection,
toggleSelection,
} from "./answers.ts";
import {
getAnswer,
getCurrentOption,
getCurrentQuestion,
getQuestionById,
getRenderableOptions,
isSubmitTab,
} from "./selectors.ts";
import {
inputView,
isEditingView,
navigateView,
optionNoteView,
questionNoteView,
submitView,
} from "./view.ts";
const SUBMIT_ACTION_COUNT = 2;
export function createInitialState(params: {
title?: string;
questions: AskState["questions"];
}): AskState {
return {
title: params.title,
questions: params.questions,
activeTabIndex: 0,
activeOptionIndex: 0,
activeSubmitActionIndex: 0,
view: navigateView(),
answers: {},
completed: false,
cancelled: false,
};
}
export function reduceAskState(state: AskState, action: AskAction): AskState {
switch (action.type) {
case "MOVE_TAB":
return moveTabBy(state, action.delta);
case "MOVE_OPTION":
return moveOptionBy(state, action.delta);
case "OPEN_INPUT":
return setView(state, inputView(action.questionId));
case "OPEN_QUESTION_NOTE":
return setView(state, questionNoteView(action.questionId));
case "OPEN_OPTION_NOTE":
return setView(
state,
optionNoteView(action.questionId, action.optionValue),
);
case "CONFIRM":
return confirmCurrentSelection(state);
case "TOGGLE_MULTI":
return toggleCurrentMultiOption(state);
case "NUMBER_SHORTCUT":
return applyNumberShortcut(state, action.digit);
case "SAVE_INPUT":
return saveInputValue(state, action.value, action.submit ?? false);
case "SAVE_NOTE":
return saveNoteValue(state, action.value);
case "CANCEL":
return cancelFlow(state);
default:
return state;
}
}
export function moveTab(state: AskState, delta: number): AskState {
return reduceAskState(state, {
type: "MOVE_TAB",
delta: delta < 0 ? -1 : 1,
});
}
export function moveOption(state: AskState, delta: number): AskState {
return reduceAskState(state, {
type: "MOVE_OPTION",
delta: delta < 0 ? -1 : 1,
});
}
export function enterInputMode(state: AskState, questionId: string): AskState {
return reduceAskState(state, { type: "OPEN_INPUT", questionId });
}
export function enterQuestionNoteMode(
state: AskState,
questionId: string,
): AskState {
return reduceAskState(state, { type: "OPEN_QUESTION_NOTE", questionId });
}
export function enterOptionNoteMode(
state: AskState,
questionId: string,
optionValue: string,
): AskState {
return reduceAskState(state, {
type: "OPEN_OPTION_NOTE",
questionId,
optionValue,
});
}
export function toggleCurrentMultiOption(state: AskState): AskState {
const question = getCurrentQuestion(state);
if (!question || question.type === "single") {
return state;
}
const option = getCurrentOption(state);
if (!option) {
return state;
}
if (option.isCustomOption) {
return setView(state, inputView(question.id));
}
return updateAnswer(state, question.id, (answer) =>
toggleSelection(answer, option, state.activeOptionIndex),
);
}
export function confirmCurrentSelection(state: AskState): AskState {
if (isSubmitTab(state)) {
return state.activeSubmitActionIndex === 1
? { ...state, cancelled: true, completed: true }
: { ...state, completed: true };
}
const question = getCurrentQuestion(state);
const option = getCurrentOption(state);
if (!question || !option) {
return state;
}
if (question.type === "multi") {
if (option.isCustomOption) {
return setView(state, inputView(question.id));
}
return advanceToNextTab(state);
}
if (option.isCustomOption) {
return setView(state, inputView(question.id));
}
const nextState = updateAnswer(state, question.id, (answer) =>
setSingleSelection(answer, option, state.activeOptionIndex),
);
return advanceToNextTab(nextState);
}
export function applyNumberShortcut(state: AskState, digit: number): AskState {
if (digit <= 0 || isSubmitTab(state)) {
return state;
}
const question = getCurrentQuestion(state);
if (!question) {
return state;
}
const index = digit - 1;
const option = getRenderableOptions(question)[index];
if (!option) {
return state;
}
const selectedState = { ...state, activeOptionIndex: index };
if (option.isCustomOption) {
return setView(selectedState, inputView(question.id));
}
if (question.type === "multi") {
return updateAnswer(selectedState, question.id, (answer) =>
toggleSelection(answer, option, index),
);
}
const nextState = updateAnswer(selectedState, question.id, (answer) =>
setSingleSelection(answer, option, index),
);
return advanceToNextTab(nextState);
}
export function saveCustomAnswer(state: AskState, rawValue: string): AskState {
return saveInputValue(state, rawValue, false);
}
export function submitCustomAnswer(
state: AskState,
rawValue: string,
): AskState {
return saveInputValue(state, rawValue, true);
}
export function saveNote(state: AskState, rawValue: string): AskState {
return saveNoteValue(state, rawValue);
}
export function cancelFlow(state: AskState): AskState {
if (isEditingView(state)) {
return exitEditingView(state);
}
return {
...state,
cancelled: true,
completed: true,
};
}
function saveInputValue(
state: AskState,
rawValue: string,
submit: boolean,
): AskState {
if (state.view.kind !== "input") {
return state;
}
const question = getQuestionById(state, state.view.questionId);
if (!question) {
return exitEditingView(state);
}
const nextState = updateAnswer(
exitEditingView(state),
question.id,
(answer) => saveCustomText(answer, rawValue),
);
if (!submit || !isAnswerAnswered(nextState.answers[question.id])) {
return nextState;
}
return advanceToNextTab(nextState);
}
function saveNoteValue(state: AskState, rawValue: string): AskState {
if (state.view.kind !== "note") {
return exitEditingView(state);
}
const { questionId, optionValue } = state.view;
const nextState = updateAnswer(
exitEditingView(state),
questionId,
(answer) =>
optionValue
? saveOptionNote(answer, optionValue, rawValue)
: saveQuestionNote(answer, rawValue),
);
return nextState;
}
function moveTabBy(state: AskState, delta: 1 | -1): AskState {
const totalTabs = state.questions.length + 1;
const activeTabIndex = (state.activeTabIndex + delta + totalTabs) % totalTabs;
return {
...state,
activeTabIndex,
activeOptionIndex: 0,
activeSubmitActionIndex: 0,
view:
activeTabIndex >= state.questions.length ? submitView() : navigateView(),
};
}
function moveOptionBy(state: AskState, delta: 1 | -1): AskState {
if (isSubmitTab(state)) {
return {
...state,
activeSubmitActionIndex: clamp(
state.activeSubmitActionIndex + delta,
0,
SUBMIT_ACTION_COUNT - 1,
),
};
}
const options = getRenderableOptions(getCurrentQuestion(state));
return {
...state,
activeOptionIndex: clamp(
state.activeOptionIndex + delta,
0,
Math.max(0, options.length - 1),
),
};
}
function setView(state: AskState, view: AskState["view"]): AskState {
return {
...state,
view,
};
}
function exitEditingView(state: AskState): AskState {
return {
...state,
view: isSubmitTab(state) ? submitView() : navigateView(),
};
}
function advanceToNextTab(state: AskState): AskState {
const nextTab = Math.min(state.activeTabIndex + 1, state.questions.length);
return {
...state,
activeTabIndex: nextTab,
activeOptionIndex: 0,
activeSubmitActionIndex: 0,
view: nextTab === state.questions.length ? submitView() : navigateView(),
};
}
function updateAnswer(
state: AskState,
questionId: string,
mutate: (
answer: ReturnType<typeof emptyAnswer>,
) => ReturnType<typeof emptyAnswer>,
): AskState {
const existing = getAnswer(state, questionId) ?? emptyAnswer();
const nextAnswer = mutate(existing);
const answers = { ...state.answers };
if (isAnswerEmpty(nextAnswer)) {
delete answers[questionId];
} else {
answers[questionId] = nextAnswer;
}
return {
...state,
answers,
};
}
function clamp(value: number, min: number, max: number): number {
return Math.min(max, Math.max(min, value));
}
+28
View File
@@ -0,0 +1,28 @@
import type { AskState, ViewState } from "../types.ts";
export function navigateView(): ViewState {
return { kind: "navigate" };
}
export function submitView(): ViewState {
return { kind: "submit" };
}
export function inputView(questionId: string): ViewState {
return { kind: "input", questionId };
}
export function questionNoteView(questionId: string): ViewState {
return { kind: "note", questionId };
}
export function optionNoteView(
questionId: string,
optionValue: string,
): ViewState {
return { kind: "note", questionId, optionValue };
}
export function isEditingView(state: AskState): boolean {
return state.view.kind === "input" || state.view.kind === "note";
}