Skip to content
Source
Sign inSign up

pendingUserInput.ts

176 lines5.7 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​
19function normalizeDraftAnswer(value: string | undefined): string | null {
20 if (typeof value !== "string") return null;
21 const trimmed = value.trim();
22 return trimmed.length > 0 ? trimmed : null;
24​
25// Provider option values stay exact, including whitespace. Only options without a value match by label.
26function resolvePendingUserInputOptionValue(
27 question: PendingUserInputQuestion,
28 value: string,
29): string | null {
30 if (question.options.some((option) => option.value === value)) {
31 return value;
32 }
33​
34 const label = value.trim();
35 return label.length > 0 &&
36 question.options.some((option) => option.value === undefined && option.label.trim() === label)
37 ? label
38 : null;
40​
41/** The draft's selections that name an offered option, deduplicated, in selection order. */
42export function normalizeSelectedOptionValues(
43 question: PendingUserInputQuestion,
44 value: ReadonlyArray<string> | undefined,
45): ReadonlyArray<string> {
46 if (!Array.isArray(value)) {
47 return [];
48 }
49​
50 return Array.from(
51 new Set(
52 value
53 .map((entry) => resolvePendingUserInputOptionValue(question, entry))
54 .filter((entry): entry is string => entry !== null),
55 ),
56 );
58​
59/** The answer to submit for one question, or null while it is unanswered. */
60export function resolvePendingUserInputAnswer(
61 question: PendingUserInputQuestion,
62 draft: PendingUserInputDraftAnswer | undefined,
63): PendingUserInputAnswer | null {
64 if (draft?.attachmentsBlocked) return null;
65 const customAnswer =
66 question.allowCustomAnswer === false
67 ? null
68 : question.initialAnswer !== undefined
69 ? (draft?.customAnswer ?? null)
70 : normalizeDraftAnswer(draft?.customAnswer);
71 if (customAnswer !== null && customAnswer.length > 0) {
72 return customAnswer;
73 }
74​
75 const selectedOptionValues = normalizeSelectedOptionValues(question, draft?.selectedOptionValues);
76 if (question.multiSelect) {
77 return selectedOptionValues.length > 0
78 ? selectedOptionValues
79 : (customAnswer ??
80 (question.allowCustomAnswer !== false && (draft?.attachmentCount ?? 0) > 0 ? "" : null));
81 }
82 return (
83 selectedOptionValues[0] ??
84 customAnswer ??
85 (question.allowCustomAnswer !== false && (draft?.attachmentCount ?? 0) > 0 ? "" : null)
86 );
88​
89export function setPendingUserInputCustomAnswer(
90 question: PendingUserInputQuestion,
91 draft: PendingUserInputDraftAnswer | undefined,
92 customAnswer: string,
93): PendingUserInputDraftAnswer {
94 if (question.allowCustomAnswer === false) {
95 return draft ?? {};
96 }
97​
98 const selectedOptionValues =
99 question.initialAnswer !== undefined || customAnswer.trim().length > 0
100 ? undefined
101 : normalizeSelectedOptionValues(question, draft?.selectedOptionValues);
102 return {
103 customAnswer,
104 ...(selectedOptionValues && selectedOptionValues.length > 0 ? { selectedOptionValues } : {}),
105 };
107​
108export function isPendingUserInputOptionSelected(
109 question: PendingUserInputQuestion,
110 draft: PendingUserInputDraftAnswer | undefined,
111 optionValue: string,
112): boolean {
113 if (question.allowCustomAnswer !== false && normalizeDraftAnswer(draft?.customAnswer)) {
114 return false;
115 }
116​
117 const resolvedOptionValue = resolvePendingUserInputOptionValue(question, optionValue);
118 return (
119 resolvedOptionValue !== null &&
120 normalizeSelectedOptionValues(question, draft?.selectedOptionValues).includes(
121 resolvedOptionValue,
122 )
123 );
125​
126/** Toggles an offered option. An unknown option returns the same draft. */
127export function togglePendingUserInputOptionSelection(
128 question: PendingUserInputQuestion,
129 draft: PendingUserInputDraftAnswer | undefined,
130 optionValue: string,
131): PendingUserInputDraftAnswer {
132 const resolvedOptionValue = resolvePendingUserInputOptionValue(question, optionValue);
133 if (resolvedOptionValue === null) {
134 return draft ?? {};
135 }
136​
137 if (question.multiSelect) {
138 const selectedOptionValues = normalizeSelectedOptionValues(
139 question,
140 draft?.selectedOptionValues,
141 );
142 const nextSelectedOptionValues = selectedOptionValues.includes(resolvedOptionValue)
143 ? selectedOptionValues.filter((value) => value !== resolvedOptionValue)
144 : [...selectedOptionValues, resolvedOptionValue];
145​
146 return {
147 customAnswer: "",
148 ...(nextSelectedOptionValues.length > 0
149 ? { selectedOptionValues: nextSelectedOptionValues }
150 : {}),
151 };
152 }
153​
154 return {
155 customAnswer: "",
156 selectedOptionValues: [resolvedOptionValue],
157 };
159​
160/** Every question's answer keyed by question id, or null while any is unanswered. */
161export function buildPendingUserInputAnswers(
162 questions: ReadonlyArray<PendingUserInputQuestion>,
163 draftAnswers: Record<string, PendingUserInputDraftAnswer>,
164): Record<string, PendingUserInputAnswer> | null {
165 const entries: Array<[string, PendingUserInputAnswer]> = [];
166​
167 for (const question of questions) {
168 const answer = resolvePendingUserInputAnswer(question, draftAnswers[question.id]);
169 if (answer === null) {
170 return null;
171 }
172 entries.push([question.id, answer]);
173 }
174​
175 return Object.fromEntries(entries);