From 2b053fb71b51f77b68f3f2d4972c64e1f8335678 Mon Sep 17 00:00:00 2001 From: Sammy Au <69769431+samau3@users.noreply.github.com> Date: Thu, 31 Oct 2024 17:53:36 -0700 Subject: [PATCH] [Closes #119] Add a Physician Registration Form Modal (#144) * Rename patient registration route file to follow convention * Create physician registration route with basic info * Add additional schema and data clean up logic * Basic client flow of showing physician registration set up * Format file * Move UI for registering new physician to empty physician search dropdown * Move modal outside of combobox to resolve inputting bug * Create an API request to server for registering physician * Send physician data to server * Working basic flow of registering new physician for client side * Change title of register physician modal * Fix linter issues * Create more descriptive error message for duplicate phone and email * Add tests for physician register route * Remove extra space if there is a middle name * Resolve prop type warning about mismatched types * Show register option always and make it look like normal option * Style register physician option to differentiate from other options * Display more descriptive error message to user * Style register physician button * Add a confirmation modal when trying to close physician registration * Update dropdown options to show results as if searching for new physician * Change open fn to new renamed one * Style confirmation modal * Disconnect hospital or physician if user clears input field * Add an animation for the error alert displaying * Reset form values and mutation when confirmed closing * Remove commented out code * Remove console logs * Style register physician label Remove the background color so it does not look as if it is selected by default Use bold font weight to differentiate from the other options * Reset form and mutation after successful submission * Rename variables to be more descriptive * Switch to using classNames and CSS modules for performance * Fix merge bug * Prevent register physician showing as an option for hospital search * Remove dash after parents for emergency contact phone * Remove dash after parens for physician phone number * Include new physcian phone number in input field after registration for consistency * Update validation to prevent phone field from being empty * Reset form validation errors when modal closes and not dirty * Remove onFocus so modal closing keeps dropdown closed * Restyle modal title and text --------- Co-authored-by: Francis Li --- client/src/pages/patients/LifelineAPI.js | 12 + .../patients/register/PatientRegistration.jsx | 4 +- .../register/PatientRegistrationAccordion.jsx | 4 +- .../inputs/HealthcareChoicesSearch.jsx | 74 ++++-- .../register/inputs/RegisterPhysician.jsx | 217 ++++++++++++++++++ .../inputs/RegisterPhysician.module.css | 9 + .../inputs/SearchDatabaseInputField.jsx | 20 +- .../v1/patients/{create.js => register.js} | 0 server/routes/api/v1/patients/update.js | 2 +- server/routes/api/v1/physicians/register.js | 93 ++++++++ server/test/fixtures/db/Physician.yml | 6 +- server/test/routes/api/v1/patients.test.js | 8 +- server/test/routes/api/v1/physicians.test.js | 119 ++++++++++ 13 files changed, 532 insertions(+), 36 deletions(-) create mode 100644 client/src/pages/patients/register/inputs/RegisterPhysician.jsx create mode 100644 client/src/pages/patients/register/inputs/RegisterPhysician.module.css rename server/routes/api/v1/patients/{create.js => register.js} (100%) create mode 100644 server/routes/api/v1/physicians/register.js diff --git a/client/src/pages/patients/LifelineAPI.js b/client/src/pages/patients/LifelineAPI.js index 6366a97f..4cda65b4 100644 --- a/client/src/pages/patients/LifelineAPI.js +++ b/client/src/pages/patients/LifelineAPI.js @@ -23,6 +23,18 @@ export default class LifelineAPI { }); } + static async registerPhysician(data) { + const response = await fetch(`${SERVER_BASE_URL}/physicians`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(data), + }); + + return response; + } + static async getHospitals(query) { const response = await fetch( `${SERVER_BASE_URL}/hospitals?hospital=${query}`, diff --git a/client/src/pages/patients/register/PatientRegistration.jsx b/client/src/pages/patients/register/PatientRegistration.jsx index d4b9dcfd..926cdde2 100644 --- a/client/src/pages/patients/register/PatientRegistration.jsx +++ b/client/src/pages/patients/register/PatientRegistration.jsx @@ -119,9 +119,9 @@ export default function PatientRegistration() { }, contactData: { phone: (value) => - value.length === 0 || value.match(/^\(\d{3}\)-\d{3}-\d{4}$/) + value.length === 0 || value.match(/^\(\d{3}\) \d{3}-\d{4}$/) ? null - : 'Phone number is not in XXX-XXX-XXXX format', + : 'Phone number is not in (XXX) XXX-XXXX format', }, }, validateInputOnBlur: true, diff --git a/client/src/pages/patients/register/PatientRegistrationAccordion.jsx b/client/src/pages/patients/register/PatientRegistrationAccordion.jsx index 34cd913f..a396c832 100644 --- a/client/src/pages/patients/register/PatientRegistrationAccordion.jsx +++ b/client/src/pages/patients/register/PatientRegistrationAccordion.jsx @@ -150,8 +150,8 @@ export default function PatientRegistrationAccordion({ diff --git a/client/src/pages/patients/register/inputs/HealthcareChoicesSearch.jsx b/client/src/pages/patients/register/inputs/HealthcareChoicesSearch.jsx index 6ab80bcc..d8a07f88 100644 --- a/client/src/pages/patients/register/inputs/HealthcareChoicesSearch.jsx +++ b/client/src/pages/patients/register/inputs/HealthcareChoicesSearch.jsx @@ -1,11 +1,12 @@ import PropTypes from 'prop-types'; import { useState, useRef, useEffect } from 'react'; -import { Combobox, useCombobox, ScrollArea } from '@mantine/core'; +import { Combobox, useCombobox, ScrollArea, Text } from '@mantine/core'; import { notifications } from '@mantine/notifications'; -import { useDebouncedCallback } from '@mantine/hooks'; +import { useDebouncedCallback, useDisclosure } from '@mantine/hooks'; import SearchDatabaseInputField from './SearchDatabaseInputField'; +import RegisterPhysician from './RegisterPhysician'; import LifelineAPI from '../../LifelineAPI.js'; @@ -24,6 +25,10 @@ export default function HealthcareChoicesSearch({ form, choice, initialData }) { const [data, setData] = useState([]); const [empty, setEmpty] = useState(false); const [search, setSearch] = useState(''); + const [ + registerPhysicianOpened, + { open: openRegisterPhysician, close: closeRegisterPhysician }, + ] = useDisclosure(false); const abortController = useRef(); @@ -70,8 +75,11 @@ export default function HealthcareChoicesSearch({ form, choice, initialData }) { }; const handleSelectValue = (id, key) => { - const name = key.children; - setSearch(name.join('')); + if (id === '$register') { + return; + } + const name = key?.children?.join('') ?? key; + setSearch(name); form.setFieldValue(`healthcareChoices.${choice}Id`, id); combobox.closeDropdown(); }; @@ -90,7 +98,18 @@ export default function HealthcareChoicesSearch({ form, choice, initialData }) { */ function renderComboxContent() { if (empty) { - return No results found; + return choice === 'physician' ? ( + <> + No results found + + + + Register new physician + + + + ) : ( + No results found + ); } if (data.length === 0) { @@ -100,24 +119,43 @@ export default function HealthcareChoicesSearch({ form, choice, initialData }) { return ( {options} + {choice === 'physician' && ( + + + + Register new physician + + + )} ); } return ( - + <> + + {choice === 'physician' && ( + + )} + ); } diff --git a/client/src/pages/patients/register/inputs/RegisterPhysician.jsx b/client/src/pages/patients/register/inputs/RegisterPhysician.jsx new file mode 100644 index 00000000..fe20e54a --- /dev/null +++ b/client/src/pages/patients/register/inputs/RegisterPhysician.jsx @@ -0,0 +1,217 @@ +import PropTypes from 'prop-types'; + +import { + TextInput, + InputBase, + Button, + Alert, + Modal, + Transition, + Text, +} from '@mantine/core'; +import { useForm, isNotEmpty } from '@mantine/form'; +import { useDisclosure } from '@mantine/hooks'; + +import { IMaskInput } from 'react-imask'; +import { useMutation } from '@tanstack/react-query'; +import LifelineAPI from '../../LifelineAPI.js'; +import { StatusCodes } from 'http-status-codes'; + +import classes from './RegisterPhysician.module.css'; + +const registerPhysicianProps = { + setPhysician: PropTypes.func.isRequired, + registerPhysicianOpened: PropTypes.bool.isRequired, + closeRegisterPhysician: PropTypes.func.isRequired, + fetchOptions: PropTypes.func.isRequired, +}; + +/** + * + * @param {PropTypes.InferProps} props + */ +export default function RegisterPhysician({ + setPhysician, + registerPhysicianOpened, + closeRegisterPhysician, + fetchOptions, +}) { + const [ + confirmationModalOpened, + { open: openConfirmationModal, close: closeConfirmationModal }, + ] = useDisclosure(false); + + const form = useForm({ + mode: 'uncontrolled', + initialValues: { + firstName: '', + middleName: '', + lastName: '', + phone: '', + email: '', + }, + validate: { + firstName: isNotEmpty('First Name is required'), + lastName: isNotEmpty('Last Name is required'), + phone: (value) => + value.length > 0 || value.match(/^\(\d{3}\) \d{3}-\d{4}$/) + ? null + : 'Phone number is not in (XXX) XXX-XXXX format', + email: (value) => + value.length === 0 || + value.match(/^\w+([.-]?\w+)*@\w+([.-]?\w+)*(\.\w{2,3})+$/) + ? null + : 'Email is not in valid format', + }, + }); + + const { + error, + reset: mutationReset, + mutateAsync, + } = useMutation({ + mutationKey: ['physician'], + mutationFn: async (data) => { + const res = await LifelineAPI.registerPhysician(data); + if (res.status === StatusCodes.CREATED) { + return await res.json(); + } else { + const { message } = await res.json(); + throw new Error(message); + } + }, + }); + + const handleSubmit = async (values) => { + try { + const result = await mutateAsync(values); + const { firstName, middleName, lastName, phone } = result; + const fullName = `${firstName}${middleName ? ' ' + middleName + ' ' : ' '}${lastName}`; + setPhysician(result.id, `${fullName}${phone ? ` - ${phone}` : ''}`); + fetchOptions(fullName); + form.reset(); + mutationReset(); + closeRegisterPhysician(); + } catch (error) { + console.error(error.message); + } + }; + + const confirmClose = (confirmed) => { + if (form.isDirty() && confirmed) { + closeConfirmationModal(); + form.reset(); + mutationReset(); + } + if (form.isDirty()) { + openConfirmationModal(); + } else { + form.reset(); + closeRegisterPhysician(); + } + }; + + return ( + <> + +
+ + {(transitionStyle) => ( + + {error?.message} + + )} + + + + + + + + +
+ + + Are you sure you want to close this form without submitting? + + + + + + ); +} + +RegisterPhysician.propTypes = registerPhysicianProps; diff --git a/client/src/pages/patients/register/inputs/RegisterPhysician.module.css b/client/src/pages/patients/register/inputs/RegisterPhysician.module.css new file mode 100644 index 00000000..d9391881 --- /dev/null +++ b/client/src/pages/patients/register/inputs/RegisterPhysician.module.css @@ -0,0 +1,9 @@ +.title { + font-size: var(--mantine-font-size-xl); + font-weight: 600; + color: var(--mantine-color-red-6); +} + +.button { + margin-top: 1rem; +} diff --git a/client/src/pages/patients/register/inputs/SearchDatabaseInputField.jsx b/client/src/pages/patients/register/inputs/SearchDatabaseInputField.jsx index 032add1f..30abe461 100644 --- a/client/src/pages/patients/register/inputs/SearchDatabaseInputField.jsx +++ b/client/src/pages/patients/register/inputs/SearchDatabaseInputField.jsx @@ -41,12 +41,6 @@ export default function SearchDatabaseInputField({ { - combobox.openDropdown(); - if (data === null) { - fetchOptions(searchQuery); - } - }} onClick={() => { combobox.openDropdown(); if (data === null) { @@ -63,6 +57,20 @@ export default function SearchDatabaseInputField({ combobox.updateSelectedOptionIndex(); combobox.openDropdown(); }} + onKeyDown={(event) => { + const { key, target } = event; + const { value, selectionStart, selectionEnd } = target; + // Check if the entire input is selected or if there is one or fewer characters + if ( + key === 'Backspace' && + (value.length <= 1 || + (selectionStart === 0 && selectionEnd === value.length)) + ) { + event.preventDefault(); + fetchOptions(''); + handleSelectValue('', ''); + } + }} rightSection={loading ? : null} /> diff --git a/server/routes/api/v1/patients/create.js b/server/routes/api/v1/patients/register.js similarity index 100% rename from server/routes/api/v1/patients/create.js rename to server/routes/api/v1/patients/register.js diff --git a/server/routes/api/v1/patients/update.js b/server/routes/api/v1/patients/update.js index a667e3b9..25d1e4e1 100644 --- a/server/routes/api/v1/patients/update.js +++ b/server/routes/api/v1/patients/update.js @@ -55,7 +55,7 @@ export default async function (fastify, _opts) { phone: { type: 'string', anyOf: [ - { pattern: '^(\\([0-9]{3}\\))-[0-9]{3}-[0-9]{4}$' }, + { pattern: '^(\\([0-9]{3}\\)) [0-9]{3}-[0-9]{4}$' }, { pattern: '^$' }, ], }, diff --git a/server/routes/api/v1/physicians/register.js b/server/routes/api/v1/physicians/register.js new file mode 100644 index 00000000..d16d8853 --- /dev/null +++ b/server/routes/api/v1/physicians/register.js @@ -0,0 +1,93 @@ +import { Role } from '../../../../models/user.js'; +import { StatusCodes } from 'http-status-codes'; + +export default async function (fastify) { + fastify.post( + '/', + { + schema: { + body: { + type: 'object', + required: ['firstName', 'lastName', 'phone'], + properties: { + firstName: { type: 'string' }, + middleName: { type: 'string' }, + lastName: { type: 'string' }, + email: { + type: 'string', + anyOf: [{ format: 'email' }, { pattern: '^$' }], + }, + phone: { + type: 'string', + pattern: '^(\\([0-9]{3}\\)) [0-9]{3}-[0-9]{4}$', + }, + }, + }, + response: { + [StatusCodes.CREATED]: { + type: 'object', + properties: { + id: { type: 'string' }, + firstName: { type: 'string' }, + middleName: { type: 'string' }, + lastName: { type: 'string' }, + email: { type: 'string' }, + phone: { type: 'string' }, + }, + }, + }, + }, + onRequest: fastify.requireUser([Role.ADMIN, Role.STAFF, Role.VOLUNTEER]), + }, + async (request, reply) => { + const { phone, email } = request.body; + + try { + // Check if the physician already exists + const exists = await fastify.prisma.physician.findFirst({ + where: { + OR: [{ phone }, { email }], + }, + }); + if (exists) { + let duplicateFields = []; + if (exists.phone === phone) { + duplicateFields.push(`phone ${phone}`); + } + + if (exists.email === email) { + duplicateFields.push(`email ${email}`); + } + + throw new Error( + `Physician with ${duplicateFields.join(' and ')} already exists.`, + ); + } + + const newPhysicianData = {}; + + for (const [key, value] of Object.entries(request.body)) { + if (value) newPhysicianData[key] = value.trim(); + if (key === 'middleName' && value.length === 0) { + newPhysicianData[key] = null; + } + } + + const newPhysician = await fastify.prisma.physician.create({ + data: { + ...newPhysicianData, + }, + }); + + reply.code(StatusCodes.CREATED).send(newPhysician); + } catch (error) { + if (error.message.includes('already exists')) { + return reply.status(StatusCodes.BAD_REQUEST).send({ + message: error.message, + }); + } + throw error; + } + }, + ); +} diff --git a/server/test/fixtures/db/Physician.yml b/server/test/fixtures/db/Physician.yml index ce682354..f79cf284 100644 --- a/server/test/fixtures/db/Physician.yml +++ b/server/test/fixtures/db/Physician.yml @@ -6,17 +6,17 @@ items: firstName: Jane middleName: A lastName: Doe - phone: 123-456-7890 + phone: 123 456-7890 email: physician1@test.com physician2: id: bbbf7f99-36cc-40b5-a26c-cd95daae04b5 firstName: John lastName: Smith - phone: 123-456-9999 + phone: 123 456-9999 email: physician2@test.com physician3: id: 4f177289-f23a-47df-aa16-d9e54108daae firstName: Bob lastName: Smith - phone: 123-456-9998 + phone: 123 456-9998 email: physician3@test.com diff --git a/server/test/routes/api/v1/patients.test.js b/server/test/routes/api/v1/patients.test.js index bf41a6c0..16cd3a7c 100644 --- a/server/test/routes/api/v1/patients.test.js +++ b/server/test/routes/api/v1/patients.test.js @@ -482,7 +482,7 @@ describe('/api/v1/patients', () => { contactData: { firstName: 'Jane', lastName: 'Doe', - phone: '(123)-456-7890', + phone: '(123) 456-7890', relationship: 'PARENT', }, }) @@ -496,7 +496,7 @@ describe('/api/v1/patients', () => { middleName: '', lastName: 'Doe', email: '', - phone: '(123)-456-7890', + phone: '(123) 456-7890', relationship: 'PARENT', }); }); @@ -520,7 +520,7 @@ describe('/api/v1/patients', () => { contactData: { firstName: ' Smith ', lastName: 'Doe ', - phone: '(123)-456-7890', + phone: '(123) 456-7890', relationship: 'PARENT', }, }) @@ -540,7 +540,7 @@ describe('/api/v1/patients', () => { middleName: '', lastName: 'Doe', email: '', - phone: '(123)-456-7890', + phone: '(123) 456-7890', relationship: 'PARENT', }); }); diff --git a/server/test/routes/api/v1/physicians.test.js b/server/test/routes/api/v1/physicians.test.js index 03b29475..a665ea83 100644 --- a/server/test/routes/api/v1/physicians.test.js +++ b/server/test/routes/api/v1/physicians.test.js @@ -100,4 +100,123 @@ describe('/api/v1/physicians', () => { assert.deepStrictEqual(JSON.parse(reply2.payload)[0].lastName, 'Smith'); }); }); + + describe('POST /', () => { + it('should create a new physician for ADMIN user', async (t) => { + const app = await build(t); + await t.loadFixtures(); + const headers = await t.authenticate('admin.user@test.com', 'test'); + const reply = await app + .inject() + .post('/api/v1/physicians') + .payload({ + firstName: 'Jane', + lastName: 'Doe', + phone: '(555) 555-5555', + email: 'jane.doe@test.com', + }) + .headers(headers); + + assert.deepStrictEqual(reply.statusCode, StatusCodes.CREATED); + assert.deepStrictEqual(JSON.parse(reply.payload).firstName, 'Jane'); + assert.deepStrictEqual(JSON.parse(reply.payload).middleName, ''); + assert.deepStrictEqual(JSON.parse(reply.payload).lastName, 'Doe'); + assert.deepStrictEqual(JSON.parse(reply.payload).phone, '(555) 555-5555'); + assert.deepStrictEqual( + JSON.parse(reply.payload).email, + 'jane.doe@test.com', + ); + }); + + it('should error for incorrect phone or email format', async (t) => { + const app = await build(t); + await t.loadFixtures(); + const headers = await t.authenticate('admin.user@test.com', 'test'); + let reply = await app + .inject() + .post('/api/v1/physicians') + .payload({ + firstName: 'Jane', + lastName: 'Doe', + phone: '(555)-555-5555', + email: 'jane.doe@test.com', + }) + .headers(headers); + + assert.deepStrictEqual(reply.statusCode, StatusCodes.BAD_REQUEST); + + reply = await app + .inject() + .post('/api/v1/physicians') + .payload({ + firstName: 'Jane', + lastName: 'Doe', + phone: '(555) 555-5555', + email: 'jane.doe@', + }) + .headers(headers); + + assert.deepStrictEqual(reply.statusCode, StatusCodes.BAD_REQUEST); + }); + + it('should error if physician already exists without registering new duplicate', async (t) => { + const app = await build(t); + await t.loadFixtures(); + const headers = await t.authenticate('admin.user@test.com', 'test'); + let reply = await app + .inject() + .post('/api/v1/physicians') + .payload({ + firstName: 'Jane', + lastName: 'Doe', + phone: '(555) 555-5555', + email: 'jane.doe@test.com', + }) + .headers(headers); + + assert.deepStrictEqual(reply.statusCode, StatusCodes.CREATED); + + reply = await app + .inject() + .post('/api/v1/physicians') + .payload({ + firstName: 'Jackson', + lastName: 'Washington', + phone: '(555) 555-1111', + email: 'jane.doe@test.com', + }) + .headers(headers); + + assert.deepStrictEqual(reply.statusCode, StatusCodes.BAD_REQUEST); + assert.deepStrictEqual( + JSON.parse(reply.payload).message, + 'Physician with email jane.doe@test.com already exists.', + ); + + reply = await app + .inject() + .post('/api/v1/physicians') + .payload({ + firstName: 'Jackson', + lastName: 'Washington', + phone: '(555) 555-5555', + email: 'jane.doe@test.com', + }) + .headers(headers); + + assert.deepStrictEqual(reply.statusCode, StatusCodes.BAD_REQUEST); + assert.deepStrictEqual( + JSON.parse(reply.payload).message, + 'Physician with phone (555) 555-5555 and email jane.doe@test.com already exists.', + ); + + reply = await app + .inject() + .get('/api/v1/physicians?physician=jackson washington') + .headers(headers); + + assert.deepStrictEqual(reply.statusCode, StatusCodes.OK); + assert.deepStrictEqual(JSON.parse(reply.payload).length, 0); + }); + }); });