From ec30598e04ff123ddbf476f30641bb44a8b8c29b Mon Sep 17 00:00:00 2001 From: Hamza Mostafa Date: Sat, 24 Aug 2024 13:55:31 -0400 Subject: [PATCH 1/6] add phone number field --- .../app/routes/_profile.profile.general.tsx | 11 +++++++++++ .../20240824172428_add_phone_number_field.ts | 12 ++++++++++++ packages/db/src/scripts/seed.ts | 1 + packages/types/src/domain/student.ts | 6 ++++++ 4 files changed, 30 insertions(+) create mode 100644 packages/db/src/migrations/20240824172428_add_phone_number_field.ts diff --git a/apps/member-profile/app/routes/_profile.profile.general.tsx b/apps/member-profile/app/routes/_profile.profile.general.tsx index b5631883..1ff62c98 100644 --- a/apps/member-profile/app/routes/_profile.profile.general.tsx +++ b/apps/member-profile/app/routes/_profile.profile.general.tsx @@ -53,6 +53,7 @@ export async function loader({ request }: LoaderFunctionArgs) { 'headline', 'lastName', 'preferredName', + 'phoneNumber', ]) .executeTakeFirstOrThrow(); @@ -73,6 +74,7 @@ const UpdateGeneralInformation = Student.pick({ headline: true, lastName: true, preferredName: true, + phoneNumber: true, }).extend({ currentLocation: Student.shape.currentLocation.unwrap(), currentLocationLatitude: Student.shape.currentLocationLatitude.unwrap(), @@ -174,6 +176,15 @@ export default function UpdateGeneralInformationSection() { longitudeName={keys.currentLocationLongitude} /> + + Save diff --git a/packages/db/src/migrations/20240824172428_add_phone_number_field.ts b/packages/db/src/migrations/20240824172428_add_phone_number_field.ts new file mode 100644 index 00000000..ad070f72 --- /dev/null +++ b/packages/db/src/migrations/20240824172428_add_phone_number_field.ts @@ -0,0 +1,12 @@ +import { type Kysely } from 'kysely'; + +export async function up(db: Kysely) { + await db.schema + .alterTable('students') + .addColumn('phone_number', 'integer') + .execute(); +} + +export async function down(db: Kysely) { + await db.schema.alterTable('students').dropColumn('phone_number').execute(); +} diff --git a/packages/db/src/scripts/seed.ts b/packages/db/src/scripts/seed.ts index 42ed57f0..7c324043 100644 --- a/packages/db/src/scripts/seed.ts +++ b/packages/db/src/scripts/seed.ts @@ -125,6 +125,7 @@ async function seed(trx: Transaction) { otherDemographics: [], race: [], schoolId: schoolId1, + phoneNumber: 1234567890, }, ]) .execute(); diff --git a/packages/types/src/domain/student.ts b/packages/types/src/domain/student.ts index 95d43b6b..257a41fa 100644 --- a/packages/types/src/domain/student.ts +++ b/packages/types/src/domain/student.ts @@ -134,6 +134,12 @@ export const Student = Entity.merge(StudentSocialLinks) joinedMemberDirectoryAt: z.coerce.date().nullable(), joinedSlackAt: z.coerce.date().optional(), lastName: z.string().trim().min(1), + phoneNumber: z + .number() + .int() + .gte(1000000000) // 10-digit numbers start from 1000000000 + .lte(9999999999) // 10-digit numbers end at 9999999999 + .optional(), /** * Enum that represents all of the accepted majors from the ColorStack From 7e4164f028c3c67da14428a1cab45ab586c4fa56 Mon Sep 17 00:00:00 2001 From: Hamza Mostafa Date: Sat, 24 Aug 2024 14:34:56 -0400 Subject: [PATCH 2/6] fix bug with phone number --- .../app/routes/_profile.profile.general.tsx | 3 ++- .../migrations/20240824172428_add_phone_number_field.ts | 2 +- packages/db/src/scripts/seed.ts | 1 - packages/types/src/domain/student.ts | 8 ++++---- packages/ui/src/components/form.tsx | 4 +++- 5 files changed, 10 insertions(+), 8 deletions(-) diff --git a/apps/member-profile/app/routes/_profile.profile.general.tsx b/apps/member-profile/app/routes/_profile.profile.general.tsx index 1ff62c98..092f4eed 100644 --- a/apps/member-profile/app/routes/_profile.profile.general.tsx +++ b/apps/member-profile/app/routes/_profile.profile.general.tsx @@ -177,12 +177,13 @@ export default function UpdateGeneralInformationSection() { /> diff --git a/packages/db/src/migrations/20240824172428_add_phone_number_field.ts b/packages/db/src/migrations/20240824172428_add_phone_number_field.ts index ad070f72..cb167393 100644 --- a/packages/db/src/migrations/20240824172428_add_phone_number_field.ts +++ b/packages/db/src/migrations/20240824172428_add_phone_number_field.ts @@ -3,7 +3,7 @@ import { type Kysely } from 'kysely'; export async function up(db: Kysely) { await db.schema .alterTable('students') - .addColumn('phone_number', 'integer') + .addColumn('phone_number', 'text') .execute(); } diff --git a/packages/db/src/scripts/seed.ts b/packages/db/src/scripts/seed.ts index 7c324043..42ed57f0 100644 --- a/packages/db/src/scripts/seed.ts +++ b/packages/db/src/scripts/seed.ts @@ -125,7 +125,6 @@ async function seed(trx: Transaction) { otherDemographics: [], race: [], schoolId: schoolId1, - phoneNumber: 1234567890, }, ]) .execute(); diff --git a/packages/types/src/domain/student.ts b/packages/types/src/domain/student.ts index 257a41fa..aa15a185 100644 --- a/packages/types/src/domain/student.ts +++ b/packages/types/src/domain/student.ts @@ -135,10 +135,10 @@ export const Student = Entity.merge(StudentSocialLinks) joinedSlackAt: z.coerce.date().optional(), lastName: z.string().trim().min(1), phoneNumber: z - .number() - .int() - .gte(1000000000) // 10-digit numbers start from 1000000000 - .lte(9999999999) // 10-digit numbers end at 9999999999 + .string() + .trim() + .length(10, 'Phone Number must be a 10-digit number') + .regex(/^\d+$/, 'Phone Number must contain only digits') .optional(), /** diff --git a/packages/ui/src/components/form.tsx b/packages/ui/src/components/form.tsx index 41dbfbc9..008c2b71 100644 --- a/packages/ui/src/components/form.tsx +++ b/packages/ui/src/components/form.tsx @@ -71,7 +71,7 @@ Form.Field = function FormField({ type InputFieldProps = FieldProps & Pick & - Pick; + Pick; export function InputField({ defaultValue, @@ -82,6 +82,7 @@ export function InputField({ name, placeholder, required, + type = 'text', }: InputFieldProps) { return ( ); From b0018ebb4a4381f91825c026e3a88cff585a3678 Mon Sep 17 00:00:00 2001 From: Hamza Mostafa Date: Mon, 2 Sep 2024 07:09:36 -0400 Subject: [PATCH 3/6] address comments --- apps/member-profile/app/routes/_profile.profile.general.tsx | 3 +-- packages/ui/src/components/form.tsx | 4 +--- 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/apps/member-profile/app/routes/_profile.profile.general.tsx b/apps/member-profile/app/routes/_profile.profile.general.tsx index 092f4eed..71f72720 100644 --- a/apps/member-profile/app/routes/_profile.profile.general.tsx +++ b/apps/member-profile/app/routes/_profile.profile.general.tsx @@ -182,8 +182,7 @@ export default function UpdateGeneralInformationSection() { error={errors.phoneNumber} label="Phone Number" name={keys.phoneNumber} - placeholder="1234567890" - type="number" + placeholder="5551234567" /> diff --git a/packages/ui/src/components/form.tsx b/packages/ui/src/components/form.tsx index 008c2b71..41dbfbc9 100644 --- a/packages/ui/src/components/form.tsx +++ b/packages/ui/src/components/form.tsx @@ -71,7 +71,7 @@ Form.Field = function FormField({ type InputFieldProps = FieldProps & Pick & - Pick; + Pick; export function InputField({ defaultValue, @@ -82,7 +82,6 @@ export function InputField({ name, placeholder, required, - type = 'text', }: InputFieldProps) { return ( ); From 60f6a5c5db49addac9020588b79eeee022d6ea68 Mon Sep 17 00:00:00 2001 From: Hamza Mostafa Date: Fri, 6 Sep 2024 12:43:49 -0700 Subject: [PATCH 4/6] address comments --- .../app/routes/_profile.profile.general.tsx | 6 +-- .../app/shared/components/profile.general.tsx | 43 ++++++++++++++++++- packages/types/src/domain/student.ts | 6 ++- packages/ui/src/components/form.tsx | 13 +++++- packages/ui/src/components/input.tsx | 3 +- 5 files changed, 62 insertions(+), 9 deletions(-) diff --git a/apps/member-profile/app/routes/_profile.profile.general.tsx b/apps/member-profile/app/routes/_profile.profile.general.tsx index 71f72720..2d70907e 100644 --- a/apps/member-profile/app/routes/_profile.profile.general.tsx +++ b/apps/member-profile/app/routes/_profile.profile.general.tsx @@ -28,6 +28,7 @@ import { } from '@/shared/components/profile'; import { CurrentLocationField, + PhoneNumberField, PreferredNameField, } from '@/shared/components/profile.general'; import { getMember } from '@/shared/queries/index'; @@ -176,13 +177,10 @@ export default function UpdateGeneralInformationSection() { longitudeName={keys.currentLocationLongitude} /> - diff --git a/apps/member-profile/app/shared/components/profile.general.tsx b/apps/member-profile/app/shared/components/profile.general.tsx index 482e30c4..575d4a3c 100644 --- a/apps/member-profile/app/shared/components/profile.general.tsx +++ b/apps/member-profile/app/shared/components/profile.general.tsx @@ -1,7 +1,7 @@ import { useState } from 'react'; import { CityCombobox, type CityComboboxProps } from '@oyster/core/location/ui'; -import { type FieldProps, Form, Input, Text } from '@oyster/ui'; +import { type FieldProps, Form, Input, InputField, Text } from '@oyster/ui'; export function CurrentLocationField({ defaultValue, @@ -62,3 +62,44 @@ export function PreferredNameField({ ); } + +const formatPhoneNumber = (input: string) => { + const cleaned = input.replace(/\D/g, ''); + + if (cleaned.length == 0) { + return ''; + } else if (cleaned.length <= 3) { + return `(${cleaned}`; + } else if (cleaned.length <= 6) { + return `(${cleaned.slice(0, 3)})-${cleaned.slice(3)}`; + } else { + return `(${cleaned.slice(0, 3)})-${cleaned.slice(3, 6)}-${cleaned.slice(6, 10)}`; + } +}; + +export const PhoneNumberField = ({ + defaultValue, + error, + name, +}: FieldProps) => { + const [phoneNumber, setPhoneNumber] = useState(defaultValue || ''); + + const handleChange = (e: React.ChangeEvent) => { + const formatted = formatPhoneNumber(e.target.value); + + setPhoneNumber(formatted); + }; + + return ( + + ); +}; diff --git a/packages/types/src/domain/student.ts b/packages/types/src/domain/student.ts index aa15a185..29bcac9d 100644 --- a/packages/types/src/domain/student.ts +++ b/packages/types/src/domain/student.ts @@ -137,8 +137,10 @@ export const Student = Entity.merge(StudentSocialLinks) phoneNumber: z .string() .trim() - .length(10, 'Phone Number must be a 10-digit number') - .regex(/^\d+$/, 'Phone Number must contain only digits') + .regex( + /^\(\d{3}\)-\d{3}-\d{4}$/, + 'Phone Number must be in the format (555)-123-4567' + ) .optional(), /** diff --git a/packages/ui/src/components/form.tsx b/packages/ui/src/components/form.tsx index 41dbfbc9..d2dfce7e 100644 --- a/packages/ui/src/components/form.tsx +++ b/packages/ui/src/components/form.tsx @@ -71,7 +71,10 @@ Form.Field = function FormField({ type InputFieldProps = FieldProps & Pick & - Pick; + Pick< + InputProps, + 'disabled' | 'placeholder' | 'type' | 'pattern' | 'onChange' | 'value' + >; export function InputField({ defaultValue, @@ -82,6 +85,10 @@ export function InputField({ name, placeholder, required, + pattern, + type = 'text', + onChange, + value, }: InputFieldProps) { return ( ); diff --git a/packages/ui/src/components/input.tsx b/packages/ui/src/components/input.tsx index af7f0c6b..b41e2772 100644 --- a/packages/ui/src/components/input.tsx +++ b/packages/ui/src/components/input.tsx @@ -22,8 +22,9 @@ export type InputProps = Pick< | 'required' | 'type' | 'value' + | 'pattern' > & { - type?: Extract; + type?: Extract; }; export const Input = React.forwardRef( From 0c379c89a28346fbc1b0ab33414c0fb542f4ca5c Mon Sep 17 00:00:00 2001 From: Rami Abdou Date: Tue, 24 Sep 2024 15:35:38 -0700 Subject: [PATCH 5/6] move phone number input to ui package --- .../app/routes/_profile.profile.general.tsx | 20 +++-- .../app/shared/components/profile.general.tsx | 43 +---------- ...ield.ts => 20240924211318_phone_number.ts} | 0 packages/types/src/domain/student.ts | 23 ++++-- packages/ui/src/components/form.tsx | 16 +--- packages/ui/src/components/input.tsx | 74 ++++++++++++++++++- packages/ui/src/index.ts | 2 +- 7 files changed, 106 insertions(+), 72 deletions(-) rename packages/db/src/migrations/{20240824172428_add_phone_number_field.ts => 20240924211318_phone_number.ts} (100%) diff --git a/apps/member-profile/app/routes/_profile.profile.general.tsx b/apps/member-profile/app/routes/_profile.profile.general.tsx index 2d70907e..2f00e157 100644 --- a/apps/member-profile/app/routes/_profile.profile.general.tsx +++ b/apps/member-profile/app/routes/_profile.profile.general.tsx @@ -16,8 +16,10 @@ import { Student } from '@oyster/types'; import { Button, Divider, + Form, getErrors, InputField, + PhoneNumberInput, validateForm, } from '@oyster/ui'; @@ -28,7 +30,6 @@ import { } from '@/shared/components/profile'; import { CurrentLocationField, - PhoneNumberField, PreferredNameField, } from '@/shared/components/profile.general'; import { getMember } from '@/shared/queries/index'; @@ -53,8 +54,8 @@ export async function loader({ request }: LoaderFunctionArgs) { 'genderPronouns', 'headline', 'lastName', - 'preferredName', 'phoneNumber', + 'preferredName', ]) .executeTakeFirstOrThrow(); @@ -177,11 +178,18 @@ export default function UpdateGeneralInformationSection() { longitudeName={keys.currentLocationLongitude} /> - + label="Phone Number" + labelFor={keys.phoneNumber} + > + + Save diff --git a/apps/member-profile/app/shared/components/profile.general.tsx b/apps/member-profile/app/shared/components/profile.general.tsx index 575d4a3c..482e30c4 100644 --- a/apps/member-profile/app/shared/components/profile.general.tsx +++ b/apps/member-profile/app/shared/components/profile.general.tsx @@ -1,7 +1,7 @@ import { useState } from 'react'; import { CityCombobox, type CityComboboxProps } from '@oyster/core/location/ui'; -import { type FieldProps, Form, Input, InputField, Text } from '@oyster/ui'; +import { type FieldProps, Form, Input, Text } from '@oyster/ui'; export function CurrentLocationField({ defaultValue, @@ -62,44 +62,3 @@ export function PreferredNameField({ ); } - -const formatPhoneNumber = (input: string) => { - const cleaned = input.replace(/\D/g, ''); - - if (cleaned.length == 0) { - return ''; - } else if (cleaned.length <= 3) { - return `(${cleaned}`; - } else if (cleaned.length <= 6) { - return `(${cleaned.slice(0, 3)})-${cleaned.slice(3)}`; - } else { - return `(${cleaned.slice(0, 3)})-${cleaned.slice(3, 6)}-${cleaned.slice(6, 10)}`; - } -}; - -export const PhoneNumberField = ({ - defaultValue, - error, - name, -}: FieldProps) => { - const [phoneNumber, setPhoneNumber] = useState(defaultValue || ''); - - const handleChange = (e: React.ChangeEvent) => { - const formatted = formatPhoneNumber(e.target.value); - - setPhoneNumber(formatted); - }; - - return ( - - ); -}; diff --git a/packages/db/src/migrations/20240824172428_add_phone_number_field.ts b/packages/db/src/migrations/20240924211318_phone_number.ts similarity index 100% rename from packages/db/src/migrations/20240824172428_add_phone_number_field.ts rename to packages/db/src/migrations/20240924211318_phone_number.ts diff --git a/packages/types/src/domain/student.ts b/packages/types/src/domain/student.ts index 29bcac9d..52fe4dd4 100644 --- a/packages/types/src/domain/student.ts +++ b/packages/types/src/domain/student.ts @@ -134,14 +134,6 @@ export const Student = Entity.merge(StudentSocialLinks) joinedMemberDirectoryAt: z.coerce.date().nullable(), joinedSlackAt: z.coerce.date().optional(), lastName: z.string().trim().min(1), - phoneNumber: z - .string() - .trim() - .regex( - /^\(\d{3}\)-\d{3}-\d{4}$/, - 'Phone Number must be in the format (555)-123-4567' - ) - .optional(), /** * Enum that represents all of the accepted majors from the ColorStack @@ -167,6 +159,21 @@ export const Student = Entity.merge(StudentSocialLinks) otherMajor: z.string().optional(), otherSchool: z.string().optional(), + /** + * A 10-digit phone number without any formatting. Note that since we only + * serve US and Canadian students, we will not worry about asking for nor + * storing the country code, it is by default +1. We will only store + * 10-digit values without any formatting (ie: parentheses, dashes, etc). + * + * @example "1112223333" + * @example "1234567890" + */ + phoneNumber: z + .string() + .trim() + .regex(/^\d{10}$/, 'Must be a 10-digit number.') + .optional(), + /** * The preferred name that a member would like to go by. This will typically * just be a first name. diff --git a/packages/ui/src/components/form.tsx b/packages/ui/src/components/form.tsx index d2dfce7e..b381b606 100644 --- a/packages/ui/src/components/form.tsx +++ b/packages/ui/src/components/form.tsx @@ -71,11 +71,11 @@ Form.Field = function FormField({ type InputFieldProps = FieldProps & Pick & - Pick< - InputProps, - 'disabled' | 'placeholder' | 'type' | 'pattern' | 'onChange' | 'value' - >; + Pick; +/** + * @deprecated Instead, just compose the `Form.Field` and `Input` together. + */ export function InputField({ defaultValue, description, @@ -85,10 +85,6 @@ export function InputField({ name, placeholder, required, - pattern, - type = 'text', - onChange, - value, }: InputFieldProps) { return ( ); diff --git a/packages/ui/src/components/input.tsx b/packages/ui/src/components/input.tsx index b41e2772..cbd53ad1 100644 --- a/packages/ui/src/components/input.tsx +++ b/packages/ui/src/components/input.tsx @@ -1,4 +1,4 @@ -import React, { type HTMLInputTypeAttribute } from 'react'; +import React, { type HTMLInputTypeAttribute, useState } from 'react'; import { cx } from '../utils/cx'; @@ -22,9 +22,8 @@ export type InputProps = Pick< | 'required' | 'type' | 'value' - | 'pattern' > & { - type?: Extract; + type?: Extract; }; export const Input = React.forwardRef( @@ -43,6 +42,75 @@ export const Input = React.forwardRef( } ); +type PhoneNumberInputProps = Pick & { + defaultValue?: string; // Limit the default value to a string. +}; + +export function PhoneNumberInput({ + defaultValue, + name, + ...rest +}: PhoneNumberInputProps) { + const [value, setValue] = useState(defaultValue || ''); + + const formattedValue = formatPhoneNumber(value); + const rawValue = formattedValue.replace(/\D/g, ''); + + return ( + <> + setValue(e.target.value)} + pattern="\(\d{3}\) \d{3}-\d{4}" + placeholder="(123) 456-7890" + type="tel" + value={formattedValue} + {...rest} + /> + + + + ); +} + +/** + * Formats a phone number to the format: (xxx) xxx-xxxx. + * + * @param number - The phone number to format. + * @returns The formatted phone number. + * + * @example + * formatPhoneNumber("") => "" + * formatPhoneNumber("1") => "(1" + * formatPhoneNumber("12") => "(12" + * formatPhoneNumber("123") => "(123" + * formatPhoneNumber("1234") => "(123) 4" + * formatPhoneNumber("12345") => "(123) 45" + * formatPhoneNumber("123456") => "(123) 456" + * formatPhoneNumber("1234567") => "(123) 456-7" + * formatPhoneNumber("12345678") => "(123) 456-78" + * formatPhoneNumber("123456789") => "(123) 456-789" + * formatPhoneNumber("1234567890") => "(123) 456-7890" + * formatPhoneNumber("1234567890123") => "(123) 456-7890" + */ +function formatPhoneNumber(input: string): string { + const digits = input.replace(/\D/g, ''); + + if (digits.length === 0) { + return ''; + } + + if (digits.length <= 3) { + return `(${digits}`; + } + + if (digits.length <= 6) { + return `(${digits.slice(0, 3)}) ${digits.slice(3)}`; + } + + return `(${digits.slice(0, 3)}) ${digits.slice(3, 6)}-${digits.slice(6, 10)}`; +} + export function getInputCn() { return cx( 'w-full rounded-lg border border-gray-300 p-2', diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts index f82e481b..a30fb1cc 100644 --- a/packages/ui/src/index.ts +++ b/packages/ui/src/index.ts @@ -13,7 +13,7 @@ export { MB_IN_BYTES, FileUploader } from './components/file-uploader'; export { Form, getErrors, InputField, validateForm } from './components/form'; export type { DescriptionProps, FieldProps } from './components/form'; export { IconButton, getIconButtonCn } from './components/icon-button'; -export { Input, getInputCn } from './components/input'; +export { Input, PhoneNumberInput, getInputCn } from './components/input'; export type { InputProps } from './components/input'; export { Link } from './components/link'; export { Login } from './components/login'; From 907d77e17fdfbd8598b1f0df7aa71d75842474fe Mon Sep 17 00:00:00 2001 From: Rami Abdou Date: Tue, 24 Sep 2024 15:51:59 -0700 Subject: [PATCH 6/6] update placeholder --- packages/ui/src/components/input.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/ui/src/components/input.tsx b/packages/ui/src/components/input.tsx index cbd53ad1..a01f2d95 100644 --- a/packages/ui/src/components/input.tsx +++ b/packages/ui/src/components/input.tsx @@ -62,7 +62,7 @@ export function PhoneNumberInput({ className={getInputCn()} onChange={(e) => setValue(e.target.value)} pattern="\(\d{3}\) \d{3}-\d{4}" - placeholder="(123) 456-7890" + placeholder="(555) 123-4567" type="tel" value={formattedValue} {...rest}