diff --git a/.env.example b/.env.example index 8cd523f2..0b3b4eef 100644 --- a/.env.example +++ b/.env.example @@ -1,15 +1,59 @@ -NEXTAUTH_SECRET=dAbxJF2DRzqwGYn+BWKdj8o9ieMri4FWsmIRn77r2F8= +# Please modify the following environment variables accordingly. + +CTIMS_ENV=development +CTIMS_API_VERSION=2.1 + + +#DATABASE_URL=mysql://ctims:ctims@localhost:3306/ctims +#AES 256 key, it can be generated using https://cloak.47ng.com/key +#Random example key given here. Please replace this key for your install. +#PRISMA_FIELD_ENCRYPTION_KEY=k1.aesgcm256.wPyWsb8gQ2Xn-UgiaOQRqs3nb4FWIYR01DDihQgeqBg= + +# command to create a new secret - openssl rand -base64 32 +# NEXTAUTH Secret is used to encrypt the NextAuth.js JWT +#Example value QG0ip+EjLW4j9BNlXPcC5kXjXcEAW/cYP1mbZKLP1q8= +NEXTAUTH_SECRET=secret REACT_APP_API_URL=http://localhost:3333/api NEXTAUTH_URL=http://localhost:3000 -NEXT_AUTH_API_URL=http://backend:3333/api +NEXTAUTH_API_URL=http://backend:3333/api +NEXT_PUBLIC_SIGNOUT_REDIRECT_URL=http://localhost:3000 +NEXT_PUBLIC_TRIAL_LOCK_PING_TIME_MS=240000 +NEXT_TRIAL_LOCK_CLEAR_TIME_MS=300000 + + +#keycloak settings KEYCLOAK_URL= KEYCLOAK_CLIENT_ID= +# Keycloak client uuid. This is different from the id above. +# If you cannot find it in the client settings, the URL usually includes it. Example: http://localhost:8080/admin/master/console/#/ctims/clients/3813811a-***/settings, 3813811a-*** is the uuid KEYCLOAK_CLIENT_UUID= +# We suggest creating a new realm instead of using the default master realm. KEYCLOAK_REALM= +# This is the client secret that stored under the Credentials tab in the client page. +# The realm needs to have client authentication enabled to see the Credentials tab. KEYCLOAK_CLIENT_SECRET= -KEYCLOAK_ADMIN_CLIENT_ID=ctims-admin # a client with sevice account enabled +# a client with sevice account enabled +# It does not need to be a separate client. If your above client already has service account enabled, use the same ID here. +KEYCLOAK_ADMIN_CLIENT_ID=ctims-admin KEYCLOAK_ADMIN_CLIENT_SECRET= -KEYCLOAK_TOKEN_ENDPOINT=https://cbioportal.pmgenomics.ca/newauth/realms/UHN/protocol/openid-connect/token -NEXT_PUBLIC_ENABLE_MATCHMINER_INTEGRATION=true -NEXT_PUBLIC_TRIAL_LOCK_PING_TIME_MS=240000 -NEXT_TRIAL_LOCK_CLEAR_TIME_MS=300000 +# This is the endpoint to get access token. +KEYCLOAK_TOKEN_ENDPOINT= + +#matchminer settings - only if matchminer integration is enabled +NEXT_PUBLIC_ENABLE_MATCHMINER_INTEGRATION = true +MM_API_URL=/api +MM_API_TOKEN= + +#Rabbitmq settings for MatchMiner communication +RABBITMQ_URL=rabbitmq +RABBITMQ_PORT=5672 +MATCHMINER_SEND_QUEUE=run_match +MATCHMINER_RECEIVE_QUEUE=match_message + +# email notification settings +#example Host name gmail.com +MAIL_HOST= +MAIL_USERNAME= +MAIL_PASSWORD= +CTIMS_SUPPORT_EMAIL= +CTIMS_URL= diff --git a/.gitignore b/.gitignore index 2bf67af2..c08ab7f9 100644 --- a/.gitignore +++ b/.gitignore @@ -19,6 +19,8 @@ *.sublime-workspace apps/api/.env apps/web/.env +apps/api/prisma/.env +.env # IDE - VSCode .vscode/* diff --git a/Dockerfile b/Dockerfile index 651bc8d6..be5f824e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -42,9 +42,6 @@ COPY --from=build /app/dist/apps/web . RUN rm -rf /var/www/html/.next/cache RUN sh -c 'echo "[]" > /var/www/html/.next/server/next-font-manifest.json' -ENV NEXTAUTH_SECRET=dAbxJF2DRzqwGYn+BWKdj8o9ieMri4FWsmIRn77r2F8= -ENV REACT_APP_API_URL=http://backend:3333/api -ENV NEXTAUTH_URL=http://localhost:3000 RUN yarn install --production diff --git a/apps/api/prisma/seed/index.js b/apps/api/prisma/seed/index.js index 7f9e6dfc..25e29569 100644 --- a/apps/api/prisma/seed/index.js +++ b/apps/api/prisma/seed/index.js @@ -42,7 +42,7 @@ main().then(() => { async function fetchHgncData() { try { const response = await get( - 'https://ftp.ebi.ac.uk/pub/databases/genenames/hgnc/json/hgnc_complete_set.json' + 'http://storage.googleapis.com/public-download-files/hgnc/json/json/hgnc_complete_set.json' ); if (response.status != 200) { throw new Error('Failed to fetch data'); @@ -63,7 +63,7 @@ async function saveGenes(data) { hugoSymbol: x.symbol, }; }); - + await prisma.gene.createMany({ data: genesData, skipDuplicates: true, diff --git a/apps/api/src/app/trial/trial.service.ts b/apps/api/src/app/trial/trial.service.ts index cfa66a96..02950369 100644 --- a/apps/api/src/app/trial/trial.service.ts +++ b/apps/api/src/app/trial/trial.service.ts @@ -134,9 +134,9 @@ export class TrialService implements OnModuleInit { where: { id: existing_trial.id }, data: { status, - principal_investigator, - nickname, - nct_id, + principal_investigator: principal_investigator === undefined? null : principal_investigator, + nickname: nickname === undefined? null : nickname, + nct_id: nct_id === undefined? null : nct_id, ctml_schemas: { connect: { version: ctml_schema_version diff --git a/apps/web/components/editor/EditorTopBar.tsx b/apps/web/components/editor/EditorTopBar.tsx index 83cc84d7..bf623704 100644 --- a/apps/web/components/editor/EditorTopBar.tsx +++ b/apps/web/components/editor/EditorTopBar.tsx @@ -18,9 +18,9 @@ import useHandleSignOut from "../../hooks/useHandleSignOut"; import useUpdateTrialLock from "../../hooks/useUpdateTrialLock"; interface EditorTopBarProps { - title?: string; - lastSaved: string; - setLastSaved: any; + title?: string; + lastSaved: string; + setLastSaved: any; } const EditorTopBar = (props: EditorTopBarProps) => { @@ -264,7 +264,7 @@ const EditorTopBar = (props: EditorTopBarProps) => { <> setIsConfirmationDialogVisible(false)} message="Are you sure you want to leave this page? Unsaved inputs will be lost." - header="Confirmation" acceptLabel="Discard" rejectLabel="Cancel" accept={accept} reject={reject} + header="Confirmation" acceptLabel="Discard" rejectLabel="Cancel" accept={accept} reject={reject} /> { sendCtmlClicked={onSendClick} onCTMLDialogHide={() => setIsSendDialogVisible(false)} onIsOKClicked={handleSendCTMLOKClicked}/> -
-
- {'logo'} -
-
-
-
backClick(e)}> - -
-
{props.title ? props.title : "New CTML"}
+
+
+ {'logo'}
-
Last saved: {props.lastSaved}
-
- {/*
+ <> + {isGroupAdmin && +
+
-
) } diff --git a/apps/web/components/trials/Results.module.scss b/apps/web/components/trials/Results.module.scss index 10c200b5..7d9b1d1b 100644 --- a/apps/web/components/trials/Results.module.scss +++ b/apps/web/components/trials/Results.module.scss @@ -4,6 +4,9 @@ width: 100%; box-shadow: 0px 1px 4px rgba(0, 0, 0, 0.25); border-radius: 8px; + overflow: hidden; + + } .titleText { diff --git a/apps/web/components/trials/Results.tsx b/apps/web/components/trials/Results.tsx index 3db4aab7..ac8eddb9 100644 --- a/apps/web/components/trials/Results.tsx +++ b/apps/web/components/trials/Results.tsx @@ -4,9 +4,10 @@ import useGetMatchResults from '../../hooks/useGetMatchResults'; import useDownloadResults from '../../hooks/useDownloadResults'; import { classNames } from 'primereact/utils'; import styles from './Results.module.scss'; -import { DataTable } from 'primereact/datatable'; +import {DataTable, DataTableSortMeta} from 'primereact/datatable'; import { Column } from 'primereact/column'; import { CSVLink } from "react-csv"; +import {MULTI_SORT_META_RESULTS} from "../../constants/appConstants"; import {TrialStatusEnum} from "../../../../libs/types/src/trial-status.enum"; import { setIsLongOperation } from 'apps/web/store/slices/contextSlice'; import { useDispatch } from 'react-redux'; @@ -48,6 +49,10 @@ const Results = (props: {trials: [], getTrialsForUsersInGroupLoading: boolean}) useEffect(() => { if (getMatchResultsResponse) { const processedData = postProcess(getMatchResultsResponse); + + // Sort by 'updatedAt' in descending order BEFORE rendering + processedData.sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()); + setResults(processedData); } }, [getMatchResultsResponse]) @@ -75,6 +80,10 @@ const Results = (props: {trials: [], getTrialsForUsersInGroupLoading: boolean}) // result download file name const [resultFileName, setResultFileName] = useState(''); + + const DEFAULT_SORT: DataTableSortMeta[] = [{ field: 'updatedAt', order: -1 }]; + const [multiSortMeta, setMultiSortMeta] = useState(DEFAULT_SORT); + const headers = [ {label: "Trial Id", key: "trialId"}, {label: "Nickname", key: "nickname"}, @@ -114,6 +123,43 @@ const Results = (props: {trials: [], getTrialsForUsersInGroupLoading: boolean}) // don't have global const state for csvLink as it can be null as component is mounted/unmounted when datatable is changed let csvLink: React.MutableRefObject = useRef(null); + const onSort = (event: any) => { + if (event.multiSortMeta?.length) { + setMultiSortMeta(event.multiSortMeta); + sessionStorage.setItem(MULTI_SORT_META_RESULTS, JSON.stringify(event.multiSortMeta)); + } else { + setMultiSortMeta(DEFAULT_SORT); + sessionStorage.removeItem(MULTI_SORT_META_RESULTS); + } + }; + + // Restore sorting when the component mounts + useEffect(() => { + const savedSortMeta = sessionStorage.getItem(MULTI_SORT_META_RESULTS); + if (savedSortMeta) { + setMultiSortMeta(JSON.parse(savedSortMeta)); + } + }, []); + + const extractDateTime = (dateStr) => { + if (!dateStr || typeof dateStr !== 'string') { + return ''; // Return an empty string to prevent crashes + } + + // Extract date and time before "by" in the string + const match = dateStr.match(/(.*?)(?=\s+by\s+|$)/); + return match ? match[1] : dateStr; + }; + + const customDateSort = (event) => { + const { data, order, field } = event; + return [...data].sort((a, b) => { + const dateA = new Date(extractDateTime(a[field])).getTime(); + const dateB = new Date(extractDateTime(b[field])).getTime(); + return order * (dateA - dateB); + }); + }; + const downloadBodyTemplate = (rowData) => { // set the ref in body template so we know it's mounted csvLink = React.useRef(); @@ -183,20 +229,36 @@ const Results = (props: {trials: [], getTrialsForUsersInGroupLoading: boolean}) Match Results
- - - - - - - - - - + + + + + + + + + @@ -205,4 +267,5 @@ const Results = (props: {trials: [], getTrialsForUsersInGroupLoading: boolean}) ) } + export default Results; diff --git a/apps/web/components/trials/Trials.module.scss b/apps/web/components/trials/Trials.module.scss index 2cebfc66..cc7a0ee7 100644 --- a/apps/web/components/trials/Trials.module.scss +++ b/apps/web/components/trials/Trials.module.scss @@ -46,6 +46,8 @@ width: 100%; box-shadow: 0px 1px 4px rgba(0, 0, 0, 0.25); border-radius: 8px; + overflow: hidden; // Add this to contain the table + } .trailsEllipseBtn { diff --git a/apps/web/components/trials/Trials.tsx b/apps/web/components/trials/Trials.tsx index d5d12e90..35d4f547 100644 --- a/apps/web/components/trials/Trials.tsx +++ b/apps/web/components/trials/Trials.tsx @@ -3,7 +3,7 @@ import { useDispatch, useSelector } from 'react-redux'; import {RootState, store} from '../../store/store'; import {signOut, useSession} from 'next-auth/react'; import React, { useEffect, useRef, useState } from 'react'; -import { DataTable, DataTableRowMouseEventParams } from 'primereact/datatable'; +import {DataTable, DataTableRowMouseEventParams, DataTableSortMeta} from 'primereact/datatable'; import { useRouter } from 'next/router'; import { setIsFormChanged, setIsFormDisabled, setTrialNctId} from '../../store/slices/contextSlice'; import { ConfirmDialog, confirmDialog } from 'primereact/confirmdialog'; @@ -14,7 +14,7 @@ import { Column } from 'primereact/column'; import {Toast} from "primereact/toast"; import {parse} from "yaml"; import NewTrialIdDialog from './NewTrialIdDialog'; -import {IS_FORM_DISABLED} from "../../constants/appConstants"; +import {IS_FORM_DISABLED, MULTI_SORT_META_TRIALS} from "../../constants/appConstants"; import useSendMatchminerJob from "../../hooks/useSendMatchminerJob"; import SendCTMLDialog from "./SendCTMLDialog"; import useSendMultipleCTMLs from "../../hooks/useSendMultipleCTMLs"; @@ -38,6 +38,28 @@ const Trials = (props: {selectedTrialGroup: { plainRole: string, isAdmin: boolea const dispatch = useDispatch(); const menu = useRef(null); + const [multiSortMeta, setMultiSortMeta] = useState([ + { field: '_rawUpdatedAt', order: -1 }, // Default sort by Modified on descending + ]); + const DEFAULT_SORT: DataTableSortMeta[] = [{ field: 'updatedAt', order: -1 }]; + const onSort = (event: any) => { + if (event.multiSortMeta?.length) { + setMultiSortMeta(event.multiSortMeta); + sessionStorage.setItem(MULTI_SORT_META_TRIALS, JSON.stringify(event.multiSortMeta)); + } else { + setMultiSortMeta(DEFAULT_SORT); + sessionStorage.removeItem(MULTI_SORT_META_TRIALS); + } + }; + + // Restore sorting when the component mounts + useEffect(() => { + const savedSortMeta = sessionStorage.getItem(MULTI_SORT_META_TRIALS); + if (savedSortMeta) { + setMultiSortMeta(JSON.parse(savedSortMeta)); + } + }, []); + const [isTrialIdDialogVisible, setIsTrialIdDialogVisible] = useState(false); const [isSendDialogVisible, setIsSendDialogVisible] = useState(false); const [selectedTrialsToMatch, setSelectedTrialsToMatch] = useState([]); @@ -115,6 +137,7 @@ const Trials = (props: {selectedTrialGroup: { plainRole: string, isAdmin: boolea }); isFormDisabled = true; } + dispatch(setIsFormDisabled(isFormDisabled)); sessionStorage.setItem(IS_FORM_DISABLED, isFormDisabled.toString().toUpperCase()); router.push(`/trials/edit/${rowClicked.id}`); @@ -166,6 +189,22 @@ const Trials = (props: {selectedTrialGroup: { plainRole: string, isAdmin: boolea ); } + // Add these two functions here: + const extractDateTime = (dateStr) => { + // Extract date and time before "by" in the string + const match = dateStr.match(/(.*?)(?=\s+by\s+|$)/); + return match ? match[1] : dateStr; + }; + + const customDateSort = (event) => { + const { data, order, field } = event; + return [...data].sort((a, b) => { + const dateA = new Date(extractDateTime(a[field])).getTime(); + const dateB = new Date(extractDateTime(b[field])).getTime(); + return order * (dateA - dateB); + }); + }; + const myClick = (event, rowData) => { menu.current.toggle(event); setRowClicked(rowData); @@ -371,23 +410,39 @@ const Trials = (props: {selectedTrialGroup: { plainRole: string, isAdmin: boolea +
- setRowEntered(event.data)} onRowMouseLeave={() => setRowEntered(null)} - sortField="createdOn" sortOrder={-1} emptyMessage={!props.selectedTrialGroup ? 'Select a Trial Group to start' : 'No CTML files. Select the \'Create\' button to start.'} + sortMode="multiple" + multiSortMeta={multiSortMeta} + onSort={onSort} + removableSort > - - - - - - - - + + + + + + rowData.createdAt} // Show formatted version + /> + rowData.updatedAt} // Show formatted version + /> + +
diff --git a/apps/web/constants/appConstants.ts b/apps/web/constants/appConstants.ts index e8568bf9..0f6a1913 100644 --- a/apps/web/constants/appConstants.ts +++ b/apps/web/constants/appConstants.ts @@ -2,3 +2,5 @@ export const SELECTED_TRIAL_GROUP_ID = 'SELECTED_TRIAL_GROUP_ID'; export const SELECTED_TRIAL_GROUP_IS_ADMIN = 'SELECTED_TRIAL_GROUP_IS_ADMIN'; export const IS_FORM_DISABLED = 'IS_FORM_DISABLED'; +export const MULTI_SORT_META_TRIALS = 'MULTI_SORT_META_TRIALS'; +export const MULTI_SORT_META_RESULTS = 'MULTI_SORT_META_RESULTS'; diff --git a/apps/web/hooks/useGetTrialsForUsersInGroup.tsx b/apps/web/hooks/useGetTrialsForUsersInGroup.tsx index 9c04cb39..613a1129 100644 --- a/apps/web/hooks/useGetTrialsForUsersInGroup.tsx +++ b/apps/web/hooks/useGetTrialsForUsersInGroup.tsx @@ -17,16 +17,25 @@ const useGetTrialsForUsersInGroup = () => { 'Authorization': 'Bearer ' + accessToken, } try { - const userTrials = await operation({ method: 'get', url: `/trial-group/${groupId}`, headers }); - const mapped = userTrials.data.map((trial) => { + // Sort the trials by updatedAt before mapping + const sortedTrials = [...userTrials.data].sort((a, b) => + new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime() + ); + + const mapped = sortedTrials.map((trial) => { let createdAtDate = new Date(trial.createdAt) let updatedAtDate = new Date(trial.updatedAt) + + // Store raw dates for sorting + const rawUpdatedAt = trial.updatedAt; + const rawCreatedAt = trial.createdAt; + let createdAtFormatted = createdAtDate.toLocaleString(undefined, { month: 'short', day: 'numeric', @@ -48,6 +57,8 @@ const useGetTrialsForUsersInGroup = () => { ...trial, createdAt: createdAtFormatted, updatedAt: updatedAtFormatted, + _rawUpdatedAt: rawUpdatedAt, // Keep raw date for sorting + _rawCreatedAt: rawCreatedAt, // Keep raw date for sorting ctml_status_label, user: trial.user, lockStatus: trial.trial_lock[0] ? "Locked" : "Unlocked", diff --git a/apps/web/hooks/useSendCTML.tsx b/apps/web/hooks/useSendCTML.tsx index ad93e4d1..144fd7a7 100644 --- a/apps/web/hooks/useSendCTML.tsx +++ b/apps/web/hooks/useSendCTML.tsx @@ -2,6 +2,7 @@ import {useState} from 'react'; import {useSelector} from "react-redux"; import {RootState} from "../store/store"; import useAxios from "./useAxios"; +import { flattenGenericObject } from 'libs/ui/src/lib/components/helpers'; const useSendCTML = () => { @@ -17,7 +18,8 @@ const useSendCTML = () => { let ctmlModelCopy; const age_group = ctmlModel.age_group; const trialInformation = ctmlModel.trialInformation; - ctmlModelCopy = {'trial_list' : [{...ctmlModel, ...trialInformation, ...age_group}]}; + const treatmentListFlatted = flattenGenericObject(ctmlModel.treatment_list); + ctmlModelCopy = {'trial_list' : [{...ctmlModel, ...trialInformation, ...age_group, treatment_list: treatmentListFlatted}]}; delete ctmlModelCopy.age_group; delete ctmlModelCopy.trialInformation; delete ctmlModelCopy.ctml_status; diff --git a/apps/web/pages/api/auth/[...nextauth].tsx b/apps/web/pages/api/auth/[...nextauth].tsx index b02eee8b..6e2f4506 100644 --- a/apps/web/pages/api/auth/[...nextauth].tsx +++ b/apps/web/pages/api/auth/[...nextauth].tsx @@ -1,9 +1,9 @@ import NextAuth from "next-auth"; import CredentialsProvider from "next-auth/providers/credentials"; import {createAction} from "@reduxjs/toolkit"; -console.log('process.env.NEXTAUTH_SECRET', process.env.NEXTAUTH_SECRET) -console.log('process.env.REACT_APP_API_URL', process.env.REACT_APP_API_URL) -console.log('process.env.NEXTAUTH_URL', process.env.NEXTAUTH_URL) +// console.log('process.env.NEXTAUTH_SECRET', process.env.NEXTAUTH_SECRET) +// console.log('process.env.REACT_APP_API_URL', process.env.REACT_APP_API_URL) +// console.log('process.env.NEXTAUTH_URL', process.env.NEXTAUTH_URL) export default NextAuth({ // Configure one or more authentication providers session: { diff --git a/apps/web/pages/styles.css b/apps/web/pages/styles.css index 01e4e2e7..e96c81ea 100644 --- a/apps/web/pages/styles.css +++ b/apps/web/pages/styles.css @@ -725,3 +725,28 @@ summary svg { .p-progress-spinner-circle { stroke: #2E72D2 !important; } + +.p-datatable { + .p-datatable-wrapper { + overflow: visible; + } + + & table { + table-layout: fixed; + width: 100%; + } + + + .p-datatable-thead > tr > th { + padding: 1rem; + + + .p-datatable-tbody > tr > td { + padding: 1rem; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + } +} + diff --git a/docker-compose.yml b/docker-compose.yml index 3514a748..05fa9a41 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -10,6 +10,9 @@ services: REACT_APP_API_URL: ${REACT_APP_API_URL} NEXTAUTH_URL: ${NEXTAUTH_URL} NEXTAUTH_API_URL: ${NEXTAUTH_API_URL} + NEXT_PUBLIC_SIGNOUT_REDIRECT_URL: ${NEXT_PUBLIC_SIGNOUT_REDIRECT_URL} + NEXT_PUBLIC_ENABLE_MATCHMINER_INTEGRATION: ${NEXT_PUBLIC_ENABLE_MATCHMINER_INTEGRATION} + NEXT_PUBLIC_TRIAL_LOCK_PING_TIME_MS: ${NEXT_PUBLIC_TRIAL_LOCK_PING_TIME_MS} dockerfile: Dockerfile ports: - "3000:3000" @@ -17,7 +20,7 @@ services: - /etc/localtime:/etc/localtime:ro restart: on-failure networks: - - my-network + - ctims-network backend: container_name: ctims-api @@ -35,15 +38,29 @@ services: KEYCLOAK_CLIENT_UUID: ${KEYCLOAK_CLIENT_UUID} PRISMA_FIELD_ENCRYPTION_KEY: ${PRISMA_FIELD_ENCRYPTION_KEY} MM_API_URL: ${MM_API_URL} - CTIMS_ENV: ${CTIMS_ENV} MM_API_TOKEN: ${MM_API_TOKEN} + CTIMS_ENV: ${CTIMS_ENV} + CTIMS_URL: ${CTIMS_URL} + CTIMS_API_VERSION: ${CTIMS_API_VERSION} + CTIMS_API_KEY: ${CTIMS_API_KEY} + MATCHMINER_SEND_QUEUE: ${MATCHMINER_SEND_QUEUE} + MATCHMINER_RECEIVE_QUEUE: ${MATCHMINER_RECEIVE_QUEUE} + RABBITMQ_URL: ${RABBITMQ_URL} + RABBITMQ_PORT: ${RABBITMQ_PORT} + MAIL_HOST: ${MAIL_HOST} + MAIL_USERNAME: ${MAIL_USERNAME} + MAIL_PASSWORD: ${MAIL_PASSWORD} + CTIMS_SUPPORT_EMAIL: ${CTIMS_SUPPORT_EMAIL} + NEXT_PUBLIC_TRIAL_LOCK_PING_TIME_MS: ${NEXT_PUBLIC_TRIAL_LOCK_PING_TIME_MS} + NEXT_TRIAL_LOCK_CLEAR_TIME_MS: ${NEXT_TRIAL_LOCK_CLEAR_TIME_MS} + environment: DATABASE_URL: mysql://ctims:ctims@database:3306/ctims ports: - "3333:3333" restart: on-failure networks: - - my-network + - ctims-network database: container_name: ctims-db @@ -54,7 +71,7 @@ services: - "3306:3306" restart: on-failure networks: - - my-network + - ctims-network # https://github.com/prisma/prisma/releases/tag/2.17.0 database_shadow: @@ -66,8 +83,8 @@ services: - "3307:3306" restart: on-failure networks: - - my-network + - ctims-network networks: - my-network: + ctims-network: diff --git a/libs/ui/src/lib/components/CtimsAutoCompleteComponent.module.css b/libs/ui/src/lib/components/CtimsAutoCompleteComponent.module.css index 7000df6f..a11bd99d 100644 --- a/libs/ui/src/lib/components/CtimsAutoCompleteComponent.module.css +++ b/libs/ui/src/lib/components/CtimsAutoCompleteComponent.module.css @@ -10,3 +10,26 @@ display: flex; flex-direction: column; } + +.label-container { + display: flex; + flex-direction: row; +} + +.label { + font-family: Inter, sans-serif; + font-weight: 400; + font-size: 14px; + margin-bottom: 7px; + margin-top: 7px; +} + +.optional-label { + font-family: Inter, sans-serif; + font-weight: 400; + font-size: 14px; + margin-bottom: 7px; + margin-top: 7px; + margin-left: auto; + color: rgba(0, 0, 0, 0.6); +} \ No newline at end of file diff --git a/libs/ui/src/lib/components/CtimsAutoCompleteComponent.tsx b/libs/ui/src/lib/components/CtimsAutoCompleteComponent.tsx index 4a19a15f..f0dfbd7a 100644 --- a/libs/ui/src/lib/components/CtimsAutoCompleteComponent.tsx +++ b/libs/ui/src/lib/components/CtimsAutoCompleteComponent.tsx @@ -3,25 +3,56 @@ import { AutoComplete } from 'primereact/autocomplete'; import { Tooltip } from 'antd'; import styles from "./CtimsAutoCompleteComponent.module.css"; import useGetGenes from '../../../../../apps/web/hooks/useGetGenes'; +import IOSSwitch from '../components/IOSSwitch'; +import { hugo_symblo_validation_func } from "../components/helpers"; +import cn from "clsx"; const AutocompleteField = ({ onChange, ...props }) => { const { filteredHugoSymbols, loading, searchSymbols } = useGetGenes(); - const [selectedHugoSymbol, setSelectedHugoSymbol] = useState([props.value]); + const [selectedHugoSymbol, setSelectedHugoSymbol] = useState(props.value ? props.value.replace('!', '') : ''); + const [excludeToggle, setExcludeToggle] = useState(props.value ? props.value.startsWith('!') : false); + const [hugoSymbolError, setHugoSymbolError] = React.useState(false); + useEffect(() => { + if (props.value) { + const isExcluded: boolean = props.value.startsWith('!'); + setSelectedHugoSymbol(props.value.replace('!', '')); + setExcludeToggle(isExcluded); + } + else { + setSelectedHugoSymbol(''); + setExcludeToggle(false); + } + if (!hugo_symblo_validation_func(props.value)) { + setHugoSymbolError(true) + } else { + setHugoSymbolError(false) + } + }, [props.value]); - useEffect(() => { - setSelectedHugoSymbol([props.value]); -}, [props.value]); - - const handleInputChange = (e: {value: string}) => { + const handleInputChange = (e: { value: string }) => { const trimmedValue = e.value.trim(); - trimmedValue !== "" ? - (setSelectedHugoSymbol([trimmedValue]), onChange(trimmedValue)): - (setSelectedHugoSymbol([]), onChange(undefined)); + //The below check make sures there are no multiple ! in the input string. + if(trimmedValue.startsWith('!') && props.value?.startsWith('!')){ + setSelectedHugoSymbol(trimmedValue.replace(/^!/, "")); + } + else if (trimmedValue.startsWith('!') || excludeToggle) { + setSelectedHugoSymbol(trimmedValue.replace(/^!/, "")); + setExcludeToggle(true); + excludeToggle ? onChange(`!${trimmedValue}`) : onChange(trimmedValue); + } else { + if (trimmedValue !== "") { + setSelectedHugoSymbol(trimmedValue); + setExcludeToggle(false); + onChange(trimmedValue); + } else { + setSelectedHugoSymbol(""); + onChange(undefined); + } + } }; - const arrayContainer = { // width: '640px', flexGrow: 1, @@ -39,6 +70,11 @@ const AutocompleteField = ({ onChange, ...props }) => { color: '#495057', }; + const handleToggleChange = (checked: boolean) => { + setExcludeToggle(checked); + const newValue = checked ? `!${selectedHugoSymbol}` : selectedHugoSymbol.replace('!', ''); + onChange(newValue); + }; const questionMarkStyle = `dropdown-target-icon ${styles['question-mark']} pi pi-question-circle question-mark-target `; @@ -46,7 +82,7 @@ const AutocompleteField = ({ onChange, ...props }) => {
{props.schema.title && (
); }; diff --git a/libs/ui/src/lib/components/LeftMenuComponent.tsx b/libs/ui/src/lib/components/LeftMenuComponent.tsx index dfc87839..d48a7fc9 100644 --- a/libs/ui/src/lib/components/LeftMenuComponent.tsx +++ b/libs/ui/src/lib/components/LeftMenuComponent.tsx @@ -17,8 +17,8 @@ import { deleteNodeFromChildrenArrayByKey, findArrayContainingKeyInsideATree, findObjectByKeyInTree, - flattenVariantCategoryContainerObject, - flattenVariantCategoryContainerObjectInCtmlMatchModel, + flattenCategoryContainerObject, + flattenCategoryContainerObjectInCtmlMatchModel, getNodeLabel, isObjectEmpty, traverseNode, @@ -92,7 +92,7 @@ const LeftMenuComponent = memo((props: ILeftMenuComponentProps) => { const updateReduxViewModelAndCtmlModel = (newRootNodes: TreeNode[], state: RootState) => { const activeArmId: string = state.matchViewModelActions.activeArmId; const viewModel: IKeyToViewModel = {}; - const flattenedNewRootNodes = flattenVariantCategoryContainerObject(newRootNodes); + const flattenedNewRootNodes = flattenCategoryContainerObject(newRootNodes); viewModel[activeArmId] = structuredClone(flattenedNewRootNodes); dispatch(setMatchViewModel(viewModel)) // convert view model (rootNodes) to ctims format @@ -193,7 +193,7 @@ const LeftMenuComponent = memo((props: ILeftMenuComponentProps) => { const currentCtmlMatchModel: any = state.matchViewModelActions.ctmlMatchModel; const curMatch = currentCtmlMatchModel.match; if (curMatch && curMatch.length > 0) { - const flattenedMatch = flattenVariantCategoryContainerObjectInCtmlMatchModel(curMatch[0]); + const flattenedMatch = flattenCategoryContainerObjectInCtmlMatchModel(curMatch[0]); dispatch(setCtmlDialogModel({match: [flattenedMatch]})); } @@ -472,6 +472,12 @@ const LeftMenuComponent = memo((props: ILeftMenuComponentProps) => { newNode.data.formData = c; } } + else if (newNode.label === 'Prior Treatment') { + if (newNode.data.formData && !newNode.data.formData.treatmentCategoryContainerObject) { + const c = {treatmentCategoryContainerObject: newNode.data.formData}; + newNode.data.formData = c; + } + } setSelectedNode(newNode); setSelectedKeys(newNode.key as string) onTreeNodeClick(newNode.data.type, newNode); diff --git a/libs/ui/src/lib/components/forms/GenomicForm.tsx b/libs/ui/src/lib/components/forms/GenomicForm.tsx index 1ac00805..685ff7ac 100644 --- a/libs/ui/src/lib/components/forms/GenomicForm.tsx +++ b/libs/ui/src/lib/components/forms/GenomicForm.tsx @@ -21,8 +21,10 @@ import CtimsInput from "../../custom-rjsf-templates/CtimsInput"; import CtimsDropdown from "../../custom-rjsf-templates/CtimsDropdown"; import {CtimsDialogContext, CtimsDialogContextType} from "../CtimsMatchDialog"; import { Checkbox } from 'primereact/checkbox'; -import {wildcard_protein_change_validation_func, getCurrentOperator, protein_change_validation_func} from "../helpers"; +import {wildcard_protein_change_validation_func, getCurrentOperator, protein_change_validation_func, hugo_symblo_validation_func} from "../helpers"; import AutocompleteField from "../CtimsAutoCompleteComponent"; +import CtimsDropdownWithExcludeToggle from '../../custom-rjsf-templates/CtimsDropdownWithExcludeToggle'; +import CtimsInputWithExcludeToggle from '../../custom-rjsf-templates/CtimsInputWithExcludeToggle'; const RjsfForm = withTheme(PrimeTheme) @@ -160,6 +162,10 @@ export const GenomicForm = (props: IFormProps) => { "Homozygous deletion", "Gain", "High level amplification", + "!Heterozygous deletion", + "!Homozygous deletion", + "!Gain", + "!High level amplification", ] }, 'wildtype': { @@ -499,6 +505,15 @@ export const GenomicForm = (props: IFormProps) => { "variantCategoryContainerObject": { "hugo_symbol": { "ui:widget": AutocompleteField, + }, + "fusion_partner_hugo_symbol": { + "ui:widget": AutocompleteField, + }, + "cnv_call": { + "ui:widget": CtimsDropdownWithExcludeToggle, + }, + "protein_change": { + "ui:widget": CtimsInputWithExcludeToggle, } } } @@ -605,6 +620,8 @@ export const GenomicForm = (props: IFormProps) => { // reset protein change and wildcard protein change error first let proteinChangeHasError = false; let wildCardProteinChangeHasError = false; + let hugoSymbolHasError = false; + let fusionPartnerHugoSymbolHasError = false; if (!wildcard_protein_change_validation_func(myFormData.wildcard_protein_change) && !myFormData.protein_change) { @@ -615,6 +632,17 @@ export const GenomicForm = (props: IFormProps) => { myErrors.protein_change.addError('Must start with p.'); proteinChangeHasError = true; } + + if (!hugo_symblo_validation_func(myFormData.hugo_symbol)) { + myErrors.hugo_symbol.addError('At least have a single character'); + hugoSymbolHasError = true; + } + + if (!hugo_symblo_validation_func(myFormData.fusion_partner_hugo_symbol)) { + myErrors.fusion_partner_hugo_symbol.addError('Fusion Partner Hugo Symbol must have a single character'); + fusionPartnerHugoSymbolHasError = true; + } + if (myFormData.protein_change && myFormData.wildcard_protein_change) { myErrors.protein_change.addError('Cannot have both protein change and wildcard protein change filled.'); myErrors.wildcard_protein_change.addError('Cannot have both protein change and wildcard protein change filled.'); @@ -639,10 +667,18 @@ export const GenomicForm = (props: IFormProps) => { if (errors.length > 0) { const proteinChangeError = errors.find(error => error.property === '.protein_change'); const wildcardProteinChangeError = errors.find(error => error.property === '.wildcard_protein_change'); + const hugoSymbolError = errors.find(error => error.property === '.hugo_symbol'); + const fusionPartnerHugoSymbolError = errors.find(error => error.property === '.fusion_partner_hugo_symbol'); if (proteinChangeError && wildcardProteinChangeError && proteinChangeError.message === wildcardProteinChangeError.message) { addInvalidClassToElement('root_variantCategoryContainerObject_protein_change'); addInvalidClassToElement('root_variantCategoryContainerObject_wildcard_protein_change'); } + if (hugoSymbolError) { + addInvalidClassToElement('root_variantCategoryContainerObject_hugo_symbol'); + } + if (fusionPartnerHugoSymbolError) { + addInvalidClassToElement('root_variantCategoryContainerObject_fusion_partner_hugo_symbol'); + } } } diff --git a/libs/ui/src/lib/components/forms/PriorTreatmentForm.tsx b/libs/ui/src/lib/components/forms/PriorTreatmentForm.tsx index 5413e8bd..b7bfcbb8 100644 --- a/libs/ui/src/lib/components/forms/PriorTreatmentForm.tsx +++ b/libs/ui/src/lib/components/forms/PriorTreatmentForm.tsx @@ -5,7 +5,7 @@ import CtimsErrorListTemplate from "../../custom-rjsf-templates/CtimsErrorListTe import {RegistryWidgetsType, ValidationData} from "@rjsf/utils"; import CtimsInput from "../../custom-rjsf-templates/CtimsInput"; import CtimsDropdown from "../../custom-rjsf-templates/CtimsDropdown"; -import React, {CSSProperties, useContext, useEffect, useRef} from "react"; +import React, {CSSProperties, useContext, useEffect, useRef, useState} from "react"; import {IFormProps} from "../MatchingMenuAndForm"; import {CtimsDialogContext, CtimsDialogContextType} from "../CtimsMatchDialog"; import {useDispatch} from "react-redux"; @@ -55,19 +55,145 @@ export const PriorTreatmentForm = (props: IFormProps) => { const priorTreatmentFormRef = useRef(null); + // tie the form data to the component, so we can manually reset the values, see onFormChange + const [myFormData, setMyFormData] = useState(node.data.formData); + + useEffect(() => { node.data.formValid = false; + const formData = node.data.formData; + if (formData && !formData.hasOwnProperty('treatmentCategoryContainerObject')) { + setMyFormData({'treatmentCategoryContainerObject': formData}); + } else { + setMyFormData(formData); + } }, [node]); const dispatch = useDispatch(); const priorTreatmentFormSchema = { + 'definitions': { + "treatment_category": { + "enumNames": [ + "Medical Therapy", + "Surgery", + "Radiation Therapy" + ], + "enum": [ + "Medical Therapy", + "Surgery", + "Radiation Therapy" + ] + }, + 'medical_treatment_subtype': { + "enumNames": [ + " ", + "Immunotherapy", + "Targeted Therapy", + "Hormone Therapy", + "Chemotherapy", + ], + "enum": [ + "", + "Immunotherapy", + "Targeted Therapy", + "Hormone Therapy", + "Chemotherapy", + ] + }, + }, "type": "object", "required": [], - "properties": { - "prior_treatment_agent": { - 'type': 'string', - 'title': 'Agent', + 'properties': { + "treatmentCategoryContainerObject": { + 'title': '', + 'type': 'object', + 'properties': { + "treatment_category": { + "$ref": "#/definitions/treatment_category", + 'title': 'Prior Treatment Type', + "description": "Type of treatment", + }, + }, + "dependencies": { + "treatment_category": { + "allOf": [ + { + "if": { + "properties": { + "treatment_category": { + "const": "Medical Therapy" + } + } + }, + "then": { + "properties": { + 'subtype': { + 'title': 'Medical Therapy Subtype', + '$ref': '#/definitions/medical_treatment_subtype', + "description": "Medical Therapy Subtype", + }, + 'agent_class': { + 'type': 'string', + 'title': 'Agent Class', + "description": "Prior Treatment Agent Class", + }, + 'agent': { + 'type': 'string', + 'title': 'Agent', + "description": "Prior Treatment Agent", + } + }, + "required": [] + } + }, + { + "if": { + "properties": { + "treatment_category": { + "const": "Surgery" + } + } + }, + "then": { + "properties": { + 'surgery_type': { + 'type': 'string', + 'title': 'Surgery type', + "description": "Surgery type", + } + }, + "required": [] + } + }, + { + "if": { + "properties": { + "treatment_category": { + "const": "Radiation Therapy" + } + } + }, + "then": { + "properties": { + 'radiation_type': { + 'type': 'string', + 'title': 'Radiation Type', + "description": "Radiation Type", + }, + 'radiation_site': { + 'type': 'string', + 'title': 'Radiation Site', + "description": "Radiation Site", + } + }, + "required": [] + } + } + ] + } + }, + "required": ['treatment_category'] } } } @@ -78,22 +204,61 @@ export const PriorTreatmentForm = (props: IFormProps) => { } } - const onFormChange = (data: any) => { + const validateFormFromRef = (data?: any) => { const form: Form = priorTreatmentFormRef.current; form?.validateForm(); - const errorDetails: ValidationData = form?.validate(data.formData); - if (typeof errorDetails === 'undefined' || errorDetails?.errors.length > 0) { - node.data.formValid = false; - const payload = {[nk]: true}; - dispatch(setMatchDialogErrors(payload)); + let errorDetails: ValidationData; + if (data) { + errorDetails = form?.validate(data.formData); + } else { + errorDetails = form?.validate(form.state.formData); + } + // console.log('onFormChange errorDetails: ', errorDetails); + if (errorDetails?.errors.length > 0) { + errorsInFormDispatch(); } if (errorDetails?.errors.length === 0) { - node.data.formValid = true; - dispatch(deleteMatchDialogError(nk)); - setSaveBtnState(false) + noErrorsInFormDispatch(); + } + } + + const noErrorsInFormDispatch = () => { + node.data.formValid = true; + dispatch(deleteMatchDialogError(nk)); + setSaveBtnState(false) + } + + const errorsInFormDispatch = () => { + node.data.formValid = false; + const payload = {[nk]: true}; + dispatch(setMatchDialogErrors(payload)); + } + + const onFormChange = (data: any) => { + // reset form if variant category changed + const oldTreatmentCategory = myFormData?.treatmentCategoryContainerObject?.treatment_category; + const newTreatmentCategory = data.formData.treatmentCategoryContainerObject?.treatment_category; + // if oldTreatmentCategory is defined and category is now different, reset the form in with the variantCategoryContainer format + if ((oldTreatmentCategory && newTreatmentCategory && (oldTreatmentCategory !== newTreatmentCategory)) + // or the if form is new so oldTreatmentCategory is undefined, and newTreatmentCategoryContainer exists + || (!oldTreatmentCategory && data.formData.treatmentCategoryContainerObject)) { + const myFormData = { + treatmentCategoryContainerObject: { + treatment_category: newTreatmentCategory + } + } + setMyFormData(myFormData); + node.data.formData = myFormData; + data.formData = myFormData; + } + const flattenedFormData = { + ...data, + // formData: data.formData.variantCategoryContainerObject, /*can't change this, otherwise form won't render*/ + errorSchema: data.errorSchema.treatmentCategoryContainerObject, } - console.log('onFormChange errorDetails: ', errorDetails); + validateFormFromRef(flattenedFormData); node.data.formData = data.formData; + setMyFormData(flattenedFormData.formData); dispatch(formChange()); console.log('onFormChange node: ', node) } @@ -112,10 +277,23 @@ export const PriorTreatmentForm = (props: IFormProps) => { } const customValidate = (formData: any, errors: any, uiSchema: any) => { - if (typeof formData.prior_treatment_agent === 'undefined') { - errors.prior_treatment_agent.addError('Must have at least one field filled.'); + let myFormData = formData; + let myErrors = errors; + if (formData.treatmentCategoryContainerObject) { + myFormData = formData.treatmentCategoryContainerObject; + myErrors = errors.treatmentCategoryContainerObject; } - return errors; + if (typeof myFormData.treatment_category === 'undefined' && + typeof myFormData.subtype === 'undefined' && + typeof myFormData.agent_class === 'undefined' && + typeof myFormData.agent === 'undefined' && + typeof myFormData.surgery_type === 'undefined' && + typeof myFormData.radiation_type === 'undefined' && + typeof myFormData.radiation_site === 'undefined') { + myErrors.treatment_category.addError('Must have at least one field filled.'); + } + + return myErrors; } return ( @@ -132,7 +310,7 @@ export const PriorTreatmentForm = (props: IFormProps) => { { it('should flatten VariantCategoryContainerObject to CTML format', () => { @@ -30,7 +30,7 @@ describe('test adding variantCategoryContainerObject to genomic criteria node fo "icon": "and-icon" } ] - const result = flattenVariantCategoryContainerObject(nodes); + const result = flattenCategoryContainerObject(nodes); expect(result).toEqual( [ { @@ -119,7 +119,7 @@ describe('test adding variantCategoryContainerObject to genomic criteria node fo } ]; - const result = flattenVariantCategoryContainerObject(nodes); + const result = flattenCategoryContainerObject(nodes); expect(result).toEqual( [ { @@ -190,7 +190,7 @@ describe('test adding variantCategoryContainerObject to genomic criteria node fo } ]; - const result = addVariantCategoryContainerObject(matchCriteria); + const result = addCategoryContainerObject(matchCriteria); const expected = [ { "and": [ @@ -232,7 +232,7 @@ describe('test adding variantCategoryContainerObject to genomic criteria node fo ] } ]; - const result = addVariantCategoryContainerObject(matchCriteria); + const result = addCategoryContainerObject(matchCriteria); const expected = [ { "and": [ diff --git a/libs/ui/src/lib/components/helpers.ts b/libs/ui/src/lib/components/helpers.ts index e65fbf9f..3adcc477 100644 --- a/libs/ui/src/lib/components/helpers.ts +++ b/libs/ui/src/lib/components/helpers.ts @@ -18,7 +18,18 @@ export const wildcard_protein_change_validation_func = (str: string) => { } export const protein_change_validation_func = (str: string) => { - const regex = /^p\.(([A-Z]\d+_[A-Z]\d+)(del$|dup$)|([A-Z]\d+_[A-Z]\d+)(ins[A-Z]+\*?|ins\*\d*|delins[A-Z]+\*?|delins\*\d*|fs\*?\d*)|([A-Z]\d+[A-Z])$|([A-Z]\d+[A-Z])(fs\*?\d*)$|([A-Z]\d+)(\*)$|([A-Z]\d+)(del$|dup$)|([A-Z]\d+)(delins[A-Z]+\*?$|fs\*?(\d*))$)$/; + const regex = /^!?p\.(([A-Z]\d+_[A-Z]\d+)(del$|dup$)|([A-Z]\d+_[A-Z]\d+)(ins[A-Z]+\*?|ins\*\d*|delins[A-Z]+\*?|delins\*\d*|fs\*?\d*)|([A-Z]\d+[A-Z])$|([A-Z]\d+[A-Z])(fs\*?\d*)$|([A-Z]\d+)(\*)$|([A-Z]\d+)(del$|dup$)|([A-Z]\d+)(delins[A-Z]+\*?$|fs\*?(\d*))$)$/; + + if (!str) { + return true; + } + return regex.test(str); +} + +export const hugo_symblo_validation_func = (str: string) => { + // Validates that the string starts with an optional '!' followed by a letter (A-Z or a-z). + // Allows any characters (including special characters) after the first letter. + const regex = /^(!?[A-Za-z].*)$/; if (!str) { return true; @@ -431,36 +442,53 @@ export const getNodeLabel = (node: TreeNode): string => { label = hugo_symbol; } } else if (node.label === 'Prior Treatment' && node.data.formData) { - label = node.data.formData.prior_treatment_agent; + let treatmentObj = node.data.formData; + if (node.data.formData.treatmentCategoryContainerObject) { + treatmentObj = node.data.formData.treatmentCategoryContainerObject; + } + const { agent, surgery_type, radiation_type} = treatmentObj; + if (agent) { + label = agent; + } else if (surgery_type) { + label = surgery_type; + } else if (radiation_type) { + label = radiation_type; + } } return label; } /* - recursively go through tree nodes, if it has the key 'variantCategoryContainerObject', + recursively go through tree nodes, if it has the key 'variantCategoryContainerObject', 'treatmentCategoryContainerObject' flatten the object so it matches the format of the CTML */ -export const flattenVariantCategoryContainerObject = (nodes: TreeNode[]) => { +export const flattenCategoryContainerObject = (nodes: TreeNode[]) => { return nodes.map((node: TreeNode) => { const newNode = {...node}; if (newNode.data && newNode.data.formData && newNode.data.formData.variantCategoryContainerObject) { newNode.data.formData = newNode.data.formData.variantCategoryContainerObject; } + else if (newNode.data && newNode.data.formData && newNode.data.formData.treatmentCategoryContainerObject) { + newNode.data.formData = newNode.data.formData.treatmentCategoryContainerObject; + } if (newNode.children) { - newNode.children = flattenVariantCategoryContainerObject(newNode.children); + newNode.children = flattenCategoryContainerObject(newNode.children); } return newNode; }); } - + // same as above, but less restrictive on the input object -export const flattenVariantCategoryContainerObjectInCtmlMatchModel = (ctmlMatchModel: any) => { +export const flattenCategoryContainerObjectInCtmlMatchModel = (ctmlMatchModel: any) => { const cloned = structuredClone(ctmlMatchModel); const flattenGenomicObject = (obj: any) => { const newObj = {...obj}; if (newObj.genomic && newObj.genomic.variantCategoryContainerObject) { newObj.genomic = { ...newObj.genomic.variantCategoryContainerObject }; } + else if (newObj.prior_treatment && newObj.prior_treatment.treatmentCategoryContainerObject) { + newObj.prior_treatment = { ...newObj.prior_treatment.treatmentCategoryContainerObject }; + } // Recursively flatten 'and' or 'or' arrays if they exist ['and', 'or'].forEach(key => { if (newObj[key]) { @@ -474,6 +502,7 @@ export const flattenVariantCategoryContainerObjectInCtmlMatchModel = (ctmlMatchM return flattenGenomicObject(cloned); }; + // Third attempt at a generic flattening function export const flattenGenericObject = (ctmlMatchModel: any) => { const cloned = structuredClone(ctmlMatchModel); @@ -482,7 +511,7 @@ export const flattenGenericObject = (ctmlMatchModel: any) => { return arr.map(item => { Object.keys(item).forEach(key => { if (key === 'match' && Array.isArray(item[key])) { - item[key] = item[key].map(flattenVariantCategoryContainerObjectInCtmlMatchModel); + item[key] = item[key].map(flattenCategoryContainerObjectInCtmlMatchModel); } else if (Array.isArray(item[key])) { item[key] = processArray(item[key]); } @@ -498,14 +527,14 @@ export const flattenGenericObject = (ctmlMatchModel: any) => { return cloned; } - // Recursively traverse through the match criteria and add the variantCategoryContainerObject key to the genomic object -export const addVariantCategoryContainerObject = (matchCriteria: any[]) => { +// Recursively traverse through the match criteria and add the variantCategoryContainerObject key to the genomic object +export const addCategoryContainerObject = (matchCriteria: any[]) => { return matchCriteria.map((criteria) => { if (criteria.and || criteria.or) { const operator = criteria.and ? 'and' : 'or'; const children = criteria[operator]; const ret: { [key in 'and' | 'or']?: any[] } = {}; - ret[operator] = addVariantCategoryContainerObject(children); + ret[operator] = addCategoryContainerObject(children); return ret; } else if (criteria.genomic) { if (!criteria.genomic.variantCategoryContainerObject) { @@ -517,6 +546,17 @@ export const addVariantCategoryContainerObject = (matchCriteria: any[]) => { return c; } return criteria; + } else if (criteria.prior_treatment) { + if (!criteria.prior_treatment.treatmentCategoryContainerObject) { + const c: any = { + prior_treatment: { + treatmentCategoryContainerObject: criteria.prior_treatment + } + } + return c; + } + return criteria; + } else { // clinical node, no need to modify return criteria; @@ -524,6 +564,7 @@ export const addVariantCategoryContainerObject = (matchCriteria: any[]) => { }) } + // function to recursively trim all CtimsInput fields, and remove the key if value is empty after trim export const trimFields = (obj: any) => { for (let key in obj) { diff --git a/libs/ui/src/lib/custom-rjsf-templates/CtimsDropdownWithExcludeToggle.module.css b/libs/ui/src/lib/custom-rjsf-templates/CtimsDropdownWithExcludeToggle.module.css new file mode 100644 index 00000000..297f5525 --- /dev/null +++ b/libs/ui/src/lib/custom-rjsf-templates/CtimsDropdownWithExcludeToggle.module.css @@ -0,0 +1,35 @@ +.container { + display: flex; + flex-direction: column; +} + +.label-container { + display: flex; + flex-direction: row; +} + +.label { + font-family: Inter, sans-serif; + font-weight: 400; + font-size: 14px; + margin-bottom: 7px; + margin-top: 7px; +} + +.optional-label { + font-family: Inter, sans-serif; + font-weight: 400; + font-size: 14px; + margin-bottom: 7px; + margin-top: 7px; + margin-left: auto; + color: rgba(0, 0, 0, 0.6); +} + +.question-mark { + margin-left: 8px; + margin-top: 8px; + margin-bottom: 7px; + cursor: pointer; + color: rgba(0, 0, 0, 0.6); +} \ No newline at end of file diff --git a/libs/ui/src/lib/custom-rjsf-templates/CtimsDropdownWithExcludeToggle.tsx b/libs/ui/src/lib/custom-rjsf-templates/CtimsDropdownWithExcludeToggle.tsx new file mode 100644 index 00000000..4e172156 --- /dev/null +++ b/libs/ui/src/lib/custom-rjsf-templates/CtimsDropdownWithExcludeToggle.tsx @@ -0,0 +1,130 @@ +import { WidgetProps } from '@rjsf/utils'; +import React, { useEffect, useState } from 'react'; +import styles from './CtimsDropdownWithExcludeToggle.module.css'; +import { Tooltip } from 'primereact/tooltip'; +import { Dropdown } from 'primereact/dropdown'; +import cn from 'clsx'; +import IOSSwitch from '../components/IOSSwitch'; + +const CtimsDropdownWithExcludeToggle = (props: WidgetProps) => { + let { + id, + placeholder, + required, + readonly, + disabled, + label, + value, + onChange, + onBlur, + onFocus, + autofocus, + options, + schema, + uiSchema, + rawErrors = [], + } = props; + + const [valueState, setValueState] = useState(value ? value.replace('!', '') : ''); + const [isNegated, setIsNegated] = useState(value ? value.startsWith('!') : false); + useEffect(() => { + const currentURL = window.location.href; + if (label === 'Trial ID' && currentURL.includes('/trials/create')) { + onChange(''); + } + }, []); + + useEffect(() => { + if (value) { + setValueState(value.replace('!', '')); + setIsNegated(value.startsWith('!')); + } + else { + setValueState(''); + setIsNegated(false); + } + }, [value]); + + const dropdownOptions = + options.enumOptions + ?.filter((opt: any) => !opt.value.startsWith('!')) + .map((opt: any) => ({ + label: opt.label || opt.value, + value: opt.value, + })) || []; + + const getBackendValue = (displayValue: string, negated: boolean) => + negated ? `!${displayValue}` : displayValue; + + useEffect(() => { + const backendValue = getBackendValue(valueState, isNegated); + onChange(backendValue); + }, [valueState, isNegated]); + + const handleDropdownChange = (e: { value: any }) => { + const selectedValue = e.value; + setValueState(selectedValue); + if (!selectedValue) { + setIsNegated(false); + } + }; + + const handleToggleChange = (checked: boolean) => { + setIsNegated(checked); + }; + + const handleBlur = () => { + const trimmedValue = valueState.trim(); + onBlur(id, trimmedValue); + }; + + const handleFocus = () => onFocus(id, valueState); + + const labelValue = uiSchema?.['ui:title'] || schema.title || label; + const questionMarkStyle = `input-target-icon ${styles['question-mark']} pi pi-question-circle .question-mark-target `; + + return ( +
+
+ {labelValue && {labelValue}} + + {schema.description && ( + + )} +
+ + 0 ? 'p-invalid' : '')} + options={dropdownOptions} + value={valueState} + onChange={handleDropdownChange} + onBlur={handleBlur} + onFocus={handleFocus} + appendTo='self' + /> + +
+
Exclude this criteria from matches.
+
+ +
+
+
+ ); +}; + +export default CtimsDropdownWithExcludeToggle; diff --git a/libs/ui/src/lib/custom-rjsf-templates/CtimsMatchingCriteriaWidget.tsx b/libs/ui/src/lib/custom-rjsf-templates/CtimsMatchingCriteriaWidget.tsx index 6fb3ab6d..5956b6f7 100644 --- a/libs/ui/src/lib/custom-rjsf-templates/CtimsMatchingCriteriaWidget.tsx +++ b/libs/ui/src/lib/custom-rjsf-templates/CtimsMatchingCriteriaWidget.tsx @@ -6,7 +6,7 @@ import { TabView, TabPanel } from 'primereact/tabview'; import {useSelector} from "react-redux"; import {stringify} from 'yaml' import { - flattenVariantCategoryContainerObjectInCtmlMatchModel, + flattenCategoryContainerObjectInCtmlMatchModel, isObjectEmpty } from "../components/helpers"; import {RootState} from "../../../../../apps/web/store/store"; @@ -79,7 +79,7 @@ const CtimsMatchingCriteriaWidget = (props: WidgetProps) => { let match = formContext.match; if (match) { - const flatten = flattenVariantCategoryContainerObjectInCtmlMatchModel(match[0]); + const flatten = flattenCategoryContainerObjectInCtmlMatchModel(match[0]); match = [flatten]; } diff --git a/libs/ui/src/lib/ui.tsx b/libs/ui/src/lib/ui.tsx index 2728bbfe..9bf7f21d 100644 --- a/libs/ui/src/lib/ui.tsx +++ b/libs/ui/src/lib/ui.tsx @@ -19,7 +19,7 @@ import useSaveTrial from "../../../../apps/web/hooks/useSaveTrial"; import {useRouter} from "next/router"; import { Toast } from 'primereact/toast'; import { ConfirmDialog, confirmDialog } from 'primereact/confirmdialog'; -import {addVariantCategoryContainerObject} from "./components/helpers"; +import {addCategoryContainerObject} from "./components/helpers"; import useHandleSignOut from "../../../../apps/web/hooks/useHandleSignOut"; @@ -100,7 +100,7 @@ export const Ui = (props: UiProps) => { console.log('handleSpecialClick armCode: ', formData.arm_code); console.log('handleSpecialClick id: ', id); if (Array.isArray(formData.match)) { - const formDataWithVariantCategoryContainerObject = addVariantCategoryContainerObject(formData.match); + const formDataWithVariantCategoryContainerObject = addCategoryContainerObject(formData.match); formData.match = formDataWithVariantCategoryContainerObject; } setArmCode(formData.arm_code)