From 2f58aecce5d2a88d6b70e9967b3c491391790354 Mon Sep 17 00:00:00 2001 From: YashK2005 Date: Thu, 30 Oct 2025 20:19:08 -0400 Subject: [PATCH 1/7] added timezone migration, updated intake form frontend and procesor to store user's timezone --- backend/app/models/UserData.py | 1 + .../implementations/intake_form_processor.py | 12 +++ ...a88d08_add_timezone_column_to_user_data.py | 29 +++++++ .../intake/demographic-cancer-form.tsx | 65 ++++++++++++++-- frontend/src/constants/form.ts | 3 + .../src/pages/participant/intake/index.tsx | 4 + frontend/src/pages/volunteer/intake/index.tsx | 4 + frontend/src/utils/timezoneUtils.ts | 77 +++++++++++++++++++ 8 files changed, 189 insertions(+), 6 deletions(-) create mode 100644 backend/migrations/versions/2ccee7a88d08_add_timezone_column_to_user_data.py create mode 100644 frontend/src/utils/timezoneUtils.ts diff --git a/backend/app/models/UserData.py b/backend/app/models/UserData.py index ee8d17f4..ebc4d9aa 100644 --- a/backend/app/models/UserData.py +++ b/backend/app/models/UserData.py @@ -59,6 +59,7 @@ class UserData(Base): ethnic_group = Column(JSON, nullable=True) # Array of strings marital_status = Column(Text, nullable=True) has_kids = Column(Text, nullable=True) + timezone = Column(Text, nullable=True) # Cancer Experience diagnosis = Column(Text, nullable=True) diff --git a/backend/app/services/implementations/intake_form_processor.py b/backend/app/services/implementations/intake_form_processor.py index 5d32f404..a4ebe9b1 100644 --- a/backend/app/services/implementations/intake_form_processor.py +++ b/backend/app/services/implementations/intake_form_processor.py @@ -9,6 +9,9 @@ logger = logging.getLogger(__name__) +# Valid Canadian timezone abbreviations +VALID_TIMEZONES = {"NST", "AST", "EST", "CST", "MST", "PST"} + class IntakeFormProcessor: """ @@ -177,6 +180,15 @@ def _process_demographics(self, user_data: UserData, demographics: Dict[str, Any user_data.ethnic_group = demographics.get("ethnic_group", []) user_data.marital_status = self._trim_text(demographics.get("marital_status")) user_data.has_kids = demographics.get("has_kids") + + # Validate and set timezone + timezone = self._trim_text(demographics.get("timezone")) + if timezone and timezone not in VALID_TIMEZONES: + raise ValueError( + f"Invalid timezone: {timezone}. Must be one of {sorted(VALID_TIMEZONES)}" + ) + user_data.timezone = timezone + user_data.other_ethnic_group = self._trim_text(demographics.get("ethnic_group_custom")) user_data.gender_identity_custom = self._trim_text(demographics.get("gender_identity_custom")) diff --git a/backend/migrations/versions/2ccee7a88d08_add_timezone_column_to_user_data.py b/backend/migrations/versions/2ccee7a88d08_add_timezone_column_to_user_data.py new file mode 100644 index 00000000..9984c07d --- /dev/null +++ b/backend/migrations/versions/2ccee7a88d08_add_timezone_column_to_user_data.py @@ -0,0 +1,29 @@ +"""add_timezone_column_to_user_data + +Revision ID: 2ccee7a88d08 +Revises: 9f1a6d727929 +Create Date: 2025-10-30 19:02:10.801071 + +""" +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = '2ccee7a88d08' +down_revision: Union[str, None] = '9f1a6d727929' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('user_data', sa.Column('timezone', sa.Text(), nullable=True)) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column('user_data', 'timezone') + # ### end Alembic commands ### diff --git a/frontend/src/components/intake/demographic-cancer-form.tsx b/frontend/src/components/intake/demographic-cancer-form.tsx index 57480c80..d344eb93 100644 --- a/frontend/src/components/intake/demographic-cancer-form.tsx +++ b/frontend/src/components/intake/demographic-cancer-form.tsx @@ -7,6 +7,7 @@ import { CheckboxGroup } from '@/components/ui/checkbox-group'; import { COLORS, VALIDATION } from '@/constants/form'; import baseAPIClient from '@/APIClients/baseAPIClient'; import { IntakeExperience, IntakeTreatment } from '@/types/intakeTypes'; +import { detectCanadianTimezone } from '@/utils/timezoneUtils'; // Reusable Select component to replace inline styling type StyledSelectProps = React.SelectHTMLAttributes & { @@ -48,23 +49,25 @@ interface DemographicCancerFormData { ethnicGroup: string[]; maritalStatus: string; hasKids: string; + timezone: string; diagnosis: string; dateOfDiagnosis: string; treatments: string[]; experiences: string[]; } -const DEFAULT_VALUES: DemographicCancerFormData = { +const getDefaultValues = (): DemographicCancerFormData => ({ genderIdentity: '', pronouns: [], ethnicGroup: [], maritalStatus: '', hasKids: '', + timezone: detectCanadianTimezone(), diagnosis: '', dateOfDiagnosis: '', treatments: [], experiences: [], -}; +}); const DIAGNOSIS_OPTIONS = [ 'Acute Myeloid Leukaemia', @@ -106,6 +109,8 @@ const PRONOUNS_OPTIONS = [ 'Self-describe', ]; +const TIMEZONE_OPTIONS = ['NST', 'AST', 'EST', 'CST', 'MST', 'PST']; + const ETHNIC_OPTIONS = [ 'Indigenous', 'Arab', @@ -274,7 +279,7 @@ export function DemographicCancerForm({ caringForSomeone, }: DemographicCancerFormProps) { const { control, handleSubmit, formState, watch } = useForm({ - defaultValues: DEFAULT_VALUES, + defaultValues: getDefaultValues(), }); const { errors, isSubmitting } = formState; @@ -516,6 +521,29 @@ export function DemographicCancerForm({ )} + {/* Time Zone - Left aligned */} + + + + ( + + + {TIMEZONE_OPTIONS.map((tz) => ( + + ))} + + )} + /> + + + + {/* Ethnic or Cultural Group - Left aligned */} @@ -803,15 +831,17 @@ interface BasicDemographicsFormData { ethnicGroup: string[]; maritalStatus: string; hasKids: string; + timezone: string; } -const BASIC_DEFAULT_VALUES: BasicDemographicsFormData = { +const getBasicDefaultValues = (): BasicDemographicsFormData => ({ genderIdentity: '', pronouns: [], ethnicGroup: [], maritalStatus: '', hasKids: '', -}; + timezone: detectCanadianTimezone(), +}); interface BasicDemographicsFormProps { formType?: 'participant' | 'volunteer'; @@ -825,7 +855,7 @@ export function BasicDemographicsForm({ formType, onNext }: BasicDemographicsFor formState: { errors, isSubmitting }, watch, } = useForm({ - defaultValues: BASIC_DEFAULT_VALUES, + defaultValues: getBasicDefaultValues(), }); // Local state for custom values @@ -1020,6 +1050,29 @@ export function BasicDemographicsForm({ formType, onNext }: BasicDemographicsFor )} + {/* Time Zone - Left aligned */} + + + + ( + + + {TIMEZONE_OPTIONS.map((tz) => ( + + ))} + + )} + /> + + + + {/* Ethnic or Cultural Group */} diff --git a/frontend/src/constants/form.ts b/frontend/src/constants/form.ts index 5c1ca94b..33ebc56f 100644 --- a/frontend/src/constants/form.ts +++ b/frontend/src/constants/form.ts @@ -58,6 +58,7 @@ export interface IntakeFormData { ethnicGroup: string[]; maritalStatus: string; hasKids: string; + timezone: string; }; // User's Cancer Experience (if applicable) @@ -111,6 +112,7 @@ export interface DemographicsData { ethnicGroup: string[]; maritalStatus: string; hasKids: string; + timezone: string; } export interface CancerExperienceData { @@ -149,5 +151,6 @@ export const INITIAL_INTAKE_FORM_DATA: IntakeFormData = { ethnicGroup: [], maritalStatus: '', hasKids: '', + timezone: '', }, }; diff --git a/frontend/src/pages/participant/intake/index.tsx b/frontend/src/pages/participant/intake/index.tsx index 6d8fd31a..736e6567 100644 --- a/frontend/src/pages/participant/intake/index.tsx +++ b/frontend/src/pages/participant/intake/index.tsx @@ -27,6 +27,7 @@ interface DemographicCancerFormData { ethnicGroup: string[]; maritalStatus: string; hasKids: string; + timezone: string; diagnosis: string; dateOfDiagnosis: string; treatments: string[]; @@ -49,6 +50,7 @@ interface BasicDemographicsFormData { ethnicGroup: string[]; maritalStatus: string; hasKids: string; + timezone: string; } export default function ParticipantIntakePage() { @@ -128,6 +130,7 @@ export default function ParticipantIntakePage() { ethnicGroup: data.ethnicGroup, maritalStatus: data.maritalStatus, hasKids: data.hasKids, + timezone: data.timezone, }, ...(prev.hasBloodCancer === 'yes' && { cancerExperience: { @@ -184,6 +187,7 @@ export default function ParticipantIntakePage() { ethnicGroup: data.ethnicGroup, maritalStatus: data.maritalStatus, hasKids: data.hasKids, + timezone: data.timezone, }, }; void advanceAfterUpdate(updated); diff --git a/frontend/src/pages/volunteer/intake/index.tsx b/frontend/src/pages/volunteer/intake/index.tsx index 28c42c9e..3b3d4026 100644 --- a/frontend/src/pages/volunteer/intake/index.tsx +++ b/frontend/src/pages/volunteer/intake/index.tsx @@ -27,6 +27,7 @@ interface DemographicCancerFormData { ethnicGroup: string[]; maritalStatus: string; hasKids: string; + timezone: string; diagnosis: string; dateOfDiagnosis: string; treatments: string[]; @@ -49,6 +50,7 @@ interface BasicDemographicsFormData { ethnicGroup: string[]; maritalStatus: string; hasKids: string; + timezone: string; } export default function VolunteerIntakePage() { @@ -137,6 +139,7 @@ export default function VolunteerIntakePage() { ethnicGroup: data.ethnicGroup, maritalStatus: data.maritalStatus, hasKids: data.hasKids, + timezone: data.timezone, }, ...(prev.hasBloodCancer === 'yes' && { cancerExperience: { @@ -193,6 +196,7 @@ export default function VolunteerIntakePage() { ethnicGroup: data.ethnicGroup, maritalStatus: data.maritalStatus, hasKids: data.hasKids, + timezone: data.timezone, }, }; void advanceAfterUpdate(updated); diff --git a/frontend/src/utils/timezoneUtils.ts b/frontend/src/utils/timezoneUtils.ts new file mode 100644 index 00000000..d7df48a8 --- /dev/null +++ b/frontend/src/utils/timezoneUtils.ts @@ -0,0 +1,77 @@ +/** + * Detects the user's timezone and maps it to Canadian timezone abbreviations. + * Returns one of: NST, AST, EST, CST, MST, PST, or empty string if detection fails. + */ +export function detectCanadianTimezone(): string { + try { + // Use Intl API to get the IANA timezone identifier + const timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone; + + // Map IANA timezone identifiers to Canadian timezone abbreviations + const timezoneMap: Record = { + // Newfoundland Standard Time + 'America/St_Johns': 'NST', + + // Atlantic Standard Time + 'America/Halifax': 'AST', + 'America/Moncton': 'AST', + 'America/Glace_Bay': 'AST', + 'America/Goose_Bay': 'AST', + 'America/Blanc-Sablon': 'AST', + + // Eastern Standard Time + 'America/Toronto': 'EST', + 'America/Montreal': 'EST', + 'America/Ottawa': 'EST', + 'America/Thunder_Bay': 'EST', + 'America/Nipigon': 'EST', + 'America/Rainy_River': 'EST', + 'America/Atikokan': 'EST', + + // Central Standard Time + 'America/Winnipeg': 'CST', // Manitoba uses Central Time + 'America/Regina': 'CST', + 'America/Swift_Current': 'CST', + + // Mountain Standard Time + 'America/Edmonton': 'MST', + 'America/Calgary': 'MST', + 'America/Yellowknife': 'MST', + 'America/Inuvik': 'MST', + 'America/Cambridge_Bay': 'MST', + 'America/Dawson_Creek': 'MST', + 'America/Fort_Nelson': 'MST', + + // Pacific Standard Time + 'America/Vancouver': 'PST', + 'America/Whitehorse': 'PST', + 'America/Dawson': 'PST', + }; + + // Check if we have a direct mapping + if (timezoneMap[timeZone]) { + return timezoneMap[timeZone]; + } + + // Fallback: Use timezone offset to estimate + // Get UTC offset in hours (accounting for DST) + const now = new Date(); + const offsetMinutes = now.getTimezoneOffset(); + const offsetHours = -offsetMinutes / 60; // Invert because getTimezoneOffset returns opposite sign + + // Map UTC offsets to Canadian timezones (accounting for DST) + // During DST, offsets are shifted by 1 hour, so we map to standard time equivalents + if (offsetHours === -3.5 || offsetHours === -2.5) return 'NST'; // Newfoundland (standard or daylight) + if (offsetHours === -4 || offsetHours === -3) return 'AST'; // Atlantic (standard or daylight) + if (offsetHours === -5 || offsetHours === -4) return 'EST'; // Eastern (standard or daylight) + if (offsetHours === -6 || offsetHours === -5) return 'CST'; // Central (standard or daylight) + if (offsetHours === -7 || offsetHours === -6) return 'MST'; // Mountain (standard or daylight) + if (offsetHours === -8 || offsetHours === -7) return 'PST'; // Pacific (standard or daylight) + + return ''; + } catch (error) { + console.warn('Unable to detect timezone:', error); + return ''; + } +} + From 091e53020dfe507aabad14df574d2b85a7bc494b Mon Sep 17 00:00:00 2001 From: YashK2005 Date: Thu, 30 Oct 2025 23:20:16 -0400 Subject: [PATCH 2/7] fix linting --- .../implementations/intake_form_processor.py | 8 +++---- ...a88d08_add_timezone_column_to_user_data.py | 9 ++++---- frontend/src/utils/timezoneUtils.ts | 21 +++++++++---------- 3 files changed, 18 insertions(+), 20 deletions(-) diff --git a/backend/app/services/implementations/intake_form_processor.py b/backend/app/services/implementations/intake_form_processor.py index a4ebe9b1..4d96529d 100644 --- a/backend/app/services/implementations/intake_form_processor.py +++ b/backend/app/services/implementations/intake_form_processor.py @@ -180,15 +180,13 @@ def _process_demographics(self, user_data: UserData, demographics: Dict[str, Any user_data.ethnic_group = demographics.get("ethnic_group", []) user_data.marital_status = self._trim_text(demographics.get("marital_status")) user_data.has_kids = demographics.get("has_kids") - + # Validate and set timezone timezone = self._trim_text(demographics.get("timezone")) if timezone and timezone not in VALID_TIMEZONES: - raise ValueError( - f"Invalid timezone: {timezone}. Must be one of {sorted(VALID_TIMEZONES)}" - ) + raise ValueError(f"Invalid timezone: {timezone}. Must be one of {sorted(VALID_TIMEZONES)}") user_data.timezone = timezone - + user_data.other_ethnic_group = self._trim_text(demographics.get("ethnic_group_custom")) user_data.gender_identity_custom = self._trim_text(demographics.get("gender_identity_custom")) diff --git a/backend/migrations/versions/2ccee7a88d08_add_timezone_column_to_user_data.py b/backend/migrations/versions/2ccee7a88d08_add_timezone_column_to_user_data.py index 9984c07d..3cebc0e6 100644 --- a/backend/migrations/versions/2ccee7a88d08_add_timezone_column_to_user_data.py +++ b/backend/migrations/versions/2ccee7a88d08_add_timezone_column_to_user_data.py @@ -5,25 +5,26 @@ Create Date: 2025-10-30 19:02:10.801071 """ + from typing import Sequence, Union import sqlalchemy as sa from alembic import op # revision identifiers, used by Alembic. -revision: str = '2ccee7a88d08' -down_revision: Union[str, None] = '9f1a6d727929' +revision: str = "2ccee7a88d08" +down_revision: Union[str, None] = "9f1a6d727929" branch_labels: Union[str, Sequence[str], None] = None depends_on: Union[str, Sequence[str], None] = None def upgrade() -> None: # ### commands auto generated by Alembic - please adjust! ### - op.add_column('user_data', sa.Column('timezone', sa.Text(), nullable=True)) + op.add_column("user_data", sa.Column("timezone", sa.Text(), nullable=True)) # ### end Alembic commands ### def downgrade() -> None: # ### commands auto generated by Alembic - please adjust! ### - op.drop_column('user_data', 'timezone') + op.drop_column("user_data", "timezone") # ### end Alembic commands ### diff --git a/frontend/src/utils/timezoneUtils.ts b/frontend/src/utils/timezoneUtils.ts index d7df48a8..d8040c16 100644 --- a/frontend/src/utils/timezoneUtils.ts +++ b/frontend/src/utils/timezoneUtils.ts @@ -6,19 +6,19 @@ export function detectCanadianTimezone(): string { try { // Use Intl API to get the IANA timezone identifier const timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone; - + // Map IANA timezone identifiers to Canadian timezone abbreviations const timezoneMap: Record = { // Newfoundland Standard Time 'America/St_Johns': 'NST', - + // Atlantic Standard Time 'America/Halifax': 'AST', 'America/Moncton': 'AST', 'America/Glace_Bay': 'AST', 'America/Goose_Bay': 'AST', 'America/Blanc-Sablon': 'AST', - + // Eastern Standard Time 'America/Toronto': 'EST', 'America/Montreal': 'EST', @@ -27,12 +27,12 @@ export function detectCanadianTimezone(): string { 'America/Nipigon': 'EST', 'America/Rainy_River': 'EST', 'America/Atikokan': 'EST', - + // Central Standard Time 'America/Winnipeg': 'CST', // Manitoba uses Central Time 'America/Regina': 'CST', 'America/Swift_Current': 'CST', - + // Mountain Standard Time 'America/Edmonton': 'MST', 'America/Calgary': 'MST', @@ -41,24 +41,24 @@ export function detectCanadianTimezone(): string { 'America/Cambridge_Bay': 'MST', 'America/Dawson_Creek': 'MST', 'America/Fort_Nelson': 'MST', - + // Pacific Standard Time 'America/Vancouver': 'PST', 'America/Whitehorse': 'PST', 'America/Dawson': 'PST', }; - + // Check if we have a direct mapping if (timezoneMap[timeZone]) { return timezoneMap[timeZone]; } - + // Fallback: Use timezone offset to estimate // Get UTC offset in hours (accounting for DST) const now = new Date(); const offsetMinutes = now.getTimezoneOffset(); const offsetHours = -offsetMinutes / 60; // Invert because getTimezoneOffset returns opposite sign - + // Map UTC offsets to Canadian timezones (accounting for DST) // During DST, offsets are shifted by 1 hour, so we map to standard time equivalents if (offsetHours === -3.5 || offsetHours === -2.5) return 'NST'; // Newfoundland (standard or daylight) @@ -67,11 +67,10 @@ export function detectCanadianTimezone(): string { if (offsetHours === -6 || offsetHours === -5) return 'CST'; // Central (standard or daylight) if (offsetHours === -7 || offsetHours === -6) return 'MST'; // Mountain (standard or daylight) if (offsetHours === -8 || offsetHours === -7) return 'PST'; // Pacific (standard or daylight) - + return ''; } catch (error) { console.warn('Unable to detect timezone:', error); return ''; } } - From f08e620c864cdc47dbad4c526ae06ba1cf11a062 Mon Sep 17 00:00:00 2001 From: YashK2005 Date: Mon, 10 Nov 2025 00:09:20 -0500 Subject: [PATCH 3/7] Update migration --- .../versions/2ccee7a88d08_add_timezone_column_to_user_data.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/backend/migrations/versions/2ccee7a88d08_add_timezone_column_to_user_data.py b/backend/migrations/versions/2ccee7a88d08_add_timezone_column_to_user_data.py index 3cebc0e6..b59d83db 100644 --- a/backend/migrations/versions/2ccee7a88d08_add_timezone_column_to_user_data.py +++ b/backend/migrations/versions/2ccee7a88d08_add_timezone_column_to_user_data.py @@ -1,7 +1,7 @@ """add_timezone_column_to_user_data Revision ID: 2ccee7a88d08 -Revises: 9f1a6d727929 +Revises: 8d2cd99b9eb8 Create Date: 2025-10-30 19:02:10.801071 """ @@ -13,7 +13,7 @@ # revision identifiers, used by Alembic. revision: str = "2ccee7a88d08" -down_revision: Union[str, None] = "9f1a6d727929" +down_revision: Union[str, None] = "8d2cd99b9eb8" branch_labels: Union[str, Sequence[str], None] = None depends_on: Union[str, Sequence[str], None] = None From 5542de864eb5341cf539171bd39f96c1a35ab84c Mon Sep 17 00:00:00 2001 From: YashK2005 Date: Mon, 10 Nov 2025 00:59:46 -0500 Subject: [PATCH 4/7] Update styling of dropdowns and input fields in onboarding forms to match figma --- .../intake/demographic-cancer-form.tsx | 408 +++++++----------- .../src/components/intake/loved-one-form.tsx | 103 ++--- .../components/intake/personal-info-form.tsx | 40 +- .../intake/volunteer-profile-form.tsx | 5 +- .../intake/volunteer-references-form.tsx | 23 +- frontend/src/components/ui/input-group.tsx | 33 +- .../src/pages/participant/intake/index.tsx | 4 +- frontend/src/pages/volunteer/intake/index.tsx | 4 +- frontend/src/pages/volunteer/secondary.tsx | 11 +- 9 files changed, 250 insertions(+), 381 deletions(-) diff --git a/frontend/src/components/intake/demographic-cancer-form.tsx b/frontend/src/components/intake/demographic-cancer-form.tsx index d344eb93..f3c2119b 100644 --- a/frontend/src/components/intake/demographic-cancer-form.tsx +++ b/frontend/src/components/intake/demographic-cancer-form.tsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect, useRef } from 'react'; +import React, { useState, useEffect } from 'react'; import { Box, Heading, Button, VStack, HStack, Text, Input } from '@chakra-ui/react'; import { Controller, useForm } from 'react-hook-form'; import { FormField } from '@/components/ui/form-field'; @@ -8,6 +8,8 @@ import { COLORS, VALIDATION } from '@/constants/form'; import baseAPIClient from '@/APIClients/baseAPIClient'; import { IntakeExperience, IntakeTreatment } from '@/types/intakeTypes'; import { detectCanadianTimezone } from '@/utils/timezoneUtils'; +import { SingleSelectDropdown } from '@/components/ui/single-select-dropdown'; +import { MultiSelectDropdown } from '@/components/ui/multi-select-dropdown'; // Reusable Select component to replace inline styling type StyledSelectProps = React.SelectHTMLAttributes & { @@ -70,17 +72,17 @@ const getDefaultValues = (): DemographicCancerFormData => ({ }); const DIAGNOSIS_OPTIONS = [ - 'Acute Myeloid Leukaemia', - 'Acute Lymphoblastic Leukaemia', - 'Chronic Myeloid Leukaemia', - 'Chronic Lymphocytic Leukaemia', - 'Hodgkin Lymphoma', - 'Non-Hodgkin Lymphoma', - 'Multiple Myeloma', - 'Myelodysplastic Syndrome', - 'Myelofibrosis', - 'Aplastic Anemia', - 'Other', + 'Unknown', + 'Acute Myeloid Leukemia', + 'Acute Lymphoblastic Leukemia', + 'Acute Promyelocytic Leukemia', + 'Mixed Phenotype Leukemia', + 'Chronic Lymphocytic Leukemia/Small Lymphocytic Lymphoma', + 'Chronic Myeloid Leukemia', + 'Hairy Cell Leukemia', + 'Myeloma/Multiple Myeloma', + "Hodgkin's Lymphoma", + "Indolent/Low Grade Non-Hodgkin's Lymphoma", ]; interface DemographicCancerFormProps { @@ -111,167 +113,24 @@ const PRONOUNS_OPTIONS = [ const TIMEZONE_OPTIONS = ['NST', 'AST', 'EST', 'CST', 'MST', 'PST']; +const MARITAL_STATUS_OPTIONS = ['Single', 'Married/Common Law', 'Divorced', 'Widowed']; + +const HAS_KIDS_OPTIONS = ['Yes', 'No', 'Prefer not to answer']; + const ETHNIC_OPTIONS = [ - 'Indigenous', - 'Arab', - 'Black', - 'Chinese', - 'Filipino', - 'Japanese', - 'Korean', - 'Latin American', + 'Black (including African and Caribbean descent)', + 'Middle Eastern, Western or Central Asian', + 'East Asian', 'South Asian', 'Southeast Asian', - 'West Asian', + 'Indigenous person from Canada', + 'Latin American', 'White', + 'Mixed Ethnicity (Individuals who identify with more than one racial/ethnic or cultural group)', 'Prefer not to answer', - 'Self-describe', + 'Another background/Prefer to self-describe (please specify):', ]; -// Multi-select dropdown component -const MultiSelectDropdown: React.FC<{ - options: string[]; - selectedValues: string[]; - onSelectionChange: (values: string[]) => void; - placeholder: string; -}> = ({ options, selectedValues, onSelectionChange, placeholder }) => { - const [isOpen, setIsOpen] = useState(false); - const dropdownRef = useRef(null); - - // Close dropdown when clicking outside - useEffect(() => { - const handleClickOutside = (event: MouseEvent) => { - if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) { - setIsOpen(false); - } - }; - - if (isOpen) { - document.addEventListener('mousedown', handleClickOutside); - } - - return () => { - document.removeEventListener('mousedown', handleClickOutside); - }; - }, [isOpen]); - - const handleCheckboxChange = (option: string, checked: boolean) => { - if (checked) { - onSelectionChange([...selectedValues, option]); - } else { - onSelectionChange(selectedValues.filter((val) => val !== option)); - } - }; - - const displayText = selectedValues.length > 0 ? selectedValues.join(', ') : placeholder; - - return ( - - - - {isOpen && ( - - {options.map((option) => ( - { - e.stopPropagation(); - const isSelected = selectedValues.includes(option); - handleCheckboxChange(option, !isSelected); - }} - > - handleCheckboxChange(option, e.target.checked)} - style={{ - width: '18px', - height: '18px', - accentColor: COLORS.teal, - cursor: 'pointer', - }} - onClick={(e) => e.stopPropagation()} - /> - - {option} - - - ))} - - )} - - ); -}; - export function DemographicCancerForm({ formType, onNext, @@ -348,8 +207,14 @@ export function DemographicCancerForm({ pronouns: data.pronouns.includes('Self-describe') ? data.pronouns.map((p) => (p === 'Self-describe' ? pronounsCustom : p)) : data.pronouns, - ethnicGroup: data.ethnicGroup.includes('Self-describe') - ? data.ethnicGroup.map((e) => (e === 'Self-describe' ? ethnicGroupCustom : e)) + ethnicGroup: data.ethnicGroup.includes( + 'Another background/Prefer to self-describe (please specify):', + ) + ? data.ethnicGroup.map((e) => + e === 'Another background/Prefer to self-describe (please specify):' + ? ethnicGroupCustom + : e, + ) : data.ethnicGroup, }; @@ -432,14 +297,13 @@ export function DemographicCancerForm({ }, }} render={({ field }) => ( - - - {GENDER_IDENTITY_OPTIONS.map((option) => ( - - ))} - + )} /> @@ -455,11 +319,12 @@ export function DemographicCancerForm({ fontFamily="system-ui, -apple-system, sans-serif" fontSize="14px" color={COLORS.veniceBlue} - borderColor="#d1d5db" borderRadius="6px" h="40px" - border="1px solid" px={3} + border="1px solid" + borderColor="#d1d5db" + boxShadow="0 1px 2px 0 rgba(0, 0, 0, 0.05)" _placeholder={{ color: '#9ca3af' }} _focus={{ borderColor: COLORS.teal, boxShadow: `0 0 0 3px ${COLORS.teal}20` }} /> @@ -492,6 +357,7 @@ export function DemographicCancerForm({ selectedValues={field.value || []} onSelectionChange={field.onChange} placeholder="Pronouns" + error={!!errors.pronouns} /> )} /> @@ -508,11 +374,12 @@ export function DemographicCancerForm({ fontFamily="system-ui, -apple-system, sans-serif" fontSize="14px" color={COLORS.veniceBlue} - borderColor="#d1d5db" borderRadius="6px" h="40px" - border="1px solid" px={3} + border="1px solid" + borderColor="#d1d5db" + boxShadow="0 1px 2px 0 rgba(0, 0, 0, 0.05)" _placeholder={{ color: '#9ca3af' }} _focus={{ borderColor: COLORS.teal, boxShadow: `0 0 0 3px ${COLORS.teal}20` }} /> @@ -530,14 +397,13 @@ export function DemographicCancerForm({ control={control} rules={{ required: 'Time zone is required' }} render={({ field }) => ( - - - {TIMEZONE_OPTIONS.map((tz) => ( - - ))} - + )} /> @@ -556,8 +422,13 @@ export function DemographicCancerForm({ if (!value || value.length === 0) { return 'Please select at least one ethnic or cultural group'; } - if (value.includes('Self-describe') && !ethnicGroupCustom.trim()) { - return 'Please specify your ethnic or cultural group when selecting Self-describe'; + if ( + value.includes( + 'Another background/Prefer to self-describe (please specify):', + ) && + !ethnicGroupCustom.trim() + ) { + return 'Please specify your ethnic or cultural group when selecting self-describe'; } return true; }, @@ -568,13 +439,16 @@ export function DemographicCancerForm({ selectedValues={field.value || []} onSelectionChange={field.onChange} placeholder="Ethnic or Cultural Group" + error={!!errors.ethnicGroup} /> )} /> - {ethnicGroup.includes('Self-describe') && ( + {ethnicGroup.includes( + 'Another background/Prefer to self-describe (please specify):', + ) && ( @@ -605,16 +480,13 @@ export function DemographicCancerForm({ control={control} rules={{ required: 'Marital status is required' }} render={({ field }) => ( - - - - - - - - - - + )} /> @@ -625,12 +497,13 @@ export function DemographicCancerForm({ control={control} rules={{ required: 'Please specify if you have kids' }} render={({ field }) => ( - - - - - - + )} /> @@ -671,14 +544,13 @@ export function DemographicCancerForm({ control={control} rules={{ required: 'Diagnosis is required' }} render={({ field }) => ( - - - {DIAGNOSIS_OPTIONS.map((option) => ( - - ))} - + )} /> @@ -706,7 +578,7 @@ export function DemographicCancerForm({ fontFamily="system-ui, -apple-system, sans-serif" fontSize="14px" color={COLORS.veniceBlue} - borderColor={errors.dateOfDiagnosis ? 'red.500' : '#d1d5db'} + borderColor={errors.dateOfDiagnosis ? 'red.500' : undefined} borderRadius="6px" h="40px" _placeholder={{ color: '#9ca3af' }} @@ -877,8 +749,14 @@ export function BasicDemographicsForm({ formType, onNext }: BasicDemographicsFor pronouns: data.pronouns.includes('Self-describe') ? data.pronouns.map((p) => (p === 'Self-describe' ? pronounsCustom : p)) : data.pronouns, - ethnicGroup: data.ethnicGroup.includes('Self-describe') - ? data.ethnicGroup.map((e) => (e === 'Self-describe' ? ethnicGroupCustom : e)) + ethnicGroup: data.ethnicGroup.includes( + 'Another background/Prefer to self-describe (please specify):', + ) + ? data.ethnicGroup.map((e) => + e === 'Another background/Prefer to self-describe (please specify):' + ? ethnicGroupCustom + : e, + ) : data.ethnicGroup, }; @@ -961,14 +839,13 @@ export function BasicDemographicsForm({ formType, onNext }: BasicDemographicsFor }, }} render={({ field }) => ( - - - {GENDER_IDENTITY_OPTIONS.map((option) => ( - - ))} - + )} /> @@ -984,11 +861,12 @@ export function BasicDemographicsForm({ formType, onNext }: BasicDemographicsFor fontFamily="system-ui, -apple-system, sans-serif" fontSize="14px" color={COLORS.veniceBlue} - borderColor="#d1d5db" borderRadius="6px" h="40px" - border="1px solid" px={3} + border="1px solid" + borderColor="#d1d5db" + boxShadow="0 1px 2px 0 rgba(0, 0, 0, 0.05)" _placeholder={{ color: '#9ca3af' }} _focus={{ borderColor: COLORS.teal, boxShadow: `0 0 0 3px ${COLORS.teal}20` }} /> @@ -1021,6 +899,7 @@ export function BasicDemographicsForm({ formType, onNext }: BasicDemographicsFor selectedValues={field.value || []} onSelectionChange={field.onChange} placeholder="Pronouns" + error={!!errors.pronouns} /> )} /> @@ -1037,11 +916,12 @@ export function BasicDemographicsForm({ formType, onNext }: BasicDemographicsFor fontFamily="system-ui, -apple-system, sans-serif" fontSize="14px" color={COLORS.veniceBlue} - borderColor="#d1d5db" borderRadius="6px" h="40px" - border="1px solid" px={3} + border="1px solid" + borderColor="#d1d5db" + boxShadow="0 1px 2px 0 rgba(0, 0, 0, 0.05)" _placeholder={{ color: '#9ca3af' }} _focus={{ borderColor: COLORS.teal, boxShadow: `0 0 0 3px ${COLORS.teal}20` }} /> @@ -1059,14 +939,13 @@ export function BasicDemographicsForm({ formType, onNext }: BasicDemographicsFor control={control} rules={{ required: 'Time zone is required' }} render={({ field }) => ( - - - {TIMEZONE_OPTIONS.map((tz) => ( - - ))} - + )} /> @@ -1085,8 +964,13 @@ export function BasicDemographicsForm({ formType, onNext }: BasicDemographicsFor if (!value || value.length === 0) { return 'Please select at least one ethnic or cultural group'; } - if (value.includes('Self-describe') && !ethnicGroupCustom.trim()) { - return 'Please specify your ethnic or cultural group when selecting Self-describe'; + if ( + value.includes( + 'Another background/Prefer to self-describe (please specify):', + ) && + !ethnicGroupCustom.trim() + ) { + return 'Please specify your ethnic or cultural group when selecting self-describe'; } return true; }, @@ -1097,13 +981,16 @@ export function BasicDemographicsForm({ formType, onNext }: BasicDemographicsFor selectedValues={field.value || []} onSelectionChange={field.onChange} placeholder="Ethnic or Cultural Group" + error={!!errors.ethnicGroup} /> )} /> - {ethnicGroup.includes('Self-describe') && ( + {ethnicGroup.includes( + 'Another background/Prefer to self-describe (please specify):', + ) && ( @@ -1134,16 +1022,13 @@ export function BasicDemographicsForm({ formType, onNext }: BasicDemographicsFor control={control} rules={{ required: 'Marital status is required' }} render={({ field }) => ( - - - - - - - - - - + )} /> @@ -1154,12 +1039,13 @@ export function BasicDemographicsForm({ formType, onNext }: BasicDemographicsFor control={control} rules={{ required: 'Please specify if you have kids' }} render={({ field }) => ( - - - - - - + )} /> diff --git a/frontend/src/components/intake/loved-one-form.tsx b/frontend/src/components/intake/loved-one-form.tsx index a47559c9..b4779e88 100644 --- a/frontend/src/components/intake/loved-one-form.tsx +++ b/frontend/src/components/intake/loved-one-form.tsx @@ -7,40 +7,16 @@ import { CheckboxGroup } from '@/components/ui/checkbox-group'; import { COLORS, VALIDATION } from '@/constants/form'; import { IntakeExperience, IntakeTreatment } from '@/types/intakeTypes'; import baseAPIClient from '@/APIClients/baseAPIClient'; - -// Reusable Select component to replace inline styling -type StyledSelectProps = React.SelectHTMLAttributes & { - children: React.ReactNode; - error?: boolean; -}; - -const StyledSelect = React.forwardRef( - ({ children, error, style, ...props }, ref) => ( - - ), -); -StyledSelect.displayName = 'StyledSelect'; +import { SingleSelectDropdown } from '@/components/ui/single-select-dropdown'; + +const GENDER_IDENTITY_OPTIONS = [ + 'Male', + 'Female', + 'Non-binary', + 'Transgender', + 'Prefer not to answer', + 'Self-describe', +]; interface LovedOneFormData { genderIdentity: string; @@ -61,17 +37,17 @@ const DEFAULT_VALUES: LovedOneFormData = { }; const DIAGNOSIS_OPTIONS = [ - 'Acute Myeloid Leukaemia', - 'Acute Lymphoblastic Leukaemia', - 'Chronic Myeloid Leukaemia', - 'Chronic Lymphocytic Leukaemia', - 'Hodgkin Lymphoma', - 'Non-Hodgkin Lymphoma', - 'Multiple Myeloma', - 'Myelodysplastic Syndrome', - 'Myelofibrosis', - 'Aplastic Anemia', - 'Other', + 'Unknown', + 'Acute Myeloid Leukemia', + 'Acute Lymphoblastic Leukemia', + 'Acute Promyelocytic Leukemia', + 'Mixed Phenotype Leukemia', + 'Chronic Lymphocytic Leukemia/Small Lymphocytic Lymphoma', + 'Chronic Myeloid Leukemia', + 'Hairy Cell Leukemia', + 'Myeloma/Multiple Myeloma', + "Hodgkin's Lymphoma", + "Indolent/Low Grade Non-Hodgkin's Lymphoma", ]; interface LovedOneFormProps { @@ -205,15 +181,13 @@ export function LovedOneForm({ formType = 'participant', onSubmit }: LovedOneFor }, }} render={({ field }) => ( - - - - - - - - - + )} /> @@ -229,10 +203,8 @@ export function LovedOneForm({ formType = 'participant', onSubmit }: LovedOneFor fontFamily="system-ui, -apple-system, sans-serif" fontSize="14px" color={COLORS.veniceBlue} - borderColor="#d1d5db" borderRadius="6px" h="40px" - border="1px solid" px={3} _placeholder={{ color: '#9ca3af' }} _focus={{ borderColor: COLORS.teal, boxShadow: `0 0 0 3px ${COLORS.teal}20` }} @@ -255,7 +227,7 @@ export function LovedOneForm({ formType = 'participant', onSubmit }: LovedOneFor fontFamily="system-ui, -apple-system, sans-serif" fontSize="14px" color={COLORS.veniceBlue} - borderColor={errors.age ? 'red.500' : '#d1d5db'} + borderColor={errors.age ? 'red.500' : undefined} borderRadius="6px" h="40px" _placeholder={{ color: '#9ca3af' }} @@ -300,14 +272,13 @@ export function LovedOneForm({ formType = 'participant', onSubmit }: LovedOneFor control={control} rules={{ required: 'Diagnosis is required' }} render={({ field }) => ( - - - {DIAGNOSIS_OPTIONS.map((option) => ( - - ))} - + )} /> @@ -335,7 +306,7 @@ export function LovedOneForm({ formType = 'participant', onSubmit }: LovedOneFor fontFamily="system-ui, -apple-system, sans-serif" fontSize="14px" color={COLORS.veniceBlue} - borderColor={errors.dateOfDiagnosis ? 'red.500' : '#d1d5db'} + borderColor={errors.dateOfDiagnosis ? 'red.500' : undefined} borderRadius="6px" h="40px" _placeholder={{ color: '#9ca3af' }} diff --git a/frontend/src/components/intake/personal-info-form.tsx b/frontend/src/components/intake/personal-info-form.tsx index cd2f0fae..22ac5609 100644 --- a/frontend/src/components/intake/personal-info-form.tsx +++ b/frontend/src/components/intake/personal-info-form.tsx @@ -8,6 +8,7 @@ import { ExperienceTypeSection } from '@/components/intake/experience-type-secti import { COLORS, PROVINCES, VALIDATION, ExperienceData, PersonalData } from '@/constants/form'; import { CustomRadio } from '@/components/CustomRadio'; import { useRouter } from 'next/router'; +import { SingleSelectDropdown } from '@/components/ui/single-select-dropdown'; interface PersonalInfoFormData { hasBloodCancer: 'yes' | 'no' | ''; @@ -153,7 +154,7 @@ export function PersonalInfoForm({ formType, onSubmit }: PersonalInfoFormProps) fontFamily="system-ui, -apple-system, sans-serif" fontSize="14px" color={COLORS.veniceBlue} - borderColor={errors.firstName ? 'red.500' : '#d1d5db'} + borderColor={errors.firstName ? 'red.500' : undefined} borderRadius="6px" h="40px" _placeholder={{ color: '#9ca3af' }} @@ -178,7 +179,7 @@ export function PersonalInfoForm({ formType, onSubmit }: PersonalInfoFormProps) fontFamily="system-ui, -apple-system, sans-serif" fontSize="14px" color={COLORS.veniceBlue} - borderColor={errors.lastName ? 'red.500' : '#d1d5db'} + borderColor={errors.lastName ? 'red.500' : undefined} borderRadius="6px" h="40px" _placeholder={{ color: '#9ca3af' }} @@ -206,7 +207,7 @@ export function PersonalInfoForm({ formType, onSubmit }: PersonalInfoFormProps) fontFamily="system-ui, -apple-system, sans-serif" fontSize="14px" color={COLORS.veniceBlue} - borderColor={errors.dateOfBirth ? 'red.500' : '#d1d5db'} + borderColor={errors.dateOfBirth ? 'red.500' : undefined} borderRadius="6px" h="40px" _placeholder={{ color: '#9ca3af' }} @@ -236,7 +237,7 @@ export function PersonalInfoForm({ formType, onSubmit }: PersonalInfoFormProps) fontFamily="system-ui, -apple-system, sans-serif" fontSize="14px" color={COLORS.veniceBlue} - borderColor={errors.phoneNumber ? 'red.500' : '#d1d5db'} + borderColor={errors.phoneNumber ? 'red.500' : undefined} borderRadius="6px" h="40px" _placeholder={{ color: '#9ca3af' }} @@ -269,7 +270,7 @@ export function PersonalInfoForm({ formType, onSubmit }: PersonalInfoFormProps) fontFamily="system-ui, -apple-system, sans-serif" fontSize="14px" color={COLORS.veniceBlue} - borderColor={errors.postalCode ? 'red.500' : '#d1d5db'} + borderColor={errors.postalCode ? 'red.500' : undefined} borderRadius="6px" h="40px" _placeholder={{ color: '#9ca3af' }} @@ -293,7 +294,7 @@ export function PersonalInfoForm({ formType, onSubmit }: PersonalInfoFormProps) fontFamily="system-ui, -apple-system, sans-serif" fontSize="14px" color={COLORS.veniceBlue} - borderColor={errors.city ? 'red.500' : '#d1d5db'} + borderColor={errors.city ? 'red.500' : undefined} borderRadius="6px" h="40px" _placeholder={{ color: '#9ca3af' }} @@ -314,28 +315,15 @@ export function PersonalInfoForm({ formType, onSubmit }: PersonalInfoFormProps) control={control} rules={{ required: 'Province is required' }} render={({ field }) => ( - - - + )} /> - - {PROVINCES.map((province) => ( - {/* Empty box to maintain two-column layout */} diff --git a/frontend/src/components/intake/volunteer-profile-form.tsx b/frontend/src/components/intake/volunteer-profile-form.tsx index 79e3af44..d4dd4edb 100644 --- a/frontend/src/components/intake/volunteer-profile-form.tsx +++ b/frontend/src/components/intake/volunteer-profile-form.tsx @@ -146,13 +146,14 @@ export function VolunteerProfileForm({ onNext, onBack }: VolunteerProfileFormPro fontFamily="system-ui, -apple-system, sans-serif" fontSize="14px" color={COLORS.veniceBlue} - borderColor={errors.experience ? 'red.500' : '#d1d5db'} borderRadius="6px" minH="200px" resize="vertical" - border="1px solid" px={3} py={3} + border="1px solid" + borderColor={errors.experience ? 'red.500' : '#d1d5db'} + boxShadow="0 1px 2px 0 rgba(0, 0, 0, 0.05)" _placeholder={{ color: '#9ca3af' }} _focus={{ borderColor: COLORS.teal, diff --git a/frontend/src/components/intake/volunteer-references-form.tsx b/frontend/src/components/intake/volunteer-references-form.tsx index ccdb73a6..499e4757 100644 --- a/frontend/src/components/intake/volunteer-references-form.tsx +++ b/frontend/src/components/intake/volunteer-references-form.tsx @@ -126,10 +126,9 @@ export function VolunteerReferencesForm({ onNext, onBack }: VolunteerReferencesF fontFamily="system-ui, -apple-system, sans-serif" fontSize="14px" color={COLORS.veniceBlue} - borderColor={errors.reference1?.fullName ? 'red.500' : '#d1d5db'} + borderColor={errors.reference1?.fullName ? 'red.500' : undefined} borderRadius="6px" h="40px" - border="1px solid" px={3} _placeholder={{ color: '#9ca3af' }} _focus={{ @@ -174,10 +173,9 @@ export function VolunteerReferencesForm({ onNext, onBack }: VolunteerReferencesF fontFamily="system-ui, -apple-system, sans-serif" fontSize="14px" color={COLORS.veniceBlue} - borderColor={errors.reference1?.email ? 'red.500' : '#d1d5db'} + borderColor={errors.reference1?.email ? 'red.500' : undefined} borderRadius="6px" h="40px" - border="1px solid" px={3} _placeholder={{ color: '#9ca3af' }} _focus={{ @@ -221,10 +219,9 @@ export function VolunteerReferencesForm({ onNext, onBack }: VolunteerReferencesF fontFamily="system-ui, -apple-system, sans-serif" fontSize="14px" color={COLORS.veniceBlue} - borderColor={errors.reference1?.phoneNumber ? 'red.500' : '#d1d5db'} + borderColor={errors.reference1?.phoneNumber ? 'red.500' : undefined} borderRadius="6px" h="40px" - border="1px solid" px={3} _placeholder={{ color: '#9ca3af' }} _focus={{ @@ -280,10 +277,9 @@ export function VolunteerReferencesForm({ onNext, onBack }: VolunteerReferencesF fontFamily="system-ui, -apple-system, sans-serif" fontSize="14px" color={COLORS.veniceBlue} - borderColor={errors.reference2?.fullName ? 'red.500' : '#d1d5db'} + borderColor={errors.reference2?.fullName ? 'red.500' : undefined} borderRadius="6px" h="40px" - border="1px solid" px={3} _placeholder={{ color: '#9ca3af' }} _focus={{ @@ -328,10 +324,9 @@ export function VolunteerReferencesForm({ onNext, onBack }: VolunteerReferencesF fontFamily="system-ui, -apple-system, sans-serif" fontSize="14px" color={COLORS.veniceBlue} - borderColor={errors.reference2?.email ? 'red.500' : '#d1d5db'} + borderColor={errors.reference2?.email ? 'red.500' : undefined} borderRadius="6px" h="40px" - border="1px solid" px={3} _placeholder={{ color: '#9ca3af' }} _focus={{ @@ -375,10 +370,9 @@ export function VolunteerReferencesForm({ onNext, onBack }: VolunteerReferencesF fontFamily="system-ui, -apple-system, sans-serif" fontSize="14px" color={COLORS.veniceBlue} - borderColor={errors.reference2?.phoneNumber ? 'red.500' : '#d1d5db'} + borderColor={errors.reference2?.phoneNumber ? 'red.500' : undefined} borderRadius="6px" h="40px" - border="1px solid" px={3} _placeholder={{ color: '#9ca3af' }} _focus={{ @@ -420,13 +414,14 @@ export function VolunteerReferencesForm({ onNext, onBack }: VolunteerReferencesF fontFamily="system-ui, -apple-system, sans-serif" fontSize="14px" color={COLORS.veniceBlue} - borderColor="#d1d5db" borderRadius="6px" minH="120px" resize="vertical" - border="1px solid" px={3} py={3} + border="1px solid" + borderColor="#d1d5db" + boxShadow="0 1px 2px 0 rgba(0, 0, 0, 0.05)" _placeholder={{ color: '#9ca3af' }} _focus={{ borderColor: COLORS.teal, diff --git a/frontend/src/components/ui/input-group.tsx b/frontend/src/components/ui/input-group.tsx index 3a36aaea..8f80c94c 100644 --- a/frontend/src/components/ui/input-group.tsx +++ b/frontend/src/components/ui/input-group.tsx @@ -27,6 +27,25 @@ export const InputGroup = React.forwardRef( const child = React.Children.only>(children); + const filteredProps = Object.fromEntries( + Object.entries(children.props).filter(([, value]) => value !== undefined), + ); + + const getBorderColor = (color: unknown): string => { + if (color === 'red.500' || color === 'red') return '#ef4444'; + if (typeof color === 'string' && color.startsWith('#')) return color; + return '#d1d5db'; + }; + + const borderColor = filteredProps.borderColor || '#d1d5db'; + const borderColorHex = getBorderColor(borderColor); + const mergedProps = { + ...filteredProps, + border: filteredProps.border || `1px solid ${borderColorHex}`, + borderColor: borderColor, + boxShadow: filteredProps.boxShadow || '0 1px 2px 0 rgba(0, 0, 0, 0.05)', + } as Record; + return ( {startElement && ( @@ -39,14 +58,12 @@ export const InputGroup = React.forwardRef( ps: `calc(var(--input-height) - ${startOffset})`, }), ...(endElement && { pe: `calc(var(--input-height) - ${endOffset})` }), - ...children.props, - borderRadius: 'md', - border: '1px solid', - borderColor: 'gray.300', - padding: '10px', - fontSize: 'md', - _focus: { borderColor: 'teal.500', boxShadow: '0 0 0 1px #319795' }, - _placeholder: { color: 'gray.400' }, + borderRadius: mergedProps.borderRadius || 'md', + padding: mergedProps.padding || '10px', + fontSize: mergedProps.fontSize || 'md', + _focus: mergedProps._focus || { borderColor: 'teal.500', boxShadow: '0 0 0 1px #319795' }, + _placeholder: mergedProps._placeholder || { color: 'gray.400' }, + ...mergedProps, })} {endElement && ( diff --git a/frontend/src/pages/participant/intake/index.tsx b/frontend/src/pages/participant/intake/index.tsx index 736e6567..80bbed06 100644 --- a/frontend/src/pages/participant/intake/index.tsx +++ b/frontend/src/pages/participant/intake/index.tsx @@ -198,7 +198,7 @@ export default function ParticipantIntakePage() { return ( - + {currentStepType === 'experience-personal' && ( diff --git a/frontend/src/pages/volunteer/intake/index.tsx b/frontend/src/pages/volunteer/intake/index.tsx index 3b3d4026..a03fcf44 100644 --- a/frontend/src/pages/volunteer/intake/index.tsx +++ b/frontend/src/pages/volunteer/intake/index.tsx @@ -207,7 +207,7 @@ export default function VolunteerIntakePage() { return ( - + {currentStepType === 'experience-personal' && ( diff --git a/frontend/src/pages/volunteer/secondary.tsx b/frontend/src/pages/volunteer/secondary.tsx index 76633925..90fe6cbe 100644 --- a/frontend/src/pages/volunteer/secondary.tsx +++ b/frontend/src/pages/volunteer/secondary.tsx @@ -40,7 +40,7 @@ export default function VolunteerSecondary() { const handleInputBlur = (e: React.FocusEvent) => { e.target.style.borderColor = 'rgb(209 213 219)'; - e.target.style.boxShadow = 'none'; + e.target.style.boxShadow = '0 1px 2px 0 rgba(0, 0, 0, 0.05)'; }; const handleSubmit = async () => { @@ -178,9 +178,9 @@ export default function VolunteerSecondary() { }} placeholder="Type here...." className="w-3/5 h-56 p-4 border border-gray-300 rounded-lg resize-none outline-none bg-white text-gray-900 placeholder-gray-400" + style={{ boxShadow: '0 1px 2px 0 rgba(0, 0, 0, 0.05)', fontSize: '16px' }} onFocus={handleInputFocus} onBlur={handleInputBlur} - style={{ fontSize: '16px' }} />
handleReferenceChange(0, 'name', e.target.value)} placeholder="John Doe" className="w-full p-3 border border-gray-300 rounded-lg outline-none bg-white text-gray-900" + style={{ boxShadow: '0 1px 2px 0 rgba(0, 0, 0, 0.05)' }} onFocus={handleInputFocus} onBlur={handleInputBlur} /> @@ -262,6 +263,7 @@ export default function VolunteerSecondary() { onChange={(e) => handleReferenceChange(0, 'email', e.target.value)} placeholder="john.doe@gmail.com" className="w-full p-3 border border-gray-300 rounded-lg outline-none bg-white text-gray-900" + style={{ boxShadow: '0 1px 2px 0 rgba(0, 0, 0, 0.05)' }} onFocus={handleInputFocus} onBlur={handleInputBlur} /> @@ -276,6 +278,7 @@ export default function VolunteerSecondary() { onChange={(e) => handleReferenceChange(0, 'phone', e.target.value)} placeholder="###-###-####" className="w-full p-3 border border-gray-300 rounded-lg outline-none bg-white text-gray-900" + style={{ boxShadow: '0 1px 2px 0 rgba(0, 0, 0, 0.05)' }} onFocus={handleInputFocus} onBlur={handleInputBlur} /> @@ -295,6 +298,7 @@ export default function VolunteerSecondary() { onChange={(e) => handleReferenceChange(1, 'name', e.target.value)} placeholder="John Doe" className="w-full p-3 border border-gray-300 rounded-lg outline-none bg-white text-gray-900" + style={{ boxShadow: '0 1px 2px 0 rgba(0, 0, 0, 0.05)' }} onFocus={handleInputFocus} onBlur={handleInputBlur} /> @@ -307,6 +311,7 @@ export default function VolunteerSecondary() { onChange={(e) => handleReferenceChange(1, 'email', e.target.value)} placeholder="john.doe@gmail.com" className="w-full p-3 border border-gray-300 rounded-lg outline-none bg-white text-gray-900" + style={{ boxShadow: '0 1px 2px 0 rgba(0, 0, 0, 0.05)' }} onFocus={handleInputFocus} onBlur={handleInputBlur} /> @@ -321,6 +326,7 @@ export default function VolunteerSecondary() { onChange={(e) => handleReferenceChange(1, 'phone', e.target.value)} placeholder="###-###-####" className="w-full p-3 border border-gray-300 rounded-lg outline-none bg-white text-gray-900" + style={{ boxShadow: '0 1px 2px 0 rgba(0, 0, 0, 0.05)' }} onFocus={handleInputFocus} onBlur={handleInputBlur} /> @@ -338,6 +344,7 @@ export default function VolunteerSecondary() { onChange={(e) => setAdditionalComments(e.target.value)} placeholder="Type here...." className="w-2/3 h-32 p-4 border border-gray-300 rounded-lg resize-none outline-none bg-white text-gray-900 placeholder-gray-400" + style={{ boxShadow: '0 1px 2px 0 rgba(0, 0, 0, 0.05)' }} onFocus={handleInputFocus} onBlur={handleInputBlur} /> From 6ab00dbbaf9c5761cbf7a3f0cc118a31c7b3ffab Mon Sep 17 00:00:00 2001 From: YashK2005 Date: Mon, 10 Nov 2025 01:00:26 -0500 Subject: [PATCH 5/7] Add reusable single-select and multi-select dropdown components --- .../components/ui/multi-select-dropdown.tsx | 231 ++++++++++++++++++ .../components/ui/single-select-dropdown.tsx | 219 +++++++++++++++++ 2 files changed, 450 insertions(+) create mode 100644 frontend/src/components/ui/multi-select-dropdown.tsx create mode 100644 frontend/src/components/ui/single-select-dropdown.tsx diff --git a/frontend/src/components/ui/multi-select-dropdown.tsx b/frontend/src/components/ui/multi-select-dropdown.tsx new file mode 100644 index 00000000..4b21cad3 --- /dev/null +++ b/frontend/src/components/ui/multi-select-dropdown.tsx @@ -0,0 +1,231 @@ +import React, { useState, useEffect, useRef } from 'react'; +import { Box, Text } from '@chakra-ui/react'; +import { COLORS } from '@/constants/form'; + +interface MultiSelectDropdownProps { + options: string[]; + selectedValues: string[]; + onSelectionChange: (values: string[]) => void; + placeholder: string; + error?: boolean; +} + +export const MultiSelectDropdown: React.FC = ({ + options, + selectedValues, + onSelectionChange, + placeholder, + error, +}) => { + const [isOpen, setIsOpen] = useState(false); + const dropdownRef = useRef(null); + + // Close dropdown when clicking outside + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) { + setIsOpen(false); + } + }; + + if (isOpen) { + document.addEventListener('mousedown', handleClickOutside); + } + + return () => { + document.removeEventListener('mousedown', handleClickOutside); + }; + }, [isOpen]); + + const handleCheckboxChange = (option: string, checked: boolean) => { + if (checked) { + onSelectionChange([...selectedValues, option]); + } else { + onSelectionChange(selectedValues.filter((val) => val !== option)); + } + }; + + const handleRemoveChip = (e: React.MouseEvent, option: string) => { + e.stopPropagation(); + onSelectionChange(selectedValues.filter((val) => val !== option)); + }; + + return ( + + + + )) + ) : ( + + {placeholder} + + )} + + + {isOpen ? '▲' : '▼'} + + + + {isOpen && ( + + {options.map((option) => { + const isSelected = selectedValues.includes(option); + return ( + { + e.stopPropagation(); + handleCheckboxChange(option, !isSelected); + }} + borderBottom="1px solid #f3f4f6" + _last={{ borderBottom: 'none' }} + > + + + {option} + + + ); + })} + + )} + + ); +}; diff --git a/frontend/src/components/ui/single-select-dropdown.tsx b/frontend/src/components/ui/single-select-dropdown.tsx new file mode 100644 index 00000000..e6515532 --- /dev/null +++ b/frontend/src/components/ui/single-select-dropdown.tsx @@ -0,0 +1,219 @@ +import React, { useState, useEffect, useRef } from 'react'; +import { Box, Text } from '@chakra-ui/react'; +import { COLORS } from '@/constants/form'; + +interface SingleSelectDropdownProps { + options: string[]; + selectedValue: string; + onSelectionChange: (value: string) => void; + placeholder: string; + error?: boolean; +} + +export const SingleSelectDropdown: React.FC = ({ + options, + selectedValue, + onSelectionChange, + placeholder, + error, +}) => { + const [isOpen, setIsOpen] = useState(false); + const dropdownRef = useRef(null); + + // Close dropdown when clicking outside + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) { + setIsOpen(false); + } + }; + + if (isOpen) { + document.addEventListener('mousedown', handleClickOutside); + } + + return () => { + document.removeEventListener('mousedown', handleClickOutside); + }; + }, [isOpen]); + + const handleSelect = (option: string) => { + onSelectionChange(option); + setIsOpen(false); + }; + + const handleRemove = (e: React.MouseEvent) => { + e.stopPropagation(); + onSelectionChange(''); + }; + + return ( + + + + ) : ( + + {placeholder} + + )} + + + {isOpen ? '▲' : '▼'} + + + + {isOpen && ( + + {options.map((option) => ( + handleSelect(option)} + borderBottom="1px solid #f3f4f6" + _last={{ borderBottom: 'none' }} + > + + + {option} + + + ))} + + )} + + ); +}; From 1830a280845a8b2ee25be18916483d1459cd1257 Mon Sep 17 00:00:00 2001 From: YashK2005 Date: Mon, 10 Nov 2025 01:18:16 -0500 Subject: [PATCH 6/7] Update seeder + tests to reflect updated dropdown options --- backend/app/seeds/users.py | 16 +++++----- backend/docs/intake_api.md | 2 +- .../tests/unit/test_intake_form_processor.py | 30 +++++++++---------- 3 files changed, 24 insertions(+), 24 deletions(-) diff --git a/backend/app/seeds/users.py b/backend/app/seeds/users.py index 80e1be00..ca86311a 100644 --- a/backend/app/seeds/users.py +++ b/backend/app/seeds/users.py @@ -33,7 +33,7 @@ def seed_users(session: Session) -> None: "gender_identity": "Woman", "pronouns": ["she", "her"], "ethnic_group": ["White/Caucasian"], - "marital_status": "Married", + "marital_status": "Married/Common Law", "has_kids": "Yes", "diagnosis": "Acute Lymphoblastic Leukemia", "date_of_diagnosis": date(2023, 8, 10), @@ -83,7 +83,7 @@ def seed_users(session: Session) -> None: "gender_identity": "Woman", "pronouns": ["she", "her"], "ethnic_group": ["Hispanic/Latino"], - "marital_status": "Married", + "marital_status": "Married/Common Law", "has_kids": "Yes", "has_blood_cancer": "No", "caring_for_someone": "Yes", @@ -115,7 +115,7 @@ def seed_users(session: Session) -> None: "gender_identity": "Man", "pronouns": ["he", "him"], "ethnic_group": ["White/Caucasian"], - "marital_status": "Married", + "marital_status": "Married/Common Law", "has_kids": "Yes", "diagnosis": "Acute Lymphoblastic Leukemia", "date_of_diagnosis": date(2018, 4, 20), # Survivor @@ -149,7 +149,7 @@ def seed_users(session: Session) -> None: "gender_identity": "Woman", # Same as Sarah "pronouns": ["she", "her"], "ethnic_group": ["Asian"], - "marital_status": "Married", # Same as Sarah + "marital_status": "Married/Common Law", # Same as Sarah "has_kids": "Yes", # Same as Sarah "diagnosis": "Acute Lymphoblastic Leukemia", # Same diagnosis as Sarah! "date_of_diagnosis": date(2020, 8, 15), # Survivor @@ -200,7 +200,7 @@ def seed_users(session: Session) -> None: "gender_identity": "Woman", # Same as Sarah "pronouns": ["she", "her"], "ethnic_group": ["Asian"], - "marital_status": "Married", # Same as Sarah + "marital_status": "Married/Common Law", # Same as Sarah "has_kids": "Yes", # Same as Sarah "diagnosis": "Acute Lymphoblastic Leukemia", # Same diagnosis as Sarah! "date_of_diagnosis": date(2019, 5, 10), # Survivor @@ -225,7 +225,7 @@ def seed_users(session: Session) -> None: "gender_identity": "Woman", # Same as Sarah "pronouns": ["she", "her"], "ethnic_group": ["Hispanic/Latino"], - "marital_status": "Married", # Same as Sarah + "marital_status": "Married/Common Law", # Same as Sarah "has_kids": "Yes", # Same as Sarah "diagnosis": "Acute Lymphoblastic Leukemia", # Same diagnosis as Sarah! "date_of_diagnosis": date(2021, 3, 18), # Survivor @@ -250,7 +250,7 @@ def seed_users(session: Session) -> None: "gender_identity": "Woman", # Same as Sarah "pronouns": ["she", "her"], "ethnic_group": ["White/Caucasian"], - "marital_status": "Married", # Same as Sarah + "marital_status": "Married/Common Law", # Same as Sarah "has_kids": "Yes", # Same as Sarah "diagnosis": "Acute Lymphoblastic Leukemia", # Same diagnosis as Sarah! "date_of_diagnosis": date(2018, 9, 25), # Survivor @@ -276,7 +276,7 @@ def seed_users(session: Session) -> None: "gender_identity": "Woman", "pronouns": ["she", "her"], "ethnic_group": ["White/Caucasian"], - "marital_status": "Married", + "marital_status": "Married/Common Law", "has_kids": "Yes", "has_blood_cancer": "No", # Not a cancer patient herself "caring_for_someone": "Yes", # Is a caregiver diff --git a/backend/docs/intake_api.md b/backend/docs/intake_api.md index b7fd40e2..75f951cf 100644 --- a/backend/docs/intake_api.md +++ b/backend/docs/intake_api.md @@ -35,7 +35,7 @@ Create a new form submission and process it into structured data. "pronouns": ["array of strings (optional)"], "ethnicGroup": ["array of strings (optional)"], "maritalStatus": "string (optional)", - "hasKids": "yes|no|prefer not to say (optional)", + "hasKids": "Yes|No|Prefer not to answer (optional)", "ethnicGroupCustom": "string (optional)", "genderIdentityCustom": "string (optional)" }, diff --git a/backend/tests/unit/test_intake_form_processor.py b/backend/tests/unit/test_intake_form_processor.py index 47f6a71e..c861033f 100644 --- a/backend/tests/unit/test_intake_form_processor.py +++ b/backend/tests/unit/test_intake_form_processor.py @@ -158,8 +158,8 @@ def test_participant_with_cancer_only(db_session, test_user): "gender_identity": "Male", "pronouns": ["he", "him"], "ethnic_group": ["White"], - "marital_status": "Married", - "has_kids": "yes", + "marital_status": "Married/Common Law", + "has_kids": "Yes", }, "cancer_experience": { "diagnosis": "Leukemia", @@ -185,8 +185,8 @@ def test_participant_with_cancer_only(db_session, test_user): assert user_data.gender_identity == "Male" assert user_data.pronouns == ["he", "him"] assert user_data.ethnic_group == ["White"] - assert user_data.marital_status == "Married" - assert user_data.has_kids == "yes" + assert user_data.marital_status == "Married/Common Law" + assert user_data.has_kids == "Yes" # Assert - Cancer Experience assert user_data.diagnosis == "Leukemia" @@ -247,7 +247,7 @@ def test_volunteer_caregiver_experience_processing(db_session, test_user): "pronouns": ["she", "her"], "ethnic_group": ["Indigenous"], "marital_status": "Divorced", - "has_kids": "yes", + "has_kids": "Yes", }, "caregiver_experience": { "experiences": ["Anxiety", "Depression"], @@ -339,8 +339,8 @@ def test_form_submission_json_structure(db_session, test_user): "pronouns": ["they", "them"], "ethnic_group": ["Other", "Asian"], "ethnic_group_custom": "Mixed heritage - Filipino and Indigenous", - "marital_status": "Common-law", - "has_kids": "yes", + "marital_status": "Married/Common Law", + "has_kids": "Yes", }, "cancer_experience": { "diagnosis": "Ovarian Cancer", @@ -471,8 +471,8 @@ def test_participant_caregiver_without_cancer(db_session, test_user): "gender_identity": "Female", "pronouns": ["she", "her"], "ethnic_group": ["Black"], - "marital_status": "Married", - "has_kids": "yes", + "marital_status": "Married/Common Law", + "has_kids": "Yes", }, "loved_one": { "demographics": {"gender_identity": "Male", "age": "55-64"}, @@ -550,8 +550,8 @@ def test_participant_cancer_patient_and_caregiver(db_session, test_user): "pronouns": ["he", "him"], "ethnic_group": ["White", "Other"], "ethnic_group_custom": "Mixed European heritage", - "marital_status": "Married", - "has_kids": "yes", + "marital_status": "Married/Common Law", + "has_kids": "Yes", }, "cancer_experience": { "diagnosis": "Lymphoma", @@ -658,7 +658,7 @@ def test_participant_no_cancer_experience(db_session, test_user): assert "Asian" in user_data.ethnic_group assert "Indigenous" in user_data.ethnic_group assert user_data.marital_status == "Single" - assert user_data.has_kids == "no" + assert user_data.has_kids == "No" # Assert - No cancer-related data assert user_data.diagnosis is None @@ -770,8 +770,8 @@ def test_volunteer_cancer_patient_and_caregiver(db_session, test_user): "gender_identity": "Female", "pronouns": ["she", "her"], "ethnic_group": ["White"], - "marital_status": "Married", - "has_kids": "yes", + "marital_status": "Married/Common Law", + "has_kids": "Yes", }, "cancer_experience": { "diagnosis": "Breast Cancer", @@ -870,7 +870,7 @@ def test_volunteer_no_cancer_experience(db_session, test_user): assert user_data.pronouns == ["he", "him"] assert user_data.ethnic_group == ["White"] assert user_data.marital_status == "Single" - assert user_data.has_kids == "no" + assert user_data.has_kids == "No" # Assert - No cancer-related data assert user_data.diagnosis is None From 1a413457e8b316dfac1532e570eebe60b2ffdd0a Mon Sep 17 00:00:00 2001 From: YashK2005 Date: Mon, 10 Nov 2025 01:22:03 -0500 Subject: [PATCH 7/7] linting --- .../tests/unit/test_intake_form_processor.py | 4 +-- .../components/ui/multi-select-dropdown.tsx | 23 ++----------- .../components/ui/single-select-dropdown.tsx | 33 +++++-------------- 3 files changed, 14 insertions(+), 46 deletions(-) diff --git a/backend/tests/unit/test_intake_form_processor.py b/backend/tests/unit/test_intake_form_processor.py index c861033f..15c958f7 100644 --- a/backend/tests/unit/test_intake_form_processor.py +++ b/backend/tests/unit/test_intake_form_processor.py @@ -635,7 +635,7 @@ def test_participant_no_cancer_experience(db_session, test_user): "pronouns": ["she", "her"], "ethnic_group": ["Asian", "Indigenous"], "marital_status": "Single", - "has_kids": "no", + "has_kids": "No", }, # No cancer_experience, caregiver_experience, or loved_one sections } @@ -848,7 +848,7 @@ def test_volunteer_no_cancer_experience(db_session, test_user): "pronouns": ["he", "him"], "ethnic_group": ["White"], "marital_status": "Single", - "has_kids": "no", + "has_kids": "No", }, # No cancer_experience, caregiver_experience, or loved_one sections } diff --git a/frontend/src/components/ui/multi-select-dropdown.tsx b/frontend/src/components/ui/multi-select-dropdown.tsx index 4b21cad3..e82ea5f5 100644 --- a/frontend/src/components/ui/multi-select-dropdown.tsx +++ b/frontend/src/components/ui/multi-select-dropdown.tsx @@ -83,14 +83,7 @@ export const MultiSelectDropdown: React.FC = ({ e.currentTarget.style.boxShadow = '0 1px 2px 0 rgba(0, 0, 0, 0.05)'; }} > - + {selectedValues.length > 0 ? ( selectedValues.map((value) => ( = ({ )) ) : ( - + {placeholder} )} - + {isOpen ? '▲' : '▼'} diff --git a/frontend/src/components/ui/single-select-dropdown.tsx b/frontend/src/components/ui/single-select-dropdown.tsx index e6515532..d009aaac 100644 --- a/frontend/src/components/ui/single-select-dropdown.tsx +++ b/frontend/src/components/ui/single-select-dropdown.tsx @@ -80,14 +80,7 @@ export const SingleSelectDropdown: React.FC = ({ e.currentTarget.style.boxShadow = '0 1px 2px 0 rgba(0, 0, 0, 0.05)'; }} > - + {selectedValue ? ( = ({ ) : ( - + {placeholder} )} - + {isOpen ? '▲' : '▼'} @@ -189,12 +172,14 @@ export const SingleSelectDropdown: React.FC = ({ appearance: 'none', WebkitAppearance: 'none', MozAppearance: 'none', - border: selectedValue === option ? `1px solid ${COLORS.teal}` : '1px solid #d1d5db', + border: + selectedValue === option ? `1px solid ${COLORS.teal}` : '1px solid #d1d5db', borderRadius: '3px', backgroundColor: selectedValue === option ? COLORS.teal : 'white', - backgroundImage: selectedValue === option - ? "url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath fill='white' d='M10 3L4.5 8.5L2 6l1.5-1.5L4.5 6L8.5 2L10 3z'/%3E%3C/svg%3E\")" - : 'none', + backgroundImage: + selectedValue === option + ? "url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath fill='white' d='M10 3L4.5 8.5L2 6l1.5-1.5L4.5 6L8.5 2L10 3z'/%3E%3C/svg%3E\")" + : 'none', backgroundRepeat: 'no-repeat', backgroundPosition: 'center', backgroundSize: '12px 12px',