import type { UserInputQuestion } from "@t3tools/contracts"; export interface PendingUserInputDraftAnswer { selectedOptionValues?: string[]; customAnswer?: string; attachmentCount?: number; attachmentsBlocked?: boolean;} export interface PendingUserInputProgress { questionIndex: number; activeQuestion: UserInputQuestion | null; activeDraft: PendingUserInputDraftAnswer | undefined; selectedOptionValues: string[]; customAnswer: string; resolvedAnswer: string | string[] | null; usingCustomAnswer: boolean; answeredQuestionCount: number; isLastQuestion: boolean; isComplete: boolean; canAdvance: boolean;} function normalizeDraftAnswer(value: string | undefined): string | null { if (typeof value !== "string") { return null; } const trimmed = value.trim(); return trimmed.length > 0 ? trimmed : null;} function normalizeSelectedOptionValues(value: string[] | undefined): string[] { if (!Array.isArray(value)) { return []; } // Provider option IDs must stay unchanged, including whitespace. return Array.from(new Set(value.filter((entry) => typeof entry === "string")));} export function resolvePendingUserInputAnswer( question: UserInputQuestion, draft: PendingUserInputDraftAnswer | undefined,): string | string[] | null { if (draft?.attachmentsBlocked) return null; const customAnswer = question.allowCustomAnswer === false ? null : normalizeDraftAnswer(draft?.customAnswer); if (customAnswer) { return customAnswer; } const selectedOptionValues = normalizeSelectedOptionValues(draft?.selectedOptionValues).filter( (value) => question.options.some((option) => (option.value ?? option.label) === value), ); if (question.multiSelect) { return selectedOptionValues.length > 0 ? selectedOptionValues : question.allowCustomAnswer !== false && (draft?.attachmentCount ?? 0) > 0 ? "" : null; } return ( selectedOptionValues[0] ?? (question.allowCustomAnswer !== false && (draft?.attachmentCount ?? 0) > 0 ? "" : null) );} export function setPendingUserInputCustomAnswer( draft: PendingUserInputDraftAnswer | undefined, customAnswer: string,): PendingUserInputDraftAnswer { const selectedOptionValues = customAnswer.trim().length > 0 ? undefined : normalizeSelectedOptionValues(draft?.selectedOptionValues); return { customAnswer, ...(selectedOptionValues && selectedOptionValues.length > 0 ? { selectedOptionValues } : {}), };} export function togglePendingUserInputOptionSelection( question: UserInputQuestion, draft: PendingUserInputDraftAnswer | undefined, optionValue: string,): PendingUserInputDraftAnswer { if (question.multiSelect) { const selectedOptionValues = normalizeSelectedOptionValues(draft?.selectedOptionValues); const nextSelectedOptionValues = selectedOptionValues.includes(optionValue) ? selectedOptionValues.filter((value) => value !== optionValue) : [...selectedOptionValues, optionValue]; return { customAnswer: "", ...(nextSelectedOptionValues.length > 0 ? { selectedOptionValues: nextSelectedOptionValues } : {}), }; } return { customAnswer: "", selectedOptionValues: [optionValue], };} export function buildPendingUserInputAnswers( questions: ReadonlyArray<UserInputQuestion>, draftAnswers: Record<string, PendingUserInputDraftAnswer>,): Record<string, string | string[]> | null { const answers: Record<string, string | string[]> = {}; for (const question of questions) { const answer = resolvePendingUserInputAnswer(question, draftAnswers[question.id]); if (answer === null) { return null; } answers[question.id] = answer; } return answers;} export function countAnsweredPendingUserInputQuestions( questions: ReadonlyArray<UserInputQuestion>, draftAnswers: Record<string, PendingUserInputDraftAnswer>,): number { return questions.reduce((count, question) => { return resolvePendingUserInputAnswer(question, draftAnswers[question.id]) !== null ? count + 1 : count; }, 0);} export function derivePendingUserInputProgress( questions: ReadonlyArray<UserInputQuestion>, draftAnswers: Record<string, PendingUserInputDraftAnswer>, questionIndex: number,): PendingUserInputProgress { const normalizedQuestionIndex = questions.length === 0 ? 0 : Math.max(0, Math.min(questionIndex, questions.length - 1)); const activeQuestion = questions[normalizedQuestionIndex] ?? null; const activeDraft = activeQuestion ? draftAnswers[activeQuestion.id] : undefined; const resolvedAnswer = activeQuestion ? resolvePendingUserInputAnswer(activeQuestion, activeDraft) : null; const customAnswer = activeQuestion?.allowCustomAnswer === false ? "" : (activeDraft?.customAnswer ?? ""); const answeredQuestionCount = countAnsweredPendingUserInputQuestions(questions, draftAnswers); const isLastQuestion = questions.length === 0 ? true : normalizedQuestionIndex >= questions.length - 1; return { questionIndex: normalizedQuestionIndex, activeQuestion, activeDraft, selectedOptionValues: normalizeSelectedOptionValues(activeDraft?.selectedOptionValues), customAnswer, resolvedAnswer, usingCustomAnswer: customAnswer.trim().length > 0, answeredQuestionCount, isLastQuestion, isComplete: buildPendingUserInputAnswers(questions, draftAnswers) !== null, canAdvance: resolvedAnswer !== null, };}
import type { UserInputQuestion } from "@t3tools/contracts";
export interface PendingUserInputDraftAnswer {
selectedOptionValues?: string[];
customAnswer?: string;
attachmentCount?: number;
attachmentsBlocked?: boolean;
}
export interface PendingUserInputProgress {
questionIndex: number;
activeQuestion: UserInputQuestion | null;
activeDraft: PendingUserInputDraftAnswer | undefined;
selectedOptionValues: string[];
customAnswer: string;
resolvedAnswer: string | string[] | null;
usingCustomAnswer: boolean;
answeredQuestionCount: number;
isLastQuestion: boolean;
isComplete: boolean;
canAdvance: boolean;
}
function normalizeDraftAnswer(value: string | undefined): string | null {
if (typeof value !== "string") {
return null;
}
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : null;
}
function normalizeSelectedOptionValues(value: string[] | undefined): string[] {
if (!Array.isArray(value)) {
return [];
}
// Provider option IDs must stay unchanged, including whitespace.
return Array.from(new Set(value.filter((entry) => typeof entry === "string")));
}
export function resolvePendingUserInputAnswer(
question: UserInputQuestion,
draft: PendingUserInputDraftAnswer | undefined,
): string | string[] | null {
if (draft?.attachmentsBlocked) return null;
const customAnswer =
question.allowCustomAnswer === false ? null : normalizeDraftAnswer(draft?.customAnswer);
if (customAnswer) {
return customAnswer;
}
const selectedOptionValues = normalizeSelectedOptionValues(draft?.selectedOptionValues).filter(
(value) => question.options.some((option) => (option.value ?? option.label) === value),
);
if (question.multiSelect) {
return selectedOptionValues.length > 0
? selectedOptionValues
: question.allowCustomAnswer !== false && (draft?.attachmentCount ?? 0) > 0
? ""
: null;
}
return (
selectedOptionValues[0] ??
(question.allowCustomAnswer !== false && (draft?.attachmentCount ?? 0) > 0 ? "" : null)
);
}
export function setPendingUserInputCustomAnswer(
draft: PendingUserInputDraftAnswer | undefined,
customAnswer: string,
): PendingUserInputDraftAnswer {
const selectedOptionValues =
customAnswer.trim().length > 0
? undefined
: normalizeSelectedOptionValues(draft?.selectedOptionValues);
return {
customAnswer,
...(selectedOptionValues && selectedOptionValues.length > 0 ? { selectedOptionValues } : {}),
};
}
export function togglePendingUserInputOptionSelection(
question: UserInputQuestion,
draft: PendingUserInputDraftAnswer | undefined,
optionValue: string,
): PendingUserInputDraftAnswer {
if (question.multiSelect) {
const selectedOptionValues = normalizeSelectedOptionValues(draft?.selectedOptionValues);
const nextSelectedOptionValues = selectedOptionValues.includes(optionValue)
? selectedOptionValues.filter((value) => value !== optionValue)
: [...selectedOptionValues, optionValue];
return {
customAnswer: "",
...(nextSelectedOptionValues.length > 0
? { selectedOptionValues: nextSelectedOptionValues }
: {}),
};
}
return {
customAnswer: "",
selectedOptionValues: [optionValue],
};
}
export function buildPendingUserInputAnswers(
questions: ReadonlyArray<UserInputQuestion>,
draftAnswers: Record<string, PendingUserInputDraftAnswer>,
): Record<string, string | string[]> | null {
const answers: Record<string, string | string[]> = {};
for (const question of questions) {
const answer = resolvePendingUserInputAnswer(question, draftAnswers[question.id]);
if (answer === null) {
return null;
}
answers[question.id] = answer;
}
return answers;
}
export function countAnsweredPendingUserInputQuestions(
questions: ReadonlyArray<UserInputQuestion>,
draftAnswers: Record<string, PendingUserInputDraftAnswer>,
): number {
return questions.reduce((count, question) => {
return resolvePendingUserInputAnswer(question, draftAnswers[question.id]) !== null
? count + 1
: count;
}, 0);
}
export function derivePendingUserInputProgress(
questions: ReadonlyArray<UserInputQuestion>,
draftAnswers: Record<string, PendingUserInputDraftAnswer>,
questionIndex: number,
): PendingUserInputProgress {
const normalizedQuestionIndex =
questions.length === 0 ? 0 : Math.max(0, Math.min(questionIndex, questions.length - 1));
const activeQuestion = questions[normalizedQuestionIndex] ?? null;
const activeDraft = activeQuestion ? draftAnswers[activeQuestion.id] : undefined;
const resolvedAnswer = activeQuestion
? resolvePendingUserInputAnswer(activeQuestion, activeDraft)
: null;
const customAnswer =
activeQuestion?.allowCustomAnswer === false ? "" : (activeDraft?.customAnswer ?? "");
const answeredQuestionCount = countAnsweredPendingUserInputQuestions(questions, draftAnswers);
const isLastQuestion =
questions.length === 0 ? true : normalizedQuestionIndex >= questions.length - 1;
return {
questionIndex: normalizedQuestionIndex,
activeQuestion,
activeDraft,
selectedOptionValues: normalizeSelectedOptionValues(activeDraft?.selectedOptionValues),
customAnswer,
resolvedAnswer,
usingCustomAnswer: customAnswer.trim().length > 0,
answeredQuestionCount,
isLastQuestion,
isComplete: buildPendingUserInputAnswers(questions, draftAnswers) !== null,
canAdvance: resolvedAnswer !== null,
};
}