Skip to content

Commit 6a1bc2e

Browse files
committed
fix: allow for country code on phone number application
1 parent f4cff18 commit 6a1bc2e

16 files changed

Lines changed: 1260 additions & 290 deletions

‎client/portal/package.json‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,8 @@
1010
"format": "prettier --write \"{src,branding}/**/*.{ts,tsx,css,json}\"",
1111
"format:check": "prettier --check \"{src,branding}/**/*.{ts,tsx,css,json}\"",
1212
"preview": "vite preview",
13-
"test:reviews": "node --test scripts/review-regressions.test.mjs"
13+
"test:reviews": "node --test scripts/review-regressions.test.mjs",
14+
"test:applications": "node --test scripts/application-regressions.test.mjs"
1415
},
1516
"dependencies": {
1617
"@hookform/resolvers": "^5.2.2",
Lines changed: 343 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,343 @@
1+
import assert from "node:assert/strict";
2+
import { createRequire } from "node:module";
3+
import { afterEach, test } from "node:test";
4+
import { fileURLToPath } from "node:url";
5+
6+
import { build } from "esbuild";
7+
8+
const project = fileURLToPath(new URL("../", import.meta.url));
9+
const require = createRequire(new URL("../package.json", import.meta.url));
10+
const bundle = await build({
11+
absWorkingDir: project,
12+
stdin: {
13+
contents: `
14+
export * from './src/pages/hacker/apply/draft.ts';
15+
export { updateMyApplication } from './src/pages/hacker/apply/api.ts';
16+
export { buildApplicationResolver } from './src/pages/hacker/apply/validations.ts';
17+
export { buildZodSchema, getObsoleteOptions, isFieldVisible } from './src/shared/lib/schema-utils.ts';
18+
export * from './src/shared/lib/phone-input.ts';
19+
`,
20+
resolveDir: project,
21+
loader: "ts",
22+
},
23+
platform: "node",
24+
format: "cjs",
25+
bundle: true,
26+
write: false,
27+
});
28+
const module = { exports: {} };
29+
new Function("module", "exports", "require", bundle.outputFiles[0].text)(
30+
module,
31+
module.exports,
32+
require,
33+
);
34+
const {
35+
buildZodSchema,
36+
buildApplicationResolver,
37+
getObsoleteOptions,
38+
isFieldVisible,
39+
reconcileDraftValues,
40+
reconcileDraftStep,
41+
draftResponses,
42+
createDraftSaver,
43+
updateMyApplication,
44+
} = module.exports;
45+
const originalFetch = globalThis.fetch;
46+
const { splitPhoneNumber, formatPhoneNational, joinPhoneNumber } =
47+
module.exports;
48+
afterEach(() => {
49+
globalThis.fetch = originalFetch;
50+
});
51+
52+
const field = (extra = {}) => ({
53+
id: "level_of_study",
54+
type: "select",
55+
label: "Level of Study",
56+
required: true,
57+
section: "education",
58+
display_order: 0,
59+
options: ["Undergraduate", "Graduate"],
60+
...extra,
61+
});
62+
const schema = [
63+
field(),
64+
field({
65+
id: "first_name",
66+
type: "text",
67+
section: "personal",
68+
options: undefined,
69+
}),
70+
];
71+
const answers = { level_of_study: "Senior", first_name: "Ada" };
72+
73+
test("outdated draft choices allow Continue but fail final validation until corrected", async () => {
74+
const resolver = buildApplicationResolver(schema);
75+
const navigation = await resolver(
76+
answers,
77+
{},
78+
{ fields: {}, shouldUseNativeValidation: false },
79+
);
80+
assert.deepEqual(navigation.errors, {});
81+
const validation = buildZodSchema(schema, answers).safeParse(answers);
82+
assert.equal(validation.success, false);
83+
assert.deepEqual(
84+
validation.error.issues.map((issue) => issue.path[0]),
85+
["level_of_study"],
86+
);
87+
assert.equal(
88+
buildZodSchema(schema).safeParse({
89+
...answers,
90+
level_of_study: "Undergraduate",
91+
}).success,
92+
true,
93+
);
94+
const missing = await resolver(
95+
{ ...answers, first_name: "" },
96+
{},
97+
{ fields: {}, shouldUseNativeValidation: false },
98+
);
99+
assert.ok(missing.errors.first_name, "required fields still gate navigation");
100+
});
101+
102+
test("membership is exact, optional blanks pass, and empty option lists remain unrestricted", () => {
103+
for (const [f, value, valid] of [
104+
[field(), "undergraduate", false],
105+
[field(), " Undergraduate ", false],
106+
[field(), 123, false],
107+
[field(), "", false],
108+
[field({ required: false }), "", true],
109+
[field({ required: false }), " ", true],
110+
[field({ options: [] }), "Senior", true],
111+
[field({ type: "multi_select" }), ["Undergraduate", "Senior"], false],
112+
[field({ type: "multi_select" }), ["Undergraduate"], true],
113+
[field({ type: "multi_select" }), [123], false],
114+
[field({ type: "multi_select", required: false }), [], true],
115+
[field({ type: "multi_select", options: [] }), ["Senior"], true],
116+
]) {
117+
assert.equal(
118+
buildZodSchema([f]).safeParse({ [f.id]: value }).success,
119+
valid,
120+
JSON.stringify({ f, value }),
121+
);
122+
}
123+
});
124+
125+
test("obsolete choices include hidden questions and can be removed without losing valid selections", () => {
126+
const f = field({ type: "multi_select", validation: { show_if: "travel" } });
127+
const values = {
128+
travel: false,
129+
level_of_study: ["Senior", "Graduate", "Senior"],
130+
};
131+
assert.equal(isFieldVisible(f, values), false);
132+
const obsolete = getObsoleteOptions(f, values.level_of_study);
133+
assert.deepEqual(obsolete, ["Senior"]);
134+
const corrected = values.level_of_study.filter(
135+
(choice) => !obsolete.includes(choice),
136+
);
137+
assert.deepEqual(corrected, ["Graduate"]);
138+
assert.equal(buildZodSchema([f], values).safeParse(values).success, false);
139+
assert.equal(
140+
buildZodSchema([f], values).safeParse({
141+
...values,
142+
level_of_study: corrected,
143+
}).success,
144+
true,
145+
);
146+
assert.deepEqual(
147+
getObsoleteOptions(field({ type: "text" }), "Free text"),
148+
[],
149+
);
150+
});
151+
152+
test("schema reconciliation preserves answers and the active section", () => {
153+
const additional = field({
154+
id: "new_question",
155+
type: "text",
156+
section: "new",
157+
section_order: 0,
158+
});
159+
const expanded = [additional, ...schema];
160+
const values = reconcileDraftValues(answers, expanded);
161+
assert.deepEqual(values, { ...answers, new_question: "" });
162+
assert.deepEqual(answers, { level_of_study: "Senior", first_name: "Ada" });
163+
assert.equal(reconcileDraftStep(0, schema, expanded), 1);
164+
assert.equal(
165+
reconcileDraftStep(2, schema, expanded),
166+
3,
167+
"review stays selected",
168+
);
169+
assert.equal(
170+
reconcileDraftStep(3, expanded, schema),
171+
2,
172+
"removed section clamps safely",
173+
);
174+
assert.deepEqual(
175+
draftResponses(values, schema),
176+
answers,
177+
"removed questions are excluded from requests",
178+
);
179+
});
180+
181+
test("PATCH errors preserve the offending question ids", async () => {
182+
globalThis.fetch = async () =>
183+
Response.json(
184+
{ error: "validation errors", fields: ["level_of_study"] },
185+
{ status: 400 },
186+
);
187+
assert.deepEqual(await updateMyApplication({ responses: answers }), {
188+
status: 400,
189+
error: "validation errors",
190+
fields: ["level_of_study"],
191+
});
192+
});
193+
194+
test("serialized saves preserve edits made during a request and use refreshed questions for queued saves", async () => {
195+
let state = { values: { ...answers }, schema, revision: 0 };
196+
const writes = [];
197+
const completions = [];
198+
let release;
199+
globalThis.fetch = async (_url, options) => {
200+
const payload = JSON.parse(options.body);
201+
writes.push(payload);
202+
if (writes.length === 1)
203+
await new Promise((resolve) => {
204+
release = resolve;
205+
});
206+
return Response.json({
207+
data: {
208+
id: "draft",
209+
responses: payload.responses,
210+
application_schema: [
211+
...schema,
212+
field({ id: "new_question", type: "text" }),
213+
],
214+
},
215+
});
216+
};
217+
const save = createDraftSaver({
218+
read: () => state,
219+
request: updateMyApplication,
220+
onStart() {},
221+
onSaved(app) {
222+
state = {
223+
...state,
224+
schema: app.application_schema,
225+
values: reconcileDraftValues(state.values, app.application_schema),
226+
};
227+
},
228+
async onFailure() {
229+
assert.fail("unexpected failure");
230+
},
231+
onFinish(result) {
232+
completions.push(result);
233+
},
234+
});
235+
const first = save();
236+
await new Promise((resolve) => setImmediate(resolve));
237+
state.values.first_name = "New edit";
238+
state.revision++;
239+
const second = save();
240+
assert.equal(writes.length, 1, "no concurrent writes");
241+
release();
242+
await Promise.all([first, second]);
243+
assert.equal(writes[0].responses.first_name, "Ada");
244+
assert.equal(writes[1].responses.first_name, "New edit");
245+
assert.equal(writes[1].responses.new_question, "");
246+
assert.equal(state.values.level_of_study, "Senior");
247+
assert.equal(
248+
completions[0].current,
249+
false,
250+
"old response cannot report current edits saved",
251+
);
252+
assert.equal(completions[1].current, true);
253+
});
254+
255+
test("a failed save leaves values intact and a later retry succeeds", async () => {
256+
const state = { values: { ...answers }, schema, revision: 0 };
257+
let offline = true;
258+
let failure;
259+
globalThis.fetch = async () => {
260+
if (offline) throw new Error("Offline");
261+
return Response.json({
262+
data: { id: "draft", responses: answers, application_schema: schema },
263+
});
264+
};
265+
const save = createDraftSaver({
266+
read: () => state,
267+
request: updateMyApplication,
268+
onStart() {},
269+
onSaved() {},
270+
async onFailure(res) {
271+
failure = res;
272+
},
273+
onFinish() {},
274+
});
275+
assert.equal((await save()).response.status, 500);
276+
assert.equal(failure.error, "Offline");
277+
assert.deepEqual(state.values, answers);
278+
offline = false;
279+
assert.equal((await save()).response.status, 200);
280+
});
281+
282+
test("phone validation accepts editable country codes and preserves existing US numbers", () => {
283+
const phone = field({ id: "phone", type: "phone" });
284+
for (const value of [
285+
"+12025551234",
286+
"+442079460958",
287+
"+919876543210",
288+
"+61293744000",
289+
]) {
290+
assert.equal(
291+
buildZodSchema([phone]).safeParse({ phone: value }).success,
292+
true,
293+
value,
294+
);
295+
}
296+
for (const value of [
297+
"2025551234",
298+
"+",
299+
"+0123456789",
300+
"+12025551234123456",
301+
"+1202abc1234",
302+
]) {
303+
assert.equal(
304+
buildZodSchema([phone]).safeParse({ phone: value }).success,
305+
false,
306+
value,
307+
);
308+
}
309+
assert.equal(
310+
buildZodSchema([{ ...phone, required: false }]).safeParse({ phone: "" })
311+
.success,
312+
true,
313+
);
314+
});
315+
316+
test("phone entry adds the plus automatically and restores the US mask", () => {
317+
const parts = { countryCode: "1", national: "2025551234" };
318+
assert.equal(formatPhoneNational(parts), "(202) 555-1234");
319+
assert.equal(joinPhoneNumber(parts), "+12025551234");
320+
assert.deepEqual(splitPhoneNumber("+1 (202) 555-1234"), parts);
321+
assert.deepEqual(splitPhoneNumber(""), { countryCode: "1", national: "" });
322+
assert.equal(joinPhoneNumber({ countryCode: "44", national: "" }), "");
323+
assert.equal(
324+
joinPhoneNumber({ countryCode: "", national: "2025551234" }),
325+
"2025551234",
326+
);
327+
});
328+
329+
test("international phone numbers round-trip without lost digits or country codes", () => {
330+
for (const value of [
331+
"+442079460958",
332+
"+919876543210",
333+
"+61293744000",
334+
"+971501234567",
335+
"+74951234567",
336+
]) {
337+
const parts = splitPhoneNumber(value);
338+
assert.equal(joinPhoneNumber(parts), value);
339+
assert.equal(formatPhoneNational(parts).replace(/\D/g, ""), parts.national);
340+
}
341+
const longer = { countryCode: "49", national: "1234567890123" };
342+
assert.equal(formatPhoneNational(longer).replace(/\D/g, ""), longer.national);
343+
});

‎client/portal/src/components/IncompleteFormAlert.tsx‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ interface IncompleteFormAlertProps {
1111
*/
1212
onJumpToSection?: (sectionId: string) => void;
1313
className?: string;
14+
description?: string;
1415
}
1516

1617
/**
@@ -22,6 +23,7 @@ export function IncompleteFormAlert({
2223
sections,
2324
onJumpToSection,
2425
className,
26+
description = "Answer the questions below, then submit again.",
2527
}: IncompleteFormAlertProps) {
2628
if (sections.length === 0) return null;
2729

@@ -50,9 +52,7 @@ export function IncompleteFormAlert({
5052
<div className="min-w-0 flex-1 space-y-3">
5153
<div className="space-y-1">
5254
<p className="text-sm font-normal text-destructive">{title}</p>
53-
<p className="text-xs font-light text-[#8A8A8A]">
54-
Answer the questions below, then submit again.
55-
</p>
55+
<p className="text-xs font-light text-[#8A8A8A]">{description}</p>
5656
</div>
5757

5858
<ul className="space-y-3">

‎client/portal/src/pages/hacker/apply/api.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ export async function updateMyApplication(
4747
);
4848

4949
if (res.status !== 200) {
50-
return { status: res.status, error: res.error };
50+
return { status: res.status, error: res.error, fields: res.fields };
5151
}
5252

5353
const application = unwrapPatchedApplication(res.data);

0 commit comments

Comments
 (0)