refactor: split ask flow state and view models
This commit is contained in:
@@ -0,0 +1,190 @@
|
||||
import { isOptionSelected } from "../../state/answers.ts";
|
||||
import {
|
||||
getAnswer,
|
||||
getOptionNote,
|
||||
getQuestionNote,
|
||||
isInputOpenForQuestion,
|
||||
isOptionNoteOpen,
|
||||
isQuestionNoteOpen,
|
||||
} from "../../state/selectors.ts";
|
||||
import type { AskDisplayOption } from "../../types.ts";
|
||||
import { UI_DIMENSIONS, UI_TEXT } from "../constants.ts";
|
||||
import type { QuestionRenderContext } from "../render-types.ts";
|
||||
|
||||
export type QuestionNoteModel =
|
||||
| { kind: "editor"; placeholder: string }
|
||||
| { kind: "saved"; text: string };
|
||||
|
||||
export type OptionDetailModel =
|
||||
| { kind: "editor"; placeholder: string; withGap: boolean }
|
||||
| { kind: "saved-note"; text: string; withGap: boolean }
|
||||
| { kind: "custom-text"; text: string; withGap: boolean };
|
||||
|
||||
export interface OptionRowModel {
|
||||
color: "accent" | "text" | "success";
|
||||
description?: string;
|
||||
detail?: OptionDetailModel;
|
||||
index: number;
|
||||
isCustom: boolean;
|
||||
label: string;
|
||||
pointer: string;
|
||||
prefix: string;
|
||||
selected: boolean;
|
||||
}
|
||||
|
||||
export interface QuestionScreenModel {
|
||||
mode: "preview" | "standard";
|
||||
previewLayout?: "custom" | "stacked" | "wide";
|
||||
questionNote?: QuestionNoteModel;
|
||||
rows: OptionRowModel[];
|
||||
selectedOption?: AskDisplayOption;
|
||||
selectedOptionDetail?: OptionDetailModel;
|
||||
}
|
||||
|
||||
export function buildQuestionScreenModel(
|
||||
context: QuestionRenderContext
|
||||
): QuestionScreenModel {
|
||||
const rows = context.options.map((option, index) =>
|
||||
buildOptionRowModel(context, option, index)
|
||||
);
|
||||
const questionNote = buildQuestionNoteModel(context);
|
||||
if (context.question.type !== "preview") {
|
||||
return {
|
||||
mode: "standard",
|
||||
questionNote,
|
||||
rows,
|
||||
};
|
||||
}
|
||||
|
||||
const selectedOption = context.options[context.state.activeOptionIndex];
|
||||
return {
|
||||
mode: "preview",
|
||||
previewLayout: getPreviewLayout(context, selectedOption),
|
||||
questionNote,
|
||||
rows,
|
||||
selectedOption,
|
||||
selectedOptionDetail: buildOptionDetailModel(context, selectedOption, true),
|
||||
};
|
||||
}
|
||||
|
||||
function buildQuestionNoteModel(
|
||||
context: QuestionRenderContext
|
||||
): QuestionNoteModel | undefined {
|
||||
const { state, question } = context;
|
||||
if (isQuestionNoteOpen(state, question.id)) {
|
||||
return { kind: "editor", placeholder: UI_TEXT.editorPlaceholderNote };
|
||||
}
|
||||
const note = getQuestionNote(state, question.id);
|
||||
return note ? { kind: "saved", text: note } : undefined;
|
||||
}
|
||||
|
||||
function buildOptionRowModel(
|
||||
context: QuestionRenderContext,
|
||||
option: AskDisplayOption,
|
||||
index: number
|
||||
): OptionRowModel {
|
||||
const { question, state } = context;
|
||||
const answer = getAnswer(state, question.id);
|
||||
const selected = index === state.activeOptionIndex;
|
||||
const answered = option.isCustomOption
|
||||
? !!answer?.customText
|
||||
: isOptionSelected(answer, option.value);
|
||||
return {
|
||||
color: getOptionColor(answered, selected),
|
||||
description: option.description,
|
||||
detail:
|
||||
context.question.type === "preview"
|
||||
? undefined
|
||||
: buildOptionDetailModel(context, option, selected),
|
||||
index,
|
||||
isCustom: !!option.isCustomOption,
|
||||
label: option.label,
|
||||
pointer: selected ? "❯ " : " ",
|
||||
prefix: getOptionPrefix(question.type, option, answered),
|
||||
selected,
|
||||
};
|
||||
}
|
||||
|
||||
function buildOptionDetailModel(
|
||||
context: QuestionRenderContext,
|
||||
option: AskDisplayOption | undefined,
|
||||
selected: boolean
|
||||
): OptionDetailModel | undefined {
|
||||
if (!option) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { state, question } = context;
|
||||
const answer = getAnswer(state, question.id);
|
||||
const inputOpen =
|
||||
!!option.isCustomOption &&
|
||||
selected &&
|
||||
isInputOpenForQuestion(state, question.id);
|
||||
const noteOpen =
|
||||
!option.isCustomOption &&
|
||||
isOptionNoteOpen(state, question.id, option.value);
|
||||
const customText = option.isCustomOption ? answer?.customText : undefined;
|
||||
const note = option.isCustomOption
|
||||
? undefined
|
||||
: getOptionNote(state, question.id, option.value);
|
||||
const withGap = !option.isCustomOption;
|
||||
|
||||
if (noteOpen) {
|
||||
return {
|
||||
kind: "editor",
|
||||
placeholder: UI_TEXT.editorPlaceholderNote,
|
||||
withGap,
|
||||
};
|
||||
}
|
||||
if (inputOpen) {
|
||||
return {
|
||||
kind: "editor",
|
||||
placeholder: UI_TEXT.editorPlaceholderInput,
|
||||
withGap,
|
||||
};
|
||||
}
|
||||
if (customText) {
|
||||
return { kind: "custom-text", text: customText, withGap };
|
||||
}
|
||||
if (note) {
|
||||
return { kind: "saved-note", text: note, withGap };
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
function getPreviewLayout(
|
||||
context: QuestionRenderContext,
|
||||
selectedOption: AskDisplayOption | undefined
|
||||
): "custom" | "stacked" | "wide" {
|
||||
if (selectedOption?.isCustomOption) {
|
||||
return "custom";
|
||||
}
|
||||
return context.width >= UI_DIMENSIONS.previewWideMinWidth &&
|
||||
context.options.length > 0
|
||||
? "wide"
|
||||
: "stacked";
|
||||
}
|
||||
|
||||
function getOptionPrefix(
|
||||
questionType: QuestionRenderContext["question"]["type"],
|
||||
option: AskDisplayOption,
|
||||
answered: boolean
|
||||
): string {
|
||||
if (questionType !== "multi" || option.isCustomOption) {
|
||||
return "";
|
||||
}
|
||||
return `[${answered ? "x" : " "}] `;
|
||||
}
|
||||
|
||||
function getOptionColor(
|
||||
answered: boolean,
|
||||
selected: boolean
|
||||
): "accent" | "text" | "success" {
|
||||
if (answered) {
|
||||
return "success";
|
||||
}
|
||||
if (selected) {
|
||||
return "accent";
|
||||
}
|
||||
return "text";
|
||||
}
|
||||
Reference in New Issue
Block a user