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";
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import { visibleWidth } from "@mariozechner/pi-tui";
|
||||
import { SUBMIT_CHOICES } from "../../constants.ts";
|
||||
import { isCustomOnlyAnswer } from "../../state/answers.ts";
|
||||
import {
|
||||
type ReviewAnswer,
|
||||
shouldRenderAnswersIndividually,
|
||||
toReviewAnswer,
|
||||
} from "../../state/result.ts";
|
||||
import type { AskState } from "../../types.ts";
|
||||
import { UI_DIMENSIONS } from "../constants.ts";
|
||||
|
||||
export interface ReviewSelectionModel {
|
||||
label: string;
|
||||
note?: string;
|
||||
}
|
||||
|
||||
export interface ReviewQuestionModel {
|
||||
answerText?: string;
|
||||
extraOptionNotes?: Array<{ label: string; note: string }>;
|
||||
isCustomOnly?: boolean;
|
||||
label: string;
|
||||
note?: string;
|
||||
selections?: ReviewSelectionModel[];
|
||||
unanswered: boolean;
|
||||
}
|
||||
|
||||
export interface ReviewScreenModel {
|
||||
actionColumnWidth: number;
|
||||
actions: Array<{ label: string; selected: boolean }>;
|
||||
layout: "stacked" | "wide";
|
||||
questions: ReviewQuestionModel[];
|
||||
}
|
||||
|
||||
export function buildReviewScreenModel(
|
||||
state: AskState,
|
||||
width: number
|
||||
): ReviewScreenModel {
|
||||
const showAllNotes = state.activeSubmitActionIndex === 1;
|
||||
const actionColumnWidth = getSubmitActionColumnWidth();
|
||||
return {
|
||||
actionColumnWidth,
|
||||
actions: SUBMIT_CHOICES.map((label, index) => ({
|
||||
label,
|
||||
selected: index === state.activeSubmitActionIndex,
|
||||
})),
|
||||
layout: shouldUseWideSubmitLayout(width, actionColumnWidth)
|
||||
? "wide"
|
||||
: "stacked",
|
||||
questions: state.questions.map((question) =>
|
||||
toReviewQuestionModel(
|
||||
question.label,
|
||||
toReviewAnswer(question, state.answers[question.id], showAllNotes)
|
||||
)
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function toReviewQuestionModel(
|
||||
label: string,
|
||||
answer: ReviewAnswer | undefined
|
||||
): ReviewQuestionModel {
|
||||
if (!answer) {
|
||||
return { label, unanswered: true };
|
||||
}
|
||||
|
||||
return {
|
||||
answerText: shouldRenderAnswersIndividually(answer)
|
||||
? undefined
|
||||
: answer.labels.join(", "),
|
||||
extraOptionNotes: answer.extraOptionNotes,
|
||||
isCustomOnly: isCustomOnlyAnswer(answer),
|
||||
label,
|
||||
note: answer.note,
|
||||
selections: shouldRenderAnswersIndividually(answer)
|
||||
? answer.labels.map((selectionLabel, index) => ({
|
||||
label: selectionLabel,
|
||||
note: answer.optionNotes?.[answer.values[index] ?? selectionLabel],
|
||||
}))
|
||||
: undefined,
|
||||
unanswered: false,
|
||||
};
|
||||
}
|
||||
|
||||
function getSubmitActionColumnWidth(): number {
|
||||
return Math.max(
|
||||
...SUBMIT_CHOICES.map((choice, index) =>
|
||||
visibleWidth(`❯ ${index + 1}. ${choice}`)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function shouldUseWideSubmitLayout(
|
||||
width: number,
|
||||
actionColumnWidth: number
|
||||
): boolean {
|
||||
return (
|
||||
width >= UI_DIMENSIONS.submitWideMinWidth &&
|
||||
width - actionColumnWidth - 2 >= UI_DIMENSIONS.submitMinReviewWidth
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user