1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
|
import React, {SyntheticEvent} from "react";
import * as Sentry from "@sentry/react";
import RenderedQuestion from "../../components/Question";
import {RefMapType} from "./FormPage";
import ApiClient from "../../api/client";
import {Question, QuestionType, UnittestFailure} from "../../api/question";
import {checkScopes, OAuthScopes} from "../../api/auth";
export enum FormState {
INITIAL = "initial",
SENDING = "sending",
SENT = "sent",
VALIDATION_ERROR = "validation_error",
UNKNOWN_ERROR = "error",
}
/**
* Handle validation and submission of a form.
*
* @param event The submission event.
* @param formID The form ID.
* @param questions A list of :RenderedQuestion: elements.
* @param refMap A map of question ID to object refs.
* @param setState A consumer which marks the current state of the form.
* @param OAuthRef A reference to the OAuth button to scroll to if the user is not logged in.
* @param scopes The OAuth scopes required to submit the form.
*/
export default async function handleSubmit(
event: SyntheticEvent,
formID: string,
questions: RenderedQuestion[],
refMap: RefMapType,
setState: (state: string) => void,
OAuthRef: React.RefObject<HTMLDivElement>,
scopes: OAuthScopes[]
): Promise<void> {
try {
event.preventDefault();
if (scopes.length && !checkScopes(scopes)) {
// The form requires certain scopes, but the user is not logged in
if (!OAuthRef.current) {
Sentry.captureMessage("OAuthRef was not set, could not scroll to the button.");
} else {
OAuthRef.current.scrollIntoView({behavior: "smooth", block: "end"});
}
return;
}
if (!validate(questions, refMap)) {
return;
}
// FIXME: Save state while sending
// setState(FormState.SENDING);
await ApiClient.post(`forms/submit/${formID}`, {response: parseAnswers(questions, refMap)})
.then(() => setState(FormState.SENT))
.catch(error => {
if (!error.response) {
throw error;
}
switch (error.response.status) {
case 422:
// TODO: Re-enable this once we have better state management
// setState(FormState.VALIDATION_ERROR);
showUnitTestFailures(refMap, error.response.data);
break;
case 500:
default:
throw error;
}
});
} catch (e) {
// Send exception to sentry, and display an error page
Sentry.captureException(e);
console.error(e);
setState(FormState.UNKNOWN_ERROR);
}
}
/**
* Parse submission errors on unittests, and set up the environment for displaying the errors.
*/
function showUnitTestFailures(refMap: RefMapType, errors: UnittestFailure) {
for (const error of errors.test_results) {
const questionRef = refMap.get(error.question_id);
if (!questionRef?.current) {
throw new Error("Could not find question reference while verifying unittest failure.");
}
questionRef.current.setState({
valid: false,
unittestsFailed: true,
testFailure: error.return_code === 0,
error: error.result
});
}
}
/**
* Run client side validation.
*/
function validate(questions: RenderedQuestion[], refMap: RefMapType): boolean {
const invalidFieldIDs: number[] = [];
questions.forEach((prop, i) => {
const question: Question = prop.props.question;
if (!question.required) {
return;
}
// Add invalid fields to list
const questionRef = refMap.get(question.id);
if (questionRef && questionRef.current) {
questionRef.current.validateField();
if (!questionRef.current.realState.valid) {
invalidFieldIDs.push(i);
}
} else {
invalidFieldIDs.push(i);
}
});
if (invalidFieldIDs.length) {
const firstErrored = questions[invalidFieldIDs[0]];
if (firstErrored && firstErrored.props.scroll_ref) {
// If any element is already focused, unfocus it to avoid not scrolling behavior.
if (document.activeElement && document.activeElement instanceof HTMLElement) {
document.activeElement.blur();
}
firstErrored.props.scroll_ref?.current?.scrollIntoView({ behavior: "smooth", block: "center" });
if (firstErrored.props.focus_ref && firstErrored.props.focus_ref.current) {
firstErrored.props.focus_ref.current.focus({ preventScroll: true });
}
}
return false;
}
return true;
}
/**
* Parse user answers into a valid submission.
*/
function parseAnswers(questions: RenderedQuestion[], refMap: RefMapType): { [key: string]: unknown } {
const answers: { [key: string]: unknown } = {};
questions.forEach(prop => {
const question: Question = prop.props.question;
const questionRef = refMap.get(question.id);
if (!questionRef?.current) {
throw new Error("Could not find a reference to the current question while submitting.");
}
// Parse input from each question
switch (question.type) {
case QuestionType.Section:
answers[question.id] = false;
break;
case QuestionType.Checkbox: {
const result: {[key: string]: boolean} = {};
const selected = questionRef.current.realState.value;
if (!(selected instanceof Map)) throw new Error("Could not parse checkbox answers.");
selected.forEach((value, key) => {
// Remove the index from the key and set its value
result[key.slice(6)] = value;
});
answers[question.id] = result;
break;
}
default:
answers[question.id] = questionRef.current.realState.value;
}
});
return answers;
}
|