diff --git a/src/main/java/org/mskcc/oncokb/curation/domain/enumeration/CNAConsequence.java b/src/main/java/org/mskcc/oncokb/curation/domain/enumeration/CNAConsequence.java index fc3a29e2d..781f4ee72 100644 --- a/src/main/java/org/mskcc/oncokb/curation/domain/enumeration/CNAConsequence.java +++ b/src/main/java/org/mskcc/oncokb/curation/domain/enumeration/CNAConsequence.java @@ -1,9 +1,9 @@ package org.mskcc.oncokb.curation.domain.enumeration; public enum CNAConsequence { - AMPLIFICATION, - DELETION, - GAIN, - LOSS, - UNKNOWN, + CNA_AMPLIFICATION, + CNA_DELETION, + CNA_GAIN, + CNA_LOSS, + CNA_UNKNOWN, } diff --git a/src/main/java/org/mskcc/oncokb/curation/domain/enumeration/FlagType.java b/src/main/java/org/mskcc/oncokb/curation/domain/enumeration/FlagType.java index 9ea1db8f2..f07f816ee 100644 --- a/src/main/java/org/mskcc/oncokb/curation/domain/enumeration/FlagType.java +++ b/src/main/java/org/mskcc/oncokb/curation/domain/enumeration/FlagType.java @@ -9,4 +9,5 @@ public enum FlagType { TRANSCRIPT, DRUG, HOTSPOT, + ALTERATION_CATEGORY, } diff --git a/src/main/java/org/mskcc/oncokb/curation/domain/enumeration/SVConsequence.java b/src/main/java/org/mskcc/oncokb/curation/domain/enumeration/SVConsequence.java index aea1fd65b..c0d0c00e0 100644 --- a/src/main/java/org/mskcc/oncokb/curation/domain/enumeration/SVConsequence.java +++ b/src/main/java/org/mskcc/oncokb/curation/domain/enumeration/SVConsequence.java @@ -1,11 +1,21 @@ package org.mskcc.oncokb.curation.domain.enumeration; public enum SVConsequence { - DELETION, - TRANSLOCATION, - DUPLICATION, - INSERTION, - INVERSION, - FUSION, - UNKNOWN, + SV_DELETION("Deletion"), + SV_TRANSLOCATION("Translocation"), + SV_DUPLICATION("Duplication"), + SV_INSERTION("Insertion"), + SV_INVERSION("Inversion"), + SV_FUSION("Fusion"), + SV_UNKNOWN("Unknown"); + + private final String name; + + SVConsequence(String name) { + this.name = name; + } + + public String getName() { + return name; + } } diff --git a/src/main/java/org/mskcc/oncokb/curation/util/AlterationUtils.java b/src/main/java/org/mskcc/oncokb/curation/util/AlterationUtils.java index 336bb9e4f..b4cccb1f0 100644 --- a/src/main/java/org/mskcc/oncokb/curation/util/AlterationUtils.java +++ b/src/main/java/org/mskcc/oncokb/curation/util/AlterationUtils.java @@ -26,7 +26,7 @@ private Alteration parseFusion(String alteration) { Alteration alt = new Alteration(); Consequence consequence = new Consequence(); - consequence.setTerm(SVConsequence.FUSION.name()); + consequence.setTerm(SVConsequence.SV_FUSION.name()); alt.setType(AlterationType.STRUCTURAL_VARIANT); alt.setConsequence(consequence); @@ -50,7 +50,7 @@ private Alteration parseFusion(String alteration) { } private Alteration parseCopyNumberAlteration(String alteration) { - CNAConsequence cnaTerm = CNAConsequence.UNKNOWN; + CNAConsequence cnaTerm = CNAConsequence.CNA_UNKNOWN; Optional cnaConsequenceOptional = getCNAConsequence(alteration); if (cnaConsequenceOptional.isPresent()) { diff --git a/src/main/webapp/app/app.scss b/src/main/webapp/app/app.scss index 862d6ff3a..f0b1347e4 100644 --- a/src/main/webapp/app/app.scss +++ b/src/main/webapp/app/app.scss @@ -456,3 +456,39 @@ a { .scrollbar-wrapper:focus { visibility: visible; } + +// Custom badge outline styles +@mixin badge-outline-variant( + $color, + $color-hover: color-contrast($color), + $active-background: $color, + $active-border: $color, + $active-color: color-contrast($active-background) +) { + color: $color; + border: 1px solid; + border-color: $color; + + &:hover { + color: $color-hover; + background-color: $active-background; + border-color: $active-border; + } + + &.active { + color: $active-color; + background-color: $active-background; + border-color: $active-border; + } + + &.disabled { + color: $color; + background-color: transparent; + } +} + +@each $color, $value in $theme-colors { + .badge-outline-#{$color} { + @include badge-outline-variant($value); + } +} diff --git a/src/main/webapp/app/config/colors.ts b/src/main/webapp/app/config/colors.ts index 354840fd2..6cad216c7 100644 --- a/src/main/webapp/app/config/colors.ts +++ b/src/main/webapp/app/config/colors.ts @@ -30,3 +30,9 @@ export const COLLAPSIBLE_LEVELS = { }; export const HOTSPOT = '#ff9900'; + +/* + * Bootstrap colors + */ + +export const BS_BORDER_COLOR = '#dee2e6'; diff --git a/src/main/webapp/app/config/constants/constants.ts b/src/main/webapp/app/config/constants/constants.ts index 22b825ece..89eb54405 100644 --- a/src/main/webapp/app/config/constants/constants.ts +++ b/src/main/webapp/app/config/constants/constants.ts @@ -1,5 +1,5 @@ import { AlterationTypeEnum } from 'app/shared/api/generated/curation'; -import { GREY } from '../colors'; +import { BS_BORDER_COLOR, GREY } from '../colors'; import { ToastOptions } from 'react-toastify'; export const AUTHORITIES = { @@ -461,3 +461,13 @@ export const EMPTY_THERAPY_ERROR_MESSAGE = 'You must include at least one drug f export const THERAPY_ALREADY_EXISTS_ERROR_MESSAGE = 'Therapy already exists'; export const NEW_NAME_UUID_VALUE = 'name'; + +/** + * React select styles based on Bootstrap theme + */ +export const REACT_SELECT_STYLES = { + control: (base, state) => ({ + ...base, + borderColor: BS_BORDER_COLOR, + }), +}; diff --git a/src/main/webapp/app/config/constants/html-id.ts b/src/main/webapp/app/config/constants/html-id.ts index c65ccd21a..a8ab8293c 100644 --- a/src/main/webapp/app/config/constants/html-id.ts +++ b/src/main/webapp/app/config/constants/html-id.ts @@ -29,6 +29,12 @@ export const RCT_MODAL_ID = 'relevant-cancer-type-modal'; export const DEFAULT_ADD_MUTATION_MODAL_ID = 'default-add-mutation-modal'; export const ADD_MUTATION_MODAL_INPUT_ID = 'add-mutation-modal-input'; +export const ADD_MUTATION_MODAL_EXCLUDED_ALTERATION_INPUT_ID = 'add-mutation-modal-excluded-alteration-input'; +export const ADD_MUTATION_MODAL_ADD_EXCLUDED_ALTERATION_BUTTON_ID = 'add-mutation-modal-add-excluded-alteration-button'; +export const ADD_MUTATION_MODAL_FLAG_DROPDOWN_ID = 'add-mutation-modal-flag-input'; +export const ADD_MUTATION_MODAL_FLAG_COMMENT_ID = 'add-mutation-modal-flag-comment'; +export const ADD_MUTATION_MODAL_FLAG_COMMENT_INPUT_ID = 'add-mutation-modal-flag-comment-input'; + export const SIMPLE_CONFIRM_MODAL_CONTENT_ID = 'simple-confirm-modal-content'; export const ADD_THERAPY_BUTTON_ID = 'add-therapy-button'; diff --git a/src/main/webapp/app/entities/flag/flag.store.ts b/src/main/webapp/app/entities/flag/flag.store.ts index 7da51e311..882495a72 100644 --- a/src/main/webapp/app/entities/flag/flag.store.ts +++ b/src/main/webapp/app/entities/flag/flag.store.ts @@ -5,15 +5,20 @@ import PaginationCrudStore from 'app/shared/util/pagination-crud-store'; import axios, { AxiosResponse } from 'axios'; import { ENTITY_TYPE } from 'app/config/constants/constants'; import { getEntityResourcePath } from 'app/shared/util/RouteUtils'; +import { FlagTypeEnum } from 'app/shared/model/enumerations/flag-type.enum.model'; export class FlagStore extends PaginationCrudStore { public oncokbGeneEntity: IFlag | null = null; + public alterationCategoryFlags: IFlag[] = []; getOncokbEntity = this.readHandler(this.getOncokbGeneFlag); + getFlagsByType = this.readHandler(this.getFlagsByTypeGen); constructor(protected rootStore: IRootStore) { super(rootStore, ENTITY_TYPE.FLAG); makeObservable(this, { oncokbGeneEntity: observable, + alterationCategoryFlags: observable, getOncokbEntity: action, + getFlagsByType: action, }); } @@ -25,6 +30,13 @@ export class FlagStore extends PaginationCrudStore { } return result; } + *getFlagsByTypeGen(type: FlagTypeEnum) { + const result: AxiosResponse = yield axios.get(`${getEntityResourcePath(ENTITY_TYPE.FLAG)}?type.equals=${type}`); + if (type === FlagTypeEnum.ALTERATION_CATEGORY) { + this.alterationCategoryFlags = result.data; + } + return result.data; + } } export default FlagStore; diff --git a/src/main/webapp/app/hooks/useOverflowDetector.tsx b/src/main/webapp/app/hooks/useOverflowDetector.tsx new file mode 100644 index 000000000..fd4eabd07 --- /dev/null +++ b/src/main/webapp/app/hooks/useOverflowDetector.tsx @@ -0,0 +1,37 @@ +import { useEffect, useRef, useState } from 'react'; + +export interface useOverflowDetectorProps { + onChange?: (overflow: boolean) => void; + detectHeight?: boolean; + detectWidth?: boolean; +} + +export function useOverflowDetector(props: useOverflowDetectorProps = {}) { + const [isOverflow, setIsOverflow] = useState(false); + const ref = useRef(null); + + useEffect(() => { + const updateState = () => { + if (ref.current === null) { + return; + } + + const { detectWidth: handleWidth = true, detectHeight: handleHeight = true } = props; + + const newState = + (handleWidth && ref.current.offsetWidth < ref.current.scrollWidth) || + (handleHeight && ref.current.offsetHeight < ref.current.scrollHeight); + + if (newState === isOverflow) { + return; + } + setIsOverflow(newState); + if (props.onChange) { + props.onChange(newState); + } + }; + updateState(); + }, [ref.current, props.detectWidth, props.detectHeight, props.onChange]); + + return [isOverflow, ref] as const; +} diff --git a/src/main/webapp/app/hooks/useTextareaAutoHeight.tsx b/src/main/webapp/app/hooks/useTextareaAutoHeight.tsx new file mode 100644 index 000000000..b62ad812c --- /dev/null +++ b/src/main/webapp/app/hooks/useTextareaAutoHeight.tsx @@ -0,0 +1,25 @@ +import React, { useEffect } from 'react'; + +export const useTextareaAutoHeight = ( + inputRef: React.MutableRefObject, + type: string | undefined, +) => { + useEffect(() => { + const input = inputRef.current; + if (!input || type !== 'textarea') { + return; + } + + const resizeObserver = new ResizeObserver(() => { + window.requestAnimationFrame(() => { + input.style.height = 'auto'; + input.style.height = `${input.scrollHeight}px`; + }); + }); + resizeObserver.observe(input); + + return () => { + resizeObserver.disconnect(); + }; + }, []); +}; diff --git a/src/main/webapp/app/pages/curation/BadgeGroup.tsx b/src/main/webapp/app/pages/curation/BadgeGroup.tsx index e0893a7c2..db3d21bba 100644 --- a/src/main/webapp/app/pages/curation/BadgeGroup.tsx +++ b/src/main/webapp/app/pages/curation/BadgeGroup.tsx @@ -58,19 +58,11 @@ const BadgeGroup = (props: IBadgeGroupProps) => { }, [sectionData, props.firebasePath]); if (props.showDemotedBadge) { - return ( - - Demoted - - ); + return ; } if (props.showDeletedBadge) { - return ( - - Deleted - - ); + return ; } if (props.showNotCuratableBadge?.show) { diff --git a/src/main/webapp/app/pages/curation/collapsible/BaseCollapsible.tsx b/src/main/webapp/app/pages/curation/collapsible/BaseCollapsible.tsx index c0a9618e5..8b20b21a5 100644 --- a/src/main/webapp/app/pages/curation/collapsible/BaseCollapsible.tsx +++ b/src/main/webapp/app/pages/curation/collapsible/BaseCollapsible.tsx @@ -135,10 +135,10 @@ export default function BaseCollapsible({ > {isOpen ? : } - +
{title} {badge} - +
diff --git a/src/main/webapp/app/pages/curation/collapsible/MutationCollapsible.tsx b/src/main/webapp/app/pages/curation/collapsible/MutationCollapsible.tsx index 95679293f..d79ee6f69 100644 --- a/src/main/webapp/app/pages/curation/collapsible/MutationCollapsible.tsx +++ b/src/main/webapp/app/pages/curation/collapsible/MutationCollapsible.tsx @@ -18,7 +18,7 @@ import MutationConvertIcon from 'app/shared/icons/MutationConvertIcon'; import AddMutationModal from 'app/shared/modal/AddMutationModal'; import AddVusModal from 'app/shared/modal/AddVusModal'; import ModifyCancerTypeModal from 'app/shared/modal/ModifyCancerTypeModal'; -import { Alteration, Review } from 'app/shared/model/firebase/firebase.model'; +import { Alteration, Review, AlterationCategories } from 'app/shared/model/firebase/firebase.model'; import DefaultTooltip from 'app/shared/tooltip/DefaultTooltip'; import { FlattenedHistory } from 'app/shared/util/firebase/firebase-history-utils'; import { @@ -29,12 +29,12 @@ import { isSectionRemovableWithoutReview, } from 'app/shared/util/firebase/firebase-utils'; import { componentInject } from 'app/shared/util/typed-inject'; -import { getExonRanges } from 'app/shared/util/utils'; +import { getExonRanges, parseAlterationName } from 'app/shared/util/utils'; import { IRootStore } from 'app/stores'; import { get, onValue, ref } from 'firebase/database'; import _ from 'lodash'; import { observer } from 'mobx-react'; -import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { Button } from 'reactstrap'; import BadgeGroup from '../BadgeGroup'; import { DeleteSectionButton } from '../button/DeleteSectionButton'; @@ -49,6 +49,7 @@ import { RemovableCollapsible } from './RemovableCollapsible'; import { Unsubscribe } from 'firebase/database'; import { getLocationIdentifier } from 'app/components/geneHistoryTooltip/gene-history-tooltip-utils'; import { SimpleConfirmModal } from 'app/shared/modal/SimpleConfirmModal'; +import MutationCollapsibleTitle from './mutation-collapsible/MutationCollapsibleTitle'; export interface IMutationCollapsibleProps extends StoreProps { mutationPath: string; @@ -87,6 +88,7 @@ const MutationCollapsible = ({ const [mutationNameReview, setMutationNameReview] = useState(null); const [mutationSummary, setMutationSummary] = useState(''); const [mutationAlterations, setMutationAlterations] = useState(null); + const [alterationCategories, setAlterationCategories] = useState(null); const [isRemovableWithoutReview, setIsRemovableWithoutReview] = useState(false); const [relatedAnnotationResult, setRelatedAnnotationResult] = useState([]); const [oncogenicity, setOncogenicity] = useState(''); @@ -186,6 +188,12 @@ const MutationCollapsible = ({ setOncogenicity(snapshot.val()); }), ); + callbacks.push( + onValue(ref(firebaseDb, `${mutationPath}/alteration_categories`), snapshot => { + const info = snapshot.val() as AlterationCategories; + setAlterationCategories(info); + }), + ); onValue( ref(firebaseDb, `${mutationPath}/name_uuid`), @@ -268,7 +276,13 @@ const MutationCollapsible = ({ <> + } defaultOpen={open} collapsibleClassName="mb-1" colorOptions={{ borderLeftColor: NestLevelColor[NestLevelMapping[NestLevelType.MUTATION]] }} diff --git a/src/main/webapp/app/pages/curation/collapsible/ReviewCollapsible.tsx b/src/main/webapp/app/pages/curation/collapsible/ReviewCollapsible.tsx index 3a3b6b9f3..3c61cb062 100644 --- a/src/main/webapp/app/pages/curation/collapsible/ReviewCollapsible.tsx +++ b/src/main/webapp/app/pages/curation/collapsible/ReviewCollapsible.tsx @@ -295,9 +295,8 @@ export const ReviewCollapsible = ({ if (isUnderCreationOrDeletion) { return undefined; } - return ( - {ReviewActionLabels[reviewAction ?? '']} - ); + + return ; }; const getReviewableContent = () => { diff --git a/src/main/webapp/app/pages/curation/collapsible/mutation-collapsible/MutationCollapsibleTitle.tsx b/src/main/webapp/app/pages/curation/collapsible/mutation-collapsible/MutationCollapsibleTitle.tsx new file mode 100644 index 000000000..e839e5a76 --- /dev/null +++ b/src/main/webapp/app/pages/curation/collapsible/mutation-collapsible/MutationCollapsibleTitle.tsx @@ -0,0 +1,82 @@ +import DefaultBadge from 'app/shared/badge/DefaultBadge'; +import InfoIcon from 'app/shared/icons/InfoIcon'; +import { Alteration, AlterationCategories } from 'app/shared/model/firebase/firebase.model'; +import { getAlterationName, isFlagEqualToIFlag } from 'app/shared/util/firebase/firebase-utils'; +import { componentInject } from 'app/shared/util/typed-inject'; +import { buildAlterationName, getAlterationNameComponent, parseAlterationName } from 'app/shared/util/utils'; +import { IRootStore } from 'app/stores'; +import { observer } from 'mobx-react'; +import React from 'react'; +import * as styles from './styles.module.scss'; +import classNames from 'classnames'; +import WithSeparator from 'react-with-separator'; +import _ from 'lodash'; + +export interface IMutationCollapsibleTitle extends StoreProps { + name: string | undefined; + mutationAlterations: Alteration[] | null | undefined; + alterationCategories: AlterationCategories | null; +} +const MutationCollapsibleTitle = ({ name, mutationAlterations, alterationCategories, flagEntities }: IMutationCollapsibleTitle) => { + const defaultName = 'No Name'; + let stringMutationBadges: JSX.Element | undefined; + const shouldGroupBadges = + (alterationCategories?.flags?.length || 0) > 1 || (alterationCategories?.flags?.length === 1 && alterationCategories.comment !== ''); + + if (alterationCategories?.flags && flagEntities) { + const tooltipOverlay = alterationCategories.comment ? {alterationCategories.comment} : undefined; + stringMutationBadges = ( +
+ {alterationCategories.flags.map(flag => { + const matchedFlagEntity = flagEntities.find(flagEntity => isFlagEqualToIFlag(flag, flagEntity)); + return ( + + ); + })} + {tooltipOverlay ? : undefined} +
+ ); + } + + if (mutationAlterations) { + return ( + <> + + {mutationAlterations.map(alteration => { + return getAlterationNameComponent(getAlterationName(alteration, true), alteration.comment); + })} + + {stringMutationBadges} + + ); + } + + if (name) { + const parsedAlterations = parseAlterationName(name, true); + return ( + <> + + {parsedAlterations.map(pAlt => + getAlterationNameComponent(buildAlterationName(pAlt.alteration, pAlt.name, pAlt.excluding), pAlt.comment), + )} + + {stringMutationBadges} + + ); + } + + return {defaultName}; +}; + +const mapStoreToProps = ({ flagStore }: IRootStore) => ({ + flagEntities: flagStore.alterationCategoryFlags, +}); + +type StoreProps = Partial>; + +export default componentInject(mapStoreToProps)(observer(MutationCollapsibleTitle)); diff --git a/src/main/webapp/app/pages/curation/collapsible/mutation-collapsible/styles.module.scss b/src/main/webapp/app/pages/curation/collapsible/mutation-collapsible/styles.module.scss new file mode 100644 index 000000000..746b8438e --- /dev/null +++ b/src/main/webapp/app/pages/curation/collapsible/mutation-collapsible/styles.module.scss @@ -0,0 +1,10 @@ +.flagWrapper { + background-color: #e5e5e5; + border-radius: 5px; + border: 1px solid #e5e5e5; + margin-left: 0.5rem; + padding-bottom: 0.1rem; + padding-top: 0.1rem; + display: flex; + align-items: center; +} diff --git a/src/main/webapp/app/pages/curation/geneticTypeTabs/GeneticTypeTabs.tsx b/src/main/webapp/app/pages/curation/geneticTypeTabs/GeneticTypeTabs.tsx index 4cd809e96..6d2fbcfa1 100644 --- a/src/main/webapp/app/pages/curation/geneticTypeTabs/GeneticTypeTabs.tsx +++ b/src/main/webapp/app/pages/curation/geneticTypeTabs/GeneticTypeTabs.tsx @@ -79,31 +79,41 @@ const GeneticTypeTabs = ({ geneEntity, geneticType, firebaseDb, location, histor }; if (needsReview[type]) { badges.push( - - Needs Review - , + , ); } const isGeneReleased = geneReleaseStatus[type]; if (isGeneReleased) { // Todo: In tooltip show when gene was released + badges.push( - - Released - , + , ); } else { badges.push( - Pending Release - , + text="Pending Release" + />, ); } diff --git a/src/main/webapp/app/pages/curation/mutation/MutationsSection.tsx b/src/main/webapp/app/pages/curation/mutation/MutationsSection.tsx index ce05f6cef..30627b8cc 100644 --- a/src/main/webapp/app/pages/curation/mutation/MutationsSection.tsx +++ b/src/main/webapp/app/pages/curation/mutation/MutationsSection.tsx @@ -26,6 +26,7 @@ import { extractPositionFromSingleNucleotideAlteration } from 'app/shared/util/u import { MUTATION_LIST_ID, SINGLE_MUTATION_VIEW_ID } from 'app/config/constants/html-id'; import { SentryError } from 'app/config/sentry-error'; import { MutationQuery } from 'app/stores/curation-page.store'; +import { FlagTypeEnum } from 'app/shared/model/enumerations/flag-type.enum.model'; export interface IMutationsSectionProps extends StoreProps { mutationsPath: string; @@ -47,6 +48,7 @@ function MutationsSection({ firebaseDb, annotatedAltsCache, fetchMutationListForConvertIcon, + getFlagsByType, setIsMutationListRendered, }: IMutationsSectionProps) { const [showAddMutationModal, setShowAddMutationModal] = useState(false); @@ -55,6 +57,10 @@ function MutationsSection({ const mutationSectionRef = useRef(null); + useEffect(() => { + getFlagsByType?.(FlagTypeEnum.ALTERATION_CATEGORY); + }, []); + useEffect(() => { fetchMutationListForConvertIcon?.(mutationsPath); }, []); @@ -250,6 +256,7 @@ const mapStoreToProps = ({ firebaseAppStore, curationPageStore, firebaseMutationConvertIconStore, + flagStore, }: IRootStore) => ({ addMutation: firebaseGeneService.addMutation, openMutationCollapsibleListKey: openMutationCollapsibleStore.listKey, @@ -258,6 +265,7 @@ const mapStoreToProps = ({ annotatedAltsCache: curationPageStore.annotatedAltsCache, fetchMutationListForConvertIcon: firebaseMutationConvertIconStore.fetchData, setIsMutationListRendered: curationPageStore.setIsMutationListRendered, + getFlagsByType: flagStore.getFlagsByType, }); type StoreProps = Partial>; diff --git a/src/main/webapp/app/service/firebase/firebase-gene-service.ts b/src/main/webapp/app/service/firebase/firebase-gene-service.ts index a8b1097de..1aa9325ec 100644 --- a/src/main/webapp/app/service/firebase/firebase-gene-service.ts +++ b/src/main/webapp/app/service/firebase/firebase-gene-service.ts @@ -27,7 +27,12 @@ import AuthStore from '../../stores/authentication.store'; import { FirebaseRepository } from '../../stores/firebase/firebase-repository'; import { FirebaseMetaService } from './firebase-meta-service'; import { PATHOGENIC_VARIANTS } from 'app/config/constants/firebase'; -import { generateUuid, isPromiseOk } from 'app/shared/util/utils'; +import { + convertAlterationDataToAlteration, + convertEntityStatusAlterationToAlterationData, + generateUuid, + isPromiseOk, +} from 'app/shared/util/utils'; import { notifyError } from 'app/oncokb-commons/components/util/NotificationUtils'; import { getErrorMessage } from 'app/oncokb-commons/components/alert/ErrorAlertUtils'; import { FirebaseDataStore } from 'app/stores/firebase/firebase-data.store'; @@ -41,7 +46,6 @@ import { flow, flowResult } from 'mobx'; import { AxiosResponse } from 'axios'; import { IGene } from 'app/shared/model/gene.model'; import { AnnotateAlterationBody, Alteration as ApiAlteration, Gene as ApiGene } from 'app/shared/api/generated/curation'; -import { convertAlterationDataToAlteration, convertEntityStatusAlterationToAlterationData } from 'app/shared/util/alteration-utils'; export type AllLevelSummary = { [mutationUuid: string]: { @@ -113,7 +117,7 @@ export class FirebaseGeneService { if (!annotation) { return undefined; } - const alterationData = convertEntityStatusAlterationToAlterationData(annotation, PATHOGENIC_VARIANTS, [], ''); + const alterationData = convertEntityStatusAlterationToAlterationData(annotation, [], '', undefined, true); return convertAlterationDataToAlteration(alterationData); } catch (error) { notifyError(error); diff --git a/src/main/webapp/app/shared/badge/DefaultBadge.tsx b/src/main/webapp/app/shared/badge/DefaultBadge.tsx index 37720f013..5a2ff8c2b 100644 --- a/src/main/webapp/app/shared/badge/DefaultBadge.tsx +++ b/src/main/webapp/app/shared/badge/DefaultBadge.tsx @@ -4,22 +4,23 @@ import DefaultTooltip from '../tooltip/DefaultTooltip'; export interface IDefaultBadgeProps { color: string; - children: React.ReactNode; + text: string; tooltipOverlay?: (() => React.ReactNode) | React.ReactNode; className?: string; style?: React.CSSProperties; - square?: boolean; + isRoundedPill?: boolean; + onDeleteCallback?: () => void; } const DefaultBadge: React.FunctionComponent = props => { - const { className, style, color, square, tooltipOverlay } = props; + const { className, style, color, text, tooltipOverlay, isRoundedPill = true } = props; + + const badgeClassNames = ['badge', 'mx-1', `text-bg-${color}`]; + if (isRoundedPill) badgeClassNames.push('rounded-pill'); const badge = ( - - {props.children} + + {text} ); @@ -31,14 +32,7 @@ const DefaultBadge: React.FunctionComponent = props => { ); } - return ( - - {props.children} - - ); + return badge; }; export default DefaultBadge; diff --git a/src/main/webapp/app/shared/badge/NoEntryBadge.tsx b/src/main/webapp/app/shared/badge/NoEntryBadge.tsx index b6c88f715..41489bfb2 100644 --- a/src/main/webapp/app/shared/badge/NoEntryBadge.tsx +++ b/src/main/webapp/app/shared/badge/NoEntryBadge.tsx @@ -2,11 +2,7 @@ import React from 'react'; import DefaultBadge from './DefaultBadge'; const NoEntryBadge: React.FunctionComponent> = props => { - return ( - - No Entry - - ); + return ; }; export default NoEntryBadge; diff --git a/src/main/webapp/app/shared/badge/NotCuratableBadge.tsx b/src/main/webapp/app/shared/badge/NotCuratableBadge.tsx index ed53d32e8..c6b9b1581 100644 --- a/src/main/webapp/app/shared/badge/NotCuratableBadge.tsx +++ b/src/main/webapp/app/shared/badge/NotCuratableBadge.tsx @@ -69,9 +69,8 @@ const NotCuratableBadge: React.FunctionComponent = ({ m )}
} - > - Not Curatable -
+ text="Not Curatable" + /> ); }; diff --git a/src/main/webapp/app/shared/firebase/input/RealtimeBasicInput.tsx b/src/main/webapp/app/shared/firebase/input/RealtimeBasicInput.tsx index df421b60c..1ffb277d4 100644 --- a/src/main/webapp/app/shared/firebase/input/RealtimeBasicInput.tsx +++ b/src/main/webapp/app/shared/firebase/input/RealtimeBasicInput.tsx @@ -10,6 +10,7 @@ import { FormFeedback, Input, Label, LabelProps } from 'reactstrap'; import { InputType } from 'reactstrap/types/lib/Input'; import * as styles from './styles.module.scss'; import { Unsubscribe } from 'firebase/database'; +import { useTextareaAutoHeight } from 'app/hooks/useTextareaAutoHeight'; export enum RealtimeInputType { TEXT = 'text', @@ -126,24 +127,7 @@ const RealtimeBasicInput: React.FunctionComponent = (props: }; }, [firebasePath, db]); - useEffect(() => { - if (!inputValueLoaded) return; - const input = inputRef.current; - if (!input || type !== RealtimeInputType.TEXTAREA) { - return; - } - - const resizeObserver = new ResizeObserver(() => { - window.requestAnimationFrame(() => { - resizeTextArea(input); - }); - }); - resizeObserver.observe(input); - - return () => { - resizeObserver.disconnect(); - }; - }, [inputValueLoaded]); + useTextareaAutoHeight(inputRef, type); const labelComponent = label && ( diff --git a/src/main/webapp/app/shared/icons/ActionIcon.tsx b/src/main/webapp/app/shared/icons/ActionIcon.tsx index b9b3d4fb7..e5a6f12ee 100644 --- a/src/main/webapp/app/shared/icons/ActionIcon.tsx +++ b/src/main/webapp/app/shared/icons/ActionIcon.tsx @@ -10,6 +10,7 @@ export type SpanProps = JSX.IntrinsicElements['span']; export interface IActionIcon extends SpanProps { icon: IconDefinition; + text?: string; compact?: boolean; size?: 'sm' | 'lg'; color?: string; @@ -18,7 +19,7 @@ export interface IActionIcon extends SpanProps { } const ActionIcon: React.FunctionComponent = (props: IActionIcon) => { - const { icon, compact, size, color, className, onMouseLeave, onMouseEnter, tooltipProps, ...rest } = props; + const { icon, compact, size, color, className, onMouseLeave, onMouseEnter, tooltipProps, text, ...rest } = props; const defaultCompact = compact || false; const fontSize = size === 'lg' ? '1.5rem' : '1.2rem'; const defaultColor = props.disabled ? SECONDARY : color || PRIMARY; @@ -61,7 +62,7 @@ const ActionIcon: React.FunctionComponent = (props: IActionIcon) => } }; - const iconComponent = defaultCompact ? ( + let iconComponent = defaultCompact ? ( @@ -80,6 +81,17 @@ const ActionIcon: React.FunctionComponent = (props: IActionIcon) => onClick={handleClick} /> ); + + if (text) { + iconComponent = ( +
+ {iconComponent} + + {text ? {text} : undefined} +
+ ); + } + if (!tooltipProps) { return iconComponent; } diff --git a/src/main/webapp/app/shared/modal/AddMutationModal.tsx b/src/main/webapp/app/shared/modal/AddMutationModal.tsx index 531a61330..689c99029 100644 --- a/src/main/webapp/app/shared/modal/AddMutationModal.tsx +++ b/src/main/webapp/app/shared/modal/AddMutationModal.tsx @@ -1,40 +1,66 @@ -import Tabs from 'app/components/tabs/tabs'; -import { notifyError } from 'app/oncokb-commons/components/util/NotificationUtils'; import { IRootStore } from 'app/stores'; import { onValue, ref } from 'firebase/database'; import _ from 'lodash'; -import { flow, flowResult } from 'mobx'; -import React, { KeyboardEventHandler, useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { FaChevronDown, FaChevronUp, FaExclamationTriangle, FaPlus } from 'react-icons/fa'; -import ReactSelect, { GroupBase, MenuPlacement } from 'react-select'; -import CreatableSelect from 'react-select/creatable'; -import { Alert, Button, Col, Input, Row, Spinner } from 'reactstrap'; -import { Alteration, Mutation, VusObjList } from '../model/firebase/firebase.model'; -import { - AlterationAnnotationStatus, - AlterationTypeEnum, - AnnotateAlterationBody, - Gene, - Alteration as ApiAlteration, -} from '../api/generated/curation'; -import { IGene } from '../model/gene.model'; +import { flow } from 'mobx'; +import React, { KeyboardEventHandler, useEffect, useState } from 'react'; +import { Col, Input, InputGroup, InputGroupText, Row } from 'reactstrap'; +import { Mutation, AlterationCategories } from '../model/firebase/firebase.model'; +import { AlterationAnnotationStatus, AlterationTypeEnum, Gene } from '../api/generated/curation'; import { getDuplicateMutations, getFirebaseGenePath, getFirebaseVusPath } from '../util/firebase/firebase-utils'; import { componentInject } from '../util/typed-inject'; -import { hasValue, isEqualIgnoreCase, parseAlterationName } from '../util/utils'; +import { + isEqualIgnoreCase, + parseAlterationName, + convertEntityStatusAlterationToAlterationData, + convertAlterationDataToAlteration, + convertAlterationToAlterationData, + convertIFlagToFlag, +} from '../util/utils'; import { DefaultAddMutationModal } from './DefaultAddMutationModal'; import './add-mutation-modal.scss'; -import classNames from 'classnames'; -import { READABLE_ALTERATION, REFERENCE_GENOME } from 'app/config/constants/constants'; import { Unsubscribe } from 'firebase/database'; -import Select from 'react-select/dist/declarations/src/Select'; import InfoIcon from '../icons/InfoIcon'; import { SopPageLink } from '../links/SopPageLink'; -import { - AlterationData, - convertAlterationDataToAlteration, - convertEntityStatusAlterationToAlterationData, - getFullAlterationName, -} from '../util/alteration-utils'; +import { FlagTypeEnum } from '../model/enumerations/flag-type.enum.model'; +import { AsyncSaveButton } from '../button/AsyncSaveButton'; +import MutationDetails from './MutationModal/MutationDetails'; +import ExcludedAlterationContent from './MutationModal/ExcludedAlterationContent'; +import MutationListSection from './MutationModal/MutationListSection'; +import classNames from 'classnames'; +import { ADD_MUTATION_MODAL_INPUT_ID } from 'app/config/constants/html-id'; + +function getModalErrorMessage(mutationAlreadyExists: MutationExistsMeta) { + let modalErrorMessage: string | undefined = undefined; + if (mutationAlreadyExists.exists) { + modalErrorMessage = 'Mutation already exists in'; + if (mutationAlreadyExists.inMutationList && mutationAlreadyExists.inVusList) { + modalErrorMessage = 'Mutation already in mutation list and VUS list'; + } else if (mutationAlreadyExists.inMutationList) { + modalErrorMessage = 'Mutation already in mutation list'; + } else { + modalErrorMessage = 'Mutation already in VUS list'; + } + } + return modalErrorMessage; +} + +export type AlterationData = { + type: AlterationTypeEnum; + alteration: string; + name: string; + consequence: string; + comment: string; + excluding: AlterationData[]; + genes?: Gene[]; + proteinChange?: string; + proteinStart?: number; + proteinEnd?: number; + refResidues?: string; + varResidues?: string; + warning?: string; + error?: string; + alterationFieldValueWhileFetching?: string; +}; interface IAddMutationModalProps extends StoreProps { hugoSymbol: string | undefined; @@ -48,49 +74,56 @@ interface IAddMutationModalProps extends StoreProps { }; } +type MutationExistsMeta = { + exists: boolean; + inMutationList: boolean; + inVusList: boolean; +}; + function AddMutationModal({ hugoSymbol, isGermline, mutationToEditPath, mutationList, - annotateAlterations, geneEntities, - consequences, - getConsequences, onConfirm, onCancel, firebaseDb, convertOptions, + getFlagsByType, + createFlagEntity, + alterationCategoryFlagEntities, + setVusList, + setMutationToEdit, + alterationStates, + vusList, + mutationToEdit, + setShowModifyExonForm, + isFetchingAlteration, + isFetchingExcludingAlteration, + currentMutationNames, + cleanup, + fetchAlterations, + setAlterationStates, + selectedAlterationCategoryFlags, + alterationCategoryComment, + setGeneEntity, + updateAlterationStateAfterAlterationAdded, + selectedAlterationStateIndex, + hasUncommitedExonFormChanges, + unCommittedExonFormChangesWarning, }: IAddMutationModalProps) { - const typeOptions: DropdownOption[] = [ - AlterationTypeEnum.ProteinChange, - AlterationTypeEnum.CopyNumberAlteration, - AlterationTypeEnum.StructuralVariant, - AlterationTypeEnum.CdnaChange, - AlterationTypeEnum.GenomicChange, - AlterationTypeEnum.Any, - ].map(type => ({ label: READABLE_ALTERATION[type], value: type })); - const consequenceOptions: DropdownOption[] = - consequences?.map((consequence): DropdownOption => ({ label: consequence.name, value: consequence.id })) ?? []; - const [inputValue, setInputValue] = useState(''); - const [tabStates, setTabStates] = useState([]); - const [excludingInputValue, setExcludingInputValue] = useState(''); - const [excludingCollapsed, setExcludingCollapsed] = useState(true); - const [mutationAlreadyExists, setMutationAlreadyExists] = useState({ exists: false, inMutationList: false, inVusList: false }); - const [mutationToEdit, setMutationToEdit] = useState(null); - const [errorMessagesEnabled, setErrorMessagesEnabled] = useState(true); - const [isFetchingAlteration, setIsFetchingAlteration] = useState(false); - const [isFetchingExcludingAlteration, setIsFetchingExcludingAlteration] = useState(false); - const [isConfirmPending, setIsConfirmPending] = useState(false); - - const [vusList, setVusList] = useState(null); + const [mutationAlreadyExists, setMutationAlreadyExists] = useState({ + exists: false, + inMutationList: false, + inVusList: false, + }); - const inputRef = useRef> | null>(null); + const [isAddAlterationPending, setIsAddAlterationPending] = useState(false); - const geneEntity: IGene | undefined = useMemo(() => { - return geneEntities?.find(gene => gene.hugoSymbol === hugoSymbol); - }, [geneEntities]); + const [errorMessagesEnabled, setErrorMessagesEnabled] = useState(true); + const [isConfirmPending, setIsConfirmPending] = useState(false); useEffect(() => { if (!firebaseDb) { @@ -99,21 +132,28 @@ function AddMutationModal({ const callbacks: Unsubscribe[] = []; callbacks.push( onValue(ref(firebaseDb, getFirebaseVusPath(isGermline, hugoSymbol)), snapshot => { - setVusList(snapshot.val()); + setVusList?.(snapshot.val()); }), ); if (mutationToEditPath) { callbacks.push( onValue(ref(firebaseDb, mutationToEditPath), snapshot => { - setMutationToEdit(snapshot.val()); + setMutationToEdit?.(snapshot.val()); }), ); } + getFlagsByType?.(FlagTypeEnum.ALTERATION_CATEGORY); + return () => callbacks.forEach(callback => callback?.()); }, []); + useEffect(() => { + const geneEntity = geneEntities?.find(gene => gene.hugoSymbol === hugoSymbol); + setGeneEntity?.(geneEntity ?? null); + }, [geneEntities]); + useEffect(() => { if (convertOptions?.isConverting) { handleAlterationAdded(); @@ -121,50 +161,31 @@ function AddMutationModal({ }, [convertOptions?.isConverting]); useEffect(() => { - if (mutationList) { - const dupMutations = getDuplicateMutations( - currentMutationNames, - mutationList, - `${getFirebaseGenePath(isGermline, hugoSymbol)}/mutations`, - vusList ?? {}, - { - useFullAlterationName: true, - excludedMutationUuid: mutationToEdit?.name_uuid, - excludedVusName: convertOptions?.isConverting ? convertOptions.alteration : '', - exact: true, - }, - ); - setMutationAlreadyExists({ - exists: dupMutations.length > 0, - inMutationList: dupMutations.some(mutation => mutation.inMutationList), - inVusList: dupMutations.some(mutation => mutation.inVusList), - }); - } - }, [tabStates, mutationList, vusList]); + const dupMutations = getDuplicateMutations( + currentMutationNames ?? [], + mutationList, + `${getFirebaseGenePath(isGermline, hugoSymbol)}/mutations`, + vusList ?? {}, + { + useFullAlterationName: true, + excludedMutationUuid: mutationToEdit?.name_uuid, + excludedVusName: convertOptions?.isConverting ? convertOptions.alteration : '', + exact: true, + }, + ); + setMutationAlreadyExists({ + exists: dupMutations.length > 0, + inMutationList: dupMutations.some(mutation => mutation.inMutationList), + inVusList: dupMutations.some(mutation => mutation.inVusList), + }); + }, [alterationStates, mutationList, vusList]); useEffect(() => { - function convertAlterationToAlterationData(alteration: Alteration): AlterationData { - const { name: variantName } = parseAlterationName(alteration.name)[0]; - - return { - type: alteration.type, - alteration: alteration.alteration, - name: variantName || alteration.alteration, - consequence: alteration.consequence, - comment: alteration.comment, - excluding: alteration.excluding?.map(ex => convertAlterationToAlterationData(ex)) || [], - genes: alteration?.genes || [], - proteinChange: alteration?.proteinChange, - proteinStart: alteration?.proteinStart === -1 ? undefined : alteration?.proteinStart, - proteinEnd: alteration?.proteinEnd === -1 ? undefined : alteration?.proteinEnd, - refResidues: alteration?.refResidues, - varResidues: alteration?.varResidues, - }; - } - async function setExistingAlterations() { + /* eslint-disable no-console */ if (mutationToEdit?.alterations?.length !== undefined && mutationToEdit.alterations.length > 0) { - setTabStates(mutationToEdit?.alterations?.map(alt => convertAlterationToAlterationData(alt)) ?? []); + // Use the alteration model in Firebase instead of annotation from API + setAlterationStates?.(mutationToEdit?.alterations?.map(alt => convertAlterationToAlterationData(alt)) ?? []); return; } @@ -176,10 +197,14 @@ function AddMutationModal({ [] as ReturnType, ); - const entityStatusAlterationsPromise = fetchAlterations(parsedAlterations?.map(alt => alt.alteration) ?? []); + const entityStatusAlterationsPromise = fetchAlterations?.(parsedAlterations?.map(alt => alt.alteration) ?? []); + if (!entityStatusAlterationsPromise) return; const excludingEntityStatusAlterationsPromises: Promise[] = []; for (const alt of parsedAlterations ?? []) { - excludingEntityStatusAlterationsPromises.push(fetchAlterations(alt.excluding)); + const fetchedAlterations = fetchAlterations?.(alt.excluding); + if (fetchedAlterations) { + excludingEntityStatusAlterationsPromises.push(fetchedAlterations); + } } const [entityStatusAlterations, entityStatusExcludingAlterations] = await Promise.all([ entityStatusAlterationsPromise, @@ -191,31 +216,23 @@ function AddMutationModal({ for (let i = 0; i < parsedAlterations.length; i++) { const excluding: AlterationData[] = []; for (let exIndex = 0; exIndex < parsedAlterations[i].excluding.length; exIndex++) { - excluding.push( - convertEntityStatusAlterationToAlterationData( - entityStatusExcludingAlterations[i][exIndex], - parsedAlterations[i].excluding[exIndex], - [], - '', - ), - ); + excluding.push(convertEntityStatusAlterationToAlterationData(entityStatusExcludingAlterations[i][exIndex], [], '')); } excludingAlterations.push(excluding); } } if (parsedAlterations) { - const alterations = entityStatusAlterations.map((alt, index) => + const newAlerationStates = entityStatusAlterations.map((alt, index) => convertEntityStatusAlterationToAlterationData( alt, - parsedAlterations[index].alteration, excludingAlterations[index] || [], parsedAlterations[index].comment, parsedAlterations[index].name, ), ); - setTabStates(alterations); + setAlterationStates?.(newAlerationStates); } } @@ -224,316 +241,84 @@ function AddMutationModal({ } }, [mutationToEdit]); - useEffect(() => { - getConsequences?.({}); - }, []); - - useEffect(() => { - inputRef.current?.focus(); - }, []); - - const currentMutationNames = useMemo(() => { - return tabStates.map(state => getFullAlterationName({ ...state, comment: '' }).toLowerCase()).sort(); - }, [tabStates]); - - function filterAlterationsAndNotify( - alterations: ReturnType, - alterationData: AlterationData[], - alterationIndex?: number, - ) { - // remove alterations that already exist in modal - const newAlterations = alterations.filter(alt => { - return !alterationData.some((state, index) => { - if (index === alterationIndex) { - return false; - } - - const stateName = state.alteration.toLowerCase(); - const stateExcluding = state.excluding.map(ex => ex.alteration.toLowerCase()).sort(); - const altName = alt.alteration.toLowerCase(); - const altExcluding = alt.excluding.map(ex => ex.toLowerCase()).sort(); - return stateName === altName && _.isEqual(stateExcluding, altExcluding); - }); - }); - - if (alterations.length !== newAlterations.length) { - notifyError(new Error('Duplicate alteration(s) removed')); - } - - return newAlterations; - } - - async function fetchAlteration(alterationName: string): Promise { - try { - const request: AnnotateAlterationBody[] = [ - { - referenceGenome: REFERENCE_GENOME.GRCH37, - alteration: { alteration: alterationName, genes: [{ id: geneEntity?.id } as Gene] } as ApiAlteration, - }, - ]; - const alts = await flowResult(annotateAlterations?.(request)); - return alts[0]; - } catch (error) { - notifyError(error); + async function handleAlterationAdded() { + let alterationString = inputValue; + if (convertOptions?.isConverting) { + alterationString = convertOptions.alteration; } - } - - async function fetchAlterations(alterationNames: string[]) { try { - const alterationPromises = alterationNames.map(name => fetchAlteration(name)); - const alterations = await Promise.all(alterationPromises); - const filtered: AlterationAnnotationStatus[] = []; - for (const alteration of alterations) { - if (alteration !== undefined) { - filtered.push(alteration); - } - } - return filtered; - } catch (error) { - notifyError(error); - return []; + setIsAddAlterationPending(true); + await updateAlterationStateAfterAlterationAdded?.(parseAlterationName(alterationString, true)); + } finally { + setIsAddAlterationPending(false); } + setInputValue(''); } - async function fetchNormalAlteration(newAlteration: string, alterationIndex: number, alterationData: AlterationData[]) { - const newParsedAlteration = filterAlterationsAndNotify(parseAlterationName(newAlteration), alterationData, alterationIndex); - if (newParsedAlteration.length === 0) { - setTabStates(states => { - const newStates = _.cloneDeep(states); - newStates[alterationIndex].alterationFieldValueWhileFetching = undefined; - return newStates; - }); - } + async function handleConfirm() { + const newMutation = mutationToEdit ? _.cloneDeep(mutationToEdit) : new Mutation(''); + const newAlterations = alterationStates?.map(state => convertAlterationDataToAlteration(state)) ?? []; + newMutation.name = newAlterations.map(alteration => alteration.name).join(', '); + newMutation.alterations = newAlterations; - const newComment = newParsedAlteration[0].comment; - const newVariantName = newParsedAlteration[0].name; + const newAlterationCategories = await handleAlterationCategoriesConfirm(); + newMutation.alteration_categories = newAlterationCategories; - let newExcluding: AlterationData[]; - if ( - _.isEqual( - newParsedAlteration[0].excluding, - alterationData[alterationIndex]?.excluding.map(ex => ex.alteration), - ) - ) { - newExcluding = alterationData[alterationIndex].excluding; - } else { - const excludingEntityStatusAlterations = await fetchAlterations(newParsedAlteration[0].excluding); - newExcluding = - excludingEntityStatusAlterations?.map((ex, index) => - convertEntityStatusAlterationToAlterationData(ex, newParsedAlteration[0].excluding[index], [], ''), - ) ?? []; + setErrorMessagesEnabled(false); + setIsConfirmPending(true); + try { + await onConfirm(newMutation); + } finally { + setErrorMessagesEnabled(true); + setIsConfirmPending(false); + cleanup?.(); } + } - const alterationPromises: Promise[] = []; - let newAlterations: AlterationData[] = []; - if (newParsedAlteration[0].alteration !== alterationData[alterationIndex]?.alteration) { - alterationPromises.push(fetchAlteration(newParsedAlteration[0].alteration)); + async function handleAlterationCategoriesConfirm() { + let newAlterationCategories: AlterationCategories | null = new AlterationCategories(); + if (selectedAlterationCategoryFlags?.length === 0 || alterationStates?.length === 1) { + newAlterationCategories = null; } else { - alterationData[alterationIndex].excluding = newExcluding; - alterationData[alterationIndex].comment = newComment; - alterationData[alterationIndex].name = newVariantName || newParsedAlteration[0].alteration; - newAlterations.push(alterationData[alterationIndex]); - } - - for (let i = 1; i < newParsedAlteration.length; i++) { - alterationPromises.push(fetchAlteration(newParsedAlteration[i].alteration)); + newAlterationCategories.comment = alterationCategoryComment ?? ''; + const finalFlagArray = await saveNewFlags(); + if ((selectedAlterationCategoryFlags ?? []).length > 0) { + newAlterationCategories.flags = finalFlagArray.map(flag => convertIFlagToFlag(flag)); + } } - newAlterations = [ - ...newAlterations, - ...(await Promise.all(alterationPromises)) - .filter(hasValue) - .map((alt, index) => - convertEntityStatusAlterationToAlterationData( - alt, - newParsedAlteration[index + newAlterations.length].alteration, - newExcluding, - newComment, - newVariantName, - ), - ), - ]; - newAlterations[0].alterationFieldValueWhileFetching = undefined; - - setTabStates(states => { - const newStates = _.cloneDeep(states); - newStates.splice(alterationIndex, 1, ...newAlterations); - return newStates; - }); - } - - const fetchNormalAlterationDebounced = useCallback( - _.debounce(async (newAlteration: string, alterationIndex: number, alterationData: AlterationData[]) => { - await fetchNormalAlteration(newAlteration, alterationIndex, alterationData); - setIsFetchingAlteration(false); - }, 1000), - [tabStates.length], - ); + // Refresh flag entities + await getFlagsByType?.(FlagTypeEnum.ALTERATION_CATEGORY); - function handleNormalAlterationChange(newValue: string, alterationIndex: number) { - setTabStates(states => { - const newStates = _.cloneDeep(states); - newStates[alterationIndex].alterationFieldValueWhileFetching = newValue; - return newStates; - }); - fetchNormalAlterationDebounced(newValue, alterationIndex, tabStates); + return newAlterationCategories; } - async function fetchExcludedAlteration( - newAlteration: string, - alterationIndex: number, - excludingIndex: number, - alterationData: AlterationData[], - ) { - const newParsedAlteration = parseAlterationName(newAlteration); - - const currentState = alterationData[alterationIndex]; - const alteration = currentState.alteration.toLowerCase(); - let excluding: string[] = []; - for (let i = 0; i < currentState.excluding.length; i++) { - if (i === excludingIndex) { - excluding.push(...newParsedAlteration.map(alt => alt.alteration.toLowerCase())); - } else { - excluding.push(currentState.excluding[excludingIndex].alteration.toLowerCase()); - } - } - excluding = excluding.sort(); - if ( - alterationData.some( - state => - state.alteration.toLowerCase() === alteration && - _.isEqual(state.excluding.map(ex => ex.alteration.toLowerCase()).sort(), excluding), - ) - ) { - notifyError(new Error('Duplicate alteration(s) removed')); - setTabStates(states => { - const newStates = _.cloneDeep(states); - newStates[alterationIndex].excluding.splice(excludingIndex, 1); - return newStates; + async function saveNewFlags() { + const [newFlags, oldFlags] = _.partition(selectedAlterationCategoryFlags ?? [], newFlag => { + return !alterationCategoryFlagEntities?.some(existingFlag => { + return newFlag.type === existingFlag.type && newFlag.flag === existingFlag.flag; }); - return; - } - - const alterationPromises: Promise[] = []; - let newAlterations: AlterationData[] = []; - if (newParsedAlteration[0].alteration !== alterationData[alterationIndex]?.excluding[excludingIndex].alteration) { - alterationPromises.push(fetchAlteration(newParsedAlteration[0].alteration)); - } else { - newAlterations.push(alterationData[alterationIndex].excluding[excludingIndex]); - } - - for (let i = 1; i < newParsedAlteration.length; i++) { - alterationPromises.push(fetchAlteration(newParsedAlteration[i].alteration)); - } - newAlterations = [ - ...newAlterations, - ...(await Promise.all(alterationPromises)) - .map((alt, index) => - alt - ? convertEntityStatusAlterationToAlterationData( - alt, - newParsedAlteration[index].alteration, - [], - newParsedAlteration[index].comment, - ) - : undefined, - ) - .filter(hasValue), - ]; - - setTabStates(states => { - const newStates = _.cloneDeep(states); - newStates[alterationIndex].excluding.splice(excludingIndex, 1, ...newAlterations); - return newStates; }); - } - - const fetchExcludedAlterationDebounced = useCallback( - _.debounce(async (newAlteration: string, alterationIndex: number, excludingIndex: number, alterationData: AlterationData[]) => { - await fetchExcludedAlteration(newAlteration, alterationIndex, excludingIndex, alterationData); - setIsFetchingExcludingAlteration(false); - }, 1000), - [], - ); - - async function handleAlterationChange(newValue: string, alterationIndex: number, excludingIndex?: number, isDebounced = true) { - if (!_.isNil(excludingIndex)) { - setIsFetchingExcludingAlteration(true); - - if (isDebounced) { - handleExcludingFieldChange(newValue, 'alteration', alterationIndex, excludingIndex); - fetchExcludedAlterationDebounced(newValue, alterationIndex, excludingIndex, tabStates); - } else { - await fetchExcludedAlteration(newValue, alterationIndex, excludingIndex, tabStates); - setIsFetchingExcludingAlteration(false); - } - } else { - setIsFetchingAlteration(true); - - if (isDebounced) { - handleNormalAlterationChange(newValue, alterationIndex); - } else { - await fetchNormalAlteration(newValue, alterationIndex, tabStates); - setIsFetchingAlteration(false); + if (newFlags.length > 0) { + for (const newFlag of newFlags) { + const savedFlagEntity = await createFlagEntity?.({ + type: FlagTypeEnum.ALTERATION_CATEGORY, + flag: newFlag.flag, + name: newFlag.name, + description: '', + alterations: null, + genes: null, + transcripts: null, + articles: null, + drugs: null, + }); + if (savedFlagEntity?.data) { + oldFlags.push(savedFlagEntity.data); + } } } - } - function handleNormalFieldChange(newValue: string, field: keyof AlterationData, alterationIndex: number) { - setTabStates(states => { - const newStates = _.cloneDeep(states); - newStates[alterationIndex][field as string] = newValue; - return newStates; - }); - } - - function handleExcludingFieldChange(newValue: string, field: keyof AlterationData, alterationIndex: number, excludingIndex: number) { - setTabStates(states => { - const newStates = _.cloneDeep(states); - newStates[alterationIndex].excluding[excludingIndex][field as string] = newValue; - return newStates; - }); - } - - function handleFieldChange(newValue: string, field: keyof AlterationData, alterationIndex: number, excludingIndex?: number) { - !_.isNil(excludingIndex) - ? handleExcludingFieldChange(newValue, field, alterationIndex, excludingIndex) - : handleNormalFieldChange(newValue, field, alterationIndex); - } - - async function handleAlterationAdded() { - let alterationString = inputValue; - if (convertOptions?.isConverting) { - alterationString = convertOptions.alteration; - } - const newParsedAlteration = filterAlterationsAndNotify(parseAlterationName(alterationString), tabStates); - - if (newParsedAlteration.length === 0) { - return; - } - - const newEntityStatusAlterationsPromise = fetchAlterations(newParsedAlteration.map(alt => alt.alteration)); - const newEntityStatusExcludingAlterationsPromise = fetchAlterations(newParsedAlteration[0].excluding); - const [newEntityStatusAlterations, newEntityStatusExcludingAlterations] = await Promise.all([ - newEntityStatusAlterationsPromise, - newEntityStatusExcludingAlterationsPromise, - ]); - - const newExcludingAlterations = newEntityStatusExcludingAlterations.map((alt, index) => - convertEntityStatusAlterationToAlterationData(alt, newParsedAlteration[0].excluding[index], [], ''), - ); - const newAlterations = newEntityStatusAlterations.map((alt, index) => - convertEntityStatusAlterationToAlterationData( - alt, - newParsedAlteration[index].alteration, - _.cloneDeep(newExcludingAlterations), - newParsedAlteration[index].comment, - newParsedAlteration[index].name, - ), - ); - - setTabStates(states => [...states, ...newAlterations]); - setInputValue(''); + return oldFlags; } const handleKeyDown: KeyboardEventHandler = event => { @@ -544,566 +329,165 @@ function AddMutationModal({ } }; - async function handleAlterationAddedExcluding(alterationIndex: number) { - const newParsedAlteration = parseAlterationName(excludingInputValue); - - const currentState = tabStates[alterationIndex]; - const alteration = currentState.alteration.toLowerCase(); - let excluding = currentState.excluding.map(ex => ex.alteration.toLowerCase()); - excluding.push(...newParsedAlteration.map(alt => alt.alteration.toLowerCase())); - excluding = excluding.sort(); - - if ( - tabStates.some( - state => - state.alteration.toLowerCase() === alteration && - _.isEqual(state.excluding.map(ex => ex.alteration.toLowerCase()).sort(), excluding), - ) - ) { - notifyError(new Error('Duplicate alteration(s) removed')); - return; - } - - const newComment = newParsedAlteration[0].comment; - const newVariantName = newParsedAlteration[0].name; - - const newEntityStatusAlterations = await fetchAlterations(newParsedAlteration.map(alt => alt.alteration)); - - const newAlterations = newEntityStatusAlterations.map((alt, index) => - convertEntityStatusAlterationToAlterationData(alt, newParsedAlteration[index].alteration, [], newComment, newVariantName), - ); - - setTabStates(states => { - const newStates = _.cloneDeep(states); - newStates[alterationIndex].excluding.push(...newAlterations); - return newStates; - }); - - setExcludingInputValue(''); - } - - const handleKeyDownExcluding = (event: React.KeyboardEvent, alterationIndex: number) => { - if (!excludingInputValue) return; - if (event.key === 'Enter' || event.key === 'tab') { - handleAlterationAddedExcluding(alterationIndex); - event.preventDefault(); - } + const handleCancel = () => { + cleanup?.(); + onCancel(); }; - function getTabTitle(tabAlterationData: AlterationData, isExcluding = false) { - if (!tabAlterationData) { - // loading state - return <>; - } - - const fullAlterationName = getFullAlterationName(tabAlterationData, isExcluding ? false : true); + const renderInputSection = () => ( + + + + setInputValue(e.target.value)} + onClick={() => setShowModifyExonForm?.(false)} + /> + + } /> + + + + + + ); - if (tabAlterationData.error) { + // Helper function to render exon or mutation list section + const renderMutationListSection = () => { + if (alterationStates?.length !== 0) { return ( - - - {fullAlterationName} - + <> +
+ + + + + + ); } + return null; + }; - if (tabAlterationData.warning) { + // Helper function to render selected alteration state content + const renderMutationDetailSection = () => { + if ( + alterationStates !== undefined && + selectedAlterationStateIndex !== undefined && + selectedAlterationStateIndex > -1 && + !_.isNil(alterationStates[selectedAlterationStateIndex]) + ) { return ( - - - {fullAlterationName} - + <> +
+ <> + + {alterationStates[selectedAlterationStateIndex].type !== AlterationTypeEnum.GenomicChange && } + + ); } + return null; + }; - return fullAlterationName; - } - - function getTabContent(alterationData: AlterationData, alterationIndex: number, excludingIndex?: number) { - const excludingSection = - !_.isNil(excludingIndex) || alterationData.type === 'GENOMIC_CHANGE' ? <> : getExcludingSection(alterationData, alterationIndex); - - let content: JSX.Element; - switch (alterationData.type) { - case AlterationTypeEnum.ProteinChange: - content = getProteinChangeContent(alterationData, alterationIndex, excludingIndex); - break; - case AlterationTypeEnum.CopyNumberAlteration: - content = getCopyNumberAlterationContent(alterationData, alterationIndex, excludingIndex); - break; - case AlterationTypeEnum.CdnaChange: - content = getCdnaChangeContent(alterationData, alterationIndex, excludingIndex); - break; - case AlterationTypeEnum.GenomicChange: - content = getGenomicChangeContent(alterationData, alterationIndex, excludingIndex); - break; - case AlterationTypeEnum.StructuralVariant: - content = getStructuralVariantContent(alterationData, alterationIndex, excludingIndex); - break; - default: - content = getOtherContent(alterationData, alterationIndex, excludingIndex); - break; - } - - if (alterationData.error) { - return getErrorSection(alterationData, alterationIndex, excludingIndex); - } - - return ( - <> - {alterationData.warning && ( - - {alterationData.warning} - - )} - option.value === alterationData.type) ?? { label: '', value: undefined }} - onChange={newValue => handleFieldChange(newValue?.value, 'type', alterationIndex, excludingIndex)} - /> - handleAlterationChange(newValue, alterationIndex, excludingIndex)} - /> - {content} - handleFieldChange(newValue, 'comment', alterationIndex, excludingIndex)} - /> - {excludingSection} - - ); - } - - function getProteinChangeContent(alterationData: AlterationData, alterationIndex: number, excludingIndex?: number) { - return ( -
- handleFieldChange(newValue, 'name', alterationIndex, excludingIndex)} - /> - handleFieldChange(newValue, 'proteinChange', alterationIndex, excludingIndex)} - /> - handleFieldChange(newValue, 'proteinStart', alterationIndex, excludingIndex)} - /> - handleFieldChange(newValue, 'proteinEnd', alterationIndex, excludingIndex)} - /> - handleFieldChange(newValue, 'refResidues', alterationIndex, excludingIndex)} - /> - handleFieldChange(newValue, 'varResidues', alterationIndex, excludingIndex)} - /> - option.label === alterationData.consequence) ?? { label: '', value: undefined }} - options={consequenceOptions} - menuPlacement="top" - onChange={newValue => handleFieldChange(newValue?.label ?? '', 'consequence', alterationIndex, excludingIndex)} - /> -
- ); - } - - function getCdnaChangeContent(alterationData: AlterationData, alterationIndex: number, excludingIndex?: number) { - return ( -
- handleFieldChange(newValue, 'name', alterationIndex, excludingIndex)} - /> - handleFieldChange(newValue, 'proteinChange', alterationIndex, excludingIndex)} - /> -
- ); - } - - function getGenomicChangeContent(alterationData: AlterationData, alterationIndex: number, excludingIndex?: number) { - return ( -
- handleFieldChange(newValue, 'name', alterationIndex, excludingIndex)} - /> -
- ); - } - - function getCopyNumberAlterationContent(alterationData: AlterationData, alterationIndex: number, excludingIndex?: number) { - return ( -
- handleFieldChange(newValue, 'name', alterationIndex, excludingIndex)} - /> -
- ); - } - - function getStructuralVariantContent(alterationData: AlterationData, alterationIndex: number, excludingIndex?: number) { - return ( -
- handleFieldChange(newValue, 'name', alterationIndex, excludingIndex)} - /> - gene.hugoSymbol).join(', ') ?? ''} - placeholder="Input genes" - disabled - onChange={newValue => handleFieldChange(newValue, 'genes', alterationIndex, excludingIndex)} - /> -
- ); - } - - function getOtherContent(alterationData: AlterationData, alterationIndex: number, excludingIndex?: number) { - return ( -
- handleFieldChange(newValue, 'name', alterationIndex, excludingIndex)} - /> -
- ); - } - - function getExcludingSection(alterationData: AlterationData, alterationIndex: number) { - const isSectionEmpty = alterationData.excluding.length === 0; - - return ( - <> -
- - Excluding - {!isSectionEmpty && ( - <> - {excludingCollapsed ? ( - setExcludingCollapsed(false)} /> - ) : ( - setExcludingCollapsed(true)} /> - )} - - )} - - - { - if (action !== 'menu-close' && action !== 'input-blur') { - setExcludingInputValue(newInput); - } - }} - value={tabStates[alterationIndex].excluding.map(state => { - const fullAlterationName = getFullAlterationName(state, false); - return { label: fullAlterationName, value: fullAlterationName, ...state }; - })} - onChange={(newAlterations: readonly AlterationData[]) => - setTabStates(states => { - const newStates = _.cloneDeep(states); - newStates[alterationIndex].excluding = newStates[alterationIndex].excluding.filter(state => - newAlterations.some(alt => getFullAlterationName(alt) === getFullAlterationName(state)), - ); - return newStates; - }) - } - onKeyDown={event => handleKeyDownExcluding(event, alterationIndex)} - /> - - - - -
- {!isSectionEmpty && ( - - -
- ({ - title: getTabTitle(ex, true), - content: getTabContent(ex, alterationIndex, index), - }))} - isCollapsed={excludingCollapsed} - /> -
- -
- )} - - ); - } - - function getErrorSection(alterationData: AlterationData, alterationIndex: number, excludingIndex?: number) { - const suggestion = new RegExp('The alteration name is invalid, do you mean (.+)\\?').exec(alterationData.error ?? '')?.[1]; - - return ( -
- - {alterationData.error} - - {suggestion && ( -
- - -
- )} -
- ); - } - - const modalBody = ( - <> - - - { - if (action !== 'menu-close' && action !== 'input-blur') { - setInputValue(newInput); - } - }} - value={tabStates.map(state => { - const fullAlterationName = getFullAlterationName(state); - return { label: fullAlterationName, value: fullAlterationName, ...state }; - })} - onChange={(newAlterations: readonly AlterationData[]) => - setTabStates(states => - states.filter(state => newAlterations.some(alt => getFullAlterationName(alt) === getFullAlterationName(state))), - ) - } - onKeyDown={handleKeyDown} - /> - - {!convertOptions?.isConverting ? ( - <> - -
- - }> -
- - - ) : undefined} -
- {tabStates.length > 0 && ( -
- { - return { - title: getTabTitle(alterationData), - content: getTabContent(alterationData, index), - }; - })} - /> -
- )} - + const mutationModalBody = ( +
+ {!convertOptions?.isConverting && renderInputSection()} + {renderMutationListSection()} + {renderMutationDetailSection()} +
); - let modalErrorMessage: string | undefined = undefined; - if (mutationAlreadyExists.exists) { - modalErrorMessage = 'Mutation already exists in'; - if (mutationAlreadyExists.inMutationList && mutationAlreadyExists.inVusList) { - modalErrorMessage = 'Mutation already in mutation list and VUS list'; - } else if (mutationAlreadyExists.inMutationList) { - modalErrorMessage = 'Mutation already in mutation list'; - } else { - modalErrorMessage = 'Mutation already in VUS list'; - } - } + const modalErrorMessage = getModalErrorMessage(mutationAlreadyExists); - let modalWarningMessage: string | undefined = undefined; - if (convertOptions?.isConverting && !isEqualIgnoreCase(convertOptions.alteration, currentMutationNames.join(', '))) { - modalWarningMessage = 'Name differs from original VUS name'; + const modalWarningMessage: string[] = []; + if (convertOptions?.isConverting && !isEqualIgnoreCase(convertOptions.alteration, (currentMutationNames ?? []).join(', '))) { + modalWarningMessage.push('Name differs from original VUS name'); + } + if (hasUncommitedExonFormChanges && unCommittedExonFormChangesWarning) { + modalWarningMessage.push(unCommittedExonFormChangesWarning); } return ( Promoting Variant(s) to Mutation : undefined} - modalBody={modalBody} - onCancel={onCancel} - onConfirm={async () => { - const newMutation = mutationToEdit ? _.cloneDeep(mutationToEdit) : new Mutation(''); - const newAlterations = tabStates.map(state => convertAlterationDataToAlteration(state)); - newMutation.name = newAlterations.map(alteration => alteration.name).join(', '); - newMutation.alterations = newAlterations; - - setErrorMessagesEnabled(false); - setIsConfirmPending(true); - try { - await onConfirm(newMutation); - } finally { - setErrorMessagesEnabled(true); - setIsConfirmPending(false); - } - }} + modalBody={mutationModalBody} + onCancel={handleCancel} + onConfirm={handleConfirm} errorMessages={modalErrorMessage && errorMessagesEnabled ? [modalErrorMessage] : undefined} - warningMessages={modalWarningMessage ? [modalWarningMessage] : undefined} + warningMessages={modalWarningMessage ? modalWarningMessage : undefined} confirmButtonDisabled={ - tabStates.length === 0 || + alterationStates?.length === 0 || mutationAlreadyExists.exists || isFetchingAlteration || isFetchingExcludingAlteration || - tabStates.some(tab => tab.error || tab.excluding.some(ex => ex.error)) || - isConfirmPending + alterationStates?.some(tab => tab.error || tab.excluding.some(ex => ex.error)) || + isConfirmPending || + (hasUncommitedExonFormChanges ?? false) } isConfirmPending={isConfirmPending} /> ); } -interface IAddMutationModalFieldProps { - label: string; - value: string; - placeholder: string; - onChange: (newValue: string) => void; - isLoading?: boolean; - disabled?: boolean; -} - -function AddMutationModalField({ label, value: value, placeholder, onChange, isLoading, disabled }: IAddMutationModalFieldProps) { - return ( -
- -
- {label} - {isLoading && } -
- - - { - onChange(event.target.value); - }} - placeholder={placeholder} - /> - -
- ); -} +const mapStoreToProps = ({ + alterationStore, + consequenceStore, + geneStore, + firebaseAppStore, + firebaseVusStore, + firebaseMutationListStore, + flagStore, + addMutationModalStore, + transcriptStore, +}: IRootStore) => ({ + annotateAlterations: flow(alterationStore.annotateAlterations), + geneEntities: geneStore.entities, + consequences: consequenceStore.entities, + getConsequences: consequenceStore.getEntities, + firebaseDb: firebaseAppStore.firebaseDb, + vusList: firebaseVusStore.data, + mutationList: firebaseMutationListStore.data, + getFlagsByType: flagStore.getFlagsByType, + alterationCategoryFlagEntities: flagStore.alterationCategoryFlags, + createFlagEntity: flagStore.createEntity, + setVusList: addMutationModalStore.setVusList, + setMutationToEdit: addMutationModalStore.setMutationToEdit, + alterationStates: addMutationModalStore.alterationStates, + mutationToEdit: addMutationModalStore.mutationToEdit, + setShowModifyExonForm: addMutationModalStore.setShowModifyExonForm, + showModifyExonForm: addMutationModalStore.showModifyExonForm, + isFetchingAlteration: addMutationModalStore.isFetchingAlteration, + isFetchingExcludingAlteration: addMutationModalStore.isFetchingExcludingAlteration, + currentMutationNames: addMutationModalStore.currentMutationNames, + cleanup: addMutationModalStore.cleanup, + filterAlterationsAndNotify: addMutationModalStore.filterAlterationsAndNotify, + fetchAlterations: addMutationModalStore.fetchAlterations, + setAlterationStates: addMutationModalStore.setAlterationStates, + selectedAlterationCategoryFlags: addMutationModalStore.selectedAlterationCategoryFlags, + alterationCategoryComment: addMutationModalStore.alterationCategoryComment, + setGeneEntity: addMutationModalStore.setGeneEntity, + updateAlterationStateAfterAlterationAdded: addMutationModalStore.updateAlterationStateAfterAlterationAdded, + selectedAlterationStateIndex: addMutationModalStore.selectedAlterationStateIndex, + hasUncommitedExonFormChanges: addMutationModalStore.hasUncommitedExonFormChanges, + unCommittedExonFormChangesWarning: addMutationModalStore.unCommittedExonFormChangesWarning, + setProteinExons: addMutationModalStore.setProteinExons, + proteinExons: addMutationModalStore.proteinExons, +}); -type DropdownOption = { - label: string; - value: any; -}; -interface IAddMutationModalDropdownProps { - label: string; - value: DropdownOption; - options: DropdownOption[]; - menuPlacement?: MenuPlacement; - onChange: (newValue: DropdownOption | null) => void; -} +type StoreProps = Partial>; -function AddMutationModalDropdown({ label, value, options, menuPlacement, onChange }: IAddMutationModalDropdownProps) { - return ( -
- - {label} - - - - -
- ); -} +export default componentInject(mapStoreToProps)(AddMutationModal); const AddMutationInputOverlay = () => { return ( @@ -1113,7 +497,7 @@ const AddMutationInputOverlay = () => { Add button to annotate alteration(s).
-
Supported inputs:
+
String Mutation:
  • @@ -1132,24 +516,3 @@ const AddMutationInputOverlay = () => {
); }; - -const mapStoreToProps = ({ - alterationStore, - consequenceStore, - geneStore, - firebaseAppStore, - firebaseVusStore, - firebaseMutationListStore, -}: IRootStore) => ({ - annotateAlterations: flow(alterationStore.annotateAlterations), - geneEntities: geneStore.entities, - consequences: consequenceStore.entities, - getConsequences: consequenceStore.getEntities, - firebaseDb: firebaseAppStore.firebaseDb, - vusList: firebaseVusStore.data, - mutationList: firebaseMutationListStore.data, -}); - -type StoreProps = Partial>; - -export default componentInject(mapStoreToProps)(AddMutationModal); diff --git a/src/main/webapp/app/shared/modal/DefaultAddMutationModal.tsx b/src/main/webapp/app/shared/modal/DefaultAddMutationModal.tsx index a64456c66..0a0c0da98 100644 --- a/src/main/webapp/app/shared/modal/DefaultAddMutationModal.tsx +++ b/src/main/webapp/app/shared/modal/DefaultAddMutationModal.tsx @@ -18,7 +18,7 @@ export interface IDefaultAddMutationModal { export const DefaultAddMutationModal = (props: IDefaultAddMutationModal) => { return ( - + {props.modalHeader ? {props.modalHeader} : undefined}
{props.modalBody}
diff --git a/src/main/webapp/app/shared/modal/MutationModal/AddMutationModalDropdown.tsx b/src/main/webapp/app/shared/modal/MutationModal/AddMutationModalDropdown.tsx new file mode 100644 index 000000000..9df7a0301 --- /dev/null +++ b/src/main/webapp/app/shared/modal/MutationModal/AddMutationModalDropdown.tsx @@ -0,0 +1,31 @@ +import React from 'react'; +import ReactSelect, { MenuPlacement } from 'react-select'; +import { Col } from 'reactstrap'; + +export type DropdownOption = { + label: string; + value: any; +}; + +export interface IAddMutationModalDropdownProps { + label: string; + value: DropdownOption; + options: DropdownOption[]; + menuPlacement?: MenuPlacement; + onChange: (newValue: DropdownOption | null) => void; +} + +const AddMutationModalDropdown = ({ label, value, options, menuPlacement, onChange }: IAddMutationModalDropdownProps) => { + return ( +
+ + {label} + + + + +
+ ); +}; + +export default AddMutationModalDropdown; diff --git a/src/main/webapp/app/shared/modal/MutationModal/AddMutationModalField.tsx b/src/main/webapp/app/shared/modal/MutationModal/AddMutationModalField.tsx new file mode 100644 index 000000000..e19fe5fff --- /dev/null +++ b/src/main/webapp/app/shared/modal/MutationModal/AddMutationModalField.tsx @@ -0,0 +1,47 @@ +import React from 'react'; +import { useTextareaAutoHeight } from 'app/hooks/useTextareaAutoHeight'; +import { useRef } from 'react'; +import { Col, Row, Spinner } from 'reactstrap'; +import classNames from 'classnames'; +import { InputType } from 'reactstrap/types/lib/Input'; +import { Input } from 'reactstrap'; + +interface IAddMutationModalFieldProps { + label: string; + value: string; + placeholder: string; + onChange: (newValue: string) => void; + isLoading?: boolean; + disabled?: boolean; + type?: InputType; +} + +const AddMutationModalField = ({ label, value: value, placeholder, onChange, isLoading, disabled, type }: IAddMutationModalFieldProps) => { + const inputRef = useRef(null); + + useTextareaAutoHeight(inputRef, type); + + return ( + + + {label} + {isLoading && } + + + { + onChange(event.target.value); + }} + placeholder={placeholder} + type={type} + className={classNames(type === 'textarea' ? 'alteration-modal-textarea-field' : undefined)} + /> + + + ); +}; + +export default AddMutationModalField; diff --git a/src/main/webapp/app/shared/modal/MutationModal/AlterationBadgeList.tsx b/src/main/webapp/app/shared/modal/MutationModal/AlterationBadgeList.tsx new file mode 100644 index 000000000..2dedf1c34 --- /dev/null +++ b/src/main/webapp/app/shared/modal/MutationModal/AlterationBadgeList.tsx @@ -0,0 +1,208 @@ +import DefaultTooltip from 'app/shared/tooltip/DefaultTooltip'; +import classNames from 'classnames'; +import React, { useMemo, useRef } from 'react'; +import * as styles from './styles.module.scss'; +import { faXmark } from '@fortawesome/free-solid-svg-icons'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { AlterationData } from '../AddMutationModal'; +import { getFullAlterationName } from 'app/shared/util/utils'; +import { IRootStore } from 'app/stores'; +import { componentInject } from 'app/shared/util/typed-inject'; +import { FaExclamationCircle, FaExclamationTriangle } from 'react-icons/fa'; +import { useOverflowDetector } from 'app/hooks/useOverflowDetector'; +import { BS_BORDER_COLOR } from 'app/config/colors'; +import _ from 'lodash'; +import { FaCircleCheck } from 'react-icons/fa6'; +import { ADD_MUTATION_MODAL_EXCLUDED_ALTERATION_INPUT_ID } from 'app/config/constants/html-id'; +import { AddMutationModalDataTestIdType, getAddMutationModalDataTestId } from 'app/shared/util/test-id-utils'; + +export interface IAlterationBadgeList extends StoreProps { + isExclusionList?: boolean; + showInput?: boolean; + inputValue?: string; + onInputChange?: (newValue: string) => void; + onKeyDown?: (event: React.KeyboardEvent) => void; +} + +const AlterationBadgeList = ({ + alterationStates, + setAlterationStates, + selectedAlterationStateIndex, + setSelectedAlterationStateIndex, + selectedExcludedAlterationIndex, + setSelectedExcludedAlterationIndex, + onInputChange, + inputValue, + isExclusionList = false, + showInput = false, +}: IAlterationBadgeList) => { + const inputRef = useRef(null); + if (alterationStates === undefined || selectedAlterationStateIndex === undefined) return <>; + + const alterationList = isExclusionList ? alterationStates[selectedAlterationStateIndex].excluding : alterationStates; + + const handleAlterationDelete = (value: AlterationData) => { + const filteredAlterationList = alterationList?.filter( + alterationState => getFullAlterationName(value) !== getFullAlterationName(alterationState), + ); + if (!isExclusionList) { + setAlterationStates?.(filteredAlterationList); + } else { + const newAlterationStates = _.cloneDeep(alterationStates); + newAlterationStates[selectedAlterationStateIndex].excluding = newAlterationStates[selectedAlterationStateIndex].excluding.filter( + state => getFullAlterationName(value) !== getFullAlterationName(state), + ); + setAlterationStates?.(newAlterationStates); + } + }; + + const handleAlterationClick = (index: number) => { + const currentIndex = isExclusionList ? selectedExcludedAlterationIndex : selectedAlterationStateIndex; + if (currentIndex === index) { + index = -1; + } + isExclusionList ? setSelectedExcludedAlterationIndex?.(index) : setSelectedAlterationStateIndex?.(index); + }; + + return ( +
inputRef?.current?.focus()} + > + {alterationList?.map((value, index) => { + const fullAlterationName = getFullAlterationName(value, true); + return ( + handleAlterationClick(index)} + onDelete={() => handleAlterationDelete(value)} + isExcludedAlteration={isExclusionList} + /> + ); + })} + {showInput && ( +
+ onInputChange?.(event.target.value)} + placeholder={alterationList.length > 0 ? undefined : 'Enter alteration(s)'} + value={inputValue} + > +
+ )} +
+ ); +}; + +const mapStoreToProps = ({ addMutationModalStore }: IRootStore) => ({ + alterationStates: addMutationModalStore.alterationStates, + setAlterationStates: addMutationModalStore.setAlterationStates, + selectedAlterationStateIndex: addMutationModalStore.selectedAlterationStateIndex, + setSelectedAlterationStateIndex: addMutationModalStore.setSelectedAlterationStateIndex, + selectedExcludedAlterationIndex: addMutationModalStore.selectedExcludedAlterationIndex, + setSelectedExcludedAlterationIndex: addMutationModalStore.setSelectedExcludedAlterationIndex, +}); + +type StoreProps = Partial>; + +export default componentInject(mapStoreToProps)(AlterationBadgeList); + +interface IAlterationBadge { + alterationData: AlterationData; + alterationName: string; + isSelected: boolean; + onClick: () => void; + onDelete: () => void; + isExcludedAlteration?: boolean; +} + +const AlterationBadge = ({ + alterationData, + alterationName, + isSelected, + onClick, + onDelete, + isExcludedAlteration = false, +}: IAlterationBadge) => { + const [isOverflow, ref] = useOverflowDetector({ detectHeight: false }); + + const backgroundColor = useMemo(() => { + if (alterationData.error) { + return 'danger'; + } + if (alterationData.warning) { + return 'warning'; + } + if (isExcludedAlteration) { + return 'secondary'; + } + return 'success'; + }, [alterationData, isExcludedAlteration]); + + const statusIcon = useMemo(() => { + let icon = ; + if (alterationData.error) { + icon = ; + } + if (alterationData.warning) { + icon = ; + } + return
{icon}
; + }, [alterationData]); + + const badgeComponent = ( +
+
{ + event.stopPropagation(); + onClick(); + }} + > + {statusIcon} +
+ {alterationName} +
+
+
+ +
+
+ ); + + if (isOverflow) { + return ( + + {badgeComponent} + + ); + } + + return badgeComponent; +}; diff --git a/src/main/webapp/app/shared/modal/MutationModal/AlterationCategoryInputs.tsx b/src/main/webapp/app/shared/modal/MutationModal/AlterationCategoryInputs.tsx new file mode 100644 index 000000000..62e6d1b36 --- /dev/null +++ b/src/main/webapp/app/shared/modal/MutationModal/AlterationCategoryInputs.tsx @@ -0,0 +1,117 @@ +import React, { useEffect, useMemo, useState } from 'react'; +import CreatableSelect from 'react-select/creatable'; +import { Col, Row } from 'reactstrap'; +import { IFlag } from 'app/shared/model/flag.model'; +import { FlagTypeEnum } from 'app/shared/model/enumerations/flag-type.enum.model'; +import { AlterationCategories } from 'app/shared/model/firebase/firebase.model'; +import { isFlagEqualToIFlag } from 'app/shared/util/firebase/firebase-utils'; +import { componentInject } from 'app/shared/util/typed-inject'; +import { IRootStore } from 'app/stores'; +import { DropdownOption } from './AddMutationModalDropdown'; +import { ADD_MUTATION_MODAL_FLAG_DROPDOWN_ID } from 'app/config/constants/html-id'; + +const AlterationCategoryInputs = ({ + getFlagsByType, + alterationCategoryFlagEntities, + mutationToEdit, + setSelectedAlterationCategoryFlags, + selectedAlterationCategoryFlags, + setAlterationCategoryComment, +}: StoreProps) => { + const [alterationCategories, setAlterationCategories] = useState(null); + + useEffect(() => { + getFlagsByType?.(FlagTypeEnum.ALTERATION_CATEGORY); + + setAlterationCategories(mutationToEdit?.alteration_categories ?? null); + }, [mutationToEdit]); + + useEffect(() => { + if (alterationCategoryFlagEntities) { + setSelectedAlterationCategoryFlags?.( + alterationCategories?.flags?.reduce((acc: IFlag[], flag) => { + const matchedFlag = alterationCategoryFlagEntities.find(flagEntity => isFlagEqualToIFlag(flag, flagEntity)); + + if (matchedFlag) { + acc.push(matchedFlag); + } + + return acc; + }, []) ?? [], + ); + } + setAlterationCategoryComment?.(alterationCategories?.comment ?? ''); + }, [alterationCategories, alterationCategoryFlagEntities]); + + const flagDropdownOptions = useMemo(() => { + if (!alterationCategoryFlagEntities) return []; + return alterationCategoryFlagEntities.map(flag => ({ label: flag.name, value: flag })); + }, [alterationCategoryFlagEntities]); + + const handleMutationFlagAdded = (newFlagName: string) => { + // The flag name entered by user can be converted to flag by remove any non alphanumeric characters + const newFlagFlag = newFlagName + .replace(/[^a-zA-Z0-9\s]/g, ' ') + .replace(/\s+/g, '_') + .toUpperCase(); + const newSelectedFlag: Omit = { + type: FlagTypeEnum.ALTERATION_CATEGORY, + flag: newFlagFlag, + name: newFlagName, + description: '', + alterations: null, + articles: null, + drugs: null, + genes: null, + transcripts: null, + }; + setSelectedAlterationCategoryFlags?.([...(selectedAlterationCategoryFlags ?? []), newSelectedFlag]); + }; + + const handleAlterationCategoriesField = (field: keyof AlterationCategories, value: unknown) => { + if (field === 'comment') { + setAlterationCategoryComment?.(value as string); + } else if (field === 'flags') { + const flagOptions = value as DropdownOption[]; + setSelectedAlterationCategoryFlags?.(flagOptions.map(option => option.value)); + } + }; + + return ( + <> + + +
+ + String Name + + + handleAlterationCategoriesField('flags', newFlags)} + onCreateOption={handleMutationFlagAdded} + value={selectedAlterationCategoryFlags?.map(newFlag => ({ label: newFlag.name, value: newFlag }))} + /> + +
+ +
+ + ); +}; + +const mapStoreToProps = ({ flagStore, addMutationModalStore }: IRootStore) => ({ + getFlagsByType: flagStore.getFlagsByType, + createFlagEntity: flagStore.createEntity, + alterationCategoryFlagEntities: flagStore.alterationCategoryFlags, + mutationToEdit: addMutationModalStore.mutationToEdit, + setSelectedAlterationCategoryFlags: addMutationModalStore.setSelectedAlterationCategoryFlags, + selectedAlterationCategoryFlags: addMutationModalStore.selectedAlterationCategoryFlags, + setAlterationCategoryComment: addMutationModalStore.setAlterationCategoryComment, +}); + +type StoreProps = Partial>; + +export default componentInject(mapStoreToProps)(AlterationCategoryInputs); diff --git a/src/main/webapp/app/shared/modal/MutationModal/AnnotatedAlterationErrorContent.tsx b/src/main/webapp/app/shared/modal/MutationModal/AnnotatedAlterationErrorContent.tsx new file mode 100644 index 000000000..cf7a30b11 --- /dev/null +++ b/src/main/webapp/app/shared/modal/MutationModal/AnnotatedAlterationErrorContent.tsx @@ -0,0 +1,69 @@ +import React from 'react'; +import { AlterationData } from '../AddMutationModal'; +import { IRootStore } from 'app/stores'; +import { componentInject } from 'app/shared/util/typed-inject'; +import { Alert, Button } from 'reactstrap'; +import _ from 'lodash'; + +const ERROR_SUGGGESTION_REGEX = new RegExp('The alteration name is invalid, do you mean (.+)\\?'); + +export interface IAnnotatedAlterationErrorContent extends StoreProps { + alterationData: AlterationData; + alterationIndex: number; + excludingIndex?: number; + declineSuggestionCallback?: () => void; +} + +const AnnotatedAlterationErrorContent = ({ + alterationData, + alterationIndex, + excludingIndex, + declineSuggestionCallback, + addMutationModalStore, +}: IAnnotatedAlterationErrorContent) => { + const suggestion = ERROR_SUGGGESTION_REGEX.exec(alterationData.error ?? '')?.[1]; + + function handleNoClick() { + const newAlterationStates = _.cloneDeep(addMutationModalStore?.alterationStates ?? []); + if (!_.isNil(excludingIndex)) { + newAlterationStates[alterationIndex].excluding.splice(excludingIndex, 1); + } else { + newAlterationStates.splice(alterationIndex, 1); + } + addMutationModalStore?.setAlterationStates(newAlterationStates); + + declineSuggestionCallback?.(); + } + + function handleYesClick() { + if (!suggestion) return; + const newAlterationData = _.cloneDeep(alterationData); + newAlterationData.alteration = suggestion; + } + + return ( +
+ + {alterationData.error} + + {suggestion && ( +
+ + +
+ )} +
+ ); +}; + +const mapStoreToProps = ({ addMutationModalStore }: IRootStore) => ({ + addMutationModalStore, +}); + +type StoreProps = Partial>; + +export default componentInject(mapStoreToProps)(AnnotatedAlterationErrorContent); diff --git a/src/main/webapp/app/shared/modal/MutationModal/ExcludedAlterationContent.tsx b/src/main/webapp/app/shared/modal/MutationModal/ExcludedAlterationContent.tsx new file mode 100644 index 000000000..05971899e --- /dev/null +++ b/src/main/webapp/app/shared/modal/MutationModal/ExcludedAlterationContent.tsx @@ -0,0 +1,92 @@ +import { IRootStore } from 'app/stores'; +import React, { useState } from 'react'; +import { componentInject } from '../../util/typed-inject'; +import { FaPlus } from 'react-icons/fa'; +import { Button, Col, Row } from 'reactstrap'; +import { parseAlterationName } from '../../util/utils'; +import MutationDetails from './MutationDetails'; +import _ from 'lodash'; +import AlterationBadgeList from './AlterationBadgeList'; +import { ADD_MUTATION_MODAL_ADD_EXCLUDED_ALTERATION_BUTTON_ID } from 'app/config/constants/html-id'; + +export interface IExcludedAlterationContent extends StoreProps {} + +const ExcludedAlterationContent = ({ + alterationStates, + selectedAlterationStateIndex, + updateAlterationStateAfterExcludedAlterationAdded, + selectedExcludedAlterationIndex, +}: IExcludedAlterationContent) => { + const [excludingCollapsed, setExcludingCollapsed] = useState(false); + const [excludingInputValue, setExcludingInputValue] = useState(''); + + if (alterationStates === undefined || selectedAlterationStateIndex === undefined) return <>; + + const handleAlterationAddedExcluding = () => { + updateAlterationStateAfterExcludedAlterationAdded?.(parseAlterationName(excludingInputValue)); + setExcludingInputValue(''); + }; + + const handleKeyDownExcluding = (event: React.KeyboardEvent) => { + if (!excludingInputValue) return; + if (event.key === 'Enter' || event.key === 'tab') { + handleAlterationAddedExcluding(); + event.preventDefault(); + } + }; + + const isSectionEmpty = alterationStates[selectedAlterationStateIndex].excluding.length === 0; + + return ( + <> +
+ + Excluding + + + setExcludingInputValue(newValue)} + onKeyDown={handleKeyDownExcluding} + /> + + + + +
+ {!isSectionEmpty && !excludingCollapsed && selectedExcludedAlterationIndex !== undefined && ( + + + + + + )} + + ); +}; + +const mapStoreToProps = ({ addMutationModalStore }: IRootStore) => ({ + alterationStates: addMutationModalStore.alterationStates, + selectedAlterationStateIndex: addMutationModalStore.selectedAlterationStateIndex, + handleExcludingFieldChange: addMutationModalStore.handleExcludingFieldChange, + isFetchingExcludingAlteration: addMutationModalStore.isFetchingExcludingAlteration, + updateAlterationStateAfterExcludedAlterationAdded: addMutationModalStore.updateAlterationStateAfterExcludedAlterationAdded, + setAlterationStates: addMutationModalStore.setAlterationStates, + selectedExcludedAlterationIndex: addMutationModalStore.selectedExcludedAlterationIndex, +}); + +type StoreProps = Partial>; + +export default componentInject(mapStoreToProps)(ExcludedAlterationContent); diff --git a/src/main/webapp/app/shared/modal/MutationModal/MutationDetails.tsx b/src/main/webapp/app/shared/modal/MutationModal/MutationDetails.tsx new file mode 100644 index 000000000..4158c2abb --- /dev/null +++ b/src/main/webapp/app/shared/modal/MutationModal/MutationDetails.tsx @@ -0,0 +1,269 @@ +import React, { useEffect } from 'react'; +import { AlterationData } from '../AddMutationModal'; +import { AlterationTypeEnum } from 'app/shared/api/generated/curation'; +import AddMutationModalField from './AddMutationModalField'; +import AddMutationModalDropdown, { DropdownOption } from './AddMutationModalDropdown'; +import { componentInject } from 'app/shared/util/typed-inject'; +import { IRootStore } from 'app/stores'; +import _ from 'lodash'; +import { Alert } from 'reactstrap'; +import { READABLE_ALTERATION } from 'app/config/constants/constants'; +import { getFullAlterationName, getMutationRenameValueFromName } from 'app/shared/util/utils'; +import AnnotatedAlterationErrorContent from './AnnotatedAlterationErrorContent'; +import { AddMutationModalDataTestIdType, getAddMutationModalDataTestId } from 'app/shared/util/test-id-utils'; + +const ALTERATION_TYPE_OPTIONS: DropdownOption[] = [ + AlterationTypeEnum.ProteinChange, + AlterationTypeEnum.CopyNumberAlteration, + AlterationTypeEnum.StructuralVariant, + AlterationTypeEnum.CdnaChange, + AlterationTypeEnum.GenomicChange, + AlterationTypeEnum.Any, +].map(type => ({ label: READABLE_ALTERATION[type], value: type })); + +export interface IMutationDetails extends StoreProps { + alterationData: AlterationData; + excludingIndex?: number; +} + +const MutationDetails = ({ + alterationData, + excludingIndex, + getConsequences, + consequences, + selectedAlterationStateIndex, + handleExcludingFieldChange, + handleNormalFieldChange, + isFetchingAlteration, + isFetchingExcludingAlteration, + handleAlterationChange, +}: IMutationDetails) => { + useEffect(() => { + getConsequences?.({}); + }, []); + + const consequenceOptions: DropdownOption[] = + consequences?.map((consequence): DropdownOption => ({ label: consequence.name, value: consequence.id })) ?? []; + + if (alterationData === undefined || selectedAlterationStateIndex === undefined) return <>; + + const alterationName = getMutationRenameValueFromName(alterationData.name) ?? ''; + + const getProteinChangeContent = () => { + return ( +
+ handleFieldChange(newValue, 'name')} + /> + handleFieldChange(newValue, 'proteinChange')} + /> + handleFieldChange(newValue, 'proteinStart')} + /> + handleFieldChange(newValue, 'proteinEnd')} + /> + handleFieldChange(newValue, 'refResidues')} + /> + handleFieldChange(newValue, 'varResidues')} + /> + option.label === alterationData.consequence) ?? { label: '', value: undefined }} + options={consequenceOptions} + menuPlacement="top" + onChange={newValue => handleFieldChange(newValue?.label ?? '', 'consequence')} + /> +
+ ); + }; + + const getCdnaChangeContent = () => { + return ( +
+ handleFieldChange(newValue, 'name')} + /> + handleFieldChange(newValue, 'proteinChange')} + /> +
+ ); + }; + + const getGenomicChangeContent = () => { + return ( +
+ handleFieldChange(newValue, 'name')} + /> +
+ ); + }; + + const getCopyNumberAlterationContent = () => { + return ( +
+ handleFieldChange(newValue, 'name')} + /> +
+ ); + }; + + const getStructuralVariantContent = () => { + return ( +
+ handleFieldChange(newValue, 'name')} + /> + gene.hugoSymbol).join(', ') ?? ''} + placeholder="Input genes" + disabled + onChange={newValue => handleFieldChange(newValue, 'genes')} + /> +
+ ); + }; + + const getOtherContent = () => { + return ( +
+ handleFieldChange(newValue, 'name')} + /> +
+ ); + }; + + const handleFieldChange = (newValue: string, field: keyof AlterationData) => { + !_.isNil(excludingIndex) + ? handleExcludingFieldChange?.(newValue, field) + : handleNormalFieldChange?.(newValue, field, selectedAlterationStateIndex); + }; + + let content: JSX.Element; + + switch (alterationData.type) { + case AlterationTypeEnum.ProteinChange: + content = getProteinChangeContent(); + break; + case AlterationTypeEnum.CopyNumberAlteration: + content = getCopyNumberAlterationContent(); + break; + case AlterationTypeEnum.CdnaChange: + content = getCdnaChangeContent(); + break; + case AlterationTypeEnum.GenomicChange: + content = getGenomicChangeContent(); + break; + case AlterationTypeEnum.StructuralVariant: + content = getStructuralVariantContent(); + break; + default: + content = getOtherContent(); + break; + } + + if (alterationData.error) { + return ( + + ); + } + + return ( +
+
{excludingIndex !== undefined && excludingIndex > -1 ? 'Excluded Mutation Details' : 'Mutation Details'}
+ {alterationData.warning && ( + + {alterationData.warning} + + )} + option.value === alterationData.type) ?? { label: '', value: undefined }} + onChange={newValue => handleFieldChange(newValue?.value, 'type')} + /> + handleAlterationChange?.(newValue, selectedAlterationStateIndex, excludingIndex)} + /> + {content} + handleFieldChange(newValue, 'comment')} + /> +
+ ); +}; + +const mapStoreToProps = ({ consequenceStore, addMutationModalStore }: IRootStore) => ({ + consequences: consequenceStore.entities, + getConsequences: consequenceStore.getEntities, + alterationStates: addMutationModalStore.alterationStates, + selectedAlterationStateIndex: addMutationModalStore.selectedAlterationStateIndex, + handleExcludingFieldChange: addMutationModalStore.handleExcludingFieldChange, + handleNormalFieldChange: addMutationModalStore.handleNormalFieldChange, + isFetchingAlteration: addMutationModalStore.isFetchingAlteration, + isFetchingExcludingAlteration: addMutationModalStore.isFetchingExcludingAlteration, + handleAlterationChange: addMutationModalStore.handleAlterationChange, +}); + +type StoreProps = Partial>; + +export default componentInject(mapStoreToProps)(MutationDetails); diff --git a/src/main/webapp/app/shared/modal/MutationModal/MutationListSection.tsx b/src/main/webapp/app/shared/modal/MutationModal/MutationListSection.tsx new file mode 100644 index 000000000..ad7f7ca0c --- /dev/null +++ b/src/main/webapp/app/shared/modal/MutationModal/MutationListSection.tsx @@ -0,0 +1,81 @@ +import React, { useMemo } from 'react'; +import { IRootStore } from 'app/stores'; +import { componentInject } from '../../util/typed-inject'; +import { getFullAlterationName } from '../../util/utils'; +import DefaultTooltip from '../../tooltip/DefaultTooltip'; +import { Input } from 'reactstrap'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { faComment as farComment } from '@fortawesome/free-regular-svg-icons'; +import { faComment as fasComment } from '@fortawesome/free-solid-svg-icons'; +import AlterationCategoryInputs from './AlterationCategoryInputs'; +import AlterationBadgeList from './AlterationBadgeList'; +import { ADD_MUTATION_MODAL_FLAG_COMMENT_ID, ADD_MUTATION_MODAL_FLAG_COMMENT_INPUT_ID } from 'app/config/constants/html-id'; + +const MutationListSection = ({ + alterationStates, + alterationCategoryComment, + setAlterationCategoryComment, + selectedAlterationCategoryFlags, +}: StoreProps) => { + const showAlterationCategoryDropdown = (alterationStates ?? []).length > 1; + const showAlterationCategoryComment = showAlterationCategoryDropdown && (selectedAlterationCategoryFlags ?? []).length > 0; + + const finalMutationName = useMemo(() => { + return alterationStates + ?.map(alterationState => { + const altName = getFullAlterationName(alterationState, true); + return altName; + }) + .join(', '); + }, [alterationStates]); + + return ( + <> +
+
Current Mutation List
+ {showAlterationCategoryComment && ( +
+ setAlterationCategoryComment?.(event.target.value)} + /> + } + > + + +
+ )} +
+
+ {showAlterationCategoryDropdown && } + +
Name preview: {finalMutationName}
+
+ + ); +}; + +const mapStoreToProps = ({ addMutationModalStore }: IRootStore) => ({ + alterationStates: addMutationModalStore.alterationStates, + setShowModifyExonForm: addMutationModalStore.setShowModifyExonForm, + alterationCategoryComment: addMutationModalStore.alterationCategoryComment, + selectedAlterationCategoryFlags: addMutationModalStore.selectedAlterationCategoryFlags, + setAlterationStates: addMutationModalStore.setAlterationStates, + setAlterationCategoryComment: addMutationModalStore.setAlterationCategoryComment, +}); + +type StoreProps = Partial>; + +export default componentInject(mapStoreToProps)(MutationListSection); diff --git a/src/main/webapp/app/shared/modal/MutationModal/add-mutation-modal.store.ts b/src/main/webapp/app/shared/modal/MutationModal/add-mutation-modal.store.ts new file mode 100644 index 000000000..369823969 --- /dev/null +++ b/src/main/webapp/app/shared/modal/MutationModal/add-mutation-modal.store.ts @@ -0,0 +1,440 @@ +import { Mutation, VusObjList } from 'app/shared/model/firebase/firebase.model'; +import { action, computed, flow, flowResult, makeObservable, observable } from 'mobx'; +import { convertEntityStatusAlterationToAlterationData, getFullAlterationName, hasValue, parseAlterationName } from 'app/shared/util/utils'; +import _ from 'lodash'; +import { notifyError } from 'app/oncokb-commons/components/util/NotificationUtils'; +import { + AlterationAnnotationStatus, + AnnotateAlterationBody, + Gene, + Alteration as ApiAlteration, + ProteinExonDTO, +} from 'app/shared/api/generated/curation'; +import { REFERENCE_GENOME } from 'app/config/constants/constants'; +import AlterationStore from 'app/entities/alteration/alteration.store'; +import { IGene } from 'app/shared/model/gene.model'; +import { IFlag } from 'app/shared/model/flag.model'; +import { AlterationData } from '../AddMutationModal'; + +type SelectedFlag = IFlag | Omit; + +export class AddMutationModalStore { + private alterationStore: AlterationStore; + + public proteinExons: ProteinExonDTO[] = []; + public geneEntity: IGene | null = null; + public mutationToEdit: Mutation | null = null; + public vusList: VusObjList | null = null; + public alterationStates: AlterationData[] = []; + + public selectedAlterationStateIndex = -1; + public selectedExcludedAlterationIndex = -1; + + public showModifyExonForm = false; + public hasUncommitedExonFormChanges = false; + public unCommittedExonFormChangesWarning = ''; + + public isFetchingAlteration = false; + public isFetchingExcludingAlteration = false; + + public selectedAlterationCategoryFlags: SelectedFlag[] = []; + public alterationCategoryComment: string = ''; + + constructor(alterationStore: AlterationStore) { + this.alterationStore = alterationStore; + makeObservable(this, { + proteinExons: observable, + geneEntity: observable, + mutationToEdit: observable, + vusList: observable, + alterationStates: observable, + selectedAlterationStateIndex: observable, + selectedExcludedAlterationIndex: observable, + showModifyExonForm: observable, + hasUncommitedExonFormChanges: observable, + unCommittedExonFormChangesWarning: observable, + isFetchingAlteration: observable, + isFetchingExcludingAlteration: observable, + selectedAlterationCategoryFlags: observable, + alterationCategoryComment: observable, + currentMutationNames: computed, + updateAlterationStateAfterAlterationAdded: action.bound, + updateAlterationStateAfterExcludedAlterationAdded: action.bound, + setMutationToEdit: action.bound, + setVusList: action.bound, + setGeneEntity: action.bound, + setShowModifyExonForm: action.bound, + setHasUncommitedExonFormChanges: action.bound, + setAlterationStates: action.bound, + setSelectedAlterationStateIndex: action.bound, + setSelectedExcludedAlterationIndex: action.bound, + setSelectedAlterationCategoryFlags: action.bound, + setAlterationCategoryComment: action.bound, + handleAlterationChange: action.bound, + handleExcludedAlterationChange: action.bound, + handleExcludingFieldChange: action.bound, + fetchExcludedAlteration: action.bound, + handleNormalAlterationChange: action.bound, + handleNormalFieldChange: action.bound, + fetchNormalAlteration: action.bound, + filterAlterationsAndNotify: action.bound, + fetchAlteration: action.bound, + fetchAlterations: action.bound, + cleanup: action.bound, + setProteinExons: action.bound, + }); + } + + setProteinExons(proteinExons: ProteinExonDTO[]) { + this.proteinExons = proteinExons; + } + + setMutationToEdit(mutationToEdit: Mutation | null) { + this.mutationToEdit = mutationToEdit; + } + + setVusList(vusList: VusObjList | null) { + this.vusList = vusList; + } + + setGeneEntity(geneEntity: IGene | null) { + this.geneEntity = geneEntity; + } + + setShowModifyExonForm(show: boolean) { + this.showModifyExonForm = show; + this.selectedAlterationStateIndex = -1; + } + + setHasUncommitedExonFormChanges(value: boolean, isUpdate: boolean) { + this.hasUncommitedExonFormChanges = value; + if (value) { + this.unCommittedExonFormChangesWarning = `You made some changes to Exon dropdown. Please click ${isUpdate ? 'update' : 'add'} button.`; + } + } + + setAlterationStates(newAlterationStates: AlterationData[]) { + this.alterationStates = newAlterationStates; + } + + setSelectedAlterationStateIndex(index: number) { + this.selectedAlterationStateIndex = index; + } + + setSelectedExcludedAlterationIndex(index: number) { + this.selectedExcludedAlterationIndex = index; + } + + setSelectedAlterationCategoryFlags(flags: SelectedFlag[]) { + this.selectedAlterationCategoryFlags = flags; + } + + setAlterationCategoryComment(comment: string) { + this.alterationCategoryComment = comment; + } + + get currentMutationNames() { + return this.alterationStates.map(state => getFullAlterationName({ ...state, comment: '' }).toLowerCase()).sort(); + } + + async updateAlterationStateAfterAlterationAdded(parsedAlterations: ReturnType, isUpdate = false) { + const newParsedAlteration = this.filterAlterationsAndNotify(parsedAlterations) ?? []; + + if (newParsedAlteration.length === 0) { + return; + } + + const newEntityStatusAlterationsPromise = this.fetchAlterations(newParsedAlteration.map(alt => alt.alteration)) ?? []; + const newEntityStatusExcludingAlterationsPromise = this.fetchAlterations(newParsedAlteration[0].excluding) ?? []; + const [newEntityStatusAlterations, newEntityStatusExcludingAlterations] = await Promise.all([ + newEntityStatusAlterationsPromise, + newEntityStatusExcludingAlterationsPromise, + ]); + + const newExcludingAlterations = newEntityStatusExcludingAlterations.map((alt, index) => + convertEntityStatusAlterationToAlterationData(alt, [], ''), + ); + const newAlterations = newEntityStatusAlterations.map((alt, index) => + convertEntityStatusAlterationToAlterationData( + alt, + _.cloneDeep(newExcludingAlterations), + newParsedAlteration[index].comment, + newParsedAlteration[index].name, + ), + ); + + if (isUpdate) { + const newAlterationStates = _.cloneDeep(this.alterationStates); + newAlterationStates[this.selectedAlterationStateIndex] = newAlterations[0]; + this.alterationStates = newAlterationStates; + } else { + this.alterationStates = this.alterationStates.concat(newAlterations); + } + } + + async updateAlterationStateAfterExcludedAlterationAdded(parsedAlterations: ReturnType) { + const currentState = this.alterationStates[this.selectedAlterationStateIndex]; + const alteration = currentState.alteration.toLowerCase(); + let excluding = currentState.excluding.map(ex => ex.alteration.toLowerCase()); + excluding.push(...parsedAlterations.map(alt => alt.alteration.toLowerCase())); + excluding = excluding.sort(); + + if ( + this.alterationStates.some( + state => + state.alteration.toLowerCase() === alteration && + _.isEqual(state.excluding.map(ex => ex.alteration.toLowerCase()).sort(), excluding), + ) + ) { + notifyError(new Error('Duplicate alteration(s) removed')); + return; + } + + const newComment = parsedAlterations[0].comment; + const newVariantName = parsedAlterations[0].name; + + const newEntityStatusAlterations = await this.fetchAlterations(parsedAlterations.map(alt => alt.alteration)); + + const newAlterations = newEntityStatusAlterations.map((alt, index) => + convertEntityStatusAlterationToAlterationData(alt, [], newComment, newVariantName), + ); + + const newAlterationStates = _.cloneDeep(this.alterationStates); + newAlterationStates[this.selectedAlterationStateIndex].excluding.push(...newAlterations); + this.alterationStates = newAlterationStates; + } + + async handleAlterationChange(newValue: string, alterationIndex: number, excludingIndex?: number, isDebounced = true) { + if (!_.isNil(excludingIndex)) { + this.isFetchingExcludingAlteration = true; + + if (isDebounced) { + this.handleExcludedAlterationChange(newValue); + } else { + await this.fetchExcludedAlteration(newValue); + this.isFetchingExcludingAlteration = false; + } + } else { + this.isFetchingAlteration = true; + if (isDebounced) { + this.handleNormalAlterationChange(newValue, alterationIndex); + } else { + await this.fetchNormalAlteration(newValue, alterationIndex); + this.isFetchingAlteration = false; + } + } + } + + handleExcludingFieldChange(newValue: string, field: keyof AlterationData) { + const newAlterationStates = _.cloneDeep(this.alterationStates); + newAlterationStates[this.selectedAlterationStateIndex].excluding[this.selectedExcludedAlterationIndex][field as string] = newValue; + this.alterationStates = newAlterationStates; + } + + async fetchExcludedAlteration(newAlteration: string) { + const alterationIndex = this.selectedAlterationStateIndex; + const excludingIndex = this.selectedExcludedAlterationIndex; + const newParsedAlteration = parseAlterationName(newAlteration); + + const currentState = this.alterationStates[alterationIndex]; + const alteration = currentState.alteration.toLowerCase(); + let excluding: string[] = []; + for (let i = 0; i < currentState.excluding.length; i++) { + if (i === excludingIndex) { + excluding.push(...newParsedAlteration.map(alt => alt.alteration.toLowerCase())); + } else { + excluding.push(currentState.excluding[excludingIndex].alteration.toLowerCase()); + } + } + excluding = excluding.sort(); + if ( + this.alterationStates.some( + state => + state.alteration.toLowerCase() === alteration && + _.isEqual(state.excluding.map(ex => ex.alteration.toLowerCase()).sort(), excluding), + ) + ) { + notifyError(new Error('Duplicate alteration(s) removed')); + const newAlterationStates = _.cloneDeep(this.alterationStates); + newAlterationStates[alterationIndex].excluding.splice(excludingIndex, 1); + this.alterationStates = newAlterationStates; + return; + } + + const alterationPromises: Promise[] = []; + let newAlterations: AlterationData[] = []; + if (newParsedAlteration[0].alteration !== this.alterationStates[alterationIndex]?.excluding[excludingIndex].alteration) { + alterationPromises.push(this.fetchAlteration(newParsedAlteration[0].alteration)); + } else { + newAlterations.push(this.alterationStates[alterationIndex].excluding[excludingIndex]); + } + + for (let i = 1; i < newParsedAlteration.length; i++) { + alterationPromises.push(this.fetchAlteration(newParsedAlteration[i].alteration)); + } + newAlterations = [ + ...newAlterations, + ...(await Promise.all(alterationPromises)) + .map((alt, index) => (alt ? convertEntityStatusAlterationToAlterationData(alt, [], newParsedAlteration[index].comment) : undefined)) + .filter(hasValue), + ]; + + const newAlterationStates = _.cloneDeep(this.alterationStates); + newAlterationStates[alterationIndex].excluding.splice(excludingIndex, 1, ...newAlterations); + this.alterationStates = newAlterationStates; + } + + handleNormalAlterationChange(newValue: string, alterationIndex: number) { + const newAlterationStates = _.cloneDeep(this.alterationStates); + newAlterationStates[alterationIndex].alterationFieldValueWhileFetching = newValue; + this.alterationStates = newAlterationStates; + + _.debounce(async () => { + await this.fetchNormalAlteration(newValue, alterationIndex); + this.isFetchingAlteration = false; + }, 1000)(); + } + + handleExcludedAlterationChange(newValue: string) { + const newAlterationStates = _.cloneDeep(this.alterationStates); + newAlterationStates[this.selectedAlterationStateIndex].excluding[ + this.selectedExcludedAlterationIndex + ].alterationFieldValueWhileFetching = newValue; + this.alterationStates = newAlterationStates; + + _.debounce(async () => { + await this.fetchExcludedAlteration(newValue); + this.isFetchingExcludingAlteration = false; + }, 1000)(); + } + + handleNormalFieldChange(newValue: string, field: keyof AlterationData, alterationIndex: number) { + const newAlterationStates = _.cloneDeep(this.alterationStates); + newAlterationStates[alterationIndex][field as string] = newValue; + this.alterationStates = newAlterationStates; + } + + async fetchNormalAlteration(newAlteration: string, alterationIndex: number) { + const newParsedAlteration = this.filterAlterationsAndNotify(parseAlterationName(newAlteration), alterationIndex); + if (newParsedAlteration.length === 0) { + const newAlterationStates = _.cloneDeep(this.alterationStates); + newAlterationStates[alterationIndex].alterationFieldValueWhileFetching = undefined; + this.alterationStates = newAlterationStates; + } + + const newComment = newParsedAlteration[0].comment; + const newVariantName = newParsedAlteration[0].name; + + let newExcluding: AlterationData[]; + if ( + _.isEqual( + newParsedAlteration[0].excluding, + this.alterationStates[alterationIndex]?.excluding.map(ex => ex.alteration), + ) + ) { + newExcluding = this.alterationStates[alterationIndex].excluding; + } else { + const excludingEntityStatusAlterations = await this.fetchAlterations(newParsedAlteration[0].excluding); + newExcluding = excludingEntityStatusAlterations?.map((ex, index) => convertEntityStatusAlterationToAlterationData(ex, [], '')) ?? []; + } + + const alterationPromises: Promise[] = []; + let newAlterations: AlterationData[] = []; + if (newParsedAlteration[0].alteration !== this.alterationStates[alterationIndex]?.alteration) { + alterationPromises.push(this.fetchAlteration(newParsedAlteration[0].alteration)); + } else { + const newAlterationState = _.cloneDeep(this.alterationStates[alterationIndex]); + newAlterationState.excluding = newExcluding; + newAlterationState.comment = newComment; + newAlterationState.name = newVariantName || newParsedAlteration[0].alteration; + newAlterations.push(newAlterationState); + } + + for (let i = 1; i < newParsedAlteration.length; i++) { + alterationPromises.push(this.fetchAlteration(newParsedAlteration[i].alteration)); + } + + newAlterations = [ + ...newAlterations, + ...(await Promise.all(alterationPromises)) + .filter(hasValue) + .map((alt, index) => convertEntityStatusAlterationToAlterationData(alt, newExcluding, newComment, newVariantName)), + ]; + newAlterations[0].alterationFieldValueWhileFetching = undefined; + + const newAlterationStates = _.cloneDeep(this.alterationStates); + newAlterationStates.splice(alterationIndex, 1, ...newAlterations); + this.alterationStates = newAlterationStates; + } + + filterAlterationsAndNotify(alterations: ReturnType, alterationIndex?: number) { + // remove alterations that already exist in modal + const newAlterations = alterations.filter(alt => { + return !this.alterationStates.some((state, index) => { + if (index === alterationIndex) { + return false; + } + + const stateName = state.alteration.toLowerCase(); + const stateExcluding = state.excluding.map(ex => ex.alteration.toLowerCase()).sort(); + const altName = alt.alteration.toLowerCase(); + const altExcluding = alt.excluding.map(ex => ex.toLowerCase()).sort(); + return stateName === altName && _.isEqual(stateExcluding, altExcluding); + }); + }); + + if (alterations.length !== newAlterations.length) { + notifyError(new Error('Duplicate alteration(s) removed')); + } + + return newAlterations; + } + + async fetchAlteration(alterationName: string): Promise { + try { + const request: AnnotateAlterationBody[] = [ + { + referenceGenome: REFERENCE_GENOME.GRCH37, + alteration: { alteration: alterationName, genes: [{ id: this.geneEntity?.id } as Gene] } as ApiAlteration, + }, + ]; + const alts = await flowResult(flow(this.alterationStore.annotateAlterations)(request)); + return alts[0]; + } catch (error) { + notifyError(error); + } + } + + async fetchAlterations(alterationNames: string[]) { + try { + const alterationPromises = alterationNames.map(name => this.fetchAlteration(name)); + const alterations = await Promise.all(alterationPromises); + const filtered: AlterationAnnotationStatus[] = []; + for (const alteration of alterations) { + if (alteration !== undefined) { + filtered.push(alteration); + } + } + return filtered; + } catch (error) { + notifyError(error); + return []; + } + } + + cleanup() { + this.geneEntity = null; + this.mutationToEdit = null; + this.vusList = null; + this.alterationStates = []; + this.selectedAlterationStateIndex = -1; + this.selectedExcludedAlterationIndex = -1; + this.showModifyExonForm = false; + this.hasUncommitedExonFormChanges = false; + this.isFetchingAlteration = false; + this.isFetchingExcludingAlteration = false; + this.selectedAlterationCategoryFlags = []; + this.alterationCategoryComment = ''; + this.proteinExons = []; + } +} diff --git a/src/main/webapp/app/shared/modal/MutationModal/styles.module.scss b/src/main/webapp/app/shared/modal/MutationModal/styles.module.scss new file mode 100644 index 000000000..2a75c60d7 --- /dev/null +++ b/src/main/webapp/app/shared/modal/MutationModal/styles.module.scss @@ -0,0 +1,70 @@ +@import '../../../variables.scss'; + +.alterationBadge { + font-size: 14px; + font-weight: normal; + padding: 0; + display: flex; + align-items: center; + max-width: 49%; + width: fit-content; + overflow: hidden; + cursor: pointer; +} + +.alterationBadgeName { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + flex-grow: 1; + padding-right: 5px; + display: flex; + padding: 8px; + align-items: center; +} + +.actionWrapper { + flex-shrink: 0; + display: flex; + border-left: 1px dashed; +} + +.deleteButton { + display: flex; + flex-direction: column; + justify-content: center; + padding: 7px; + cursor: pointer; +} + +.alterationBadgeListInput { + color: inherit; + background: 0px center; + opacity: 1; + width: 100%; + grid-area: 1 / 2; + font: inherit; + min-width: 2px; + border: 0px; + margin: 0px; + outline: 0px; +} + +.alterationBadgeListInputWrapper { + visibility: visible; + display: flex; + justify-content: center; + flex: 1 1 auto; + margin: 2px; + padding-bottom: 2px; + padding-top: 2px; +} + +.link { + color: $oncokb-blue; + cursor: pointer; +} + +.link:hover { + text-decoration: underline; +} diff --git a/src/main/webapp/app/shared/modal/RelevantCancerTypesModal.tsx b/src/main/webapp/app/shared/modal/RelevantCancerTypesModal.tsx index d32badb62..17b47e4a6 100644 --- a/src/main/webapp/app/shared/modal/RelevantCancerTypesModal.tsx +++ b/src/main/webapp/app/shared/modal/RelevantCancerTypesModal.tsx @@ -120,9 +120,8 @@ const RelevantCancerTypesModalContent = observer( This RCT is deleted. Press the revert button to undo the deletion.} - > - Deleted - + text="Deleted" + /> )}
); diff --git a/src/main/webapp/app/shared/modal/add-mutation-modal.scss b/src/main/webapp/app/shared/modal/add-mutation-modal.scss index 3fcac187e..b532a46bc 100644 --- a/src/main/webapp/app/shared/modal/add-mutation-modal.scss +++ b/src/main/webapp/app/shared/modal/add-mutation-modal.scss @@ -3,3 +3,9 @@ justify-content: center; align-items: center; } + +.alteration-modal-textarea-field { + min-height: 20px !important; + overflow-y: hidden; + resize: none; +} diff --git a/src/main/webapp/app/shared/model/enumerations/flag-type.enum.model.ts b/src/main/webapp/app/shared/model/enumerations/flag-type.enum.model.ts new file mode 100644 index 000000000..5536c7f26 --- /dev/null +++ b/src/main/webapp/app/shared/model/enumerations/flag-type.enum.model.ts @@ -0,0 +1,8 @@ +export enum FlagTypeEnum { + GENE_TYPE = 'GENE_TYPE', + GENE_PANEL = 'GENE_PANEL', + TRANSCRIPT = 'TRANSCRIPT', + DRUG = 'DRUG', + HOTSPOT = 'HOTSPOT', + ALTERATION_CATEGORY = 'ALTERATION_CATEGORY', +} diff --git a/src/main/webapp/app/shared/model/firebase/firebase.model.ts b/src/main/webapp/app/shared/model/firebase/firebase.model.ts index 2597b87d0..93f639214 100644 --- a/src/main/webapp/app/shared/model/firebase/firebase.model.ts +++ b/src/main/webapp/app/shared/model/firebase/firebase.model.ts @@ -232,6 +232,7 @@ export class Mutation { mutation_effect: MutationEffect = new MutationEffect(); mutation_effect_uuid: string = generateUuid(); mutation_effect_comments?: CommentList = {}; // used for somatic + alteration_categories?: AlterationCategories | null; name: string = ''; name_comments?: CommentList = {}; name_review?: Review; @@ -254,6 +255,16 @@ export class Mutation { } } +export type Flag = { + type: string; + flag: string; +}; + +export class AlterationCategories { + flags?: Flag[]; + comment = ''; +} + export class MutationEffect { description = ''; description_review?: Review; diff --git a/src/main/webapp/app/shared/table/GenomicIndicatorsTable.tsx b/src/main/webapp/app/shared/table/GenomicIndicatorsTable.tsx index 1a0a5518a..ddb5c3fda 100644 --- a/src/main/webapp/app/shared/table/GenomicIndicatorsTable.tsx +++ b/src/main/webapp/app/shared/table/GenomicIndicatorsTable.tsx @@ -266,9 +266,7 @@ const GenomicIndicatorsTable = ({ firebaseDb={firebaseDb!} buildCell={genomicIndicator => { return genomicIndicator.name_review?.removed ? ( - - Deleted - + ) : ( - - {color && ( - - Outdated - - )} - + {color && } ); }, diff --git a/src/main/webapp/app/shared/util/alteration-utils.ts b/src/main/webapp/app/shared/util/alteration-utils.ts deleted file mode 100644 index 1df31c756..000000000 --- a/src/main/webapp/app/shared/util/alteration-utils.ts +++ /dev/null @@ -1,77 +0,0 @@ -import { Alteration } from 'app/shared/model/firebase/firebase.model'; -import { AlterationAnnotationStatus, AlterationTypeEnum, Gene as ApiGene } from 'app/shared/api/generated/curation'; - -export type AlterationData = { - type: AlterationTypeEnum; - alteration: string; - name: string; - consequence: string; - comment: string; - excluding: AlterationData[]; - genes?: ApiGene[]; - proteinChange?: string; - proteinStart?: number; - proteinEnd?: number; - refResidues?: string; - varResidues?: string; - warning?: string; - error?: string; - alterationFieldValueWhileFetching?: string; -}; - -export function getFullAlterationName(alterationData: AlterationData, includeVariantName = true): string { - const variantName = includeVariantName && alterationData.name !== alterationData.alteration ? ` [${alterationData.name}]` : ''; - const excluding = - alterationData.excluding.length > 0 ? ` {excluding ${alterationData.excluding.map(ex => ex.alteration).join(' ; ')}}` : ''; - const comment = alterationData.comment ? ` (${alterationData.comment})` : ''; - return `${alterationData.alteration}${variantName}${excluding}${comment}`; -} - -export function convertEntityStatusAlterationToAlterationData( - entityStatusAlteration: AlterationAnnotationStatus, - alterationName: string, - excluding: AlterationData[], - comment: string, - variantName?: string, -): AlterationData { - const alteration = entityStatusAlteration.entity; - const alterationData: AlterationData = { - type: alteration?.type ?? AlterationTypeEnum.Unknown, - alteration: alterationName, - name: (variantName || alteration?.name) ?? '', - consequence: alteration?.consequence?.name ?? '', - comment, - excluding, - genes: alteration?.genes, - proteinChange: alteration?.proteinChange, - proteinStart: alteration?.start, - proteinEnd: alteration?.end, - refResidues: alteration?.refResidues, - varResidues: alteration?.variantResidues, - warning: entityStatusAlteration.warning ? entityStatusAlteration.message : undefined, - error: entityStatusAlteration.error ? entityStatusAlteration.message : undefined, - }; - - if (alteration?.alteration !== alterationName) { - alterationData.alteration = alteration?.alteration ?? ''; - } - - return alterationData; -} - -export function convertAlterationDataToAlteration(alterationData: AlterationData): Alteration { - const alteration = new Alteration(); - alteration.type = alterationData.type; - alteration.alteration = alterationData.alteration; - alteration.name = getFullAlterationName(alterationData); - alteration.proteinChange = alterationData.proteinChange || ''; - alteration.proteinStart = alterationData.proteinStart || -1; - alteration.proteinEnd = alterationData.proteinEnd || -1; - alteration.refResidues = alterationData.refResidues || ''; - alteration.varResidues = alterationData.varResidues || ''; - alteration.consequence = alterationData.consequence; - alteration.comment = alterationData.comment; - alteration.excluding = alterationData.excluding.map(ex => convertAlterationDataToAlteration(ex)); - alteration.genes = alterationData.genes || []; - return alteration; -} diff --git a/src/main/webapp/app/shared/util/firebase/firebase-utils.tsx b/src/main/webapp/app/shared/util/firebase/firebase-utils.tsx index 56e1f4fca..d9e526998 100644 --- a/src/main/webapp/app/shared/util/firebase/firebase-utils.tsx +++ b/src/main/webapp/app/shared/util/firebase/firebase-utils.tsx @@ -9,6 +9,7 @@ import { CommentList, DX_LEVELS, FIREBASE_ONCOGENICITY, + Flag, Gene, Meta, MetaReview, @@ -30,6 +31,7 @@ import { extractPositionFromSingleNucleotideAlteration, getCancerTypeName, isUui import { isTxLevelPresent } from './firebase-level-utils'; import { parseFirebaseGenePath } from './firebase-path-utils'; import { hasReview } from './firebase-review-utils'; +import { IFlag } from 'app/shared/model/flag.model'; export const getValueByNestedKey = (obj: any, nestedKey = '', sep = '/') => { return nestedKey.split(sep).reduce((currObj, currKey) => { @@ -41,12 +43,15 @@ export const isDnaVariant = (alteration: Alteration) => { return alteration.alteration && alteration.alteration.startsWith('c.'); }; -export const getAlterationName = (alteration: Alteration) => { +export const getAlterationName = (alteration: Alteration, omitComment = false) => { if (alteration.name) { let name = alteration.name; if (alteration.proteinChange && alteration.proteinChange !== alteration.alteration) { name += ` (p.${alteration.proteinChange})`; } + if (omitComment) { + name = name.replace(/\(.*?\)/g, ''); + } return name; } else if (alteration.proteinChange) { return alteration.proteinChange; @@ -208,7 +213,16 @@ export const isSectionEmpty = (sectionValue: any, fullPath: string) => { return true; } - const ignoredKeySuffixes = ['_review', '_uuid', 'TIs', 'cancerTypes', 'excludedCancerTypes', 'name', 'alterations']; + const ignoredKeySuffixes = [ + '_review', + '_uuid', + 'TIs', + 'excludedCancerTypes', + 'cancerTypes', + 'name', + 'alterations', + 'alteration_categories', + ]; const isEmpty = isNestedObjectEmpty(sectionValue, ignoredKeySuffixes); if (!isEmpty) { @@ -1004,3 +1018,7 @@ export function areCancerTypePropertiesEqual(a: string | undefined, b: string | export function isStringEmpty(string: string | undefined | null) { return string === '' || _.isNil(string); } + +export function isFlagEqualToIFlag(flag: Flag, flagEntity: IFlag) { + return flag.flag === flagEntity.flag && flag.type === flagEntity.type; +} diff --git a/src/main/webapp/app/shared/util/test-id-utils.ts b/src/main/webapp/app/shared/util/test-id-utils.ts index af8bff7c8..a7b475e03 100644 --- a/src/main/webapp/app/shared/util/test-id-utils.ts +++ b/src/main/webapp/app/shared/util/test-id-utils.ts @@ -8,3 +8,13 @@ export enum CollapsibleDataTestIdType { export function getCollapsibleDataTestId(dataTestid: CollapsibleDataTestIdType, identifier: string | undefined) { return `${identifier}-${dataTestid}`; } + +export enum AddMutationModalDataTestIdType { + ALTERATION_BADGE_NAME = 'alteration-badge-name', + ALTERATION_BADGE_DELETE = 'alteration-badge-delete', + MUTATION_DETAILS = 'mutation-details', +} + +export function getAddMutationModalDataTestId(dataTestid: AddMutationModalDataTestIdType, identifier?: string) { + return `add-mutation-modal-${identifier}-${dataTestid}`; +} diff --git a/src/main/webapp/app/shared/util/utils.tsx b/src/main/webapp/app/shared/util/utils.tsx index 3ca44605e..94f950f28 100644 --- a/src/main/webapp/app/shared/util/utils.tsx +++ b/src/main/webapp/app/shared/util/utils.tsx @@ -9,15 +9,19 @@ import EntityActionButton from '../button/EntityActionButton'; import { SORT } from './pagination.constants'; import { PaginationState } from '../table/OncoKBAsyncTable'; import { IUser } from '../model/user.model'; -import { CancerType, DrugCollection } from '../model/firebase/firebase.model'; +import { Alteration, CancerType, DrugCollection, Flag } from '../model/firebase/firebase.model'; import _ from 'lodash'; import { ParsedRef, parseReferences } from 'app/oncokb-commons/components/RefComponent'; import { IDrug } from 'app/shared/model/drug.model'; import { IRule } from 'app/shared/model/rule.model'; import { INTEGER_REGEX, REFERENCE_LINK_REGEX, SINGLE_NUCLEOTIDE_POS_REGEX, UUID_REGEX } from 'app/config/constants/regex'; -import { ProteinExonDTO } from 'app/shared/api/generated/curation'; +import { AlterationAnnotationStatus, AlterationTypeEnum, ProteinExonDTO } from 'app/shared/api/generated/curation'; import { IQueryParams } from './jhipster-types'; -import { TumorType, TumorTypeEntity } from '../api/generated/core'; +import { TumorTypeEntity } from '../api/generated/core'; +import { IFlag } from '../model/flag.model'; +import InfoIcon from '../icons/InfoIcon'; +import { AlterationData } from '../modal/AddMutationModal'; +import { PATHOGENIC_VARIANTS } from 'app/config/constants/firebase'; export const getCancerTypeName = (cancerType: ICancerType | CancerType, omitCode = false): string => { if (!cancerType) return ''; @@ -312,6 +316,114 @@ export function parseAlterationName( })); } +export function getFullAlterationName(alterationData: AlterationData, includeVariantName = true) { + const alterationName = alterationData.alteration; + let variantName = ''; + if (includeVariantName && alterationData.name !== alterationData.alteration) { + variantName = getMutationRenameValueFromName(alterationData.name) ?? alterationData.name; + } + const excluding = alterationData.excluding.length > 0 ? alterationData.excluding.map(ex => ex.alteration) : []; + const comment = alterationData.comment ? alterationData.comment : ''; + return buildAlterationName(alterationName, variantName, excluding, comment); +} + +export function getMutationRenameValueFromName(name: string) { + return name.match(/\[([^\]]+)\]/)?.[1]; +} + +export function convertEntityStatusAlterationToAlterationData( + entityStatusAlteration: AlterationAnnotationStatus, + excluding: AlterationData[], + comment: string, + variantName?: string, + isPathogenicVariants: boolean = false, +): AlterationData { + const alteration = entityStatusAlteration.entity; + const alterationData: AlterationData = { + type: alteration?.type ?? AlterationTypeEnum.Unknown, + alteration: isPathogenicVariants ? PATHOGENIC_VARIANTS : alteration?.alteration ?? '', + name: (variantName || alteration?.name) ?? '', + consequence: alteration?.consequence?.name ?? '', + comment, + excluding, + genes: alteration?.genes, + proteinChange: alteration?.proteinChange, + proteinStart: alteration?.start, + proteinEnd: alteration?.end, + refResidues: alteration?.refResidues, + varResidues: alteration?.variantResidues, + warning: entityStatusAlteration.warning ? entityStatusAlteration.message : undefined, + error: entityStatusAlteration.error ? entityStatusAlteration.message : undefined, + }; + + return alterationData; +} + +export function convertAlterationDataToAlteration(alterationData: AlterationData) { + const alteration = new Alteration(); + alteration.type = alterationData.type; + alteration.alteration = alterationData.alteration; + alteration.name = getFullAlterationName(alterationData); + alteration.proteinChange = alterationData.proteinChange || ''; + alteration.proteinStart = alterationData.proteinStart || -1; + alteration.proteinEnd = alterationData.proteinEnd || -1; + alteration.refResidues = alterationData.refResidues || ''; + alteration.varResidues = alterationData.varResidues || ''; + alteration.consequence = alterationData.consequence; + alteration.comment = alterationData.comment; + alteration.excluding = alterationData.excluding.map(ex => convertAlterationDataToAlteration(ex)); + alteration.genes = alterationData.genes || []; + return alteration; +} + +export function convertAlterationToAlterationData(alteration: Alteration): AlterationData { + const variantName = alteration.name; + return { + type: alteration.type, + alteration: alteration.alteration, + name: variantName || alteration.alteration, + consequence: alteration.consequence, + comment: alteration.comment, + excluding: alteration.excluding?.map(ex => convertAlterationToAlterationData(ex)) || [], + genes: alteration?.genes || [], + proteinChange: alteration?.proteinChange, + proteinStart: alteration?.proteinStart === -1 ? undefined : alteration?.proteinStart, + proteinEnd: alteration?.proteinEnd === -1 ? undefined : alteration?.proteinEnd, + refResidues: alteration?.refResidues, + varResidues: alteration?.varResidues, + }; +} + +export function convertIFlagToFlag(flagEntity: IFlag | Omit): Flag { + return { + flag: flagEntity.flag, + type: flagEntity.type, + }; +} + +export function buildAlterationName(alteration: string, name = '', excluding = [] as string[], comment = '') { + if (name) { + name = ` [${name}]`; + } + let exclusionString = ''; + if (excluding.length > 0) { + exclusionString = ` {excluding ${excluding.join('; ')}}`; + } + if (comment) { + comment = ` (${comment})`; + } + return `${alteration}${name}${exclusionString}${comment}`; +} + +export function getAlterationNameComponent(alterationName: string, comment?: string) { + return ( + <> + {alterationName} + {comment && } + + ); +} + export function findIndexOfFirstCapital(str: string) { for (let i = 0; i < str.length; i++) { if (str[i] >= 'A' && str[i] <= 'Z') { diff --git a/src/main/webapp/app/stores/createStore.ts b/src/main/webapp/app/stores/createStore.ts index b41118ff5..f073bb23c 100644 --- a/src/main/webapp/app/stores/createStore.ts +++ b/src/main/webapp/app/stores/createStore.ts @@ -105,6 +105,7 @@ import { WindowStore } from './window-store'; import CdxAssociationEditStore from './cdx-association-edit.store'; /* jhipster-needle-add-store-import - JHipster will add store here */ import ManagementStore from 'app/stores/management.store'; +import { AddMutationModalStore } from 'app/shared/modal/MutationModal/add-mutation-modal.store'; export interface IRootStore { readonly loadingStore: LoadingBarStore; @@ -150,6 +151,7 @@ export interface IRootStore { readonly modifyTherapyModalStore: ModifyTherapyModalStore; readonly relevantCancerTypesModalStore: RelevantCancerTypesModalStore; readonly openMutationCollapsibleStore: OpenMutationCollapsibleStore; + readonly addMutationModalStore: AddMutationModalStore; readonly flagStore: FlagStore; readonly commentStore: CommentStore; /* Firebase stores */ @@ -218,6 +220,7 @@ export function createStores(history: History): IRootStore { rootStore.modifyTherapyModalStore = new ModifyTherapyModalStore(); rootStore.relevantCancerTypesModalStore = new RelevantCancerTypesModalStore(); rootStore.openMutationCollapsibleStore = new OpenMutationCollapsibleStore(); + rootStore.addMutationModalStore = new AddMutationModalStore(rootStore.alterationStore); rootStore.commentStore = new CommentStore(); /* Firebase Stores */ diff --git a/src/main/webapp/app/variables.scss b/src/main/webapp/app/variables.scss index ed22a628a..5625432e6 100644 --- a/src/main/webapp/app/variables.scss +++ b/src/main/webapp/app/variables.scss @@ -17,6 +17,10 @@ $warning: #ffc107; $danger: #dc3545; $inactive: #f2f4f8; // from design team +// The contrast ratio to reach against white, to determine if color changes from "light" to "dark". Acceptable values for WCAG 2.0 are 3, 4.5 and 7. +// See https://www.w3.org/TR/WCAG20/#visual-audio-contrast-contrast +$min-contrast-ratio: 3; + $link-hover-color: $oncokb-darker-blue; $nav-bg-color: $oncokb-blue;