diff --git a/cypress/component/StudyAssets/clinical_trial_list.spec.tsx b/cypress/component/StudyAssets/clinical_trial_list.spec.tsx new file mode 100644 index 000000000..fb65b169f --- /dev/null +++ b/cypress/component/StudyAssets/clinical_trial_list.spec.tsx @@ -0,0 +1,221 @@ +import React from 'react' +import { mount } from 'cypress/react' +import ClinicalTrialAddEdit from 'src/components/clinical_trial_list/ClinicalTrialAddEdit' +import ClinicalTrialList from 'src/components/clinical_trial_list/ClinicalTrialList' +import ClinicalTrialRow from 'src/components/clinical_trial_list/ClinicalTrialRow' +import ClinicalTrialSummary from 'src/components/clinical_trial_list/ClinicalTrialSummary' +import { + ClinicalTrial, + ClinicalTrialStatus, + ClinicalTrialInterventionType, + ClinicalTrialPhase, +} from 'src/types/model' + +const sampleTrial: ClinicalTrial = { + clinicalTrialId: 'ct1', + studyId: 's1', + title: 'Baseline Trial', + registry: 'ClinicalTrials.gov', + identifier: 'NCT00000001', + status: ClinicalTrialStatus.COMPLETED, + sponsor: 'NIH', + startDate: '2024-01-01', + endDate: '2025-12-31', + interventionType: ClinicalTrialInterventionType.DRUG, + description: 'Desc', + phase: ClinicalTrialPhase.PHASE2, + url: 'https://example.com/trial', + tags: ['oncology', 'phase2'], +} + +const ClinicalTrialListHarness: React.FC<{ initial: ClinicalTrial[] }> = ({ initial }) => { + const [items, setItems] = React.useState(initial) + return ( + + ) +} + +describe('ClinicalTrialAddEdit', () => { + it('disables Add until required fields filled then adds', () => { + const added: ClinicalTrial[] = [] + mount( + { added.splice(0, added.length, ...cts) }} + />, + ) + cy.get('.collaborator-form-add-save-button').should('be.disabled') + cy.get('#title').type('My Trial') + cy.get('#registry').type('Registry X') + cy.get('#identifier').type('ID123') + cy.get('#status').click() + cy.get('#status').type('Completed{enter}') + cy.get('#sponsor').type('Sponsor Y') + cy.get('#startDate').type('2024-06-01') + cy.get('#interventionType').click() + cy.get('#interventionType').type('Drug{enter}') + cy.get('#phase').click() + cy.get('#phase').type('Phase 2{enter}') + cy.get('#url').type('https://trial.example.com') + + cy.get('.collaborator-form-add-save-button').should('not.be.disabled').click() + cy.wrap(null).then(() => { + expect(added.length).to.eq(1) + expect(added[0].title).to.eq('My Trial') + expect(added[0].identifier).to.eq('ID123') + expect(added[0].status).to.eq(ClinicalTrialStatus.COMPLETED) + }) + }) + + it('edits existing trial and saves changes', () => { + const trials: ClinicalTrial[] = [sampleTrial] + mount( + { + expect(updated[0].title).to.eq('Baseline Trial Edited') + expect(updated[0].phase).to.eq(ClinicalTrialPhase.PHASE2) + }} + />, + ) + cy.get('#title').clear() + cy.get('#title').type('Baseline Trial Edited') + cy.get('.collaborator-form-add-save-button').click() + }) +}) + +describe('ClinicalTrialSummary', () => { + it('renders tags and date range', () => { + mount( + , + ) + cy.contains('Baseline Trial').should('exist') + cy.contains('ClinicalTrials.gov').should('exist') + cy.contains('NCT00000001').should('exist') + cy.contains(/Completed/i).should('exist') + cy.contains('NIH').should('exist') + cy.contains('2024-01-01 → 2025-12-31').should('exist') + cy.contains(/Drug/i).should('exist') + cy.contains(/Phase II|Phase 2/i).should('exist') + cy.contains('https://example.com/trial').should('exist') + cy.contains('oncology, phase2').should('exist') + }) +}) + +describe('ClinicalTrialRow', () => { + it('shows summary when not in edit mode and triggers edit', () => { + mount( + , + ) + cy.contains('Baseline Trial').should('exist') + cy.get('.glyphicon-pencil').click({ force: true }) + cy.get('@edit').should('have.been.calledOnce') + }) + + it('renders edit form when editMode true', () => { + mount( + , + ) + cy.get('#title').should('have.value', 'Baseline Trial') + }) +}) + +describe('ClinicalTrialList', () => { + it('adds a new clinical trial', () => { + const state: ClinicalTrial[] = [] + mount( + { state.splice(0, state.length, ...cts) }} + disabled={false} + />, + ) + cy.get('#add-clinical-trial-btn').click() + cy.get('#title').type('Added Trial') + cy.get('#registry').type('Reg A') + cy.get('#identifier').type('ID999') + cy.get('#status').click() + cy.get('#status').type('Completed{enter}') + cy.get('#sponsor').type('Org Z') + cy.get('#startDate').type('2024-02-02') + cy.get('#interventionType').click() + cy.get('#interventionType').type('Device{enter}') + cy.get('#phase').click() + cy.get('#phase').type('Phase 3{enter}') + cy.get('#url').type('https://added.example.com') + cy.get('.collaborator-form-add-save-button').click({ force: true }) + cy.wrap(null).then(() => { + expect(state.length).to.eq(1) + expect(state[0].title).to.eq('Added Trial') + expect(state[0].status).to.eq(ClinicalTrialStatus.COMPLETED) + }) + }) + + it('deletes a clinical trial via modal confirmation', () => { + mount() + cy.contains('Baseline Trial').should('exist') + cy.get('.glyphicon-trash').click({ force: true }) + cy.get('.ReactModal__Content') + .should('be.visible') + .within(() => { + cy.get('button') + .filter(':visible') + .contains(/delete/i) + .click({ force: true }) + }) + cy.get('.ReactModal__Content').should('not.exist') + cy.contains('Baseline Trial').should('not.exist') + cy.get('.collaborator-summary-card').should('have.length', 0) + }) +}) diff --git a/cypress/component/utils/clinical_trial_enum_utils.spec.ts b/cypress/component/utils/clinical_trial_enum_utils.spec.ts new file mode 100644 index 000000000..46e25a58c --- /dev/null +++ b/cypress/component/utils/clinical_trial_enum_utils.spec.ts @@ -0,0 +1,127 @@ +import { + parseLegacyStatus, + parseLegacyPhase, + parseLegacyInterventionType, + statusToDisplay, + phaseToDisplay, + interventionTypeToDisplay, + clinicalTrialStatusSelectOptions, + clinicalTrialPhaseSelectOptions, + clinicalTrialInterventionSelectOptions, +} from 'src/utils/ClinicalTrialEnumUtils' +import { + ClinicalTrialStatus, + ClinicalTrialPhase, + ClinicalTrialInterventionType, +} from 'src/types/model' + +describe('ClinicalTrialEnumUtils', () => { + describe('parseLegacyStatus', () => { + it('parses legacy display values', () => { + expect(parseLegacyStatus('Active, not recruiting')).to.eq(ClinicalTrialStatus.ACTIVE_NOT_RECRUITING) + expect(parseLegacyStatus('active, not Recruiting')).to.eq(ClinicalTrialStatus.ACTIVE_NOT_RECRUITING) + expect(parseLegacyStatus('Completed')).to.eq(ClinicalTrialStatus.COMPLETED) + expect(parseLegacyStatus('completed')).to.eq(ClinicalTrialStatus.COMPLETED) + }) + + it('returns fallback UNKNOWN for invalid input', () => { + expect(parseLegacyStatus('Invalid Status')).to.eq(ClinicalTrialStatus.UNKNOWN) + expect(parseLegacyStatus(undefined)).to.eq(ClinicalTrialStatus.UNKNOWN) + expect(parseLegacyStatus(null)).to.eq(ClinicalTrialStatus.UNKNOWN) + }) + }) + + describe('parseLegacyInterventionType', () => { + it('parses legacy intervention types case-insensitively', () => { + expect(parseLegacyInterventionType('Dietary supplement')).to.eq(ClinicalTrialInterventionType.DIETARY_SUPPLEMENT) + expect(parseLegacyInterventionType('dietary Supplement')).to.eq(ClinicalTrialInterventionType.DIETARY_SUPPLEMENT) + expect(parseLegacyInterventionType('Device')).to.eq(ClinicalTrialInterventionType.DEVICE) + expect(parseLegacyInterventionType('device')).to.eq(ClinicalTrialInterventionType.DEVICE) + }) + + it('falls back to OTHER for unknown values', () => { + expect(parseLegacyInterventionType('UnknownType')).to.eq(ClinicalTrialInterventionType.OTHER) + }) + }) + + describe('parseLegacyPhase', () => { + it('parses legacy phase values', () => { + expect(parseLegacyPhase('Phase 2')).to.eq(ClinicalTrialPhase.PHASE2) + expect(parseLegacyPhase('phase 2')).to.eq(ClinicalTrialPhase.PHASE2) + expect(parseLegacyPhase('Early Phase 1')).to.eq(ClinicalTrialPhase.EARLY_PHASE1) + }) + + it('falls back to NA for invalid values', () => { + expect(parseLegacyPhase('Phase X')).to.eq(ClinicalTrialPhase.NA) + }) + }) + + describe('display mapping', () => { + it('maps status enum to display text', () => { + expect(statusToDisplay(ClinicalTrialStatus.RECRUITING)).to.eq('Recruiting') + expect(statusToDisplay(ClinicalTrialStatus.APPROVED_FOR_MARKETING)).to.eq('Approved for marketing') + }) + + it('maps intervention type enum to display text', () => { + expect(interventionTypeToDisplay(ClinicalTrialInterventionType.BEHAVIORAL)).to.eq('Behavioral') + expect(interventionTypeToDisplay(ClinicalTrialInterventionType.RADIATION)).to.eq('Radiation') + }) + + it('maps phase enum to display text', () => { + expect(phaseToDisplay(ClinicalTrialPhase.PHASE3)).to.eq('Phase 3') + expect(phaseToDisplay(ClinicalTrialPhase.EARLY_PHASE1)).to.eq('Early Phase 1') + }) + }) + + describe('select options integrity', () => { + const findKey = (opts: { key: string }[], key: string) => opts.some(o => o.key === key) + + it('status select options exclude fallback UNKNOWN', () => { + expect(findKey(clinicalTrialStatusSelectOptions, ClinicalTrialStatus.UNKNOWN)).to.equal(false) + }) + + it('intervention select options exclude fallback OTHER', () => { + expect(findKey(clinicalTrialInterventionSelectOptions, ClinicalTrialInterventionType.OTHER)).to.equal(false) + }) + + it('phase select options exclude fallback NA', () => { + expect(findKey(clinicalTrialPhaseSelectOptions, ClinicalTrialPhase.NA)).to.equal(false) + }) + + it('all option keys are unique per group', () => { + const unique = (arr: string[]) => new Set(arr).size === arr.length + expect(unique(clinicalTrialStatusSelectOptions.map(o => o.key))).to.equal(true) + expect(unique(clinicalTrialInterventionSelectOptions.map(o => o.key))).to.equal(true) + expect(unique(clinicalTrialPhaseSelectOptions.map(o => o.key))).to.equal(true) + }) + + it('displayText matches expected legacy values sample', () => { + const statusSample = clinicalTrialStatusSelectOptions.find(o => o.key === ClinicalTrialStatus.TERMINATED) + expect(statusSample?.displayText).to.eq('Terminated') + const interventionSample = clinicalTrialInterventionSelectOptions.find(o => o.key === ClinicalTrialInterventionType.DRUG) + expect(interventionSample?.displayText).to.eq('Drug') + const phaseSample = clinicalTrialPhaseSelectOptions.find(o => o.key === ClinicalTrialPhase.PHASE4) + expect(phaseSample?.displayText).to.eq('Phase 4') + }) + }) + + describe('round-trip parsing and display', () => { + it('status round-trips legacy display -> enum -> display', () => { + const legacy = 'Active, not recruiting' + const enumVal = parseLegacyStatus(legacy) + expect(statusToDisplay(enumVal)).to.eq(legacy) + }) + + it('intervention type round-trips', () => { + const legacy = 'Combination product' + const enumVal = parseLegacyInterventionType(legacy) + expect(interventionTypeToDisplay(enumVal)).to.eq(legacy) + }) + + it('phase round-trips', () => { + const legacy = 'Phase 2' + const enumVal = parseLegacyPhase(legacy) + expect(phaseToDisplay(enumVal)).to.eq(legacy) + }) + }) +}) diff --git a/src/components/clinical_trial_list/ClinicalTrialAddEdit.tsx b/src/components/clinical_trial_list/ClinicalTrialAddEdit.tsx new file mode 100644 index 000000000..6a4855a34 --- /dev/null +++ b/src/components/clinical_trial_list/ClinicalTrialAddEdit.tsx @@ -0,0 +1,268 @@ +import React, { useState } from 'react' +import { FormField, FormValidators, FormFieldTypes } from 'src/components/forms/forms' +import { + ClinicalTrial, + ClinicalTrialStatus, + ClinicalTrialPhase, + ClinicalTrialInterventionType, +} from 'src/types/model' +import { validationFailed } from 'src/utils/darFormUtils' +import { ValidationError } from 'src/pages/dar_application/FormValidationState' +import { + clinicalTrialStatusSelectOptions, + clinicalTrialPhaseSelectOptions, + clinicalTrialInterventionSelectOptions, +} from 'src/utils/ClinicalTrialEnumUtils' +import { SelectEntry } from 'src/components/forms/SelectOptionInterface' + +const defaultClinicalTrial: ClinicalTrial = { + clinicalTrialId: '', + studyId: '', + title: '', + registry: '', + identifier: '', + status: ClinicalTrialStatus.UNKNOWN, + sponsor: '', + startDate: '', + endDate: '', + interventionType: ClinicalTrialInterventionType.OTHER, + description: '', + phase: ClinicalTrialPhase.NA, + url: '', + tags: [], +} + +interface FormFieldChange { + key: string + value: unknown +} + +interface ClinicalTrialAddEditProps { + readonly id: number + readonly clinicalTrial?: ClinicalTrial + readonly clinicalTrials: ClinicalTrial[] + readonly closeAction: () => void + readonly onClinicalTrialChange: (clinicalTrials: ClinicalTrial[]) => void +} + +interface Validation { + title?: ValidationError + registry?: ValidationError + identifier?: ValidationError + status?: ValidationError + sponsor?: ValidationError + startDate?: ValidationError + endDate?: ValidationError + interventionType?: ValidationError + phase?: ValidationError + url?: ValidationError +} + +const makeError = (message: string): ValidationError => ({ valid: true, failed: [message] }) + +const isFallback = (v: string) => + v === ClinicalTrialStatus.UNKNOWN + || v === ClinicalTrialPhase.NA + || v === ClinicalTrialInterventionType.OTHER + +const calcClinicalTrialErrors = (ct: ClinicalTrial): Validation => { + const v: Validation = {} + if (!ct.title?.trim()) v.title = makeError('Required') + if (!ct.registry?.trim()) v.registry = makeError('Required') + if (!ct.identifier?.trim()) v.identifier = makeError('Required') + if (isFallback(ct.status)) v.status = makeError('Select status') + if (!ct.sponsor?.trim()) v.sponsor = makeError('Required') + if (!ct.startDate?.trim()) v.startDate = makeError('Required') + else if (!FormValidators.DATE.isValid(ct.startDate)) v.startDate = makeError('Invalid date') + if (ct.endDate?.trim() && !FormValidators.DATE.isValid(ct.endDate)) v.endDate = makeError('Invalid date') + if (isFallback(ct.interventionType)) v.interventionType = makeError('Select type') + if (isFallback(ct.phase)) v.phase = makeError('Select phase') + if (!ct.url?.trim()) v.url = makeError('Required') + return v +} + +const findSelectEntry = (options: SelectEntry[], key?: string) => + key ? options.find(o => o.key === key) || null : null + +export default function ClinicalTrialAddEdit(props: ClinicalTrialAddEditProps): React.JSX.Element { + const { id, clinicalTrial, clinicalTrials, closeAction, onClinicalTrialChange } = props + const [newClinicalTrial, setNewClinicalTrial] = useState(clinicalTrial || defaultClinicalTrial) + const [validation, setValidation] = useState({}) + + const onChange = ({ key, value }: FormFieldChange) => { + let castValue: unknown = value + if (key === 'status') { + castValue = (value && typeof value === 'object' && (value as SelectEntry).key) + ? (value as SelectEntry).key as ClinicalTrialStatus + : value + } + else if (key === 'phase') { + castValue = (value && typeof value === 'object' && (value as SelectEntry).key) + ? (value as SelectEntry).key as ClinicalTrialPhase + : value + } + else if (key === 'interventionType') { + castValue = (value && typeof value === 'object' && (value as SelectEntry).key) + ? (value as SelectEntry).key as ClinicalTrialInterventionType + : value + } + else if (key === 'tags') { + castValue = typeof value === 'string' + ? value.split(',').map(t => t.trim()).filter(Boolean) + : value + } + const ctToSet: ClinicalTrial = { ...newClinicalTrial, [key]: castValue as never } + setNewClinicalTrial(ctToSet) + setValidation(calcClinicalTrialErrors(ctToSet)) + } + + const save = () => { + if (validationFailed(calcClinicalTrialErrors(newClinicalTrial))) return + if (id < 0) { + onClinicalTrialChange([...clinicalTrials, newClinicalTrial]) + } + else { + const copy = [...clinicalTrials] + copy[id] = newClinicalTrial + onClinicalTrialChange(copy) + } + closeAction() + } + + return ( +
+
+
+

{clinicalTrial === undefined ? 'New Clinical Trial Information' : `Edit ${clinicalTrial.title} Information`}

+ + + + +
+
+ + +
+
+
+ ) +} diff --git a/src/components/clinical_trial_list/ClinicalTrialList.tsx b/src/components/clinical_trial_list/ClinicalTrialList.tsx new file mode 100644 index 000000000..7fe8f5426 --- /dev/null +++ b/src/components/clinical_trial_list/ClinicalTrialList.tsx @@ -0,0 +1,101 @@ +import React, { useState } from 'react' +import ClinicalTrialAddEdit from 'src/components/clinical_trial_list/ClinicalTrialAddEdit' +import ClinicalTrialRow from 'src/components/clinical_trial_list/ClinicalTrialRow' +import { ClinicalTrial } from 'src/types/model' +import { DarErrors } from 'src/pages/dar_application/FormValidationState' +import { + parseLegacyStatus, + parseLegacyPhase, + parseLegacyInterventionType, +} from 'src/utils/ClinicalTrialEnumUtils' + +export default function ClinicalTrialList(props: { + readonly clinicalTrials: ClinicalTrial[] + readonly columnsToShow?: string[] + readonly onClinicalTrialChange: (clinicalTrials: ClinicalTrial[]) => void + readonly disabled?: boolean + readonly validation?: DarErrors +}): React.JSX.Element { + const { + clinicalTrials, + columnsToShow = [], + onClinicalTrialChange, + disabled = false, + validation, + } = props + + // Normalize any legacy string values on render + const normalized = clinicalTrials.map(ct => ({ + ...ct, + status: parseLegacyStatus(ct.status as unknown as string), + phase: parseLegacyPhase(ct.phase as unknown as string), + interventionType: parseLegacyInterventionType(ct.interventionType as unknown as string), + })) + + const [showAddEdit, setShowAddEdit] = useState(false) + const [editState, setEditState] = useState(normalized.map(() => false)) + + const toggleEditState = (index: number) => { + const copy = [...editState] + copy[index] = !copy[index] + setEditState(copy) + } + + const handleDelete = (index: number) => { + const updated = normalized.filter((_, i) => i !== index) + onClinicalTrialChange(updated) + } + + const getValidationState = () => validation?.clinicalTrials + + return ( +
+
+ + {showAddEdit && ( + setShowAddEdit(false)} + onClinicalTrialChange={onClinicalTrialChange} + /> + )} +
+
+ {normalized.map((clinicalTrial: ClinicalTrial, index: number) => ( + toggleEditState(index)} + deleteAction={() => { handleDelete(index) }} + closeAction={() => { + toggleEditState(index) + setShowAddEdit(false) + }} + onClinicalTrialChange={onClinicalTrialChange} + disabled={disabled} + /> + ))} +
+
+ ) +} diff --git a/src/components/clinical_trial_list/ClinicalTrialRow.tsx b/src/components/clinical_trial_list/ClinicalTrialRow.tsx new file mode 100644 index 000000000..8134ac65d --- /dev/null +++ b/src/components/clinical_trial_list/ClinicalTrialRow.tsx @@ -0,0 +1,55 @@ +import React from 'react' +import ClinicalTrialAddEdit from 'src/components/clinical_trial_list/ClinicalTrialAddEdit' +import ClinicalTrialSummary from 'src/components/clinical_trial_list/ClinicalTrialSummary' +import { ClinicalTrial } from 'src/types/model' + +interface ClinicalTrialRowProps { + readonly id: number + readonly editMode: boolean + readonly clinicalTrial: ClinicalTrial + readonly clinicalTrials: ClinicalTrial[] + readonly columnsToShow: string[] + readonly editAction: () => void + readonly deleteAction: () => void + readonly closeAction: () => void + readonly onClinicalTrialChange: (clinicalTrials: ClinicalTrial[]) => void + readonly disabled: boolean +} + +export default function ClinicalTrialRow(props: ClinicalTrialRowProps): React.JSX.Element { + const { + id, + editMode, + clinicalTrial, + clinicalTrials, + columnsToShow, + editAction, + deleteAction, + closeAction, + onClinicalTrialChange, + disabled, + } = props + + return ( +
+ {editMode && ( + + )} + {!editMode && ( + + )} +
+ ) +} diff --git a/src/components/clinical_trial_list/ClinicalTrialSummary.tsx b/src/components/clinical_trial_list/ClinicalTrialSummary.tsx new file mode 100644 index 000000000..897e15f10 --- /dev/null +++ b/src/components/clinical_trial_list/ClinicalTrialSummary.tsx @@ -0,0 +1,80 @@ +import React, { useState } from 'react' +import { DeletePresentationOrPublication } from 'src/components/presentation_publication_shared/DeletePresentationOrPublication' +import { ClinicalTrial } from 'src/types/model' +import { renderColumnContent } from 'src/utils/RenderUtils' +import { + statusToDisplay, + phaseToDisplay, + interventionTypeToDisplay, +} from 'src/utils/ClinicalTrialEnumUtils' + +export default function ClinicalTrialSummary(props: { + readonly clinicalTrial: ClinicalTrial + readonly columnsToShow: string[] + readonly editAction: () => void + readonly deleteAction: () => void + readonly disabled: boolean +}): React.JSX.Element { + const { clinicalTrial, columnsToShow, editAction, deleteAction, disabled } = props + const [showDeleteModal, setShowDeleteModal] = useState(false) + + const disabledStyle = { cursor: 'not-allowed', opacity: 0.5 } + const buttonStyle = disabled ? disabledStyle : {} + + const customRenderers = { + tags: (value: unknown) => Array.isArray(value) && value.length > 0 ? value.join(', ') : null, + dateRange: () => (clinicalTrial.startDate || clinicalTrial.endDate) + ? `${clinicalTrial.startDate || 'N/A'} → ${clinicalTrial.endDate || 'N/A'}` + : null, + status: () => statusToDisplay(clinicalTrial.status), + phase: () => phaseToDisplay(clinicalTrial.phase), + interventionType: () => interventionTypeToDisplay(clinicalTrial.interventionType), + } + + return ( +
+ {columnsToShow.map((column, index) => { + const contentSource = column === 'dateRange' ? 'dateRange' : column + const rawValue = clinicalTrial[column as keyof ClinicalTrial] + const columnContent = renderColumnContent(contentSource, rawValue, customRenderers) + return columnContent && ( +
+ {columnContent} +
+ ) + })} +
+ +
+ + { + deleteAction() + setShowDeleteModal(false) + }} + closeAction={() => setShowDeleteModal(false)} + /> +
+ ) +} diff --git a/src/pages/dar_application/FormValidationState.tsx b/src/pages/dar_application/FormValidationState.tsx index 3f11af6cc..aa6c57237 100644 --- a/src/pages/dar_application/FormValidationState.tsx +++ b/src/pages/dar_application/FormValidationState.tsx @@ -61,6 +61,7 @@ export interface DarErrors { closeoutProjectSuperseded?: ValidationError closeoutOther?: ValidationError closeoutOtherText?: ValidationError + clinicalTrials?: ValidationError fundingResources?: ValidationError } diff --git a/src/types/model.ts b/src/types/model.ts index fe02e87e9..1d7b04984 100644 --- a/src/types/model.ts +++ b/src/types/model.ts @@ -370,19 +370,59 @@ export interface Workspace { tags?: string[] } +export enum ClinicalTrialStatus { + ACTIVE_NOT_RECRUITING = 'ACTIVE_NOT_RECRUITING', + COMPLETED = 'COMPLETED', + ENROLLING_BY_INVITATION = 'ENROLLING_BY_INVITATION', + NOT_YET_RECRUITING = 'NOT_YET_RECRUITING', + RECRUITING = 'RECRUITING', + SUSPENDED = 'SUSPENDED', + TERMINATED = 'TERMINATED', + WITHDRAWN = 'WITHDRAWN', + AVAILABLE = 'AVAILABLE', + NO_LONGER_AVAILABLE = 'NO_LONGER_AVAILABLE', + TEMPORARILY_NOT_AVAILABLE = 'TEMPORARILY_NOT_AVAILABLE', + APPROVED_FOR_MARKETING = 'APPROVED_FOR_MARKETING', + WITHHELD = 'WITHHELD', + UNKNOWN = 'UNKNOWN', +} + +export enum ClinicalTrialInterventionType { + BEHAVIORAL = 'BEHAVIORAL', + BIOLOGICAL = 'BIOLOGICAL', + COMBINATION_PRODUCT = 'COMBINATION_PRODUCT', + DEVICE = 'DEVICE', + DIAGNOSTIC_TEST = 'DIAGNOSTIC_TEST', + DIETARY_SUPPLEMENT = 'DIETARY_SUPPLEMENT', + DRUG = 'DRUG', + GENETIC = 'GENETIC', + PROCEDURE = 'PROCEDURE', + RADIATION = 'RADIATION', + OTHER = 'OTHER', +} + +export enum ClinicalTrialPhase { + NA = 'NA', + EARLY_PHASE1 = 'EARLY_PHASE1', + PHASE1 = 'PHASE1', + PHASE2 = 'PHASE2', + PHASE3 = 'PHASE3', + PHASE4 = 'PHASE4', +} + export interface ClinicalTrial { clinicalTrialId: string studyId: string title: string registry: string identifier: string - status: string + status: ClinicalTrialStatus sponsor: string startDate: string - endDate: string - interventionType: string + endDate?: string + interventionType: ClinicalTrialInterventionType description: string - phase: string + phase: ClinicalTrialPhase url: string tags?: string[] } diff --git a/src/utils/ClinicalTrialEnumUtils.ts b/src/utils/ClinicalTrialEnumUtils.ts new file mode 100644 index 000000000..d905f6548 --- /dev/null +++ b/src/utils/ClinicalTrialEnumUtils.ts @@ -0,0 +1,98 @@ +import { + ClinicalTrialStatus, + ClinicalTrialPhase, + ClinicalTrialInterventionType, +} from 'src/types/model' +import { SelectEntry } from 'src/components/forms/SelectOptionInterface' + +export interface LegacyPair { + value: E + legacyValue: string +} + +const normalize = (s: string) => s.trim().toLowerCase() + +const buildAdapter = (pairs: LegacyPair[], fallback: E) => { + const legacyToEnum: Record = {} + const enumToLegacy: Record = {} as Record + + for (const p of pairs) { + enumToLegacy[p.value] = p.legacyValue + legacyToEnum[normalize(p.legacyValue)] = p.value + legacyToEnum[normalize(p.value)] = p.value + } + + const parseLegacy = (legacy?: string | null): E => + legacy ? (legacyToEnum[normalize(legacy)] || fallback) : fallback + + const toDisplay = (value: E): string => enumToLegacy[value] || enumToLegacy[fallback] + + // Return select options (id/displayText) excluding fallback + const selectOptions: SelectEntry[] = pairs + .filter(p => p.value !== fallback) + .map(p => ({ key: p.value, displayText: p.legacyValue })) + + return { parseLegacy, toDisplay, selectOptions } +} + +/* Status */ +const statusPairs: LegacyPair[] = [ + { value: ClinicalTrialStatus.ACTIVE_NOT_RECRUITING, legacyValue: 'Active, not recruiting' }, + { value: ClinicalTrialStatus.COMPLETED, legacyValue: 'Completed' }, + { value: ClinicalTrialStatus.ENROLLING_BY_INVITATION, legacyValue: 'Enrolling by invitation' }, + { value: ClinicalTrialStatus.NOT_YET_RECRUITING, legacyValue: 'Not yet recruiting' }, + { value: ClinicalTrialStatus.RECRUITING, legacyValue: 'Recruiting' }, + { value: ClinicalTrialStatus.SUSPENDED, legacyValue: 'Suspended' }, + { value: ClinicalTrialStatus.TERMINATED, legacyValue: 'Terminated' }, + { value: ClinicalTrialStatus.WITHDRAWN, legacyValue: 'Withdrawn' }, + { value: ClinicalTrialStatus.AVAILABLE, legacyValue: 'Available' }, + { value: ClinicalTrialStatus.NO_LONGER_AVAILABLE, legacyValue: 'No longer available' }, + { value: ClinicalTrialStatus.TEMPORARILY_NOT_AVAILABLE, legacyValue: 'Temporarily not available' }, + { value: ClinicalTrialStatus.APPROVED_FOR_MARKETING, legacyValue: 'Approved for marketing' }, + { value: ClinicalTrialStatus.WITHHELD, legacyValue: 'Withheld' }, + { value: ClinicalTrialStatus.UNKNOWN, legacyValue: 'Unknown' }, // fallback +] +const statusAdapter = buildAdapter(statusPairs, ClinicalTrialStatus.UNKNOWN) +export const parseLegacyStatus = statusAdapter.parseLegacy +export const statusToDisplay = statusAdapter.toDisplay +export const clinicalTrialStatusSelectOptions = statusAdapter.selectOptions + +/* Intervention Type */ +const interventionPairs: LegacyPair[] = [ + { value: ClinicalTrialInterventionType.BEHAVIORAL, legacyValue: 'Behavioral' }, + { value: ClinicalTrialInterventionType.BIOLOGICAL, legacyValue: 'Biological' }, + { value: ClinicalTrialInterventionType.COMBINATION_PRODUCT, legacyValue: 'Combination product' }, + { value: ClinicalTrialInterventionType.DEVICE, legacyValue: 'Device' }, + { value: ClinicalTrialInterventionType.DIAGNOSTIC_TEST, legacyValue: 'Diagnostic test' }, + { value: ClinicalTrialInterventionType.DIETARY_SUPPLEMENT, legacyValue: 'Dietary supplement' }, + { value: ClinicalTrialInterventionType.DRUG, legacyValue: 'Drug' }, + { value: ClinicalTrialInterventionType.GENETIC, legacyValue: 'Genetic' }, + { value: ClinicalTrialInterventionType.PROCEDURE, legacyValue: 'Procedure' }, + { value: ClinicalTrialInterventionType.RADIATION, legacyValue: 'Radiation' }, + { value: ClinicalTrialInterventionType.OTHER, legacyValue: 'Other' }, // fallback +] +const interventionAdapter = buildAdapter(interventionPairs, ClinicalTrialInterventionType.OTHER) +export const parseLegacyInterventionType = interventionAdapter.parseLegacy +export const interventionTypeToDisplay = interventionAdapter.toDisplay +export const clinicalTrialInterventionSelectOptions = interventionAdapter.selectOptions + +/* Phase */ +const phasePairs: LegacyPair[] = [ + { value: ClinicalTrialPhase.EARLY_PHASE1, legacyValue: 'Early Phase 1' }, + { value: ClinicalTrialPhase.PHASE1, legacyValue: 'Phase 1' }, + { value: ClinicalTrialPhase.PHASE2, legacyValue: 'Phase 2' }, + { value: ClinicalTrialPhase.PHASE3, legacyValue: 'Phase 3' }, + { value: ClinicalTrialPhase.PHASE4, legacyValue: 'Phase 4' }, + { value: ClinicalTrialPhase.NA, legacyValue: 'Not Applicable' }, // fallback +] +const phaseAdapter = buildAdapter(phasePairs, ClinicalTrialPhase.NA) +export const parseLegacyPhase = phaseAdapter.parseLegacy +export const phaseToDisplay = phaseAdapter.toDisplay +export const clinicalTrialPhaseSelectOptions = phaseAdapter.selectOptions + +/* Convenience lookups */ +export const clinicalTrialEnumDisplay = { + status: statusToDisplay, + phase: phaseToDisplay, + interventionType: interventionTypeToDisplay, +}