Skip to content
Source
Sign inSign up

pendingUserInput.ts

183 lines6.0 KB

1/** The question fields answer handling reads. Web and mobile question types both fit it. */
2export interface PendingUserInputQuestion {
3 readonly id: string;
4 readonly options: ReadonlyArray<{ readonly label: string; readonly value?: string | undefined }>;
5 readonly multiSelect?: boolean | undefined;
6 readonly allowCustomAnswer?: boolean | undefined;
7 readonly initialAnswer?: string | undefined;
8}
9​
10export interface PendingUserInputDraftAnswer {
11 readonly selectedOptionValues?: ReadonlyArray<string>;
12 readonly customAnswer?: string;
13 readonly attachmentCount?: number;
14 readonly attachmentsBlocked?: boolean;
16​
17export type PendingUserInputAnswer = string | ReadonlyArray<string>;
18​
19/** Providers may group questions into one request or issue several requests at once. */
20export function countPendingUserInputQuestions(
21 requests: ReadonlyArray<{ readonly questions: ReadonlyArray<PendingUserInputQuestion> }>,
22): number {
23 return requests.reduce((count, request) => count + request.questions.length, 0);
25​
26function normalizeDraftAnswer(value: string | undefined): string | null {
27 if (typeof value !== "string") return null;
28 const trimmed = value.trim();
29 return trimmed.length > 0 ? trimmed : null;
31​
32// Provider option values stay exact, including whitespace. Only options without a value match by label.
33function resolvePendingUserInputOptionValue(
34 question: PendingUserInputQuestion,
35 value: string,
36): string | null {
37 if (question.options.some((option) => option.value === value)) {
38 return value;
39 }
40​
41 const label = value.trim();
42 return label.length > 0 &&
43 question.options.some((option) => option.value === undefined && option.label.trim() === label)
44 ? label
45 : null;
47​
48/** The draft's selections that name an offered option, deduplicated, in selection order. */
49export function normalizeSelectedOptionValues(
50 question: PendingUserInputQuestion,
51 value: ReadonlyArray<string> | undefined,
52): ReadonlyArray<string> {
53 if (!Array.isArray(value)) {
54 return [];
55 }
56​
57 return Array.from(
58 new Set(
59 value
60 .map((entry) => resolvePendingUserInputOptionValue(question, entry))
61 .filter((entry): entry is string => entry !== null),
62 ),
63 );
65​
66/** The answer to submit for one question, or null while it is unanswered. */
67export function resolvePendingUserInputAnswer(
68 question: PendingUserInputQuestion,
69 draft: PendingUserInputDraftAnswer | undefined,
70): PendingUserInputAnswer | null {
71 if (draft?.attachmentsBlocked) return null;
72 const customAnswer =
73 question.allowCustomAnswer === false
74 ? null
75 : question.initialAnswer !== undefined
76 ? (draft?.customAnswer ?? null)
77 : normalizeDraftAnswer(draft?.customAnswer);
78 if (customAnswer !== null && customAnswer.length > 0) {
79 return customAnswer;
80 }
81​
82 const selectedOptionValues = normalizeSelectedOptionValues(question, draft?.selectedOptionValues);
83 if (question.multiSelect) {
84 return selectedOptionValues.length > 0
85 ? selectedOptionValues
86 : (customAnswer ??
87 (question.allowCustomAnswer !== false && (draft?.attachmentCount ?? 0) > 0 ? "" : null));
88 }
89 return (
90 selectedOptionValues[0] ??
91 customAnswer ??
92 (question.allowCustomAnswer !== false && (draft?.attachmentCount ?? 0) > 0 ? "" : null)
93 );
95​
96export function setPendingUserInputCustomAnswer(
97 question: PendingUserInputQuestion,
98 draft: PendingUserInputDraftAnswer | undefined,
99 customAnswer: string,
100): PendingUserInputDraftAnswer {
101 if (question.allowCustomAnswer === false) {
102 return draft ?? {};
103 }
104​
105 const selectedOptionValues =
106 question.initialAnswer !== undefined || customAnswer.trim().length > 0
107 ? undefined
108 : normalizeSelectedOptionValues(question, draft?.selectedOptionValues);
109 return {
110 customAnswer,
111 ...(selectedOptionValues && selectedOptionValues.length > 0 ? { selectedOptionValues } : {}),
112 };
114​
115export function isPendingUserInputOptionSelected(
116 question: PendingUserInputQuestion,
117 draft: PendingUserInputDraftAnswer | undefined,
118 optionValue: string,
119): boolean {
120 if (question.allowCustomAnswer !== false && normalizeDraftAnswer(draft?.customAnswer)) {
121 return false;
122 }
123​
124 const resolvedOptionValue = resolvePendingUserInputOptionValue(question, optionValue);
125 return (
126 resolvedOptionValue !== null &&
127 normalizeSelectedOptionValues(question, draft?.selectedOptionValues).includes(
128 resolvedOptionValue,
129 )
130 );
132​
133/** Toggles an offered option. An unknown option returns the same draft. */
134export function togglePendingUserInputOptionSelection(
135 question: PendingUserInputQuestion,
136 draft: PendingUserInputDraftAnswer | undefined,
137 optionValue: string,
138): PendingUserInputDraftAnswer {
139 const resolvedOptionValue = resolvePendingUserInputOptionValue(question, optionValue);
140 if (resolvedOptionValue === null) {
141 return draft ?? {};
142 }
143​
144 if (question.multiSelect) {
145 const selectedOptionValues = normalizeSelectedOptionValues(
146 question,
147 draft?.selectedOptionValues,
148 );
149 const nextSelectedOptionValues = selectedOptionValues.includes(resolvedOptionValue)
150 ? selectedOptionValues.filter((value) => value !== resolvedOptionValue)
151 : [...selectedOptionValues, resolvedOptionValue];
152​
153 return {
154 customAnswer: "",
155 ...(nextSelectedOptionValues.length > 0
156 ? { selectedOptionValues: nextSelectedOptionValues }
157 : {}),
158 };
159 }
160​
161 return {
162 customAnswer: "",
163 selectedOptionValues: [resolvedOptionValue],
164 };
166​
167/** Every question's answer keyed by question id, or null while any is unanswered. */
168export function buildPendingUserInputAnswers(
169 questions: ReadonlyArray<PendingUserInputQuestion>,
170 draftAnswers: Record<string, PendingUserInputDraftAnswer>,
171): Record<string, PendingUserInputAnswer> | null {
172 const entries: Array<[string, PendingUserInputAnswer]> = [];
173​
174 for (const question of questions) {
175 const answer = resolvePendingUserInputAnswer(question, draftAnswers[question.id]);
176 if (answer === null) {
177 return null;
178 }
179 entries.push([question.id, answer]);
180 }
181​
182 return Object.fromEntries(entries);