352 lines
8.0 KiB
TypeScript
352 lines
8.0 KiB
TypeScript
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));
|
|
}
|