diff --git a/app/public/mockServiceWorker.js b/app/public/mockServiceWorker.js new file mode 100644 index 00000000..cb86abcd --- /dev/null +++ b/app/public/mockServiceWorker.js @@ -0,0 +1,287 @@ +/* eslint-disable */ +/* tslint:disable */ + +/** + * Mock Service Worker (2.2.3). + * @see https://github.com/mswjs/msw + * - Please do NOT modify this file. + * - Please do NOT serve this file on production. + */ + +const INTEGRITY_CHECKSUM = '223d191a56023cd36aa88c802961b911' +const IS_MOCKED_RESPONSE = Symbol('isMockedResponse') +const activeClientIds = new Set() + +self.addEventListener('install', function () { + self.skipWaiting() +}) + +self.addEventListener('activate', function (event) { + event.waitUntil(self.clients.claim()) +}) + +self.addEventListener('message', async function (event) { + const clientId = event.source.id + + if (!clientId || !self.clients) { + return + } + + const client = await self.clients.get(clientId) + + if (!client) { + return + } + + const allClients = await self.clients.matchAll({ + type: 'window', + }) + + switch (event.data) { + case 'KEEPALIVE_REQUEST': { + sendToClient(client, { + type: 'KEEPALIVE_RESPONSE', + }) + break + } + + case 'INTEGRITY_CHECK_REQUEST': { + sendToClient(client, { + type: 'INTEGRITY_CHECK_RESPONSE', + payload: INTEGRITY_CHECKSUM, + }) + break + } + + case 'MOCK_ACTIVATE': { + activeClientIds.add(clientId) + + sendToClient(client, { + type: 'MOCKING_ENABLED', + payload: true, + }) + break + } + + case 'MOCK_DEACTIVATE': { + activeClientIds.delete(clientId) + break + } + + case 'CLIENT_CLOSED': { + activeClientIds.delete(clientId) + + const remainingClients = allClients.filter((client) => { + return client.id !== clientId + }) + + // Unregister itself when there are no more clients + if (remainingClients.length === 0) { + self.registration.unregister() + } + + break + } + } +}) + +self.addEventListener('fetch', function (event) { + const { request } = event + + // Bypass navigation requests. + if (request.mode === 'navigate') { + return + } + + // Opening the DevTools triggers the "only-if-cached" request + // that cannot be handled by the worker. Bypass such requests. + if (request.cache === 'only-if-cached' && request.mode !== 'same-origin') { + return + } + + // Bypass all requests when there are no active clients. + // Prevents the self-unregistered worked from handling requests + // after it's been deleted (still remains active until the next reload). + if (activeClientIds.size === 0) { + return + } + + // Generate unique request ID. + const requestId = crypto.randomUUID() + event.respondWith(handleRequest(event, requestId)) +}) + +async function handleRequest(event, requestId) { + const client = await resolveMainClient(event) + const response = await getResponse(event, client, requestId) + + // Send back the response clone for the "response:*" life-cycle events. + // Ensure MSW is active and ready to handle the message, otherwise + // this message will pend indefinitely. + if (client && activeClientIds.has(client.id)) { + ;(async function () { + const responseClone = response.clone() + + sendToClient( + client, + { + type: 'RESPONSE', + payload: { + requestId, + isMockedResponse: IS_MOCKED_RESPONSE in response, + type: responseClone.type, + status: responseClone.status, + statusText: responseClone.statusText, + body: responseClone.body, + headers: Object.fromEntries(responseClone.headers.entries()), + }, + }, + [responseClone.body], + ) + })() + } + + return response +} + +// Resolve the main client for the given event. +// Client that issues a request doesn't necessarily equal the client +// that registered the worker. It's with the latter the worker should +// communicate with during the response resolving phase. +async function resolveMainClient(event) { + const client = await self.clients.get(event.clientId) + + if (client?.frameType === 'top-level') { + return client + } + + const allClients = await self.clients.matchAll({ + type: 'window', + }) + + return allClients + .filter((client) => { + // Get only those clients that are currently visible. + return client.visibilityState === 'visible' + }) + .find((client) => { + // Find the client ID that's recorded in the + // set of clients that have registered the worker. + return activeClientIds.has(client.id) + }) +} + +async function getResponse(event, client, requestId) { + const { request } = event + + // Clone the request because it might've been already used + // (i.e. its body has been read and sent to the client). + const requestClone = request.clone() + + function passthrough() { + const headers = Object.fromEntries(requestClone.headers.entries()) + + // Remove internal MSW request header so the passthrough request + // complies with any potential CORS preflight checks on the server. + // Some servers forbid unknown request headers. + delete headers['x-msw-intention'] + + return fetch(requestClone, { headers }) + } + + // Bypass mocking when the client is not active. + if (!client) { + return passthrough() + } + + // Bypass initial page load requests (i.e. static assets). + // The absence of the immediate/parent client in the map of the active clients + // means that MSW hasn't dispatched the "MOCK_ACTIVATE" event yet + // and is not ready to handle requests. + if (!activeClientIds.has(client.id)) { + return passthrough() + } + + // Bypass requests with the explicit bypass header. + // Such requests can be issued by "ctx.fetch()". + const mswIntention = request.headers.get('x-msw-intention') + if (['bypass', 'passthrough'].includes(mswIntention)) { + return passthrough() + } + + // Notify the client that a request has been intercepted. + const requestBuffer = await request.arrayBuffer() + const clientMessage = await sendToClient( + client, + { + type: 'REQUEST', + payload: { + id: requestId, + url: request.url, + mode: request.mode, + method: request.method, + headers: Object.fromEntries(request.headers.entries()), + cache: request.cache, + credentials: request.credentials, + destination: request.destination, + integrity: request.integrity, + redirect: request.redirect, + referrer: request.referrer, + referrerPolicy: request.referrerPolicy, + body: requestBuffer, + keepalive: request.keepalive, + }, + }, + [requestBuffer], + ) + + switch (clientMessage.type) { + case 'MOCK_RESPONSE': { + return respondWithMock(clientMessage.data) + } + + case 'MOCK_NOT_FOUND': { + return passthrough() + } + } + + return passthrough() +} + +function sendToClient(client, message, transferrables = []) { + return new Promise((resolve, reject) => { + const channel = new MessageChannel() + + channel.port1.onmessage = (event) => { + if (event.data && event.data.error) { + return reject(event.data.error) + } + + resolve(event.data) + } + + client.postMessage( + message, + [channel.port2].concat(transferrables.filter(Boolean)), + ) + }) +} + +async function respondWithMock(response) { + // Setting response status code to 0 is a no-op. + // However, when responding with a "Response.error()", the produced Response + // instance will have status code set to 0. Since it's not possible to create + // a Response instance with status code 0, handle that use-case separately. + if (response.status === 0) { + return Response.error() + } + + const mockedResponse = new Response(response.body, response) + + Reflect.defineProperty(mockedResponse, IS_MOCKED_RESPONSE, { + value: true, + enumerable: true, + }) + + return mockedResponse +} diff --git a/app/src/components/common/__tests__/GuestInviteButton.test.tsx b/app/src/components/common/__tests__/GuestInviteButton.test.tsx index cbb06980..63cba846 100644 --- a/app/src/components/common/__tests__/GuestInviteButton.test.tsx +++ b/app/src/components/common/__tests__/GuestInviteButton.test.tsx @@ -113,7 +113,7 @@ describe('', () => { const message = 'There was an error sending the invite.'; server.use( - http.post(`/api/auth/invite`, () => { + http.post('/api/auth/invite', () => { return HttpResponse.json( { message, diff --git a/app/src/components/dashboard/DashboardTask.tsx b/app/src/components/dashboard/DashboardTask.tsx index 0e0a9c75..6d6295bc 100644 --- a/app/src/components/dashboard/DashboardTask.tsx +++ b/app/src/components/dashboard/DashboardTask.tsx @@ -2,34 +2,29 @@ import {Stack, Box, Typography, Button} from '@mui/material'; import CheckCircleOutlined from '@mui/icons-material/CheckCircleOutlined'; import LockIcon from '@mui/icons-material/Lock'; import AccessTimeIcon from '@mui/icons-material/AccessTime'; -import {useNavigate} from 'react-router-dom'; +import {Link} from 'react-router-dom'; import {SubTask} from '../../views/GuestApplicationTracker'; export type DashboardTaskProps = Pick< SubTask, - 'title' | 'description' | 'status' | 'buttonText' | 'url' + 'title' | 'description' | 'status' | 'linkText' | 'url' >; export const DashboardTask = ({ title, description, status, - buttonText, + linkText, url, }: DashboardTaskProps) => { - const navigate = useNavigate(); - + console.log(url); const statusIcons = { inProgress: , complete: , locked: , }; - const handleClick = () => { - navigate(url); - }; - return ( {statusIcons[status]} @@ -53,9 +48,10 @@ export const DashboardTask = ({ size="medium" variant="contained" disabled={status === 'complete'} - onClick={handleClick} + to={url} + component={Link} > - {buttonText} + {linkText} ) : ( Upcoming diff --git a/app/src/components/dashboard/DashboardTaskAccordion.tsx b/app/src/components/dashboard/DashboardTaskAccordion.tsx index 79bc657e..84df9541 100644 --- a/app/src/components/dashboard/DashboardTaskAccordion.tsx +++ b/app/src/components/dashboard/DashboardTaskAccordion.tsx @@ -69,14 +69,14 @@ export const DashboardTaskAccordion = ({ - {subTasks.map(({id, title, description, status, buttonText, url}) => { + {subTasks.map(({id, title, description, status, linkText, url}) => { return ( ); diff --git a/app/src/components/dashboard/__tests__/DashboardTask.test.tsx b/app/src/components/dashboard/__tests__/DashboardTask.test.tsx index d9fb4d0e..56189891 100644 --- a/app/src/components/dashboard/__tests__/DashboardTask.test.tsx +++ b/app/src/components/dashboard/__tests__/DashboardTask.test.tsx @@ -21,7 +21,7 @@ function setup(props?: Partial) { title: 'Application', status: 'inProgress', description: 'Start your guest application to move on to the next step.', - buttonText: 'Start Application', + linkText: 'Start Application', url: '/guest-application', ...props, }; @@ -48,17 +48,14 @@ describe('DashboardTask', () => { }); describe('when the task is in progress', () => { - it('should render a button to start the task', async () => { - const user = userEvent.setup(); + it('should render a link to start the task', async () => { + userEvent.setup(); const {props} = setup(); - const button = screen.getByRole('button', {name: props.buttonText}); + const link = screen.getByRole('link', {name: props.linkText}); - expect(button).toBeInTheDocument(); - expect(button).not.toBeDisabled(); - - await user.click(button); - expect(navigate).toHaveBeenCalledWith(props.url); + expect(link).toBeInTheDocument(); + expect(link).not.toBeDisabled(); }); it('should render clock icon', () => { @@ -69,12 +66,12 @@ describe('DashboardTask', () => { }); describe('when the task is complete', () => { - it('should render a disabled button', () => { + it('should render a disabled link', () => { const {props} = setup({status: 'complete'}); - const button = screen.getByRole('button', {name: props.buttonText}); + const link = screen.getByRole('link', {name: props.linkText}); - expect(button).toBeInTheDocument(); - expect(button).toBeDisabled(); + expect(link).toBeInTheDocument(); + expect(link).toHaveAttribute('aria-disabled', 'true'); }); it('should render check icon', () => { @@ -89,7 +86,7 @@ describe('DashboardTask', () => { const {props} = setup({status: 'locked'}); expect( - screen.queryByRole('button', {name: props.buttonText}), + screen.queryByRole('link', {name: props.linkText}), ).not.toBeInTheDocument(); expect(screen.getByText('Upcoming')).toBeInTheDocument(); }); diff --git a/app/src/components/dashboard/__tests__/DashboardTaskAccordion.test.tsx b/app/src/components/dashboard/__tests__/DashboardTaskAccordion.test.tsx index 7f76e0c4..835b69a9 100644 --- a/app/src/components/dashboard/__tests__/DashboardTaskAccordion.test.tsx +++ b/app/src/components/dashboard/__tests__/DashboardTaskAccordion.test.tsx @@ -15,7 +15,7 @@ const task: TaskAccordionProps = { title: 'Application', status: 'complete', description: 'Start your guest application to move on to the next step.', - buttonText: 'Start Application', + linkText: 'Start Application', url: '/guest-application', }, { @@ -23,7 +23,7 @@ const task: TaskAccordionProps = { title: 'Coordinator Interview', status: 'inProgress', description: 'Meet with your Coordinator to share more about yourself.', - buttonText: 'Schedule interview', + linkText: 'Schedule interview', url: '/schedule', }, { @@ -32,7 +32,7 @@ const task: TaskAccordionProps = { status: 'locked', description: 'Complete a training session to prepare you for the host home experience.', - buttonText: 'Schedule training', + linkText: 'Schedule training', url: '/schedule', }, ], diff --git a/app/src/components/intake-profile/AdditionaGuestsField.tsx b/app/src/components/intake-profile/AdditionaGuestsField.tsx new file mode 100644 index 00000000..9251c801 --- /dev/null +++ b/app/src/components/intake-profile/AdditionaGuestsField.tsx @@ -0,0 +1,99 @@ +import {faker} from '@faker-js/faker'; +import {Stack, Typography, Button, TextField} from '@mui/material'; +import {FormikErrors, FormikHandlers, FieldArray} from 'formik'; +import {InitialValues} from 'src/views/IntakeProfile'; +import {Guest} from '../../services/profile'; + +interface AdditionalGuestsFieldProps { + errors: FormikErrors; + guests: Guest[]; + fieldId: string; + groupId: string; + onChange: FormikHandlers['handleChange']; +} + +export const AdditionalGuestsField = ({ + errors, + guests, + fieldId, + groupId, + onChange, +}: AdditionalGuestsFieldProps) => { + return ( + + + {({push, remove}) => ( + <> + {guests.map((guest, index) => { + return ( + + + Guest {index + 1} + + + + + + + ); + })} + + + )} + + + ); +}; diff --git a/app/src/components/intake-profile/IntakeProfileGroups.tsx b/app/src/components/intake-profile/IntakeProfileGroups.tsx new file mode 100644 index 00000000..da739acb --- /dev/null +++ b/app/src/components/intake-profile/IntakeProfileGroups.tsx @@ -0,0 +1,193 @@ +import { + Container, + FormControl, + FormControlLabel, + FormHelperText, + MenuItem, + Radio, + RadioGroup, + Select, + Stack, + TextField, + Typography, +} from '@mui/material'; +import {FormikErrors, useFormikContext, FormikHandlers} from 'formik'; +import {useOutletContext} from 'react-router-dom'; + +import {Values, InitialValues} from 'src/views/IntakeProfile'; +import {AdditionalGuestsField} from './AdditionaGuestsField'; +import {FieldGroup, Fields, Guest} from 'src/services/profile'; + +interface OutletContext { + groupId: string; + fieldGroups: FieldGroup[]; +} + +export const FieldGroupList = () => { + const {groupId, fieldGroups} = useOutletContext(); + const {values, handleChange, errors} = useFormikContext(); + + if (fieldGroups === undefined || groupId === undefined) return null; + const fieldGroup = fieldGroups.find(group => group.id === groupId); + const fields = fieldGroup?.fields || []; + + return ( + + + {fieldGroup?.title} + {fields.map(field => { + return ( + + {field.title} + + + ); + })} + + + ); +}; + +interface RenderFieldProps { + groupId: string; + field: Fields; + values: Values; + handleChange: FormikHandlers['handleChange']; + errors: FormikErrors; +} + +export const RenderFields = ({ + groupId, + field, + values, + handleChange, + errors, +}: RenderFieldProps) => { + const props = { + name: `${groupId}.${field.id}`, + value: values[field.id], + onChange: handleChange, + }; + + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + const error = Boolean(errors[groupId]?.[field.id]); + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + const helperText = errors[groupId]?.[field.id]; + + switch (field.type) { + case 'short_text': + return ( + + ); + case 'long_text': + return ( + + ); + case 'number': + return ( + + ); + case 'email': + return ( + + ); + case 'yes_no': + return ( + + + } label="Yes" /> + } label="No" /> + + {helperText} + + ); + case 'dropdown': + if (field.properties.choices === undefined) + throw new Error('Invalid field type'); + + return ( + + + {helperText} + + ); + case 'multiple_choice': + return ( + + ); + case 'additional_guests': + return ( + + ); + default: + throw new Error('Invalid field type'); + } +}; diff --git a/app/src/main.tsx b/app/src/main.tsx index 70f19de8..ae888de2 100644 --- a/app/src/main.tsx +++ b/app/src/main.tsx @@ -43,6 +43,7 @@ import { Language, About, Review, + IntakeProfile, } from './views'; import {AccountVerification} from './views/AccountVerification'; @@ -53,6 +54,8 @@ import { GuestDashboardLayout, } from './components/layout'; import {GuestApplicationContext} from './components/common/GuestApplicationContext'; +import {FieldGroupList} from './components/intake-profile/IntakeProfileGroups'; +import {enableMocking} from './utils/test/browser'; function HuuApp() { const [session] = useSessionMutation(); @@ -123,6 +126,16 @@ function HuuApp() { } /> } /> + + + + } + > + } /> + - - - - - - - - - - - , -); +enableMocking().then(() => { + ReactDOM.createRoot(appRoot).render( + + + + + + + + + + + + , + ); +}); diff --git a/app/src/services/profile.ts b/app/src/services/profile.ts new file mode 100644 index 00000000..5394baec --- /dev/null +++ b/app/src/services/profile.ts @@ -0,0 +1,104 @@ +import {api} from './api'; + +export const fieldTypes = [ + 'short_text', + 'long_text', + 'number', + 'email', + 'dropdown', + 'multiple_choice', + 'yes_no', + 'additional_guests', +] as const; + +type FieldTypeTuple = typeof fieldTypes; + +export type FieldTypes = FieldTypeTuple[number]; + +export interface Choice { + id: string; + label: string; +} + +export interface ReduiredIf { + field_id: string; + value: string; +} + +export interface Fields { + id: string; + title: string; + type: FieldTypes; + properties: { + description?: string; + randomize?: boolean; + alphabetical_order?: boolean; + allow_multiple_selection?: boolean; + allow_other_choice?: boolean; + choices?: Choice[]; + }; + validations: { + required?: boolean; + max_characters?: number; + required_if?: ReduiredIf; + }; +} + +export interface FieldGroup { + id: string; + title: string; + fields: Fields[]; +} + +export interface Guest { + id: string; + name: string; + dob: string; + relationship: string; +} + +export interface Response { + id: string; + fieldId: string; + value: string | Guest[] | undefined; +} + +const injectedRtkApi = api.injectEndpoints({ + endpoints: build => ({ + getProfile: build.query({ + query: queryArg => ({ + url: `/profile/${queryArg.profileId}`, + }), + }), + getResponses: build.query< + GetProfileResponsesApiResponse, + GetProfileResponsesApiArg + >({ + query: queryArg => ({ + url: `/profile/responses/${queryArg.userId}`, + }), + }), + }), + overrideExisting: false, +}); + +export {injectedRtkApi as hostAPI}; + +export interface GetProfileApiResponse { + id: string; + fieldGroups: FieldGroup[]; +} + +export interface GetProfileApiArg { + profileId: string | undefined; +} + +export interface GetProfileResponsesApiResponse { + responses: Response[]; +} + +export interface GetProfileResponsesApiArg { + userId: string | undefined; +} + +export const {useGetProfileQuery, useGetResponsesQuery} = injectedRtkApi; diff --git a/app/src/utils/test/browser.ts b/app/src/utils/test/browser.ts new file mode 100644 index 00000000..9999dc0e --- /dev/null +++ b/app/src/utils/test/browser.ts @@ -0,0 +1,32 @@ +import {setupWorker} from 'msw/browser'; +import {handlers as profileHandlers} from './handlers/profile'; + +export const worker = setupWorker(...profileHandlers); + +export const enableMocking = async () => { + if (process.env.NODE_ENV !== 'development') { + return; + } + + // `worker.start()` returns a Promise that resolves + // once the Service Worker is up and ready to intercept requests. + return worker.start({ + onUnhandledRequest(req, print) { + // Ignore any requests from these URLs. + const excludedRoutes = [ + '/api/auth/user', + '/api/auth/session', + '/api/auth/refresh', + ]; + + const isExcluded = excludedRoutes.some(route => req.url.includes(route)); + + if (isExcluded || !req.url.includes('/api/')) { + return; + } + + // Otherwise, print an unhandled request warning. + print.warning(); + }, + }); +}; diff --git a/app/src/utils/test/db/profile.ts b/app/src/utils/test/db/profile.ts new file mode 100644 index 00000000..eba76a22 --- /dev/null +++ b/app/src/utils/test/db/profile.ts @@ -0,0 +1,469 @@ +import {faker} from '@faker-js/faker'; +import {GetProfileApiResponse} from 'src/services/profile'; + +export const intakeProfiles: GetProfileApiResponse[] = [ + { + id: '1', + fieldGroups: [ + { + id: faker.string.numeric(4), + title: 'Basic Information', + fields: [ + { + id: faker.string.numeric(4), + title: 'First Name', + type: 'short_text', + properties: {}, + validations: { + required: true, + }, + }, + { + id: faker.string.numeric(4), + title: 'Last Name', + type: 'short_text', + properties: {}, + validations: { + required: true, + }, + }, + { + id: faker.string.numeric(4), + title: 'Date of Birth', + type: 'short_text', + properties: {}, + validations: { + required: true, + }, + }, + { + id: faker.string.numeric(4), + title: 'Gender Identity', + type: 'dropdown', + properties: { + choices: [ + {id: faker.string.numeric(4), label: 'Woman'}, + {id: faker.string.numeric(4), label: 'Man'}, + {id: faker.string.numeric(4), label: 'Questioning'}, + {id: faker.string.numeric(4), label: 'Transgender'}, + {id: faker.string.numeric(4), label: 'Non-binary'}, + { + id: faker.string.numeric(4), + label: 'Doesn’t know or prefers not to answer ', + }, + ], + }, + validations: { + required: true, + }, + }, + ], + }, + { + id: faker.string.numeric(4), + title: 'Contact Information', + fields: [ + { + id: faker.string.numeric(4), + title: 'Email', + type: 'email', + properties: {}, + validations: { + required: true, + }, + }, + { + id: faker.string.numeric(4), + title: 'Phone Number', + type: 'number', + properties: {}, + validations: { + required: true, + }, + }, + ], + }, + { + id: faker.string.numeric(4), + title: 'Other Guests/Pets', + fields: [ + { + id: faker.string.numeric(4), + title: 'Additional Guests', + type: 'additional_guests', + properties: {}, + validations: {}, + }, + { + id: faker.string.numeric(4), + title: 'Pets', + type: 'yes_no', + properties: {}, + validations: { + required: true, + }, + }, + ], + }, + { + id: faker.string.numeric(4), + title: 'Employment Information', + fields: [ + { + id: '5478', + title: 'Are you currently employed?', + type: 'yes_no', + properties: {}, + validations: { + required: true, + }, + }, + { + id: faker.string.numeric(4), + title: 'If yes, please describe your employment.', + type: 'long_text', + properties: {}, + validations: { + required_if: { + field_id: '5478', + value: 'yes', + }, + }, + }, + { + id: faker.string.numeric(4), + title: + 'If no, are you currently looking for work? If so, what type?', + type: 'long_text', + properties: {}, + validations: { + required_if: { + field_id: '5478', + value: 'no', + }, + }, + }, + ], + }, + { + id: faker.string.numeric(4), + title: 'Education', + fields: [ + { + id: '6478', + title: 'Are you enrolled in an Educational Program?', + type: 'yes_no', + properties: {}, + validations: { + required: true, + }, + }, + { + id: faker.string.numeric(4), + title: 'If yes, please describe the program.', + type: 'long_text', + properties: {}, + validations: { + required_if: { + field_id: '6478', + value: 'yes', + }, + }, + }, + { + id: faker.string.numeric(4), + title: + 'If no, are you hoping to enroll in an Educational Program? If so, what type?', + type: 'long_text', + properties: {}, + validations: { + required_if: { + field_id: '6478', + value: 'no', + }, + }, + }, + ], + }, + { + id: faker.string.numeric(4), + title: 'Language Proficiency', + fields: [ + { + id: '5479', + title: 'Are you bilingual or multilingual?', + type: 'yes_no', + properties: {}, + validations: { + required: true, + }, + }, + { + id: faker.string.numeric(4), + title: 'If yes, what languages do you speak?', + type: 'long_text', + properties: {}, + validations: { + required_if: { + field_id: '5479', + value: 'yes', + }, + }, + }, + ], + }, + { + id: faker.string.numeric(4), + title: 'Substance Use', + fields: [ + { + id: faker.string.numeric(4), + title: 'Do you smoke cigarettes?', + type: 'yes_no', + properties: {}, + validations: { + required: true, + }, + }, + { + id: faker.string.numeric(4), + title: 'Do you drink alcohol?', + type: 'yes_no', + properties: {}, + validations: { + required: true, + }, + }, + { + id: faker.string.numeric(4), + title: 'Do you use any other substances?', + type: 'yes_no', + properties: {}, + validations: { + required: true, + }, + }, + ], + }, + { + id: faker.string.numeric(4), + title: 'Mental Health', + fields: [ + { + id: faker.string.numeric(4), + title: 'Do you suffer mental illness?', + type: 'yes_no', + properties: {}, + validations: { + required: true, + }, + }, + ], + }, + { + id: faker.string.numeric(4), + title: 'Interest in Being a Guest', + fields: [ + { + id: faker.string.numeric(4), + title: + 'Please share how you think participating in the Host Homes Program will help you obtain long-term housing and meet your educational and/or employment goals:', + type: 'long_text', + properties: {}, + validations: { + required: true, + }, + }, + { + id: faker.string.numeric(4), + title: + 'What kind of relationship do you hope to have with your host home?', + type: 'long_text', + properties: {}, + validations: { + required: true, + }, + }, + { + id: faker.string.numeric(4), + title: + 'Please describe any challenges you foresee encountering in participating in the Host Homes Program.', + type: 'long_text', + properties: {}, + validations: { + required: true, + }, + }, + ], + }, + { + id: faker.string.numeric(4), + title: 'About You', + fields: [ + { + id: faker.string.numeric(4), + title: + 'Please take some time to write an introduction of yourself that you would feel comfortable with the Host Homes Coordinator sharing with a potential host. Feel free to talk about your interests, your story or anything else that you think would be important to share:', + type: 'long_text', + properties: {}, + validations: { + required: true, + }, + }, + ], + }, + ], + }, + { + id: '2', + fieldGroups: [ + { + id: faker.string.numeric(4), + title: 'Personal Information', + fields: [ + { + id: faker.string.numeric(4), + title: 'First Name', + type: 'short_text', + properties: {}, + validations: { + required: true, + }, + }, + { + id: faker.string.numeric(4), + title: 'Last Name', + type: 'short_text', + properties: {}, + validations: { + required: true, + }, + }, + { + id: faker.string.numeric(4), + title: 'Date of Birth', + type: 'short_text', + properties: {}, + validations: { + required: true, + }, + }, + ], + }, + { + id: faker.string.numeric(4), + title: 'Contact Information', + fields: [ + { + id: faker.string.numeric(4), + title: 'Email', + type: 'email', + properties: {}, + validations: { + required: true, + }, + }, + { + id: faker.string.numeric(4), + title: 'Phone Number', + type: 'number', + properties: {}, + validations: { + required: true, + }, + }, + ], + }, + { + id: faker.string.numeric(4), + title: 'Home Information', + fields: [ + { + id: faker.string.numeric(4), + title: + 'Do you have an extra bedroom or private space in their home?', + type: 'yes_no', + properties: {}, + validations: { + required: true, + }, + }, + { + id: faker.string.numeric(4), + title: + 'Are you able to provide Guest with access to a kitchen in which to prepare meals, store food and access to shared or private bathroom?', + type: 'yes_no', + properties: {}, + validations: { + required: true, + }, + }, + { + id: faker.string.numeric(4), + title: + 'Can you commit to hosting youth Guest for at least 3-6 months?', + type: 'yes_no', + properties: {}, + validations: { + required: true, + }, + }, + ], + }, + { + id: faker.string.numeric(4), + title: 'Restrictions', + fields: [ + { + id: faker.string.numeric(4), + title: 'Do you or anyone in your houshold smoke?', + type: 'yes_no', + properties: {}, + validations: { + required: true, + }, + }, + { + id: faker.string.numeric(4), + title: 'Is smoking allowed inside your home?', + type: 'yes_no', + properties: {}, + validations: { + required: true, + }, + }, + { + id: faker.string.numeric(4), + title: 'Do you or anyone in your household drink alcohol?', + type: 'yes_no', + properties: {}, + validations: { + required: true, + }, + }, + { + id: '9846', + title: 'Do you have concerns about your drinking?', + type: 'yes_no', + properties: {}, + validations: { + required: true, + }, + }, + { + id: faker.string.numeric(4), + title: 'If yes, please explain why you are concerned', + type: 'long_text', + properties: {}, + validations: { + required_if: { + field_id: '9846', + value: 'yes', + }, + }, + }, + ], + }, + ], + }, +]; diff --git a/app/src/utils/test/handlers.ts b/app/src/utils/test/handlers/auth.ts similarity index 84% rename from app/src/utils/test/handlers.ts rename to app/src/utils/test/handlers/auth.ts index 3d705e89..b346adc7 100644 --- a/app/src/utils/test/handlers.ts +++ b/app/src/utils/test/handlers/auth.ts @@ -1,6 +1,6 @@ import {http, HttpResponse} from 'msw'; -const handlers = [ +export const handlers = [ http.post('/api/auth/forgot_password', () => { return new HttpResponse(); }), @@ -8,5 +8,3 @@ const handlers = [ return new HttpResponse(); }), ]; - -export {handlers}; diff --git a/app/src/utils/test/handlers/profile.ts b/app/src/utils/test/handlers/profile.ts new file mode 100644 index 00000000..4c1f05d0 --- /dev/null +++ b/app/src/utils/test/handlers/profile.ts @@ -0,0 +1,19 @@ +import {http, HttpResponse} from 'msw'; +import {intakeProfiles} from '../db/profile'; + +export const handlers = [ + http.get('/api/profile/:profileId', req => { + const id = req.params.profileId; + const profile = intakeProfiles.find(p => p.id === id); + + if (profile) { + return HttpResponse.json(profile); + } + + return new HttpResponse(null, {status: 404}); + }), + + http.get('/api/profile/responses/:userId', () => { + return HttpResponse.json({responses: []}); + }), +]; diff --git a/app/src/utils/test/server.ts b/app/src/utils/test/server.ts index 867a7f68..90d5af70 100644 --- a/app/src/utils/test/server.ts +++ b/app/src/utils/test/server.ts @@ -1,7 +1,8 @@ import {setupServer} from 'msw/node'; -import {handlers} from './handlers'; +import {handlers as authHandlers} from './handlers/auth'; +import {handlers as profileHandlers} from './handlers/profile'; -const server = setupServer(...handlers); +const server = setupServer(...authHandlers, ...profileHandlers); server.events.on('request:start', ({request}) => { console.log('Outgoing:', request.method, request.url); diff --git a/app/src/views/GuestApplicationTracker.tsx b/app/src/views/GuestApplicationTracker.tsx index f18b6c81..5a8db5ae 100644 --- a/app/src/views/GuestApplicationTracker.tsx +++ b/app/src/views/GuestApplicationTracker.tsx @@ -19,7 +19,7 @@ export interface SubTask { title: string; status: TaskStatus; description: string; - buttonText: string; + linkText: string; url: string; } @@ -35,15 +35,15 @@ const tasks: Task[] = [ status: 'inProgress', description: 'Start your guest application to move on to the next step.', - buttonText: 'Start Application', - url: '/guest/application/welcome', + linkText: 'Start Application', + url: 'profile/1/group/1', }, { id: 2, title: 'Coordinator Interview', status: 'locked', description: 'Meet with your Coordinator to share more about yourself.', - buttonText: 'Schedule interview', + linkText: 'Schedule interview', url: '/schedule', }, { @@ -52,7 +52,7 @@ const tasks: Task[] = [ status: 'locked', description: 'Complete a training session to prepare you for the host home experience.', - buttonText: 'Schedule training', + linkText: 'Schedule training', url: '/schedule', }, ], @@ -67,7 +67,7 @@ const tasks: Task[] = [ title: 'Match with a Host', status: 'locked', description: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit.', - buttonText: 'Find hosts', + linkText: 'Find hosts', url: '/match', }, { @@ -75,7 +75,7 @@ const tasks: Task[] = [ title: 'Meeting with Host', status: 'locked', description: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit.', - buttonText: 'Schedule meeting', + linkText: 'Schedule meeting', url: '/schedule', }, ], @@ -90,7 +90,7 @@ const tasks: Task[] = [ title: 'Sign Agreement', status: 'locked', description: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit.', - buttonText: 'Sign agreement', + linkText: 'Sign agreement', url: '/schedule', }, ], diff --git a/app/src/views/IntakeProfile/constants/index.ts b/app/src/views/IntakeProfile/constants/index.ts new file mode 100644 index 00000000..2613dcfe --- /dev/null +++ b/app/src/views/IntakeProfile/constants/index.ts @@ -0,0 +1,179 @@ +import {faker} from '@faker-js/faker'; +import {array, object, string} from 'yup'; +import {InitialValues} from '..'; +import { + FieldGroup, + Fields, + fieldTypes, + FieldTypes, + Response, +} from '../../../services/profile'; + +export const fieldGroupBuilder = ( + options: Partial = {}, +): FieldGroup => ({ + id: faker.string.numeric(4), + title: faker.lorem.sentence({min: 2, max: 4}), + fields: [], + ...options, +}); + +export const fieldBuilder = (options: Partial = {}): Fields => ({ + id: faker.string.numeric(4), + title: faker.lorem.sentence({min: 5, max: 10}), + type: faker.helpers.arrayElement(fieldTypes), + ...options, + properties: { + ...options.properties, + }, + validations: { + ...options.validations, + }, +}); + +/** + * Creates an object used for the initial Formik valiues + * It takes the form of: + * { + * fieldGroupId: { + * fieldId: responseValue + * } + * } + */ +const fieldDefaultValue = (fieldType: FieldTypes) => { + switch (fieldType) { + case 'short_text': + return ''; + case 'long_text': + return ''; + case 'dropdown': + return ''; + case 'number': + return ''; + case 'additional_guests': + return []; + case 'email': + return ''; + case 'multiple_choice': + return ''; + case 'yes_no': + return ''; + default: + return ''; + } +}; + +export const createInitialValues = ( + fieldGroups: FieldGroup[], + responses: Response[], +): InitialValues => { + return fieldGroups.reduce((acc: InitialValues, fieldGroup) => { + const fields = fieldGroup.fields.reduce((acc, field) => { + return { + ...acc, + [field.id]: + responses.find(response => response.fieldId === field.id)?.value || + fieldDefaultValue(field.type), + }; + }, {}); + + return { + ...acc, + [fieldGroup.id]: {...fields}, + }; + }, {}); +}; + +/** + * Creates a validation schema for Formik based on field type + * It takes the form of: + * { + * fieldGroupId: { + * fieldId: validationSchema + * } + * } + */ + +const phoneRegExp = + /^((\\+[1-9]{1,4}[ \\-]*)|(\\([0-9]{2,3}\\)[ \\-]*)|([0-9]{2,4})[ \\-]*)*?[0-9]{3,4}?[ \\-]*[0-9]{3,4}?$/; + +export const typeValidations = { + short_text: string(), + long_text: string(), + number: string().matches(phoneRegExp, 'phone number is not valid'), + email: string().email('Must be a valid email address'), + yes_no: string(), + dropdown: string(), + multiple_choice: string(), + additional_guests: array().of( + object().shape({ + name: string().required('Name is required'), + dob: string().required('DOB is required'), + relationship: string().required('Relationship is required'), + }), + ), +}; + +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +// @ts-ignore +const merge = (...schemas) => { + const [first, ...rest] = schemas; + + const merged = rest.reduce( + (mergedSchemas, schema) => mergedSchemas.concat(schema), + first, + ); + + return merged; +}; + +const createFieldValidationSchema = ({type, validations}: Fields) => { + if (!typeValidations[type]) { + console.error(`Invalid type: ${type}`); + } + + let schema = typeValidations[type]; + + if (validations.required) { + // only works for string types at the moment + schema = merge(schema, string().required('This field is required')); + } + + if (validations.required_if) { + const {field_id, value} = validations.required_if; + // only works for string types at the moment + const requiedIfSchema = string().when(field_id, { + is: value, + then: schema => schema.required('This field is required'), + otherwise: schema => schema, + }); + schema = merge(schema, requiedIfSchema); + } + + return schema; +}; + +export const buildValidationSchema = ( + fieldGroup: FieldGroup[], + groupId: string | undefined, +) => { + if (groupId === undefined) { + console.error('Invalid groupId'); + return object().shape({}); + } + + const fields = fieldGroup.find(group => group.id === groupId)?.fields || []; + + const schema = object().shape( + fields.reduce((acc, field) => { + return { + ...acc, + [field.id]: createFieldValidationSchema(field), + }; + }, {}), + ); + + return object().shape({ + [groupId]: object().shape({...schema.fields}), + }); +}; diff --git a/app/src/views/IntakeProfile/hooks/useFieldGroups.ts b/app/src/views/IntakeProfile/hooks/useFieldGroups.ts new file mode 100644 index 00000000..542761c2 --- /dev/null +++ b/app/src/views/IntakeProfile/hooks/useFieldGroups.ts @@ -0,0 +1,117 @@ +import {faker} from '@faker-js/faker'; +import {useEffect, useState} from 'react'; +import {fieldBuilder, fieldGroupBuilder} from '../constants'; +import {Response, FieldGroup} from 'src/services/profile'; + +interface UseFieldGroups { + profileId: string; +} + +/** + * Generates field groups and responses for a given profile id + */ + +export const useFieldGroups = ({profileId}: UseFieldGroups) => { + const [responses, setResponses] = useState([]); + const [fieldGroups, setFieldGroups] = useState([]); + + useEffect(() => { + if (profileId) { + const fieldGroups = Array.from(Array(3), () => { + const fields = Array.from(Array(3), () => { + const field = fieldBuilder(); + + if (field.type === 'multiple_choice' || field.type === 'dropdown') { + field.properties.choices = [ + { + id: faker.string.uuid(), + label: 'choice 1', + }, + { + id: faker.string.uuid(), + label: 'choice 2', + }, + { + id: faker.string.uuid(), + label: 'choice 3', + }, + ]; + + field.validations.required = true; + } + + return field; + }); + return fieldGroupBuilder({fields}); + }); + + setFieldGroups(fieldGroups); + + const responsesArr = fieldGroups + .map(fieldGroup => fieldGroup.fields) + .flat() + .map(field => { + let value; + if (field.type === 'yes_no') { + //yes or no + value = faker.helpers.arrayElement(['unanswered', 'yes', 'no']); + } + + if (field.type === 'short_text') { + //short text + value = faker.lorem.sentence(); + } + + if (field.type === 'long_text') { + //long text + value = faker.lorem.paragraph(); + } + + if (field.type === 'number') { + //number + value = faker.phone.number(); + } + + if (field.type === 'email') { + //email + value = faker.internet.email(); + } + + if (field.type === 'dropdown' || field.type === 'multiple_choice') { + //dropdown or multiple choice + value = faker.helpers.arrayElement( + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + field.properties.choices.map(choice => choice.label), + ); + } + + if (field.type === 'additional_guests') { + value = [ + { + id: faker.string.uuid(), + name: faker.person.fullName(), + dob: faker.date.birthdate().toString(), + relationship: faker.helpers.arrayElement([ + 'mom', + 'dad', + 'sibling', + 'friend', + 'partner', + ]), + }, + ]; + } + + return { + id: faker.string.uuid(), + fieldId: field.id, + value, + }; + }); + setResponses(responsesArr); + } + }, [profileId]); + + return {responses, fieldGroups}; +}; diff --git a/app/src/views/IntakeProfile/index.tsx b/app/src/views/IntakeProfile/index.tsx new file mode 100644 index 00000000..c5c1f6ab --- /dev/null +++ b/app/src/views/IntakeProfile/index.tsx @@ -0,0 +1,121 @@ +import {Button, Stack, useTheme} from '@mui/material'; +import {Link, Outlet, useParams} from 'react-router-dom'; +import {Formik} from 'formik'; + +import {buildValidationSchema, createInitialValues} from './constants'; +import { + useGetProfileQuery, + useGetResponsesQuery, + Response, +} from '../../services/profile'; + +export type Values = { + [key: string]: Response['value']; +}; + +export type InitialValues = Record; + +export const IntakeProfile = () => { + const theme = useTheme(); + const toolbarHeight = Number(theme.mixins.toolbar.minHeight); + const {profileId, groupId} = useParams(); + + const {data: profileData} = useGetProfileQuery( + {profileId: profileId}, + {skip: !profileId}, + ); + const {data: responsesData} = useGetResponsesQuery({userId: '1'}); + + if ( + profileId === undefined || + groupId === undefined || + profileData === undefined || + responsesData === undefined + ) + return null; + + const {fieldGroups} = profileData; + const {responses} = responsesData; + + const validationSchema = buildValidationSchema(fieldGroups, groupId); + const initalValues = createInitialValues(fieldGroups, responses); + + return ( + { + const updateResponses = Object.entries(values[groupId]).map( + ([fieldId, value]) => { + const response = responses.find( + response => response.fieldId === fieldId, + ); + if (response) { + response.value = value; + return response; + } else { + return { + fieldId, + value, + }; + } + }, + ); + + window.alert(JSON.stringify(updateResponses, null, 2)); + }} + > + {({errors, handleSubmit}) => ( + + + {fieldGroups.map(({id, title}) => { + const fieldTitle = title || '...'; + return ( + + ); + })} + + + + + + + + + + + )} + + ); +}; diff --git a/app/src/views/__tests__/ForgotPasswordCode.test.tsx b/app/src/views/__tests__/ForgotPasswordCode.test.tsx index 20cacdff..c1174d46 100644 --- a/app/src/views/__tests__/ForgotPasswordCode.test.tsx +++ b/app/src/views/__tests__/ForgotPasswordCode.test.tsx @@ -103,7 +103,7 @@ describe('ForgotPasswordCode page', () => { test('display an error message', async () => { server.use( - http.post(`/api/auth/forgot_password`, async () => { + http.post('/api/auth/forgot_password', async () => { await delay(); return HttpResponse.json( { diff --git a/app/src/views/index.ts b/app/src/views/index.ts index d545652e..40a9df5c 100644 --- a/app/src/views/index.ts +++ b/app/src/views/index.ts @@ -33,3 +33,4 @@ export {Sections} from './guestApplicationForm/Sections'; export {SubstanceUse} from './guestApplicationForm/SubstanceUse'; export {Welcome} from './guestApplicationForm/Welcome'; export {About} from './guestApplicationForm/About'; +export {IntakeProfile} from './IntakeProfile';