refactor(ask): reorganize ask flow internals
This commit is contained in:
@@ -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 };
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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)
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
Reference in New Issue
Block a user