From ed6152f4222dc748a9c3629c6a7c9daaacb423ab Mon Sep 17 00:00:00 2001 From: Arkadiusz Bachorski <60391032+arkadiuszbachorski@users.noreply.github.com> Date: Wed, 7 Aug 2024 20:43:15 +0200 Subject: [PATCH] Add medications selection (#25) # Add medications selection ## :recycle: Current situation & Problem Patients needs to have their medications assigned ## :gear: Release Notes ## :books: Documentation ## :white_check_mark: Testing Whole flow will be covered in E2E tests soon ### Code of Conduct & Contributing Guidelines By submitting creating this pull request, you agree to follow our [Code of Conduct](https://github.com/StanfordBDHG/.github/blob/main/CODE_OF_CONDUCT.md) and [Contributing Guidelines](https://github.com/StanfordBDHG/.github/blob/main/CONTRIBUTING.md): - [x] I agree to follow the [Code of Conduct](https://github.com/StanfordBDHG/.github/blob/main/CODE_OF_CONDUCT.md) and [Contributing Guidelines](https://github.com/StanfordBDHG/.github/blob/main/CONTRIBUTING.md). --- app/(dashboard)/patients/Medications.tsx | 359 ++++++++++++++ app/(dashboard)/patients/PatientMenu.tsx | 14 +- app/(dashboard)/patients/[id]/page.tsx | 168 +++++-- app/(dashboard)/patients/invite/page.tsx | 5 +- app/(dashboard)/patients/utils.ts | 83 ++++ app/(dashboard)/users/UserMenu.tsx | 15 +- app/(dashboard)/users/[id]/page.tsx | 58 ++- app/(dashboard)/users/actions.tsx | 1 - app/(dashboard)/users/invite/page.tsx | 5 +- app/globals.css | 4 + modules/firebase/guards.tsx | 1 - modules/firebase/localizedText.ts | 14 + modules/firebase/models/baseTypes.ts | 63 +++ modules/firebase/models/medication.ts | 107 +++++ modules/firebase/utils.ts | 64 ++- modules/user/queries.tsx | 35 ++ package-lock.json | 442 ++++++++++++++++++ package.json | 2 + packages/design-system/package-lock.json | 440 +++++++++++++++++ packages/design-system/package.json | 2 + .../src/components/Card/Card.tsx | 2 +- .../src/components/Command/Command.tsx | 149 ++++++ .../src/components/Command/index.tsx | 8 + .../src/components/DataTable/DataTable.tsx | 7 +- .../src/components/DataTable/EmptyState.tsx | 42 -- .../src/components/EmptyState/EmptyState.tsx | 40 ++ .../src/components/EmptyState/index.tsx | 8 + .../src/components/Select/Select.tsx | 2 +- .../Table/TableEmptyState/TableEmptyState.tsx | 24 + .../Table/TableEmptyState/index.tsx | 8 + .../src/components/Tabs/Tabs.tsx | 59 +++ .../src/components/Tabs/index.tsx | 8 + packages/design-system/src/main.css | 4 + 33 files changed, 2112 insertions(+), 131 deletions(-) create mode 100644 app/(dashboard)/patients/Medications.tsx create mode 100644 modules/firebase/localizedText.ts create mode 100644 modules/firebase/models/baseTypes.ts create mode 100644 modules/firebase/models/medication.ts create mode 100644 packages/design-system/src/components/Command/Command.tsx create mode 100644 packages/design-system/src/components/Command/index.tsx delete mode 100644 packages/design-system/src/components/DataTable/EmptyState.tsx create mode 100644 packages/design-system/src/components/EmptyState/EmptyState.tsx create mode 100644 packages/design-system/src/components/EmptyState/index.tsx create mode 100644 packages/design-system/src/components/Table/TableEmptyState/TableEmptyState.tsx create mode 100644 packages/design-system/src/components/Table/TableEmptyState/index.tsx create mode 100644 packages/design-system/src/components/Tabs/Tabs.tsx create mode 100644 packages/design-system/src/components/Tabs/index.tsx diff --git a/app/(dashboard)/patients/Medications.tsx b/app/(dashboard)/patients/Medications.tsx new file mode 100644 index 00000000..92325781 --- /dev/null +++ b/app/(dashboard)/patients/Medications.tsx @@ -0,0 +1,359 @@ +// +// This source file is part of the Stanford Biodesign Digital Health ENGAGE-HF open-source project +// +// SPDX-FileCopyrightText: 2023 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// +'use client' +import { Plus, Check, Trash } from 'lucide-react' +import { useMemo } from 'react' +import { z } from 'zod' +import { type MedicationsData } from '@/app/(dashboard)/patients/utils' +import { parseLocalizedText } from '@/modules/firebase/localizedText' +import { Button } from '@/packages/design-system/src/components/Button' +import { Card } from '@/packages/design-system/src/components/Card' +import { EmptyState } from '@/packages/design-system/src/components/EmptyState' +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, + SelectGroup, + SelectLabel, +} from '@/packages/design-system/src/components/Select' +import { + Table, + TableCell, + TableHeader, + TableRow, + TableBody, +} from '@/packages/design-system/src/components/Table' +import { Tooltip } from '@/packages/design-system/src/components/Tooltip' +import { Field } from '@/packages/design-system/src/forms/Field' +import { useForm } from '@/packages/design-system/src/forms/useForm' + +export const quantityOptions = [ + { label: '0.25 tbl.', value: 0.25 }, + { label: '0.5 tbl.', value: 0.5 }, + { label: '1 tbl.', value: 1 }, + { label: '2 tbl.', value: 2 }, +] + +export const timesPerDayOptions = [ + { label: 'once a day', value: 1 }, + { label: 'twice a day', value: 2 }, + { label: 'three times a day', value: 3 }, +] + +const formSchema = z.object({ + medications: z.array( + z.object({ + id: z.string(), + medication: z.string({ required_error: 'Medication is required' }), + drug: z.string({ required_error: 'Drug is required' }), + quantity: z.number().min(0), + frequencyPerDay: z.number().min(0), + }), + ), +}) + +export type MedicationsFormSchema = z.infer + +interface MedicationsProps extends MedicationsData { + onSave: (data: MedicationsFormSchema) => Promise + defaultValues?: MedicationsFormSchema +} + +export const Medications = ({ + medications: medications, + onSave, + defaultValues, +}: MedicationsProps) => { + const form = useForm({ + formSchema: formSchema, + defaultValues, + }) + + const formValues = form.watch() + + const medicationsMap = useMemo(() => { + const entries = medications.flatMap((medicationClass) => + medicationClass.medications.map( + (medication) => [medication.id, medication] as const, + ), + ) + return new Map(entries) + }, [medications]) + + const addMedication = () => + form.setValue('medications', [ + ...formValues.medications, + { + id: `${formValues.medications.length + 1}`, + // `undefined` doesn't get submitted anywhere + medication: undefined as unknown as string, + drug: undefined as unknown as string, + quantity: 1, + frequencyPerDay: 1, + }, + ]) + + const save = form.handleSubmit(async (data) => { + await onSave(data) + }) + + return ( + +
+ + +
+ {formValues.medications.length === 0 ? + + : + + + Medication + Drug + Quantity + Frequency + Daily dosage + + + + + {formValues.medications.map((medicationValue, index) => { + const isDrugSelected = !!medicationValue.drug + const selectedMedication = medicationsMap.get( + medicationValue.medication, + ) + const selectedDrug = + isDrugSelected ? + selectedMedication?.drugs.find( + (drug) => drug.id === medicationValue.drug, + ) + : undefined + + const dailyDosages = + selectedDrug?.ingredients.map((drug) => ({ + name: drug.name, + dosage: + drug.strength * + medicationValue.frequencyPerDay * + medicationValue.quantity, + })) ?? [] + + const removeMedication = () => { + form.setValue( + 'medications', + formValues.medications.filter( + (medication) => medication.id !== medicationValue.id, + ), + ) + } + + const nestedKey = (key: T) => + `medications.${index}.${key}` as const + + return ( + + + ( + + )} + /> + + + ( + + )} + /> + + + ( + + )} + /> + + + ( + + )} + /> + + + {dailyDosages.length === 0 ? + '-' + : dailyDosages.length === 1 ? + `${dailyDosages.at(0)?.dosage}mg` + :
+ {dailyDosages.map((dosage) => ( +
+ {dosage.name} - {dosage.dosage}mg +
+ ))} +
+ } +
+ + + + + +
+ ) + })} +
+
+ } +
+ ) +} diff --git a/app/(dashboard)/patients/PatientMenu.tsx b/app/(dashboard)/patients/PatientMenu.tsx index d1be39ad..a89731d9 100644 --- a/app/(dashboard)/patients/PatientMenu.tsx +++ b/app/(dashboard)/patients/PatientMenu.tsx @@ -46,14 +46,12 @@ export const PatientMenu = ({ patient }: PatientMenuProps) => { onDelete={handleDelete} /> - {patient.resourceType === 'user' && ( - - - - Edit - - - )} + + + + Edit + + Delete diff --git a/app/(dashboard)/patients/[id]/page.tsx b/app/(dashboard)/patients/[id]/page.tsx index 80d3f40a..1aefa08d 100644 --- a/app/(dashboard)/patients/[id]/page.tsx +++ b/app/(dashboard)/patients/[id]/page.tsx @@ -5,7 +5,7 @@ // // SPDX-License-Identifier: MIT // -import { updateDoc } from '@firebase/firestore' +import { runTransaction, updateDoc } from '@firebase/firestore' import { Contact } from 'lucide-react' import { revalidatePath } from 'next/cache' import { notFound } from 'next/navigation' @@ -13,53 +13,138 @@ import { PatientForm, type PatientFormSchema, } from '@/app/(dashboard)/patients/PatientForm' -import { getFormProps } from '@/app/(dashboard)/patients/utils' +import { + getFormProps, + getMedicationsData, +} from '@/app/(dashboard)/patients/utils' import { getAuthenticatedOnlyApp } from '@/modules/firebase/guards' -import { mapAuthData } from '@/modules/firebase/user' -import { getDocDataOrThrow, UserType } from '@/modules/firebase/utils' +import { + getMedicationRequestData, + getMedicationRequestMedicationIds, +} from '@/modules/firebase/models/medication' +import { + getDocDataOrThrow, + getDocsData, + type ResourceType, + UserType, +} from '@/modules/firebase/utils' import { routes } from '@/modules/routes' +import { getUserData } from '@/modules/user/queries' +import { + Tabs, + TabsContent, + TabsList, + TabsTrigger, +} from '@/packages/design-system/src/components/Tabs' import { getUserName } from '@/packages/design-system/src/modules/auth/user' import { PageTitle } from '@/packages/design-system/src/molecules/DashboardLayout' import { DashboardLayout } from '../../DashboardLayout' +import { Medications, type MedicationsFormSchema } from '../Medications' + +const getUserMedications = async (payload: { + userId: string + resourceType: ResourceType +}) => { + const { refs } = await getAuthenticatedOnlyApp() + const medicationRequests = await getDocsData(refs.medicationRequests(payload)) + return medicationRequests.map((request) => { + const ids = getMedicationRequestMedicationIds(request) + return { + id: request.id, + medication: ids.medicationId ?? '', + drug: ids.drugId ?? '', + frequencyPerDay: + request.dosageInstruction?.at(0)?.timing?.repeat?.frequency ?? 1, + quantity: + request.dosageInstruction?.at(0)?.doseAndRate?.at(0)?.doseQuantity + ?.value ?? 1, + } + }) +} interface PatientPageProps { params: { id: string } } +enum Tab { + information = 'information', + medications = 'medications', +} + const PatientPage = async ({ params }: PatientPageProps) => { - const { docRefs } = await getAuthenticatedOnlyApp() const userId = params.id - const allAuthData = await mapAuthData({ userIds: [userId] }, (data, id) => ({ - uid: id, - email: data.auth.email, - displayName: data.auth.displayName, - })) - const authUser = allAuthData.at(0) - const user = await getDocDataOrThrow(docRefs.user(userId)) - if (!authUser || user.type !== UserType.patient) { + const { user, authUser, resourceType } = await getUserData(userId) + if (!user || !authUser || user.type !== UserType.patient) { notFound() } const updatePatient = async (form: PatientFormSchema) => { 'use server' const { docRefs, callables } = await getAuthenticatedOnlyApp() - await callables.updateUserInformation({ - userId, - data: { - auth: { - displayName: form.displayName, - email: form.email, - }, - }, - }) - const userRef = docRefs.user(userId) const clinician = await getDocDataOrThrow(docRefs.user(form.clinician)) - await updateDoc(userRef, { + const authData = { + displayName: form.displayName, + email: form.email, + } + const userData = { invitationCode: form.invitationCode, clinician: form.clinician, organization: clinician.organization, + } + if (resourceType === 'user') { + await callables.updateUserInformation({ + userId, + data: { + auth: authData, + }, + }) + await updateDoc(docRefs.user(userId), userData) + } else { + const invitation = await getDocDataOrThrow(docRefs.invitation(userId)) + await updateDoc(docRefs.invitation(userId), { + auth: { + ...invitation.auth, + ...authData, + }, + user: { + ...invitation.user, + ...userData, + }, + }) + } + + revalidatePath(routes.patients.index) + } + + const saveMedications = async (form: MedicationsFormSchema) => { + 'use server' + const { docRefs, db, refs } = await getAuthenticatedOnlyApp() + const medicationRequests = await getDocsData( + refs.medicationRequests({ userId, resourceType }), + ) + // async is required to match types + // eslint-disable-next-line @typescript-eslint/require-await + await runTransaction(db, async (transaction) => { + medicationRequests.forEach((medication) => { + transaction.delete( + docRefs.medicationRequest({ + userId, + medicationRequestId: medication.id, + resourceType, + }), + ) + }) + form.medications.forEach((medication) => { + transaction.set( + docRefs.medicationRequest({ + userId, + medicationRequestId: medication.id, + resourceType, + }), + getMedicationRequestData(medication), + ) + }) }) - revalidatePath(routes.users.index) } return ( @@ -72,12 +157,33 @@ const PatientPage = async ({ params }: PatientPageProps) => { /> } > - + + + + Information + + + Medications + + + + + + + + + ) } diff --git a/app/(dashboard)/patients/invite/page.tsx b/app/(dashboard)/patients/invite/page.tsx index a589a291..4ffeb737 100644 --- a/app/(dashboard)/patients/invite/page.tsx +++ b/app/(dashboard)/patients/invite/page.tsx @@ -22,7 +22,7 @@ const InvitePatientPage = async () => { 'use server' const { callables, docRefs } = await getAuthenticatedOnlyApp() const clinician = await getDocDataOrThrow(docRefs.user(form.clinician)) - await callables.createInvitation({ + const result = await callables.createInvitation({ auth: { displayName: form.displayName, email: form.email, @@ -33,8 +33,7 @@ const InvitePatientPage = async () => { organization: clinician.organization, }, }) - redirect(routes.patients.index) - // TODO: Confirmation message + redirect(routes.patients.patient(result.data.code)) } return ( diff --git a/app/(dashboard)/patients/utils.ts b/app/(dashboard)/patients/utils.ts index ffb9913b..8653c130 100644 --- a/app/(dashboard)/patients/utils.ts +++ b/app/(dashboard)/patients/utils.ts @@ -5,6 +5,7 @@ // // SPDX-License-Identifier: MIT // +import { groupBy } from 'es-toolkit' import { query, where } from 'firebase/firestore' import { getAuthenticatedOnlyApp } from '@/modules/firebase/guards' import { mapAuthData } from '@/modules/firebase/user' @@ -38,3 +39,85 @@ export const getFormProps = async () => ({ clinicians: await getUserClinicians(), organizations: await getUserOrganizations(), }) + +export const getMedicationsData = async () => { + const { refs } = await getAuthenticatedOnlyApp() + const medicationClasses = await getDocsData(refs.medicationClasses()) + const medicationsDocs = await getDocsData(refs.medications()) + + const prefix = 'medicationClasses' + + const getMedications = medicationsDocs.map(async (doc) => { + const medicationClassExtension = doc.extension?.find((extension) => + extension.valueReference?.reference?.startsWith(prefix), + ) + const medicationClassId = + medicationClassExtension?.valueReference?.reference?.slice( + prefix.length + 1, + ) + + const drugsDocs = await getDocsData(refs.drugs(doc.id)) + const dosageInstruction = doc.extension + ?.find( + (extension) => + extension.valueMedicationRequest && + extension.url.endsWith('/targetDailyDose'), + ) + ?.valueMedicationRequest?.dosageInstruction?.at(0) + + return { + id: doc.id, + name: doc.code?.coding?.at(0)?.display ?? '', + medicationClassId, + dosage: { + frequencyPerDay: dosageInstruction?.timing?.repeat?.period ?? 1, + quantity: + dosageInstruction?.doseAndRate?.at(0)?.doseQuantity?.value ?? 1, + }, + drugs: drugsDocs + .map((drug) => ({ + id: drug.id, + medicationId: doc.id, + medicationClassId, + name: drug.code?.coding?.at(0)?.display ?? '', + ingredients: + drug.ingredient?.map((ingredient) => { + const name = + ingredient.itemCodeableConcept?.coding?.at(0)?.display ?? '' + const unit = ingredient.strength?.numerator?.unit ?? '' + const strength = + (ingredient.strength?.numerator?.value ?? 1) / + (ingredient.strength?.denominator?.value ?? 1) + return { + name, + strength, + unit, + } + }) ?? [], + })) + .sort((a, b) => { + const name = a.name.localeCompare(b.name) + return name === 0 ? + (a.ingredients.at(0)?.strength ?? 0) - + (b.ingredients.at(0)?.strength ?? 0) + : name + }), + } + }) + + const formattedMedications = await Promise.all(getMedications) + const medicationsByClass = groupBy( + formattedMedications, + (medication) => medication.medicationClassId ?? '', + ) + + const medications = medicationClasses.map((medicationClass) => ({ + id: medicationClass.id, + name: medicationClass.name, + medications: medicationsByClass[medicationClass.id] ?? [], + })) + + return { medications } +} + +export type MedicationsData = Awaited> diff --git a/app/(dashboard)/users/UserMenu.tsx b/app/(dashboard)/users/UserMenu.tsx index 174c8448..f99f90f2 100644 --- a/app/(dashboard)/users/UserMenu.tsx +++ b/app/(dashboard)/users/UserMenu.tsx @@ -45,15 +45,12 @@ export const UserMenu = ({ user }: UserMenuProps) => { onDelete={handleDelete} /> - {/* TODO: Support editing invitations */} - {user.resourceType === 'user' && ( - - - - Edit - - - )} + + + + Edit + + { await allowTypes([UserType.admin, UserType.owner]) - const { refs, docRefs } = await getAuthenticatedOnlyApp() + const { refs } = await getAuthenticatedOnlyApp() const userId = params.id - const allAuthData = await mapAuthData({ userIds: [userId] }, (data, id) => ({ - uid: id, - email: data.auth.email, - displayName: data.auth.displayName, - })) - const authUser = allAuthData.at(0) - const user = await getDocData(docRefs.user(userId)) + const { authUser, user, resourceType } = await getUserData(userId) if (!authUser || !user || user.type === UserType.patient) { notFound() } @@ -41,21 +39,37 @@ const UserPage = async ({ params }: UserPageProps) => { const updateUser = async (form: UserFormSchema) => { 'use server' const { docRefs, callables } = await getAuthenticatedOnlyApp() - await callables.updateUserInformation({ - userId, - data: { - auth: { - displayName: form.displayName, - email: form.email, - }, - }, - }) - const userRef = docRefs.user(userId) - await updateDoc(userRef, { + const authData = { + displayName: form.displayName, + email: form.email, + } + const userData = { invitationCode: form.invitationCode, organization: form.organizationId ?? deleteField(), type: form.type, - }) + } + if (resourceType === 'user') { + await callables.updateUserInformation({ + userId, + data: { + auth: authData, + }, + }) + await updateDoc(docRefs.user(userId), userData) + } else { + const invitation = await getDocDataOrThrow(docRefs.invitation(userId)) + await updateDoc(docRefs.invitation(userId), { + auth: { + ...invitation.auth, + ...authData, + }, + user: { + ...invitation.user, + ...userData, + }, + }) + } + revalidatePath(routes.users.index) } diff --git a/app/(dashboard)/users/actions.tsx b/app/(dashboard)/users/actions.tsx index 1e7e26d3..3a6f4d55 100644 --- a/app/(dashboard)/users/actions.tsx +++ b/app/(dashboard)/users/actions.tsx @@ -20,7 +20,6 @@ export const deleteUser = async (payload: { userId: string }) => { export const deleteInvitation = async (payload: { invitationId: string }) => { const { docRefs } = await getAuthenticatedOnlyApp() - // TODO: https://github.com/StanfordBDHG/ENGAGE-HF-Firebase/issues/38 await deleteDoc(docRefs.invitation(payload.invitationId)) revalidatePath(routes.users.index) return 'success' diff --git a/app/(dashboard)/users/invite/page.tsx b/app/(dashboard)/users/invite/page.tsx index dc4b4f89..e343bdc1 100644 --- a/app/(dashboard)/users/invite/page.tsx +++ b/app/(dashboard)/users/invite/page.tsx @@ -22,7 +22,7 @@ const InviteUserPage = async () => { const inviteUser = async (form: UserFormSchema) => { 'use server' const { callables } = await getAuthenticatedOnlyApp() - await callables.createInvitation({ + const result = await callables.createInvitation({ auth: { displayName: form.displayName, email: form.email, @@ -32,8 +32,7 @@ const InviteUserPage = async () => { type: form.type, }, }) - redirect(routes.users.index) - // TODO: Confirmation message + redirect(routes.users.user(result.data.code)) } return ( diff --git a/app/globals.css b/app/globals.css index 22456f38..97043c41 100644 --- a/app/globals.css +++ b/app/globals.css @@ -34,6 +34,10 @@ SPDX-License-Identifier: MIT @apply flex items-center justify-center; } + .inline-flex-center { + @apply inline-flex items-center justify-center; + } + .interactive-opacity { @apply focus-ring transition-opacity hover:opacity-60; } diff --git a/modules/firebase/guards.tsx b/modules/firebase/guards.tsx index 61a2d1e7..555682f7 100644 --- a/modules/firebase/guards.tsx +++ b/modules/firebase/guards.tsx @@ -89,6 +89,5 @@ export const getAuthenticatedOnlyApp = async () => { * */ export const allowTypes = async (types: UserType[]) => { const type = await getCurrentUserType() - // TODO: HTTP Error if (!types.includes(type)) redirect(routes.home) } diff --git a/modules/firebase/localizedText.ts b/modules/firebase/localizedText.ts new file mode 100644 index 00000000..07617275 --- /dev/null +++ b/modules/firebase/localizedText.ts @@ -0,0 +1,14 @@ +// +// This source file is part of the Stanford Biodesign Digital Health ENGAGE-HF open-source project +// +// SPDX-FileCopyrightText: 2023 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// +import { isObject } from 'lodash' +import { type LocalizedText } from '@/modules/firebase/models/medication' + +const locale = 'en' + +export const parseLocalizedText = (text: LocalizedText) => + isObject(text) ? text[locale] : text diff --git a/modules/firebase/models/baseTypes.ts b/modules/firebase/models/baseTypes.ts new file mode 100644 index 00000000..d4cdca90 --- /dev/null +++ b/modules/firebase/models/baseTypes.ts @@ -0,0 +1,63 @@ +// +// This source file is part of the ENGAGE-HF project based on the Stanford Spezi Template Application project +// +// SPDX-FileCopyrightText: 2023 Stanford University +// +// SPDX-License-Identifier: MIT +// + +import { type FHIRMedicationRequest } from './medication' + +export interface FHIRCodeableConcept extends FHIRElement { + coding?: FHIRCoding[] + text?: string +} + +export interface FHIRCoding extends FHIRElement { + system?: string + version?: string + code?: string + display?: string + userSelected?: boolean +} + +export interface FHIRElement { + id?: string + extension?: FHIRExtension[] +} + +export interface FHIRExtension { + url: string + valueQuantities?: FHIRSimpleQuantity[] + valueReference?: FHIRReference + valueMedicationRequest?: FHIRMedicationRequest +} + +export interface FHIRPeriod { + start?: Date + end?: Date +} + +export interface FHIRRatio { + numerator?: FHIRSimpleQuantity + denominator?: FHIRSimpleQuantity +} + +// the next line disables the eslint rule just because of the +// unused generic constraint that is deliberately not used +// but left as a guidance for the developer +// +// eslint-disable-next-line @typescript-eslint/no-unused-vars +export interface FHIRReference { + reference?: string + type?: string + identifier?: string + display?: string +} + +export interface FHIRSimpleQuantity { + system?: string + code?: string + value?: number + unit?: string +} diff --git a/modules/firebase/models/medication.ts b/modules/firebase/models/medication.ts new file mode 100644 index 00000000..3992a574 --- /dev/null +++ b/modules/firebase/models/medication.ts @@ -0,0 +1,107 @@ +// +// This source file is part of the ENGAGE-HF project based on the Stanford Spezi Template Application project +// +// SPDX-FileCopyrightText: 2023 Stanford University +// +// SPDX-License-Identifier: MIT +// + +import { + type FHIRSimpleQuantity, + type FHIRCodeableConcept, + type FHIRElement, + type FHIRRatio, + type FHIRReference, +} from './baseTypes.js' + +export interface FHIRMedication extends FHIRElement { + code?: FHIRCodeableConcept + form?: FHIRCodeableConcept + ingredient?: FHIRMedicationIngredient[] +} + +export interface FHIRMedicationIngredient { + strength?: FHIRRatio + itemCodeableConcept?: FHIRCodeableConcept +} + +export interface FHIRMedicationRequest extends FHIRElement { + medicationReference?: FHIRReference + dosageInstruction?: FHIRDosage[] +} + +export interface FHIRDosage extends FHIRElement { + text?: string + patientInstruction?: string + timing?: FHIRTiming + doseAndRate?: FHIRDosageDoseAndRate[] +} + +export interface FHIRDosageDoseAndRate extends FHIRElement { + type?: FHIRCodeableConcept + doseQuantity?: FHIRSimpleQuantity + maxDosePerPeriod?: FHIRRatio + maxDosePerAdministration?: FHIRSimpleQuantity + maxDosePerLifetime?: FHIRSimpleQuantity +} + +export interface FHIRTiming extends FHIRElement { + repeat?: FHIRTimingRepeat + code?: FHIRCodeableConcept +} + +export interface FHIRTimingRepeat { + frequency?: number + period?: number + periodUnit?: string + timeOfDay?: string[] +} + +export type LocalizedText = string | Record + +export interface MedicationClass { + name: LocalizedText + videoPath: string +} + +export const getMedicationRequestData = (medication: { + medication: string + drug: string + frequencyPerDay: number + quantity: number +}): FHIRMedicationRequest => ({ + medicationReference: { + reference: `medications/${medication.medication}/drugs/${medication.drug}`, + }, + dosageInstruction: [ + { + timing: { + repeat: { + frequency: medication.frequencyPerDay, + period: 1, + periodUnit: 'd', + }, + }, + doseAndRate: [ + { + doseQuantity: { + code: '{tbl}', + system: 'http://unitsofmeasure.org', + unit: 'tbl.', + value: medication.quantity, + }, + }, + ], + }, + ], +}) + +export const getMedicationRequestMedicationIds = ( + request: FHIRMedicationRequest, +) => { + const reference = request.medicationReference?.reference?.split('/') + return { + medicationId: reference?.at(1), + drugId: reference?.at(3), + } +} diff --git a/modules/firebase/utils.ts b/modules/firebase/utils.ts index 77e12c62..8936ba6f 100644 --- a/modules/firebase/utils.ts +++ b/modules/firebase/utils.ts @@ -16,6 +16,11 @@ import { getDocs, type Query, } from 'firebase/firestore' +import { + type FHIRMedication, + type FHIRMedicationRequest, + type MedicationClass, +} from '@/modules/firebase/models/medication' export interface Organization { id: string @@ -68,8 +73,19 @@ export const collectionNames = { invitations: 'invitations', users: 'users', organizations: 'organizations', + medications: 'medications', + medicationClasses: 'medicationClasses', + drugs: 'drugs', + medicationRequests: 'medicationRequests', } +export type ResourceType = 'invitation' | 'user' + +export const userPath = (resourceType: ResourceType) => + resourceType === 'invitation' ? + collectionNames.invitations + : collectionNames.users + export const getCollectionRefs = (db: Firestore) => ({ users: () => collection(db, collectionNames.users) as CollectionReference, @@ -83,6 +99,32 @@ export const getCollectionRefs = (db: Firestore) => ({ db, collectionNames.organizations, ) as CollectionReference, + medications: () => + collection( + db, + collectionNames.medications, + ) as CollectionReference, + drugs: (medicationId: string) => + collection( + db, + `/${collectionNames.medications}/${medicationId}/${collectionNames.drugs}`, + ) as CollectionReference, + medicationRequests: ({ + userId, + resourceType, + }: { + userId: string + resourceType: ResourceType + }) => + collection( + db, + `/${userPath(resourceType)}/${userId}/${collectionNames.medicationRequests}`, + ) as CollectionReference, + medicationClasses: () => + collection( + db, + collectionNames.medicationClasses, + ) as CollectionReference, }) export const getDocumentsRefs = (db: Firestore) => ({ @@ -92,17 +134,29 @@ export const getDocumentsRefs = (db: Firestore) => ({ User >, invitation: (...segments: string[]) => - doc( - db, - collectionNames.invitations, - ...segments, - ) as DocumentReference, + doc(db, collectionNames.invitations, ...segments) as DocumentReference< + Invitation, + Invitation + >, organization: (...segments: string[]) => doc( db, collectionNames.organizations, ...segments, ) as DocumentReference, + medicationRequest: ({ + userId, + medicationRequestId, + resourceType, + }: { + userId: string + medicationRequestId: string + resourceType: ResourceType + }) => + doc( + db, + `/${userPath(resourceType)}/${userId}/${collectionNames.medicationRequests}/${medicationRequestId}`, + ) as DocumentReference, }) interface Result { diff --git a/modules/user/queries.tsx b/modules/user/queries.tsx index 300b8671..3cb11fd1 100644 --- a/modules/user/queries.tsx +++ b/modules/user/queries.tsx @@ -7,7 +7,9 @@ // import { query, where } from 'firebase/firestore' import { getAuthenticatedOnlyApp } from '@/modules/firebase/guards' +import { mapAuthData } from '@/modules/firebase/user' import { + getDocData, getDocDataOrThrow, getDocsData, type Invitation, @@ -70,3 +72,36 @@ export const getUserOrganizationsMap = async () => { ), ) } + +/** + * Gets user or invitation data + * */ +export const getUserData = async (userId: string) => { + const { docRefs } = await getAuthenticatedOnlyApp() + const user = await getDocData(docRefs.user(userId)) + if (user) { + const allAuthData = await mapAuthData( + { userIds: [userId] }, + (data, id) => ({ + uid: id, + email: data.auth.email, + displayName: data.auth.displayName, + }), + ) + const authUser = allAuthData.at(0) + return { user, authUser, resourceType: 'user' as const } + } + const invitation = await getDocData(docRefs.invitation(userId)) + return { + user: invitation?.user, + authUser: + invitation?.auth ? + { + uid: userId, + email: invitation.auth.email ?? null, + displayName: invitation.auth.displayName ?? null, + } + : undefined, + resourceType: 'invitation' as const, + } +} diff --git a/package-lock.json b/package-lock.json index 9f4c9d8f..142d5ab1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,11 +20,13 @@ "@radix-ui/react-select": "^2.1.1", "@radix-ui/react-separator": "^1.0.3", "@radix-ui/react-slot": "^1.0.2", + "@radix-ui/react-tabs": "^1.1.0", "@radix-ui/react-tooltip": "^1.1.2", "@stanfordbdhg/design-system": "file:./packages/design-system", "@t3-oss/env-nextjs": "^0.10.1", "@tanstack/match-sorter-utils": "^8.15.1", "@tanstack/react-table": "^8.19.2", + "cmdk": "^1.0.0", "date-fns": "^3.6.0", "es-toolkit": "^1.13.1", "firebase": "^10.12.4", @@ -7083,6 +7085,261 @@ } } }, + "node_modules/@radix-ui/react-tabs": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-tabs/-/react-tabs-1.1.0.tgz", + "integrity": "sha512-bZgOKB/LtZIij75FSuPzyEti/XBhJH52ExgtdVqjCIh+Nx/FW+LhnbXtbCzIi34ccyMsyOja8T0thCzoHFXNKA==", + "dependencies": { + "@radix-ui/primitive": "1.1.0", + "@radix-ui/react-context": "1.1.0", + "@radix-ui/react-direction": "1.1.0", + "@radix-ui/react-id": "1.1.0", + "@radix-ui/react-presence": "1.1.0", + "@radix-ui/react-primitive": "2.0.0", + "@radix-ui/react-roving-focus": "1.1.0", + "@radix-ui/react-use-controllable-state": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/primitive": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.0.tgz", + "integrity": "sha512-4Z8dn6Upk0qk4P74xBhZ6Hd/w0mPEzOOLxy4xiPXOXqjF7jZS0VAKk7/x/H6FyY2zCkYJqePf1G5KmkmNJ4RBA==" + }, + "node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/react-collection": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.0.tgz", + "integrity": "sha512-GZsZslMJEyo1VKm5L1ZJY8tGDxZNPAoUeQUIbKeJfoi7Q4kmig5AsgLMYYuyYbfjd8fBmFORAIwYAkXMnXZgZw==", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.0", + "@radix-ui/react-context": "1.1.0", + "@radix-ui/react-primitive": "2.0.0", + "@radix-ui/react-slot": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/react-compose-refs": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.0.tgz", + "integrity": "sha512-b4inOtiaOnYf9KWyO3jAeeCG6FeyfY6ldiEPanbUjWd+xIk5wZeHa8yVwmrJ2vderhu/BQvzCrJI0lHd+wIiqw==", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/react-context": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.0.tgz", + "integrity": "sha512-OKrckBy+sMEgYM/sMmqmErVn0kZqrHPJze+Ql3DzYsDDp0hl0L62nx/2122/Bvps1qz645jlcu2tD9lrRSdf8A==", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/react-direction": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.0.tgz", + "integrity": "sha512-BUuBvgThEiAXh2DWu93XsT+a3aWrGqolGlqqw5VU1kG7p/ZH2cuDlM1sRLNnY3QcBS69UIz2mcKhMxDsdewhjg==", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/react-id": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.0.tgz", + "integrity": "sha512-EJUrI8yYh7WOjNOqpoJaf1jlFIH2LvtgAl+YcFqNCa+4hj64ZXmPkAKOFs/ukjz3byN6bdb/AVUqHkI8/uWWMA==", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/react-presence": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.0.tgz", + "integrity": "sha512-Gq6wuRN/asf9H/E/VzdKoUtT8GC9PQc9z40/vEr0VCJ4u5XvvhWIrSsCB6vD2/cH7ugTdSfYq9fLJCcM00acrQ==", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.0", + "@radix-ui/react-use-layout-effect": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/react-primitive": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.0.0.tgz", + "integrity": "sha512-ZSpFm0/uHa8zTvKBDjLFWLo8dkr4MBsiDLz0g3gMUwqgLHz9rTaRRGYDgvZPtBJgYCBKXkS9fzmoySgr8CO6Cw==", + "dependencies": { + "@radix-ui/react-slot": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/react-roving-focus": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.0.tgz", + "integrity": "sha512-EA6AMGeq9AEeQDeSH0aZgG198qkfHSbvWTf1HvoDmOB5bBG/qTxjYMWUKMnYiV6J/iP/J8MEFSuB2zRU2n7ODA==", + "dependencies": { + "@radix-ui/primitive": "1.1.0", + "@radix-ui/react-collection": "1.1.0", + "@radix-ui/react-compose-refs": "1.1.0", + "@radix-ui/react-context": "1.1.0", + "@radix-ui/react-direction": "1.1.0", + "@radix-ui/react-id": "1.1.0", + "@radix-ui/react-primitive": "2.0.0", + "@radix-ui/react-use-callback-ref": "1.1.0", + "@radix-ui/react-use-controllable-state": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/react-slot": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.1.0.tgz", + "integrity": "sha512-FUCf5XMfmW4dtYl69pdS4DbxKy8nj4M7SafBgPllysxmdachynNflAdp/gCsnYWNDnge6tI9onzMp5ARYc1KNw==", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/react-use-callback-ref": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.0.tgz", + "integrity": "sha512-CasTfvsy+frcFkbXtSJ2Zu9JHpN8TYKxkgJGWbjiZhFivxaeW7rMeZt7QELGVLaYVfFMsKHjb7Ak0nMEe+2Vfw==", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/react-use-controllable-state": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.1.0.tgz", + "integrity": "sha512-MtfMVJiSr2NjzS0Aa90NPTnvTSg6C/JLCV7ma0W6+OMV78vd8OyRpID+Ng9LxzsPbLeuBnWBA1Nq30AtBIDChw==", + "dependencies": { + "@radix-ui/react-use-callback-ref": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/react-use-layout-effect": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.0.tgz", + "integrity": "sha512-+FPE0rOdziWSrH9athwI1R0HDVbWlEhd+FR+aSDk4uWGmSJ9Z54sdZVDQPZAinJhJXwfT+qnj969mCsT2gfm5w==", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-tooltip": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@radix-ui/react-tooltip/-/react-tooltip-1.1.2.tgz", @@ -15910,6 +16167,189 @@ "node": ">=6" } }, + "node_modules/cmdk": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/cmdk/-/cmdk-1.0.0.tgz", + "integrity": "sha512-gDzVf0a09TvoJ5jnuPvygTB77+XdOSwEmJ88L6XPFPlv7T3RxbP9jgenfylrAMD0+Le1aO0nVjQUzl2g+vjz5Q==", + "dependencies": { + "@radix-ui/react-dialog": "1.0.5", + "@radix-ui/react-primitive": "1.0.3" + }, + "peerDependencies": { + "react": "^18.0.0", + "react-dom": "^18.0.0" + } + }, + "node_modules/cmdk/node_modules/@radix-ui/react-dialog": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.0.5.tgz", + "integrity": "sha512-GjWJX/AUpB703eEBanuBnIWdIXg6NvJFCXcNlSZk4xdszCdhrJgBoUd1cGk67vFO+WdA2pfI/plOpqz/5GUP6Q==", + "dependencies": { + "@babel/runtime": "^7.13.10", + "@radix-ui/primitive": "1.0.1", + "@radix-ui/react-compose-refs": "1.0.1", + "@radix-ui/react-context": "1.0.1", + "@radix-ui/react-dismissable-layer": "1.0.5", + "@radix-ui/react-focus-guards": "1.0.1", + "@radix-ui/react-focus-scope": "1.0.4", + "@radix-ui/react-id": "1.0.1", + "@radix-ui/react-portal": "1.0.4", + "@radix-ui/react-presence": "1.0.1", + "@radix-ui/react-primitive": "1.0.3", + "@radix-ui/react-slot": "1.0.2", + "@radix-ui/react-use-controllable-state": "1.0.1", + "aria-hidden": "^1.1.1", + "react-remove-scroll": "2.5.5" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0", + "react-dom": "^16.8 || ^17.0 || ^18.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/cmdk/node_modules/@radix-ui/react-dismissable-layer": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.0.5.tgz", + "integrity": "sha512-aJeDjQhywg9LBu2t/At58hCvr7pEm0o2Ke1x33B+MhjNmmZ17sy4KImo0KPLgsnc/zN7GPdce8Cnn0SWvwZO7g==", + "dependencies": { + "@babel/runtime": "^7.13.10", + "@radix-ui/primitive": "1.0.1", + "@radix-ui/react-compose-refs": "1.0.1", + "@radix-ui/react-primitive": "1.0.3", + "@radix-ui/react-use-callback-ref": "1.0.1", + "@radix-ui/react-use-escape-keydown": "1.0.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0", + "react-dom": "^16.8 || ^17.0 || ^18.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/cmdk/node_modules/@radix-ui/react-focus-guards": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.0.1.tgz", + "integrity": "sha512-Rect2dWbQ8waGzhMavsIbmSVCgYxkXLxxR3ZvCX79JOglzdEy4JXMb98lq4hPxUbLr77nP0UOGf4rcMU+s1pUA==", + "dependencies": { + "@babel/runtime": "^7.13.10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/cmdk/node_modules/@radix-ui/react-focus-scope": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.0.4.tgz", + "integrity": "sha512-sL04Mgvf+FmyvZeYfNu1EPAaaxD+aw7cYeIB9L9Fvq8+urhltTRaEo5ysKOpHuKPclsZcSUMKlN05x4u+CINpA==", + "dependencies": { + "@babel/runtime": "^7.13.10", + "@radix-ui/react-compose-refs": "1.0.1", + "@radix-ui/react-primitive": "1.0.3", + "@radix-ui/react-use-callback-ref": "1.0.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0", + "react-dom": "^16.8 || ^17.0 || ^18.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/cmdk/node_modules/@radix-ui/react-portal": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.0.4.tgz", + "integrity": "sha512-Qki+C/EuGUVCQTOTD5vzJzJuMUlewbzuKyUy+/iHM2uwGiru9gZeBJtHAPKAEkB5KWGi9mP/CHKcY0wt1aW45Q==", + "dependencies": { + "@babel/runtime": "^7.13.10", + "@radix-ui/react-primitive": "1.0.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0", + "react-dom": "^16.8 || ^17.0 || ^18.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/cmdk/node_modules/@radix-ui/react-use-escape-keydown": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-1.0.3.tgz", + "integrity": "sha512-vyL82j40hcFicA+M4Ex7hVkB9vHgSse1ZWomAqV2Je3RleKGO5iM8KMOEtfoSB0PnIelMd2lATjTGMYqN5ylTg==", + "dependencies": { + "@babel/runtime": "^7.13.10", + "@radix-ui/react-use-callback-ref": "1.0.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/cmdk/node_modules/react-remove-scroll": { + "version": "2.5.5", + "resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.5.5.tgz", + "integrity": "sha512-ImKhrzJJsyXJfBZ4bzu8Bwpka14c/fQt0k+cyFp/PBhTfyDnU5hjOtM4AG/0AMyy8oKzOTR0lDgJIM7pYXI0kw==", + "dependencies": { + "react-remove-scroll-bar": "^2.3.3", + "react-style-singleton": "^2.2.1", + "tslib": "^2.1.0", + "use-callback-ref": "^1.3.0", + "use-sidecar": "^1.1.2" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/co": { "version": "4.6.0", "resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz", @@ -28821,10 +29261,12 @@ "@radix-ui/react-select": "^2.1.1", "@radix-ui/react-separator": "^1.0.3", "@radix-ui/react-slot": "^1.0.2", + "@radix-ui/react-tabs": "^1.1.0", "@radix-ui/react-tooltip": "^1.1.2", "@tanstack/react-table": "^8.19.2", "class-variance-authority": "^0.7.0", "clsx": "^2", + "cmdk": "^1.0.0", "date-fns": "^3.6.0", "lucide-react": "^0.383.0", "react": "^18", diff --git a/package.json b/package.json index 00f0ea76..b5326776 100644 --- a/package.json +++ b/package.json @@ -38,11 +38,13 @@ "@radix-ui/react-select": "^2.1.1", "@radix-ui/react-separator": "^1.0.3", "@radix-ui/react-slot": "^1.0.2", + "@radix-ui/react-tabs": "^1.1.0", "@radix-ui/react-tooltip": "^1.1.2", "@stanfordbdhg/design-system": "file:./packages/design-system", "@t3-oss/env-nextjs": "^0.10.1", "@tanstack/match-sorter-utils": "^8.15.1", "@tanstack/react-table": "^8.19.2", + "cmdk": "^1.0.0", "date-fns": "^3.6.0", "es-toolkit": "^1.13.1", "firebase": "^10.12.4", diff --git a/packages/design-system/package-lock.json b/packages/design-system/package-lock.json index 49adc12b..cb73736f 100644 --- a/packages/design-system/package-lock.json +++ b/packages/design-system/package-lock.json @@ -17,10 +17,12 @@ "@radix-ui/react-select": "^2.1.1", "@radix-ui/react-separator": "^1.0.3", "@radix-ui/react-slot": "^1.0.2", + "@radix-ui/react-tabs": "^1.1.0", "@radix-ui/react-tooltip": "^1.1.2", "@tanstack/react-table": "^8.19.2", "class-variance-authority": "^0.7.0", "clsx": "^2", + "cmdk": "^1.0.0", "date-fns": "^3.6.0", "lucide-react": "^0.383.0", "react": "^18", @@ -5487,6 +5489,261 @@ } } }, + "node_modules/@radix-ui/react-tabs": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-tabs/-/react-tabs-1.1.0.tgz", + "integrity": "sha512-bZgOKB/LtZIij75FSuPzyEti/XBhJH52ExgtdVqjCIh+Nx/FW+LhnbXtbCzIi34ccyMsyOja8T0thCzoHFXNKA==", + "dependencies": { + "@radix-ui/primitive": "1.1.0", + "@radix-ui/react-context": "1.1.0", + "@radix-ui/react-direction": "1.1.0", + "@radix-ui/react-id": "1.1.0", + "@radix-ui/react-presence": "1.1.0", + "@radix-ui/react-primitive": "2.0.0", + "@radix-ui/react-roving-focus": "1.1.0", + "@radix-ui/react-use-controllable-state": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/primitive": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.0.tgz", + "integrity": "sha512-4Z8dn6Upk0qk4P74xBhZ6Hd/w0mPEzOOLxy4xiPXOXqjF7jZS0VAKk7/x/H6FyY2zCkYJqePf1G5KmkmNJ4RBA==" + }, + "node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/react-collection": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.0.tgz", + "integrity": "sha512-GZsZslMJEyo1VKm5L1ZJY8tGDxZNPAoUeQUIbKeJfoi7Q4kmig5AsgLMYYuyYbfjd8fBmFORAIwYAkXMnXZgZw==", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.0", + "@radix-ui/react-context": "1.1.0", + "@radix-ui/react-primitive": "2.0.0", + "@radix-ui/react-slot": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/react-compose-refs": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.0.tgz", + "integrity": "sha512-b4inOtiaOnYf9KWyO3jAeeCG6FeyfY6ldiEPanbUjWd+xIk5wZeHa8yVwmrJ2vderhu/BQvzCrJI0lHd+wIiqw==", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/react-context": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.0.tgz", + "integrity": "sha512-OKrckBy+sMEgYM/sMmqmErVn0kZqrHPJze+Ql3DzYsDDp0hl0L62nx/2122/Bvps1qz645jlcu2tD9lrRSdf8A==", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/react-direction": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.0.tgz", + "integrity": "sha512-BUuBvgThEiAXh2DWu93XsT+a3aWrGqolGlqqw5VU1kG7p/ZH2cuDlM1sRLNnY3QcBS69UIz2mcKhMxDsdewhjg==", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/react-id": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.0.tgz", + "integrity": "sha512-EJUrI8yYh7WOjNOqpoJaf1jlFIH2LvtgAl+YcFqNCa+4hj64ZXmPkAKOFs/ukjz3byN6bdb/AVUqHkI8/uWWMA==", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/react-presence": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.0.tgz", + "integrity": "sha512-Gq6wuRN/asf9H/E/VzdKoUtT8GC9PQc9z40/vEr0VCJ4u5XvvhWIrSsCB6vD2/cH7ugTdSfYq9fLJCcM00acrQ==", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.0", + "@radix-ui/react-use-layout-effect": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/react-primitive": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.0.0.tgz", + "integrity": "sha512-ZSpFm0/uHa8zTvKBDjLFWLo8dkr4MBsiDLz0g3gMUwqgLHz9rTaRRGYDgvZPtBJgYCBKXkS9fzmoySgr8CO6Cw==", + "dependencies": { + "@radix-ui/react-slot": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/react-roving-focus": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.0.tgz", + "integrity": "sha512-EA6AMGeq9AEeQDeSH0aZgG198qkfHSbvWTf1HvoDmOB5bBG/qTxjYMWUKMnYiV6J/iP/J8MEFSuB2zRU2n7ODA==", + "dependencies": { + "@radix-ui/primitive": "1.1.0", + "@radix-ui/react-collection": "1.1.0", + "@radix-ui/react-compose-refs": "1.1.0", + "@radix-ui/react-context": "1.1.0", + "@radix-ui/react-direction": "1.1.0", + "@radix-ui/react-id": "1.1.0", + "@radix-ui/react-primitive": "2.0.0", + "@radix-ui/react-use-callback-ref": "1.1.0", + "@radix-ui/react-use-controllable-state": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/react-slot": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.1.0.tgz", + "integrity": "sha512-FUCf5XMfmW4dtYl69pdS4DbxKy8nj4M7SafBgPllysxmdachynNflAdp/gCsnYWNDnge6tI9onzMp5ARYc1KNw==", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/react-use-callback-ref": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.0.tgz", + "integrity": "sha512-CasTfvsy+frcFkbXtSJ2Zu9JHpN8TYKxkgJGWbjiZhFivxaeW7rMeZt7QELGVLaYVfFMsKHjb7Ak0nMEe+2Vfw==", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/react-use-controllable-state": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.1.0.tgz", + "integrity": "sha512-MtfMVJiSr2NjzS0Aa90NPTnvTSg6C/JLCV7ma0W6+OMV78vd8OyRpID+Ng9LxzsPbLeuBnWBA1Nq30AtBIDChw==", + "dependencies": { + "@radix-ui/react-use-callback-ref": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/react-use-layout-effect": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.0.tgz", + "integrity": "sha512-+FPE0rOdziWSrH9athwI1R0HDVbWlEhd+FR+aSDk4uWGmSJ9Z54sdZVDQPZAinJhJXwfT+qnj969mCsT2gfm5w==", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-tooltip": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@radix-ui/react-tooltip/-/react-tooltip-1.1.2.tgz", @@ -10135,6 +10392,189 @@ "node": ">=6" } }, + "node_modules/cmdk": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/cmdk/-/cmdk-1.0.0.tgz", + "integrity": "sha512-gDzVf0a09TvoJ5jnuPvygTB77+XdOSwEmJ88L6XPFPlv7T3RxbP9jgenfylrAMD0+Le1aO0nVjQUzl2g+vjz5Q==", + "dependencies": { + "@radix-ui/react-dialog": "1.0.5", + "@radix-ui/react-primitive": "1.0.3" + }, + "peerDependencies": { + "react": "^18.0.0", + "react-dom": "^18.0.0" + } + }, + "node_modules/cmdk/node_modules/@radix-ui/react-dialog": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.0.5.tgz", + "integrity": "sha512-GjWJX/AUpB703eEBanuBnIWdIXg6NvJFCXcNlSZk4xdszCdhrJgBoUd1cGk67vFO+WdA2pfI/plOpqz/5GUP6Q==", + "dependencies": { + "@babel/runtime": "^7.13.10", + "@radix-ui/primitive": "1.0.1", + "@radix-ui/react-compose-refs": "1.0.1", + "@radix-ui/react-context": "1.0.1", + "@radix-ui/react-dismissable-layer": "1.0.5", + "@radix-ui/react-focus-guards": "1.0.1", + "@radix-ui/react-focus-scope": "1.0.4", + "@radix-ui/react-id": "1.0.1", + "@radix-ui/react-portal": "1.0.4", + "@radix-ui/react-presence": "1.0.1", + "@radix-ui/react-primitive": "1.0.3", + "@radix-ui/react-slot": "1.0.2", + "@radix-ui/react-use-controllable-state": "1.0.1", + "aria-hidden": "^1.1.1", + "react-remove-scroll": "2.5.5" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0", + "react-dom": "^16.8 || ^17.0 || ^18.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/cmdk/node_modules/@radix-ui/react-dismissable-layer": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.0.5.tgz", + "integrity": "sha512-aJeDjQhywg9LBu2t/At58hCvr7pEm0o2Ke1x33B+MhjNmmZ17sy4KImo0KPLgsnc/zN7GPdce8Cnn0SWvwZO7g==", + "dependencies": { + "@babel/runtime": "^7.13.10", + "@radix-ui/primitive": "1.0.1", + "@radix-ui/react-compose-refs": "1.0.1", + "@radix-ui/react-primitive": "1.0.3", + "@radix-ui/react-use-callback-ref": "1.0.1", + "@radix-ui/react-use-escape-keydown": "1.0.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0", + "react-dom": "^16.8 || ^17.0 || ^18.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/cmdk/node_modules/@radix-ui/react-focus-guards": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.0.1.tgz", + "integrity": "sha512-Rect2dWbQ8waGzhMavsIbmSVCgYxkXLxxR3ZvCX79JOglzdEy4JXMb98lq4hPxUbLr77nP0UOGf4rcMU+s1pUA==", + "dependencies": { + "@babel/runtime": "^7.13.10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/cmdk/node_modules/@radix-ui/react-focus-scope": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.0.4.tgz", + "integrity": "sha512-sL04Mgvf+FmyvZeYfNu1EPAaaxD+aw7cYeIB9L9Fvq8+urhltTRaEo5ysKOpHuKPclsZcSUMKlN05x4u+CINpA==", + "dependencies": { + "@babel/runtime": "^7.13.10", + "@radix-ui/react-compose-refs": "1.0.1", + "@radix-ui/react-primitive": "1.0.3", + "@radix-ui/react-use-callback-ref": "1.0.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0", + "react-dom": "^16.8 || ^17.0 || ^18.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/cmdk/node_modules/@radix-ui/react-portal": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.0.4.tgz", + "integrity": "sha512-Qki+C/EuGUVCQTOTD5vzJzJuMUlewbzuKyUy+/iHM2uwGiru9gZeBJtHAPKAEkB5KWGi9mP/CHKcY0wt1aW45Q==", + "dependencies": { + "@babel/runtime": "^7.13.10", + "@radix-ui/react-primitive": "1.0.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0", + "react-dom": "^16.8 || ^17.0 || ^18.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/cmdk/node_modules/@radix-ui/react-use-escape-keydown": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-1.0.3.tgz", + "integrity": "sha512-vyL82j40hcFicA+M4Ex7hVkB9vHgSse1ZWomAqV2Je3RleKGO5iM8KMOEtfoSB0PnIelMd2lATjTGMYqN5ylTg==", + "dependencies": { + "@babel/runtime": "^7.13.10", + "@radix-ui/react-use-callback-ref": "1.0.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/cmdk/node_modules/react-remove-scroll": { + "version": "2.5.5", + "resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.5.5.tgz", + "integrity": "sha512-ImKhrzJJsyXJfBZ4bzu8Bwpka14c/fQt0k+cyFp/PBhTfyDnU5hjOtM4AG/0AMyy8oKzOTR0lDgJIM7pYXI0kw==", + "dependencies": { + "react-remove-scroll-bar": "^2.3.3", + "react-style-singleton": "^2.2.1", + "tslib": "^2.1.0", + "use-callback-ref": "^1.3.0", + "use-sidecar": "^1.1.2" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/color": { "version": "4.2.3", "resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz", diff --git a/packages/design-system/package.json b/packages/design-system/package.json index 1c73b9df..68a59a52 100644 --- a/packages/design-system/package.json +++ b/packages/design-system/package.json @@ -19,10 +19,12 @@ "@radix-ui/react-select": "^2.1.1", "@radix-ui/react-separator": "^1.0.3", "@radix-ui/react-slot": "^1.0.2", + "@radix-ui/react-tabs": "^1.1.0", "@radix-ui/react-tooltip": "^1.1.2", "@tanstack/react-table": "^8.19.2", "class-variance-authority": "^0.7.0", "clsx": "^2", + "cmdk": "^1.0.0", "date-fns": "^3.6.0", "lucide-react": "^0.383.0", "react": "^18", diff --git a/packages/design-system/src/components/Card/Card.tsx b/packages/design-system/src/components/Card/Card.tsx index ad884496..c1ce14c2 100644 --- a/packages/design-system/src/components/Card/Card.tsx +++ b/packages/design-system/src/components/Card/Card.tsx @@ -13,7 +13,7 @@ import { forwardRef, type HTMLAttributes } from 'react' export const cardVariants = {} export const cardVariance = cva( - 'rounded-lg border bg-card text-card-foreground shadow-sm', + 'rounded-md border bg-card text-card-foreground shadow-sm', { variants: cardVariants }, ) diff --git a/packages/design-system/src/components/Command/Command.tsx b/packages/design-system/src/components/Command/Command.tsx new file mode 100644 index 00000000..6cfa470c --- /dev/null +++ b/packages/design-system/src/components/Command/Command.tsx @@ -0,0 +1,149 @@ +// +// This source file is part of the Stanford Biodesign Digital Health ENGAGE-HF open-source project +// +// SPDX-FileCopyrightText: 2023 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// +'use client' +import { type DialogProps } from '@radix-ui/react-dialog' +import { Command as CommandPrimitive } from 'cmdk' +import { Search } from 'lucide-react' +import { + type ComponentPropsWithoutRef, + type ElementRef, + forwardRef, + type HTMLAttributes, +} from 'react' +import { cn } from '../../utils/className' +import { Dialog, DialogContent } from '../Dialog' + +export const Command = forwardRef< + ElementRef, + ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +Command.displayName = CommandPrimitive.displayName + +interface CommandDialogProps extends DialogProps {} + +export const CommandDialog = ({ children, ...props }: CommandDialogProps) => ( + + + + {children} + + + +) + +export const CommandInput = forwardRef< + ElementRef, + ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( +
+ + +
+)) + +CommandInput.displayName = CommandPrimitive.Input.displayName + +export const CommandList = forwardRef< + ElementRef, + ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) + +CommandList.displayName = CommandPrimitive.List.displayName + +export const CommandEmpty = forwardRef< + ElementRef, + ComponentPropsWithoutRef +>((props, ref) => ( + +)) + +CommandEmpty.displayName = CommandPrimitive.Empty.displayName + +export const CommandGroup = forwardRef< + ElementRef, + ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) + +CommandGroup.displayName = CommandPrimitive.Group.displayName + +export const CommandSeparator = forwardRef< + ElementRef, + ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +CommandSeparator.displayName = CommandPrimitive.Separator.displayName + +export const CommandItem = forwardRef< + ElementRef, + ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) + +CommandItem.displayName = CommandPrimitive.Item.displayName + +export const CommandShortcut = ({ + className, + ...props +}: HTMLAttributes) => ( + +) +CommandShortcut.displayName = 'CommandShortcut' diff --git a/packages/design-system/src/components/Command/index.tsx b/packages/design-system/src/components/Command/index.tsx new file mode 100644 index 00000000..9cffe3fe --- /dev/null +++ b/packages/design-system/src/components/Command/index.tsx @@ -0,0 +1,8 @@ +// +// This source file is part of the Stanford Biodesign Digital Health ENGAGE-HF open-source project +// +// SPDX-FileCopyrightText: 2023 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// +export * from './Command' diff --git a/packages/design-system/src/components/DataTable/DataTable.tsx b/packages/design-system/src/components/DataTable/DataTable.tsx index 1fb10679..c91783ab 100644 --- a/packages/design-system/src/components/DataTable/DataTable.tsx +++ b/packages/design-system/src/components/DataTable/DataTable.tsx @@ -19,7 +19,6 @@ import { type TableOptions } from '@tanstack/table-core' import { useState } from 'react' import { useDebouncedCallback } from 'use-debounce' import { fuzzyFilter } from './DataTable.utils' -import { EmptyState } from './EmptyState' import { GlobalFilterInput } from './GlobalFilterInput' import { ToggleSortButton } from './ToggleSortButton' import { cn } from '../../utils/className' @@ -34,6 +33,7 @@ import { TableHeader, TableRow, } from '../Table' +import { TableEmptyState } from '../Table/TableEmptyState' export interface DataTableProps extends PartialSome, 'getCoreRowModel' | 'filterFns'> { @@ -128,11 +128,10 @@ export const DataTable = ({ {!rows.length ? - : rows.map((row) => ( , 'entityName'> { - globalFilter?: string - hasData: boolean - colSpan: number -} - -export const EmptyState = ({ - entityName, - hasData, - colSpan, - globalFilter, -}: EmptyStateProps) => ( - - -
- {hasData ? - - : } - - No {entityName ?? 'results'} found - {hasData && ( - <> -  for "{globalFilter}" search - - )} - . - -
-
-
-) diff --git a/packages/design-system/src/components/EmptyState/EmptyState.tsx b/packages/design-system/src/components/EmptyState/EmptyState.tsx new file mode 100644 index 00000000..a21b1c40 --- /dev/null +++ b/packages/design-system/src/components/EmptyState/EmptyState.tsx @@ -0,0 +1,40 @@ +// +// This source file is part of the Stanford Biodesign Digital Health ENGAGE-HF open-source project +// +// SPDX-FileCopyrightText: 2023 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// +import { SearchX, ListX } from 'lucide-react' +import { type HTMLProps } from 'react' +import { cn } from '../../utils/className' + +export interface EmptyStateProps extends HTMLProps { + entityName?: string + textFilter?: string +} + +export const EmptyState = ({ + entityName, + textFilter, + className, + ...props +}: EmptyStateProps) => ( +
+ {textFilter ? + + : } + + No {entityName ?? 'results'} found + {textFilter && ( + <> +  for "{textFilter}" search + + )} + . + +
+) diff --git a/packages/design-system/src/components/EmptyState/index.tsx b/packages/design-system/src/components/EmptyState/index.tsx new file mode 100644 index 00000000..1a5115b8 --- /dev/null +++ b/packages/design-system/src/components/EmptyState/index.tsx @@ -0,0 +1,8 @@ +// +// This source file is part of the Stanford Biodesign Digital Health ENGAGE-HF open-source project +// +// SPDX-FileCopyrightText: 2023 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// +export * from './EmptyState' diff --git a/packages/design-system/src/components/Select/Select.tsx b/packages/design-system/src/components/Select/Select.tsx index 6f3a6bca..a74f4b8f 100644 --- a/packages/design-system/src/components/Select/Select.tsx +++ b/packages/design-system/src/components/Select/Select.tsx @@ -29,7 +29,7 @@ export const SelectTrigger = forwardRef< span]:line-clamp-1', + 'flex h-10 w-full items-center justify-between rounded-md border border-input bg-surface-primary px-3 py-2 text-sm ring-offset-surface placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1 [&>span]:text-left', className, )} {...props} diff --git a/packages/design-system/src/components/Table/TableEmptyState/TableEmptyState.tsx b/packages/design-system/src/components/Table/TableEmptyState/TableEmptyState.tsx new file mode 100644 index 00000000..aa0cc956 --- /dev/null +++ b/packages/design-system/src/components/Table/TableEmptyState/TableEmptyState.tsx @@ -0,0 +1,24 @@ +// +// This source file is part of the Stanford Biodesign Digital Health ENGAGE-HF open-source project +// +// SPDX-FileCopyrightText: 2023 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// +import { EmptyState, type EmptyStateProps } from '../../EmptyState' +import { TableCell, TableRow } from '../Table' + +interface TableEmptyStateProps extends EmptyStateProps { + colSpan: number +} + +export const TableEmptyState = ({ + colSpan, + ...props +}: TableEmptyStateProps) => ( + + + + + +) diff --git a/packages/design-system/src/components/Table/TableEmptyState/index.tsx b/packages/design-system/src/components/Table/TableEmptyState/index.tsx new file mode 100644 index 00000000..840829d6 --- /dev/null +++ b/packages/design-system/src/components/Table/TableEmptyState/index.tsx @@ -0,0 +1,8 @@ +// +// This source file is part of the Stanford Biodesign Digital Health ENGAGE-HF open-source project +// +// SPDX-FileCopyrightText: 2023 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// +export * from './TableEmptyState' diff --git a/packages/design-system/src/components/Tabs/Tabs.tsx b/packages/design-system/src/components/Tabs/Tabs.tsx new file mode 100644 index 00000000..53a40fcd --- /dev/null +++ b/packages/design-system/src/components/Tabs/Tabs.tsx @@ -0,0 +1,59 @@ +// +// This source file is part of the Stanford Biodesign Digital Health ENGAGE-HF open-source project +// +// SPDX-FileCopyrightText: 2023 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// +'use client' +import * as TabsPrimitive from '@radix-ui/react-tabs' +import { + type ComponentPropsWithoutRef, + type ElementRef, + forwardRef, +} from 'react' +import { cn } from '../../utils/className' + +export const Tabs = TabsPrimitive.Root + +export const TabsList = forwardRef< + ElementRef, + ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +TabsList.displayName = TabsPrimitive.List.displayName + +export const TabsTrigger = forwardRef< + ElementRef, + ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +TabsTrigger.displayName = TabsPrimitive.Trigger.displayName + +export const TabsContent = forwardRef< + ElementRef, + ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +TabsContent.displayName = TabsPrimitive.Content.displayName diff --git a/packages/design-system/src/components/Tabs/index.tsx b/packages/design-system/src/components/Tabs/index.tsx new file mode 100644 index 00000000..c33f7648 --- /dev/null +++ b/packages/design-system/src/components/Tabs/index.tsx @@ -0,0 +1,8 @@ +// +// This source file is part of the Stanford Biodesign Digital Health ENGAGE-HF open-source project +// +// SPDX-FileCopyrightText: 2023 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// +export * from './Tabs' diff --git a/packages/design-system/src/main.css b/packages/design-system/src/main.css index 19866e18..4b069678 100644 --- a/packages/design-system/src/main.css +++ b/packages/design-system/src/main.css @@ -33,6 +33,10 @@ SPDX-License-Identifier: MIT @apply flex items-center justify-center; } + .inline-flex-center { + @apply inline-flex items-center justify-center; + } + .interactive-opacity { @apply focus-ring transition-opacity hover:opacity-60; }