From e190d1013e767780ddbf39f9015bed50788f84fd Mon Sep 17 00:00:00 2001 From: Calvin Lu <59149377+calvinlu3@users.noreply.github.com> Date: Thu, 29 Aug 2024 17:02:46 -0400 Subject: [PATCH 1/8] Support exon curation and redesign AddMutationModal --- .../domain/enumeration/CNAConsequence.java | 10 +- .../domain/enumeration/SVConsequence.java | 14 +- .../oncokb/curation/service/MainService.java | 114 ++--- .../curation/service/TranscriptService.java | 75 ++- .../oncokb/curation/util/AlterationUtils.java | 91 +++- .../curation/web/rest/TranscriptResource.java | 22 +- src/main/webapp/app/app.scss | 36 ++ .../webapp/app/config/constants/regex.spec.ts | 20 +- src/main/webapp/app/config/constants/regex.ts | 2 + .../entities/transcript/transcript.store.ts | 12 + .../webapp/app/hooks/useOverflowDetector.tsx | 41 ++ .../app/hooks/useTextareaAutoHeight.tsx | 26 + .../MutationCollapsible.tsx | 3 +- .../curation/mutation/MutationsSection.tsx | 3 +- .../webapp/app/shared/badge/DefaultBadge.tsx | 15 +- .../firebase/input/RealtimeBasicInput.tsx | 20 +- .../webapp/app/shared/icons/ActionIcon.tsx | 16 +- .../app/shared/modal/AddMutationModal.tsx | 156 +++--- .../shared/modal/DefaultAddMutationModal.tsx | 2 +- .../modal/MutationModal/AddExonForm.tsx | 209 ++++++++ .../AddMutationModalDropdown.tsx | 31 ++ .../MutationModal/AddMutationModalField.tsx | 49 ++ .../MutationModal/AlterationBadgeList.tsx | 173 +++++++ .../AlterationCategoryInputs.tsx | 138 +++++ .../AnnotatedAlterationContent.tsx | 273 ++++++++++ .../AnnotatedAlterationErrorContent.tsx | 69 +++ .../ExcludedAlterationContent.tsx | 144 ++++++ .../MutationModal/add-mutation-modal.store.ts | 390 ++++++++++++++ .../modal/MutationModal/styles.module.scss | 53 ++ .../app/shared/modal/NewAddMutationModal.tsx | 478 ++++++++++++++++++ .../app/shared/modal/add-mutation-modal.scss | 6 + src/main/webapp/app/shared/util/utils.tsx | 89 +++- src/main/webapp/app/stores/createStore.ts | 3 + 33 files changed, 2562 insertions(+), 221 deletions(-) create mode 100644 src/main/webapp/app/hooks/useOverflowDetector.tsx create mode 100644 src/main/webapp/app/hooks/useTextareaAutoHeight.tsx create mode 100644 src/main/webapp/app/shared/modal/MutationModal/AddExonForm.tsx create mode 100644 src/main/webapp/app/shared/modal/MutationModal/AddMutationModalDropdown.tsx create mode 100644 src/main/webapp/app/shared/modal/MutationModal/AddMutationModalField.tsx create mode 100644 src/main/webapp/app/shared/modal/MutationModal/AlterationBadgeList.tsx create mode 100644 src/main/webapp/app/shared/modal/MutationModal/AlterationCategoryInputs.tsx create mode 100644 src/main/webapp/app/shared/modal/MutationModal/AnnotatedAlterationContent.tsx create mode 100644 src/main/webapp/app/shared/modal/MutationModal/AnnotatedAlterationErrorContent.tsx create mode 100644 src/main/webapp/app/shared/modal/MutationModal/ExcludedAlterationContent.tsx create mode 100644 src/main/webapp/app/shared/modal/MutationModal/add-mutation-modal.store.ts create mode 100644 src/main/webapp/app/shared/modal/MutationModal/styles.module.scss create mode 100644 src/main/webapp/app/shared/modal/NewAddMutationModal.tsx 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/SVConsequence.java b/src/main/java/org/mskcc/oncokb/curation/domain/enumeration/SVConsequence.java index aea1fd65b..e8db80387 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,11 @@ package org.mskcc.oncokb.curation.domain.enumeration; public enum SVConsequence { - DELETION, - TRANSLOCATION, - DUPLICATION, - INSERTION, - INVERSION, - FUSION, - UNKNOWN, + SV_DELETION, + SV_TRANSLOCATION, + SV_DUPLICATION, + SV_INSERTION, + SV_INVERSION, + SV_FUSION, + SV_UNKNOWN, } diff --git a/src/main/java/org/mskcc/oncokb/curation/service/MainService.java b/src/main/java/org/mskcc/oncokb/curation/service/MainService.java index 46f4bf8d0..170279107 100644 --- a/src/main/java/org/mskcc/oncokb/curation/service/MainService.java +++ b/src/main/java/org/mskcc/oncokb/curation/service/MainService.java @@ -16,7 +16,6 @@ import org.mskcc.oncokb.curation.domain.dto.HotspotInfoDTO; import org.mskcc.oncokb.curation.domain.dto.ProteinExonDTO; import org.mskcc.oncokb.curation.domain.enumeration.*; -import org.mskcc.oncokb.curation.model.IntegerRange; import org.mskcc.oncokb.curation.service.dto.TranscriptDTO; import org.mskcc.oncokb.curation.service.mapper.TranscriptMapper; import org.mskcc.oncokb.curation.util.AlterationUtils; @@ -280,87 +279,54 @@ public AlterationAnnotationStatus annotateAlteration(ReferenceGenome referenceGe } annotationDTO.setHotspot(hotspotInfoDTO); - if ( - annotatedGenes.size() == 1 && - PROTEIN_CHANGE.equals(alteration.getType()) && - alteration.getStart() != null && - alteration.getEnd() != null - ) { - Optional transcriptOptional = transcriptService.findByGeneAndReferenceGenomeAndCanonicalIsTrue( - annotatedGenes.stream().iterator().next(), - referenceGenome - ); - if (transcriptOptional.isPresent()) { - List utrs = transcriptOptional.orElseThrow().getUtrs(); - List exons = transcriptOptional.orElseThrow().getExons(); - exons.sort((o1, o2) -> { - int diff = o1.getStart() - o2.getStart(); - if (diff == 0) { - diff = o1.getEnd() - o2.getEnd(); - } - if (diff == 0) { - diff = (int) (o1.getId() - o2.getId()); - } - return diff; - }); - - List codingExons = new ArrayList<>(); - exons.forEach(exon -> { - Integer start = exon.getStart(); - Integer end = exon.getEnd(); - for (GenomeFragment utr : utrs) { - if (utr.getStart().equals(exon.getStart())) { - start = utr.getEnd() + 1; - } - if (utr.getEnd().equals(exon.getEnd())) { - end = utr.getStart() - 1; - } + if (annotatedGenes.size() == 1) { + List proteinExons = transcriptService.getExons(annotatedGenes.stream().iterator().next(), referenceGenome); + if (PROTEIN_CHANGE.equals(alteration.getType()) && alteration.getStart() != null && alteration.getEnd() != null) { + // Filter exons based on alteration range + List overlap = proteinExons + .stream() + .filter(exon -> alteration.getStart() <= exon.getRange().getEnd() && alteration.getEnd() >= exon.getRange().getStart()) + .collect(Collectors.toList()); + annotationDTO.setExons(overlap); + } else if (AlterationUtils.isExon(alteration.getAlteration())) { + List overlap = new ArrayList<>(); + List problematicExonAlts = new ArrayList<>(); + for (String exonAlterationString : Arrays.asList(alteration.getAlteration().split("\\s*\\+\\s*"))) { + if (AlterationUtils.isAnyExon(exonAlterationString)) { + continue; } - if (start < end) { - GenomeFragment genomeFragment = new GenomeFragment(); - genomeFragment.setType(GenomeFragmentType.EXON); - genomeFragment.setStart(start); - genomeFragment.setEnd(end); - codingExons.add(genomeFragment); + Integer exonNumber = Integer.parseInt(exonAlterationString.replaceAll("\\D*", "")); + if (exonNumber > 0 && exonNumber < proteinExons.size() + 1) { + overlap.add(proteinExons.get(exonNumber - 1)); } else { - GenomeFragment genomeFragment = new GenomeFragment(); - genomeFragment.setType(GenomeFragmentType.EXON); - genomeFragment.setStart(0); - genomeFragment.setEnd(0); - codingExons.add(genomeFragment); + problematicExonAlts.add(exonAlterationString); } - }); - - if (transcriptOptional.orElseThrow().getStrand() == -1) { - Collections.reverse(codingExons); } - - List proteinExons = new ArrayList<>(); - int startAA = 1; - int previousExonCodonResidues = 0; - for (int i = 0; i < codingExons.size(); i++) { - GenomeFragment genomeFragment = codingExons.get(i); - if (genomeFragment.getStart() == 0) { - continue; + if (problematicExonAlts.isEmpty()) { + overlap.sort(Comparator.comparingInt(ProteinExonDTO::getExon)); + Boolean isConsecutiveExonRange = + overlap + .stream() + .map(ProteinExonDTO::getExon) + .reduce((prev, curr) -> (curr - prev == 1) ? curr : Integer.MIN_VALUE) + .orElse(Integer.MIN_VALUE) != + Integer.MIN_VALUE; + if (isConsecutiveExonRange && overlap.size() > 0) { + alteration.setStart(overlap.get(0).getRange().getStart()); + alteration.setEnd(overlap.get(overlap.size() - 1).getRange().getEnd()); } - int proteinLength = (previousExonCodonResidues + (genomeFragment.getEnd() - genomeFragment.getStart() + 1)) / 3; - previousExonCodonResidues = (previousExonCodonResidues + (genomeFragment.getEnd() - genomeFragment.getStart() + 1)) % 3; - ProteinExonDTO proteinExonDTO = new ProteinExonDTO(); - proteinExonDTO.setExon(i + 1); - IntegerRange integerRange = new IntegerRange(); - integerRange.setStart(startAA); - integerRange.setEnd(startAA + proteinLength - 1 + (previousExonCodonResidues > 0 ? 1 : 0)); - proteinExonDTO.setRange(integerRange); - proteinExons.add(proteinExonDTO); - startAA += proteinLength; + + annotationDTO.setExons(overlap); + } else { + StringBuilder sb = new StringBuilder(); + sb.append("The following exon(s) do not exist: "); + sb.append(problematicExonAlts.stream().collect(Collectors.joining(", "))); + alterationWithStatus.setMessage(sb.toString()); + alterationWithStatus.setType(EntityStatusType.ERROR); } - List overlap = proteinExons - .stream() - .filter(exon -> alteration.getStart() <= exon.getRange().getEnd() && alteration.getEnd() >= exon.getRange().getStart()) - .collect(Collectors.toList()); - annotationDTO.setExons(overlap); } } + alterationWithStatus.setAnnotation(annotationDTO); return alterationWithStatus; } diff --git a/src/main/java/org/mskcc/oncokb/curation/service/TranscriptService.java b/src/main/java/org/mskcc/oncokb/curation/service/TranscriptService.java index 52ced8dc9..f23998faf 100644 --- a/src/main/java/org/mskcc/oncokb/curation/service/TranscriptService.java +++ b/src/main/java/org/mskcc/oncokb/curation/service/TranscriptService.java @@ -3,8 +3,6 @@ import static org.mskcc.oncokb.curation.config.Constants.ENSEMBL_POST_THRESHOLD; import java.util.*; -import java.util.List; -import java.util.Optional; import java.util.stream.Collectors; import org.apache.commons.lang3.StringUtils; import org.genome_nexus.ApiException; @@ -13,9 +11,11 @@ import org.mskcc.oncokb.curation.config.cache.CacheCategory; import org.mskcc.oncokb.curation.config.cache.CacheNameResolver; import org.mskcc.oncokb.curation.domain.*; +import org.mskcc.oncokb.curation.domain.dto.ProteinExonDTO; import org.mskcc.oncokb.curation.domain.enumeration.GenomeFragmentType; import org.mskcc.oncokb.curation.domain.enumeration.ReferenceGenome; import org.mskcc.oncokb.curation.domain.enumeration.SequenceType; +import org.mskcc.oncokb.curation.model.IntegerRange; import org.mskcc.oncokb.curation.repository.TranscriptRepository; import org.mskcc.oncokb.curation.service.dto.ClustalOResp; import org.mskcc.oncokb.curation.service.dto.TranscriptDTO; @@ -582,6 +582,77 @@ public List getAlignmentResult( } } + public List getExons(Gene gene, ReferenceGenome referenceGenome) { + Optional transcriptOptional = this.findByGeneAndReferenceGenomeAndCanonicalIsTrue(gene, referenceGenome); + if (transcriptOptional.isPresent()) { + List utrs = transcriptOptional.orElseThrow().getUtrs(); + List exons = transcriptOptional.orElseThrow().getExons(); + exons.sort((o1, o2) -> { + int diff = o1.getStart() - o2.getStart(); + if (diff == 0) { + diff = o1.getEnd() - o2.getEnd(); + } + if (diff == 0) { + diff = (int) (o1.getId() - o2.getId()); + } + return diff; + }); + + List codingExons = new ArrayList<>(); + exons.forEach(exon -> { + Integer start = exon.getStart(); + Integer end = exon.getEnd(); + for (GenomeFragment utr : utrs) { + if (utr.getStart().equals(exon.getStart())) { + start = utr.getEnd() + 1; + } + if (utr.getEnd().equals(exon.getEnd())) { + end = utr.getStart() - 1; + } + } + if (start < end) { + GenomeFragment genomeFragment = new GenomeFragment(); + genomeFragment.setType(GenomeFragmentType.EXON); + genomeFragment.setStart(start); + genomeFragment.setEnd(end); + codingExons.add(genomeFragment); + } else { + GenomeFragment genomeFragment = new GenomeFragment(); + genomeFragment.setType(GenomeFragmentType.EXON); + genomeFragment.setStart(0); + genomeFragment.setEnd(0); + codingExons.add(genomeFragment); + } + }); + + if (transcriptOptional.orElseThrow().getStrand() == -1) { + Collections.reverse(codingExons); + } + + List proteinExons = new ArrayList<>(); + int startAA = 1; + int previousExonCodonResidues = 0; + for (int i = 0; i < codingExons.size(); i++) { + GenomeFragment genomeFragment = codingExons.get(i); + if (genomeFragment.getStart() == 0) { + continue; + } + int proteinLength = (previousExonCodonResidues + (genomeFragment.getEnd() - genomeFragment.getStart() + 1)) / 3; + previousExonCodonResidues = (previousExonCodonResidues + (genomeFragment.getEnd() - genomeFragment.getStart() + 1)) % 3; + ProteinExonDTO proteinExonDTO = new ProteinExonDTO(); + proteinExonDTO.setExon(i + 1); + IntegerRange integerRange = new IntegerRange(); + integerRange.setStart(startAA); + integerRange.setEnd(startAA + proteinLength - 1 + (previousExonCodonResidues > 0 ? 1 : 0)); + proteinExonDTO.setRange(integerRange); + proteinExons.add(proteinExonDTO); + startAA += proteinLength; + } + return proteinExons; + } + return new ArrayList<>(); + } + private Optional getEnsemblTranscriptBySequence( List availableEnsemblTranscripts, EnsemblSequence sequence 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 0165c61b2..08e60928a 100644 --- a/src/main/java/org/mskcc/oncokb/curation/util/AlterationUtils.java +++ b/src/main/java/org/mskcc/oncokb/curation/util/AlterationUtils.java @@ -8,7 +8,6 @@ import java.util.stream.Collectors; import org.apache.commons.lang3.StringUtils; import org.apache.commons.text.similarity.JaroWinklerSimilarity; -import org.checkerframework.checker.regex.qual.Regex; import org.mskcc.oncokb.curation.domain.*; import org.mskcc.oncokb.curation.domain.enumeration.*; import org.springframework.stereotype.Component; @@ -21,11 +20,15 @@ public class AlterationUtils { private static final String FUSION_REGEX = "\\s*(\\w*)" + FUSION_SEPARATOR + "(\\w*)\\s*(?i)(fusion)?\\s*"; private static final String FUSION_ALT_REGEX = "\\s*(\\w*)" + FUSION_ALTERNATIVE_SEPARATOR + "(\\w*)\\s+(?i)fusion\\s*"; + private static final String EXON_ALT_REGEX = "(Any\\s+)?Exon\\s+(\\d+)(-(\\d+))?\\s+(Deletion|Insertion|Duplication)"; + + private static final String EXON_ALTS_REGEX = "(" + EXON_ALT_REGEX + ")(\\s*\\+\\s*" + EXON_ALT_REGEX + ")*"; + 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); @@ -49,7 +52,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()) { @@ -90,6 +93,65 @@ private Alteration parseGenomicChange(String genomicChange) { return alt; } + private Alteration parseExonAlteration(String alteration) { + Alteration alt = new Alteration(); + Consequence consequence = new Consequence(); + consequence.setTerm(SVConsequence.SV_UNKNOWN.name()); + alt.setType(AlterationType.STRUCTURAL_VARIANT); + alt.setConsequence(consequence); + + Pattern pattern = Pattern.compile(EXON_ALT_REGEX, Pattern.CASE_INSENSITIVE); + Matcher matcher = pattern.matcher(alteration); + List splitResults = new ArrayList<>(); + Set consequenceTermSet = new HashSet<>(); + + while (matcher.find()) { + Boolean isAnyExon = "Any".equals(matcher.group(1).trim()); // We use "Any" to denote all possible combinations of exons + String startExonStr = matcher.group(2); // The start exon number + String endExonStr = matcher.group(4); // The end exon number (if present) + String consequenceTerm = matcher.group(5); // consequence term + + switch (consequenceTerm.toLowerCase()) { + case "insertion": + consequenceTerm = "Insertion"; + consequence.setTerm(SVConsequence.SV_INSERTION.name()); + break; + case "duplication": + consequenceTerm = "Duplication"; + consequence.setTerm(SVConsequence.SV_DUPLICATION.name()); + break; + case "deletion": + consequenceTerm = "Deletion"; + consequence.setTerm(SVConsequence.SV_DELETION.name()); + break; + default: + break; + } + + consequenceTermSet.add(consequenceTerm); + if (consequenceTermSet.size() > 1) { + consequence.setTerm(SVConsequence.SV_UNKNOWN.name()); + } + + if (isAnyExon) { + splitResults.add(alteration); + continue; + } + + int startExon = Integer.parseInt(startExonStr); + int endExon = (endExonStr != null) ? Integer.parseInt(endExonStr) : startExon; + + for (int exon = startExon; exon <= endExon; exon++) { + splitResults.add("Exon " + exon + " " + consequenceTerm); + } + } + + alt.setAlteration(splitResults.stream().collect(Collectors.joining(" + "))); + alt.setName(alteration); + + return alt; + } + public EntityStatus parseAlteration(String alteration) { EntityStatus entityWithStatus = new EntityStatus<>(); String message = ""; @@ -130,6 +192,14 @@ public EntityStatus parseAlteration(String alteration) { return entityWithStatus; } + if (isExon(alteration)) { + Alteration alt = parseExonAlteration(alteration); + entityWithStatus.setEntity(alt); + entityWithStatus.setType(status); + entityWithStatus.setMessage(message); + return entityWithStatus; + } + // the following is to parse the alteration as protein change MutationConsequence term = UNKNOWN; String ref = null; @@ -474,6 +544,21 @@ public static Boolean isGenomicChange(String alteration) { return m.matches(); } + public static Boolean isExon(String alteration) { + Pattern p = Pattern.compile(EXON_ALTS_REGEX, Pattern.CASE_INSENSITIVE); + Matcher m = p.matcher(alteration); + return m.matches(); + } + + public static Boolean isAnyExon(String alteration) { + Pattern p = Pattern.compile(EXON_ALT_REGEX, Pattern.CASE_INSENSITIVE); + Matcher m = p.matcher(alteration); + if (m.find()) { + return "Any".equals(m.group(1).trim()); + } + return false; + } + public static String removeExclusionCriteria(String proteinChange) { Matcher exclusionMatch = getExclusionCriteriaMatcher(proteinChange); if (exclusionMatch.matches()) { diff --git a/src/main/java/org/mskcc/oncokb/curation/web/rest/TranscriptResource.java b/src/main/java/org/mskcc/oncokb/curation/web/rest/TranscriptResource.java index 1f11dff78..af3a4544c 100644 --- a/src/main/java/org/mskcc/oncokb/curation/web/rest/TranscriptResource.java +++ b/src/main/java/org/mskcc/oncokb/curation/web/rest/TranscriptResource.java @@ -8,7 +8,11 @@ import java.util.Objects; import java.util.Optional; import java.util.stream.Collectors; +import org.mskcc.oncokb.curation.domain.Gene; +import org.mskcc.oncokb.curation.domain.dto.ProteinExonDTO; +import org.mskcc.oncokb.curation.domain.enumeration.ReferenceGenome; import org.mskcc.oncokb.curation.repository.TranscriptRepository; +import org.mskcc.oncokb.curation.service.GeneService; import org.mskcc.oncokb.curation.service.TranscriptQueryService; import org.mskcc.oncokb.curation.service.TranscriptService; import org.mskcc.oncokb.curation.service.criteria.TranscriptCriteria; @@ -48,14 +52,18 @@ public class TranscriptResource { private final TranscriptQueryService transcriptQueryService; + private final GeneService geneService; + public TranscriptResource( TranscriptService transcriptService, TranscriptRepository transcriptRepository, - TranscriptQueryService transcriptQueryService + TranscriptQueryService transcriptQueryService, + GeneService geneService ) { this.transcriptService = transcriptService; this.transcriptRepository = transcriptRepository; this.transcriptQueryService = transcriptQueryService; + this.geneService = geneService; } /** @@ -224,4 +232,16 @@ public ResponseEntity alignTranscripts(@RequestBody List bod log.debug("REST request to align existing transcripts"); return ResponseEntity.ok().body(transcriptService.alignTranscripts(body)); } + + @GetMapping("/transcripts/protein-exons") + public ResponseEntity> getProteinExons( + @RequestParam String hugoSymbol, + @RequestParam ReferenceGenome referenceGenome + ) { + log.debug("REST request to get protein exons for gene: {} and reference genome: {}", hugoSymbol, referenceGenome); + Gene gene = geneService + .findGeneByHugoSymbol(hugoSymbol) + .orElseThrow(() -> new BadRequestAlertException("Invalid hugoSymbol", ENTITY_NAME, "genenotfound")); + return ResponseEntity.ok().body(transcriptService.getExons(gene, referenceGenome)); + } } diff --git a/src/main/webapp/app/app.scss b/src/main/webapp/app/app.scss index cc193d79d..2eae997d7 100644 --- a/src/main/webapp/app/app.scss +++ b/src/main/webapp/app/app.scss @@ -440,3 +440,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/constants/regex.spec.ts b/src/main/webapp/app/config/constants/regex.spec.ts index 1a664984a..707893ed4 100644 --- a/src/main/webapp/app/config/constants/regex.spec.ts +++ b/src/main/webapp/app/config/constants/regex.spec.ts @@ -1,4 +1,4 @@ -import { REFERENCE_LINK_REGEX, FDA_SUBMISSION_REGEX } from './regex'; +import { REFERENCE_LINK_REGEX, FDA_SUBMISSION_REGEX, EXON_ALTERATION_REGEX } from './regex'; describe('Regex constants test', () => { describe('Reference link regex', () => { @@ -75,4 +75,22 @@ describe('Regex constants test', () => { expect(FDA_SUBMISSION_REGEX.test(submission)).toEqual(expected); }); }); + + describe('Exon alteration regex', () => { + test.each([ + ['Exon 14 Deletion', true], + ['Exon 14 Duplication', true], + ['Exon 4 Insertion', true], + ['Exon 4-8 Deletion', true], + ['Exon 4 InSERTion', true], + ['Exon 4 Duplication', true], + ['Exon 4 Deletion + Exon 5 Deletion + Exon 6 Deletion', true], + ['Exon 4-8 Deletion + Exon 10 Deletion', true], + ['Exon 4 Deletion+Exon 5 Deletion', true], + ['Exon 14 Del', false], + ['Exon 4 8 Insertion', false], + ])('should return %b for %s', (alteration, expected) => { + expect(EXON_ALTERATION_REGEX.test(alteration)).toEqual(expected); + }); + }); }); diff --git a/src/main/webapp/app/config/constants/regex.ts b/src/main/webapp/app/config/constants/regex.ts index 070f36ed3..4d808db9e 100644 --- a/src/main/webapp/app/config/constants/regex.ts +++ b/src/main/webapp/app/config/constants/regex.ts @@ -9,3 +9,5 @@ export const UUID_REGEX = new RegExp('\\w{8}-\\w{4}-\\w{4}-\\w{4}-\\w{12}'); export const WHOLE_NUMBER_REGEX = new RegExp('^\\d+$'); export const INTEGER_REGEX = /^-?\d+$/; + +export const EXON_ALTERATION_REGEX = /^(Any\s+)?(Exon\s+(\d+)(-(\d+))?\s+(Deletion|Insertion|Duplication))(\s*\+\s*(\1))*/i; diff --git a/src/main/webapp/app/entities/transcript/transcript.store.ts b/src/main/webapp/app/entities/transcript/transcript.store.ts index 740d1829c..54ab4e29b 100644 --- a/src/main/webapp/app/entities/transcript/transcript.store.ts +++ b/src/main/webapp/app/entities/transcript/transcript.store.ts @@ -2,11 +2,23 @@ import { ITranscript } from 'app/shared/model/transcript.model'; import { IRootStore } from 'app/stores'; import PaginationCrudStore from 'app/shared/util/pagination-crud-store'; import { ENTITY_TYPE } from 'app/config/constants/constants'; +import { ReferenceGenome } from 'app/shared/model/enumerations/reference-genome.model'; +import axios, { AxiosResponse } from 'axios'; +import { getEntityResourcePath } from 'app/shared/util/RouteUtils'; +import { ProteinExonDTO } from 'app/shared/api/generated/curation'; + +const apiUrl = getEntityResourcePath(ENTITY_TYPE.TRANSCRIPT); export class TranscriptStore extends PaginationCrudStore { constructor(protected rootStore: IRootStore) { super(rootStore, ENTITY_TYPE.TRANSCRIPT); } + + *getProteinExons(hugoSymbol: string, referenceGenome = ReferenceGenome.GRCh37) { + const url = `${apiUrl}/protein-exons?hugoSymbol=${hugoSymbol}&referenceGenome=${referenceGenome}`; + const result: AxiosResponse = yield axios.get(url); + return result.data; + } } export default TranscriptStore; diff --git a/src/main/webapp/app/hooks/useOverflowDetector.tsx b/src/main/webapp/app/hooks/useOverflowDetector.tsx new file mode 100644 index 000000000..ae8c8c393 --- /dev/null +++ b/src/main/webapp/app/hooks/useOverflowDetector.tsx @@ -0,0 +1,41 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; + +export interface useOverflowDetectorProps { + onChange?: (overflow: boolean) => void; + handleHeight?: boolean; + handleWidth?: boolean; +} + +export function useOverflowDetector(props: useOverflowDetectorProps = {}) { + const [overflow, setOverflow] = useState(false); + const ref = useRef(null); + + const updateState = useCallback(() => { + if (ref.current === null) { + return; + } + + const { handleWidth = true, handleHeight = true } = props; + + const newState = + (handleWidth && ref.current.offsetWidth < ref.current.scrollWidth) || + (handleHeight && ref.current.offsetHeight < ref.current.scrollHeight); + + if (newState === overflow) { + return; + } + setOverflow(newState); + if (props.onChange) { + props.onChange(newState); + } + }, [ref.current, props.handleWidth, props.handleHeight, props.onChange, setOverflow, overflow]); + + useEffect(() => { + updateState(); + }); + + return { + overflow, + ref, + }; +} diff --git a/src/main/webapp/app/hooks/useTextareaAutoHeight.tsx b/src/main/webapp/app/hooks/useTextareaAutoHeight.tsx new file mode 100644 index 000000000..922a502d4 --- /dev/null +++ b/src/main/webapp/app/hooks/useTextareaAutoHeight.tsx @@ -0,0 +1,26 @@ +import React, { useEffect } from 'react'; +import { InputType } from 'zlib'; + +export const useTextareaAutoHeight = ( + inputRef: React.MutableRefObject, + type: InputType | 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/collapsible/mutation-collapsible/MutationCollapsible.tsx b/src/main/webapp/app/pages/curation/collapsible/mutation-collapsible/MutationCollapsible.tsx index 3e7a8143b..4101d6b07 100644 --- a/src/main/webapp/app/pages/curation/collapsible/mutation-collapsible/MutationCollapsible.tsx +++ b/src/main/webapp/app/pages/curation/collapsible/mutation-collapsible/MutationCollapsible.tsx @@ -50,6 +50,7 @@ import { RemovableCollapsible } from '../RemovableCollapsible'; import { Unsubscribe } from 'firebase/database'; import { getLocationIdentifier } from 'app/components/geneHistoryTooltip/gene-history-tooltip-utils'; import MutationCollapsibleTitle from './MutationCollapsibleTitle'; +import NewAddMutationModal from 'app/shared/modal/NewAddMutationModal'; export interface IMutationCollapsibleProps extends StoreProps { mutationPath: string; @@ -581,7 +582,7 @@ const MutationCollapsible = ({ }} /> {isEditingMutation ? ( - {showAddMutationModal && ( - { diff --git a/src/main/webapp/app/shared/badge/DefaultBadge.tsx b/src/main/webapp/app/shared/badge/DefaultBadge.tsx index b10a641af..5a2ff8c2b 100644 --- a/src/main/webapp/app/shared/badge/DefaultBadge.tsx +++ b/src/main/webapp/app/shared/badge/DefaultBadge.tsx @@ -8,13 +8,18 @@ export interface IDefaultBadgeProps { tooltipOverlay?: (() => React.ReactNode) | React.ReactNode; className?: string; style?: React.CSSProperties; + isRoundedPill?: boolean; + onDeleteCallback?: () => void; } const DefaultBadge: React.FunctionComponent = props => { - const { className, style, color, text, 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 = ( - + {text} ); @@ -27,11 +32,7 @@ const DefaultBadge: React.FunctionComponent = props => { ); } - return ( - - {text} - - ); + return badge; }; export default DefaultBadge; diff --git a/src/main/webapp/app/shared/firebase/input/RealtimeBasicInput.tsx b/src/main/webapp/app/shared/firebase/input/RealtimeBasicInput.tsx index d350f6937..7ef65b06c 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', @@ -116,24 +117,7 @@ const RealtimeBasicInput: React.FunctionComponent = (props: }; }, [firebasePath, db]); - useEffect(() => { - const input = inputRef.current; - if (!input || type !== RealtimeInputType.TEXTAREA) { - return; - } - - const resizeObserver = new ResizeObserver(() => { - window.requestAnimationFrame(() => { - input.style.height = 'auto'; - input.style.height = `${input.scrollHeight}px`; - }); - }); - resizeObserver.observe(input); - - return () => { - resizeObserver.disconnect(); - }; - }, []); + 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 bed608210..11a9ef44e 100644 --- a/src/main/webapp/app/shared/modal/AddMutationModal.tsx +++ b/src/main/webapp/app/shared/modal/AddMutationModal.tsx @@ -31,6 +31,12 @@ import InfoIcon from '../icons/InfoIcon'; import { FlagTypeEnum } from '../model/enumerations/flag-type.enum.model'; import { IFlag } from '../model/flag.model'; import { SentryError } from 'app/config/sentry-error'; +import { InputType } from 'reactstrap/types/lib/Input'; +import { useTextareaAutoHeight } from 'app/hooks/useTextareaAutoHeight'; +import AddMutationModalField from './MutationModal/AddMutationModalField'; +import AddMutationModalDropdown, { DropdownOption } from './MutationModal/AddMutationModalDropdown'; +import AddExonForm from './MutationModal/AddExonForm'; +import { useMatchGeneEntity } from 'app/hooks/useMatchGeneEntity'; export type AlterationData = { type: AlterationTypeEnum; @@ -89,6 +95,8 @@ function AddMutationModal({ const consequenceOptions: DropdownOption[] = consequences?.map((consequence): DropdownOption => ({ label: consequence.name, value: consequence.id })) ?? []; + const [isExonCuration, setIsExonCuration] = useState(false); + const [inputValue, setInputValue] = useState(''); const [tabStates, setTabStates] = useState([]); const [excludingInputValue, setExcludingInputValue] = useState(''); @@ -742,6 +750,7 @@ function AddMutationModal({ onChange={newValue => handleFieldChange(newValue?.value, 'type', alterationIndex, excludingIndex)} /> - + {!convertOptions?.isConverting ? ( <> - +
: undefined} - modalBody={modalBody} + modalBody={isExonCuration ? : modalBody} onCancel={onCancel} - onConfirm={async () => { - 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; - } - - 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; - const newAlterationCategories = await handleAlterationCategoriesConfirm(); - newMutation.alteration_categories = newAlterationCategories; - - setErrorMessagesEnabled(false); - setIsConfirmPending(true); - try { - await onConfirm(newMutation, mutationList?.length || 0); - } finally { - setErrorMessagesEnabled(true); - setIsConfirmPending(false); - } - }} + onConfirm={handleConfirm} errorMessages={modalErrorMessage && errorMessagesEnabled ? [modalErrorMessage] : undefined} warningMessages={modalWarningMessage ? [modalWarningMessage] : undefined} confirmButtonDisabled={ @@ -1270,64 +1282,7 @@ function AddMutationModal({ ); } -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} - /> - -
- ); -} - -type DropdownOption = { - label: string; - value: any; -}; -interface IAddMutationModalDropdownProps { - label: string; - value: DropdownOption; - options: DropdownOption[]; - menuPlacement?: MenuPlacement; - onChange: (newValue: DropdownOption | null) => void; -} - -function AddMutationModalDropdown({ label, value, options, menuPlacement, onChange }: IAddMutationModalDropdownProps) { - return ( -
- - {label} - - - - -
- ); -} - -const AddMutationInputOverlay = () => { +export const AddMutationInputOverlay = () => { return (
@@ -1335,7 +1290,7 @@ const AddMutationInputOverlay = () => { Add button to annotate alteration(s).
-
Examples:
+
String Mutation:
  • @@ -1346,6 +1301,15 @@ const AddMutationInputOverlay = () => {
+
Exon:
+
    +
  • + Supported consequences are Insertion, Deletion and Duplication - Exon 4 Deletion +
  • +
  • + Exon range - Exon 4-8 Deletion +
  • +
); 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/AddExonForm.tsx b/src/main/webapp/app/shared/modal/MutationModal/AddExonForm.tsx new file mode 100644 index 000000000..5722aa927 --- /dev/null +++ b/src/main/webapp/app/shared/modal/MutationModal/AddExonForm.tsx @@ -0,0 +1,209 @@ +import React, { useEffect, useMemo, useState } from 'react'; +import { IRootStore } from 'app/stores'; +import { flow } from 'mobx'; +import { componentInject } from 'app/shared/util/typed-inject'; +import { ReferenceGenome } from 'app/shared/model/enumerations/reference-genome.model'; +import { ProteinExonDTO } from 'app/shared/api/generated/curation'; +import { Col, Row } from 'reactstrap'; +import { components, OptionProps } from 'react-select'; +import CreatableSelect from 'react-select/creatable'; +import _ from 'lodash'; +import { parseAlterationName } from 'app/shared/util/utils'; +import { AsyncSaveButton } from 'app/shared/button/AsyncSaveButton'; +import { notifyError } from 'app/oncokb-commons/components/util/NotificationUtils'; +import { EXON_ALTERATION_REGEX } from 'app/config/constants/regex'; +import LoadingIndicator from 'app/oncokb-commons/components/loadingIndicator/LoadingIndicator'; +import classNames from 'classnames'; + +const ANY_EXON_REGEX = /Any Exon (\d+)(-\d+)? (Deletion|Insertion|Duplication)/i; + +export interface IAddExonMutationModalBody extends StoreProps { + hugoSymbol: string; + defaultExonAlterationName?: string; +} + +type ProteinExonDropdownOption = { + label: string; + value: { exon: ProteinExonDTO; name: string } | string; + isSelected: boolean; +}; + +const EXON_CONSEQUENCES = ['Deletion', 'Insertion', 'Duplication']; + +const AddExonForm = ({ + hugoSymbol, + defaultExonAlterationName, + getProteinExons, + updateAlterationStateAfterAlterationAdded, + setShowModifyExonForm, + setAlterationStates, + alterationStates, + selectedAlterationStateIndex, +}: IAddExonMutationModalBody) => { + const [inputValue, setInputValue] = useState(''); + const [selectedExons, setSelectedExons] = useState([]); + const [proteinExons, setProteinExons] = useState([]); + + const [isPendingAddAlteration, setIsPendingAddAlteration] = useState(false); + + const exonOptions = useMemo(() => { + const options: ProteinExonDropdownOption[] = EXON_CONSEQUENCES.flatMap(consequence => { + return proteinExons.map(exon => { + const name = `Exon ${exon.exon} ${consequence}`; + return { label: `Exon ${exon.exon} ${consequence}`, value: { exon, name }, isSelected: false }; + }); + }); + return options; + }, [proteinExons]); + + const defaultSelectedExons = useMemo(() => { + if (!defaultExonAlterationName || exonOptions.length === 0) return []; + const exonAltStrings = defaultExonAlterationName.split('+').map(s => s.trim()); + return exonAltStrings.reduce((acc, exonString) => { + const match = exonString.match(EXON_ALTERATION_REGEX); + if (match) { + if (match[1].trim() === 'Any') { + acc.push({ label: exonString, value: exonString, isSelected: true }); + return acc; + } + const startExon = parseInt(match[3], 10); + const endExon = match[4] ? parseInt(match[5], 10) : startExon; + const consequence = match[6]; + + for (let exonNum = startExon; exonNum <= endExon; exonNum++) { + const targetOption = exonOptions.find(option => option.label === `Exon ${exonNum} ${consequence}`); + if (!targetOption) { + notifyError(`Error parsing alteration: ${defaultExonAlterationName}`); + return acc; + } + acc.push(targetOption); + } + } + return acc; + }, [] as ProteinExonDropdownOption[]); + }, [defaultExonAlterationName, exonOptions]); + + useEffect(() => { + setSelectedExons(defaultSelectedExons ?? []); + }, [defaultSelectedExons]); + + const finalExonName = useMemo(() => { + return selectedExons.map(option => option.label).join(' + '); + }, [selectedExons]); + + useEffect(() => { + getProteinExons?.(hugoSymbol, ReferenceGenome.GRCh37).then(value => setProteinExons(value)); + }, []); + + const standardizeAnyExonInputString = (createValue: string) => { + if (ANY_EXON_REGEX.test(createValue)) { + return createValue + .split(' ') + .map(part => _.capitalize(part)) + .join(' '); + } + return createValue; + }; + + const onCreateOption = (createInputValue: string) => { + const value = standardizeAnyExonInputString(createInputValue); + setSelectedExons(prevState => [...prevState, { label: value, value, isSelected: true }]); + }; + + async function handleAlterationAdded() { + const parsedAlterations = parseAlterationName(finalExonName); + try { + setIsPendingAddAlteration(true); + await updateAlterationStateAfterAlterationAdded?.(parsedAlterations, (defaultSelectedExons ?? []).length > 0); + } finally { + setIsPendingAddAlteration(false); + } + setShowModifyExonForm?.(false); + } + + if (_.isNil(defaultSelectedExons)) { + return ; + } + + return ( + <> + + +
{(defaultSelectedExons?.length ?? 0) > 0 ? 'Modify Selected Exons' : 'Selected Exons'}
+ +
+ + 0 ? 'col-9' : 'col-10')}> + setInputValue(newValue)} + options={exonOptions} + value={selectedExons} + onChange={newOptions => setSelectedExons(newOptions.map(option => ({ ...option, isSelected: true })))} + components={{ + Option: MultiSelectOption, + NoOptionsMessage, + }} + isMulti + closeMenuOnSelect={false} + hideSelectedOptions={false} + isClearable + isValidNewOption={createInputValue => { + return ANY_EXON_REGEX.test(createInputValue); + }} + onCreateOption={onCreateOption} + /> + + + 0 ? 'Update' : 'Add'} + disabled={isPendingAddAlteration || selectedExons.length === 0} + /> + + + {selectedExons.length > 0 && ( + + + Name preview: {finalExonName} + + + )} + + ); +}; + +const NoOptionsMessage = props => { + return ( + +
No options
+
Create a new option in the correct format:
+
{'Any Exon start-end (Deletion|Insertion|Duplication)'}
+
+ ); +}; + +const MultiSelectOption = (props: OptionProps) => { + return ( +
+ + {(props.data as any).__isNew__ ? <> : null} />}{' '} + + +
+ ); +}; + +const mapStoreToProps = ({ transcriptStore, addMutationModalStore }: IRootStore) => ({ + getProteinExons: flow(transcriptStore.getProteinExons), + updateAlterationStateAfterAlterationAdded: addMutationModalStore.updateAlterationStateAfterAlterationAdded, + setShowModifyExonForm: addMutationModalStore.setShowModifyExonForm, + alterationStates: addMutationModalStore.alterationStates, + setAlterationStates: addMutationModalStore.setAlterationStates, + selectedAlterationStateIndex: addMutationModalStore.selectedAlterationStateIndex, +}); + +type StoreProps = Partial>; + +export default componentInject(mapStoreToProps)(AddExonForm); 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..cf008b479 --- /dev/null +++ b/src/main/webapp/app/shared/modal/MutationModal/AddMutationModalField.tsx @@ -0,0 +1,49 @@ +import React from 'react'; +import { useTextareaAutoHeight } from 'app/hooks/useTextareaAutoHeight'; +import { useRef } from 'react'; +import { Col, 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..bd3b39c59 --- /dev/null +++ b/src/main/webapp/app/shared/modal/MutationModal/AlterationBadgeList.tsx @@ -0,0 +1,173 @@ +import DefaultTooltip from 'app/shared/tooltip/DefaultTooltip'; +import classNames from 'classnames'; +import React, { useEffect } 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 '../NewAddMutationModal'; +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 { faComment as farComment } from '@fortawesome/free-regular-svg-icons'; +import { faComment as fasComment } from '@fortawesome/free-solid-svg-icons'; +import AlterationCategoryInputs from './AlterationCategoryInputs'; +import { Input } from 'reactstrap'; +import { useOverflowDetector } from 'app/hooks/useOverflowDetector'; + +export interface IAlterationBadgeList extends StoreProps { + alterationData: AlterationData[]; +} + +const AlterationBadgeList = ({ + alterationStates, + alterationCategoryComment, + setAlterationStates, + selectedAlterationStateIndex, + setSelectedAlterationStateIndex, + setAlterationCategoryComment, + selectedAlterationCategoryFlags, +}: StoreProps) => { + useEffect(() => { + if (!alterationStates) return; + if ((selectedAlterationStateIndex ?? -1) >= alterationStates.length) { + setSelectedAlterationStateIndex?.(alterationStates.length - 1); + } + }, [alterationStates?.length, selectedAlterationStateIndex]); + + const showAlterationCategoryDropdown = (alterationStates ?? []).length > 1; + const showAlterationCategoryComment = showAlterationCategoryDropdown && (selectedAlterationCategoryFlags ?? []).length > 0; + + return ( + <> +
+
Current Mutation List
+ {showAlterationCategoryComment && ( +
+ setAlterationCategoryComment?.(event.target.value)} + /> + } + > + + +
+ )} +
+
+ {showAlterationCategoryDropdown && } +
+ {alterationStates?.map((value, index) => { + const fullAlterationName = getFullAlterationName(value, false); + return ( + setSelectedAlterationStateIndex?.(index)} + onDelete={() => { + setAlterationStates?.( + alterationStates.filter(alterationState => getFullAlterationName(value) !== getFullAlterationName(alterationState)), + ); + }} + /> + ); + })} +
+
+ + ); +}; + +const mapStoreToProps = ({ addMutationModalStore }: IRootStore) => ({ + alterationStates: addMutationModalStore.alterationStates, + setShowModifyExonForm: addMutationModalStore.setShowModifyExonForm, + alterationCategoryComment: addMutationModalStore.alterationCategoryComment, + selectedAlterationCategoryFlags: addMutationModalStore.selectedAlterationCategoryFlags, + setAlterationStates: addMutationModalStore.setAlterationStates, + selectedAlterationStateIndex: addMutationModalStore.selectedAlterationStateIndex, + setSelectedAlterationStateIndex: addMutationModalStore.setSelectedAlterationStateIndex, + setAlterationCategoryComment: addMutationModalStore.setAlterationCategoryComment, +}); + +type StoreProps = Partial>; + +export default componentInject(mapStoreToProps)(AlterationBadgeList); + +interface IAlterationBadge { + alterationData: AlterationData; + alterationName: string; + isSelected: boolean; + onClick: () => void; + onDelete: () => void; +} + +const AlterationBadge = ({ alterationData, alterationName, isSelected, onClick, onDelete }: IAlterationBadge) => { + const { ref, overflow } = useOverflowDetector({ handleHeight: false }); + + function getBackgroundColor() { + if (alterationData.error) { + return 'danger'; + } + if (alterationData.warning) { + return 'warning'; + } + return 'success'; + } + + function getStatusIcon() { + if (alterationData.error) { + return ; + } + if (alterationData.warning) { + ; + } + return <>; + } + + const badgeComponent = ( +
+
+ {/* {getStatusIcon()} */} +
+ {alterationName} +
+
+
+ +
+
+ ); + + if (overflow) { + 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..883da964e --- /dev/null +++ b/src/main/webapp/app/shared/modal/MutationModal/AlterationCategoryInputs.tsx @@ -0,0 +1,138 @@ +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 { DropdownOption } from './AddMutationModalDropdown'; +import { componentInject } from 'app/shared/util/typed-inject'; +import { IRootStore } from 'app/stores'; + +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)); + } + }; + + const reactSelectStyles = { + container: (provided, state) => ({ + ...provided, + padding: 0, + height: 'fit-content', + }), + control: (provided, state) => ({ + ...provided, + minHeight: 'fit-content', + height: 'fit-content', + }), + indicatorsContainer: (provided, state) => ({ + ...provided, + height: '29px', + }), + input: (provided, state) => ({ + ...provided, + height: '21px', + }), + }; + + 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/AnnotatedAlterationContent.tsx b/src/main/webapp/app/shared/modal/MutationModal/AnnotatedAlterationContent.tsx new file mode 100644 index 000000000..0450a3358 --- /dev/null +++ b/src/main/webapp/app/shared/modal/MutationModal/AnnotatedAlterationContent.tsx @@ -0,0 +1,273 @@ +import React, { useEffect, useMemo, useState } from 'react'; +import { AlterationData } from '../NewAddMutationModal'; +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 } from 'app/shared/util/utils'; +import AnnotatedAlterationErrorContent from './AnnotatedAlterationErrorContent'; + +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 IAnnotatedAlterationContent extends StoreProps { + alterationData: AlterationData; + excludingIndex?: number; +} + +const AnnotatedAlterationContent = ({ + alterationData, + excludingIndex, + getConsequences, + consequences, + selectedAlterationStateIndex, + handleExcludingFieldChange, + handleNormalFieldChange, + isFetchingAlteration, + isFetchingExcludingAlteration, + handleAlterationChange, +}: IAnnotatedAlterationContent) => { + useEffect(() => { + getConsequences?.({}); + }, []); + + const consequenceOptions: DropdownOption[] = + consequences?.map((consequence): DropdownOption => ({ label: consequence.name, value: consequence.id })) ?? []; + + if (alterationData === undefined || selectedAlterationStateIndex === undefined) return <>; + + 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')} + /> + handleFieldChange(newValue, 'proteinChange')} + /> +
+ ); + }; + + 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, selectedAlterationStateIndex, excludingIndex) + : 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)(AnnotatedAlterationContent); 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..0d4acd6ce --- /dev/null +++ b/src/main/webapp/app/shared/modal/MutationModal/AnnotatedAlterationErrorContent.tsx @@ -0,0 +1,69 @@ +import React from 'react'; +import { AlterationData } from '../NewAddMutationModal'; +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..07bf4b05a --- /dev/null +++ b/src/main/webapp/app/shared/modal/MutationModal/ExcludedAlterationContent.tsx @@ -0,0 +1,144 @@ +import { IRootStore } from 'app/stores'; +import React, { useEffect, useMemo, useState } from 'react'; +import { componentInject } from '../../util/typed-inject'; +import { FaChevronDown, FaChevronUp, FaPlus } from 'react-icons/fa'; +import { Button, Col, Row } from 'reactstrap'; +import CreatableSelect from 'react-select/creatable'; +import { getFullAlterationName, parseAlterationName } from '../../util/utils'; +import { AlterationData } from '../NewAddMutationModal'; +import { components, MultiValueGenericProps } from 'react-select'; +import * as styles from './styles.module.scss'; +import AnnotatedAlterationContent from './AnnotatedAlterationContent'; +import { AsyncSaveButton } from 'app/shared/button/AsyncSaveButton'; + +export interface IExcludedAlterationContent extends StoreProps {} + +const ExcludedAlterationContent = ({ + alterationStates, + selectedAlterationStateIndex, + updateAlterationStateAfterExcludedAlterationAdded, + setAlterationStates, +}: IExcludedAlterationContent) => { + const [excludingCollapsed, setExcludingCollapsed] = useState(false); + const [excludingInputValue, setExcludingInputValue] = useState(''); + const [isAddExcludedAlterationPending, setIsAddExcludedAlterationPending] = useState(false); + const [selectedExcludedAlteration, setSelectedExcludedAlteration] = useState(null); + + if (alterationStates === undefined || selectedAlterationStateIndex === undefined) return <>; + + const excludedAlterationIndex = useMemo(() => { + const excludingArray = alterationStates[selectedAlterationStateIndex].excluding; + return excludingArray.findIndex(ea => ea.alteration === selectedExcludedAlteration); + }, [selectedExcludedAlteration]); + + const handleAlterationAddedExcluding = async () => { + try { + setIsAddExcludedAlterationPending(true); + await updateAlterationStateAfterExcludedAlterationAdded?.(parseAlterationName(excludingInputValue)); + } finally { + setIsAddExcludedAlterationPending(false); + } + 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 + {!isSectionEmpty && ( + <> + {excludingCollapsed ? ( + setExcludingCollapsed(false)} /> + ) : ( + setExcludingCollapsed(true)} /> + )} + + )} + + + setSelectedExcludedAlteration(label)} />, + }} + isMulti + menuIsOpen={false} + placeholder="Enter alteration(s)" + inputValue={excludingInputValue} + onInputChange={(newInput, { action }) => { + if (action !== 'menu-close' && action !== 'input-blur') { + setExcludingInputValue(newInput); + } + }} + value={alterationStates[selectedAlterationStateIndex].excluding.map(state => { + const fullAlterationName = getFullAlterationName(state, false); + return { label: fullAlterationName, value: fullAlterationName, ...state }; + })} + onChange={(newAlterations: readonly AlterationData[]) => { + alterationStates[selectedAlterationStateIndex].excluding = alterationStates[selectedAlterationStateIndex].excluding.filter( + state => newAlterations.some(alt => getFullAlterationName(alt) === getFullAlterationName(state)), + ); + setAlterationStates?.(alterationStates); + }} + onKeyDown={handleKeyDownExcluding} + /> + + + + +
+ {!isSectionEmpty && !excludingCollapsed && ( + + + + + + )} + + ); +}; + +interface CustomMultiValueLabelProps extends MultiValueGenericProps { + onClick: (label: string) => void; +} +const MultiValueLabel = (props: CustomMultiValueLabelProps) => { + return ( +
{ + props.onClick(props.data.label); + }} + > + +
+ ); +}; + +const mapStoreToProps = ({ addMutationModalStore }: IRootStore) => ({ + alterationStates: addMutationModalStore.alterationStates, + selectedAlterationStateIndex: addMutationModalStore.selectedAlterationStateIndex, + handleExcludingFieldChange: addMutationModalStore.handleExcludingFieldChange, + isFetchingExcludingAlteration: addMutationModalStore.isFetchingExcludingAlteration, + updateAlterationStateAfterExcludedAlterationAdded: addMutationModalStore.updateAlterationStateAfterExcludedAlterationAdded, + setAlterationStates: addMutationModalStore.setAlterationStates, +}); + +type StoreProps = Partial>; + +export default componentInject(mapStoreToProps)(ExcludedAlterationContent); 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..b4aac67bd --- /dev/null +++ b/src/main/webapp/app/shared/modal/MutationModal/add-mutation-modal.store.ts @@ -0,0 +1,390 @@ +import { Mutation, VusObjList } from 'app/shared/model/firebase/firebase.model'; +import { action, computed, flow, flowResult, makeObservable, observable } from 'mobx'; +import { AlterationData } from '../AddMutationModal'; +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 } 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'; + +type SelectedFlag = IFlag | Omit; + +export class AddMutationModalStore { + private alterationStore: AlterationStore; + + public geneEntity: IGene | null = null; + public mutationToEdit: Mutation | null = null; + public vusList: VusObjList | null = null; + public alterationStates: AlterationData[] = []; + + public selectedAlterationStateIndex = -1; + + public showModifyExonForm = false; + + public isFetchingAlteration = false; + public isFetchingExcludingAlteration = false; + + public selectedAlterationCategoryFlags: SelectedFlag[] = []; + public alterationCategoryComment: string = ''; + + constructor(alterationStore: AlterationStore) { + this.alterationStore = alterationStore; + makeObservable(this, { + geneEntity: observable, + mutationToEdit: observable, + vusList: observable, + alterationStates: observable, + selectedAlterationStateIndex: observable, + showModifyExonForm: 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, + setAlterationStates: action.bound, + setSelectedAlterationStateIndex: action.bound, + setSelectedAlterationCategoryFlags: action.bound, + setAlterationCategoryComment: action.bound, + handleAlterationChange: 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, + }); + } + + 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; + } + + setAlterationStates(newAlterationStates: AlterationData[]) { + this.alterationStates = newAlterationStates; + } + + setSelectedAlterationStateIndex(index: number) { + this.selectedAlterationStateIndex = 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, newParsedAlteration[0].excluding[index], [], ''), + ); + const newAlterations = newEntityStatusAlterations.map((alt, index) => + convertEntityStatusAlterationToAlterationData( + alt, + newParsedAlteration[index].alteration, + _.cloneDeep(newExcludingAlterations), + newParsedAlteration[index].comment, + newParsedAlteration[index].name, + ), + ); + + if (isUpdate) { + this.alterationStates[this.selectedAlterationStateIndex] = newAlterations[0]; + } 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, parsedAlterations[index].alteration, [], newComment, newVariantName), + ); + + this.alterationStates[this.selectedAlterationStateIndex].excluding.push(...newAlterations); + } + + async handleAlterationChange(newValue: string, alterationIndex: number, excludingIndex?: number, isDebounced = true) { + if (!_.isNil(excludingIndex)) { + this.isFetchingExcludingAlteration = true; + + if (isDebounced) { + this.handleExcludingFieldChange(newValue, 'alteration', alterationIndex, excludingIndex); + _.debounce(async () => await this.fetchExcludedAlteration(newValue, alterationIndex, excludingIndex), 1000); + } else { + await this.fetchExcludedAlteration(newValue, alterationIndex, excludingIndex); + 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, alterationIndex: number, excludingIndex: number) { + this.alterationStates[alterationIndex].excluding[excludingIndex][field as string] = newValue; + } + + async fetchExcludedAlteration(newAlteration: string, alterationIndex: number, excludingIndex: number) { + 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')); + this.alterationStates[alterationIndex].excluding.splice(excludingIndex, 1); + 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].alteration, + [], + newParsedAlteration[index].comment, + ) + : undefined, + ) + .filter(hasValue), + ]; + + this.alterationStates[alterationIndex].excluding.splice(excludingIndex, 1, ...newAlterations); + } + + handleNormalAlterationChange(newValue: string, alterationIndex: number) { + this.alterationStates[alterationIndex].alterationFieldValueWhileFetching = newValue; + + _.debounce(() => this.fetchNormalAlteration(newValue, alterationIndex), 1000); + } + + handleNormalFieldChange(newValue: string, field: keyof AlterationData, alterationIndex: number) { + this.alterationStates[alterationIndex][field as string] = newValue; + } + + async fetchNormalAlteration(newAlteration: string, alterationIndex: number) { + const newParsedAlteration = this.filterAlterationsAndNotify(parseAlterationName(newAlteration), alterationIndex); + if (newParsedAlteration.length === 0) { + this.alterationStates[alterationIndex].alterationFieldValueWhileFetching = undefined; + } + + 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, newParsedAlteration[0].excluding[index], [], ''), + ) ?? []; + } + + const alterationPromises: Promise[] = []; + let newAlterations: AlterationData[] = []; + if (newParsedAlteration[0].alteration !== this.alterationStates[alterationIndex]?.alteration) { + alterationPromises.push(this.fetchAlteration(newParsedAlteration[0].alteration)); + } else { + this.alterationStates[alterationIndex].excluding = newExcluding; + this.alterationStates[alterationIndex].comment = newComment; + this.alterationStates[alterationIndex].name = newVariantName || newParsedAlteration[0].alteration; + newAlterations.push(this.alterationStates[alterationIndex]); + } + + 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, + newParsedAlteration[index + newAlterations.length].alteration, + newExcluding, + newComment, + newVariantName, + ), + ), + ]; + newAlterations[0].alterationFieldValueWhileFetching = undefined; + + this.alterationStates.splice(alterationIndex, 1, ...newAlterations); + } + + 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.showModifyExonForm = false; + this.isFetchingAlteration = false; + this.isFetchingExcludingAlteration = false; + this.selectedAlterationCategoryFlags = []; + this.alterationCategoryComment = ''; + } +} 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..a34eab4bb --- /dev/null +++ b/src/main/webapp/app/shared/modal/MutationModal/styles.module.scss @@ -0,0 +1,53 @@ +@import '../../../variables.scss'; + +.alterationBadge { + font-size: 14px; + font-weight: normal; + padding: 0; + display: flex; + align-items: center; + max-width: 98%; + 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; +} + +.actionWrapper { + flex-shrink: 0; + display: flex; + border-left: 1px dashed; +} + +// .editButton { +// display: flex; +// flex-direction: column; +// justify-content: center; +// padding-left: 5px; +// margin-left: 5px; +// border-left: 1px dashed; +// cursor: pointer; +// } + +.deleteButton { + display: flex; + flex-direction: column; + justify-content: center; + padding: 7px; + cursor: pointer; +} + +.excludedAlterationValueContainer { + &:hover { + cursor: pointer; + } +} diff --git a/src/main/webapp/app/shared/modal/NewAddMutationModal.tsx b/src/main/webapp/app/shared/modal/NewAddMutationModal.tsx new file mode 100644 index 000000000..c1f4d1cdd --- /dev/null +++ b/src/main/webapp/app/shared/modal/NewAddMutationModal.tsx @@ -0,0 +1,478 @@ +import { IRootStore } from 'app/stores'; +import { onValue, ref } from 'firebase/database'; +import _ from 'lodash'; +import { flow } from 'mobx'; +import React, { KeyboardEventHandler, useEffect, useState } from 'react'; +import { Button, 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, getFirebaseVusPath } from '../util/firebase/firebase-utils'; +import { componentInject } from '../util/typed-inject'; +import { + isEqualIgnoreCase, + parseAlterationName, + convertEntityStatusAlterationToAlterationData, + convertAlterationDataToAlteration, + convertAlterationToAlterationData, + convertIFlagToFlag, +} from '../util/utils'; +import { DefaultAddMutationModal } from './DefaultAddMutationModal'; +import './add-mutation-modal.scss'; +import { Unsubscribe } from 'firebase/database'; +import InfoIcon from '../icons/InfoIcon'; +import { FlagTypeEnum } from '../model/enumerations/flag-type.enum.model'; +import AddExonForm from './MutationModal/AddExonForm'; +import AlterationBadgeList from './MutationModal/AlterationBadgeList'; +import { AddMutationInputOverlay } from './AddMutationModal'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { faPlus } from '@fortawesome/free-solid-svg-icons'; +import { AsyncSaveButton } from '../button/AsyncSaveButton'; +import AnnotatedAlterationContent from './MutationModal/AnnotatedAlterationContent'; +import ExcludedAlterationContent from './MutationModal/ExcludedAlterationContent'; +import { EXON_ALTERATION_REGEX } from 'app/config/constants/regex'; + +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; + isGermline: boolean; + onConfirm: (mutation: Mutation, mutationFirebaseIndex: number) => Promise; + onCancel: () => void; + mutationToEditPath?: string | null; + convertOptions?: { + alteration: string; + isConverting: boolean; + }; +} + +type MutationExistsMeta = { + exists: boolean; + inMutationList: boolean; + inVusList: boolean; +}; + +function NewAddMutationModal({ + hugoSymbol, + isGermline, + mutationToEditPath, + mutationList, + geneEntities, + onConfirm, + onCancel, + firebaseDb, + convertOptions, + getFlagsByType, + createFlagEntity, + alterationCategoryFlagEntities, + setVusList, + setMutationToEdit, + alterationStates, + vusList, + mutationToEdit, + setShowModifyExonForm, + isFetchingAlteration, + isFetchingExcludingAlteration, + currentMutationNames, + showModifyExonForm, + cleanup, + fetchAlterations, + setAlterationStates, + selectedAlterationCategoryFlags, + alterationCategoryComment, + setGeneEntity, + updateAlterationStateAfterAlterationAdded, + selectedAlterationStateIndex, +}: IAddMutationModalProps) { + const [inputValue, setInputValue] = useState(''); + const [mutationAlreadyExists, setMutationAlreadyExists] = useState({ + exists: false, + inMutationList: false, + inVusList: false, + }); + + const [isAddAlterationPending, setIsAddAlterationPending] = useState(false); + + const [errorMessagesEnabled, setErrorMessagesEnabled] = useState(true); + const [isConfirmPending, setIsConfirmPending] = useState(false); + + useEffect(() => { + if (!firebaseDb) { + return; + } + const callbacks: Unsubscribe[] = []; + callbacks.push( + onValue(ref(firebaseDb, getFirebaseVusPath(isGermline, hugoSymbol)), snapshot => { + setVusList?.(snapshot.val()); + }), + ); + + if (mutationToEditPath) { + callbacks.push( + onValue(ref(firebaseDb, mutationToEditPath), snapshot => { + 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(); + } + }, [convertOptions?.isConverting]); + + useEffect(() => { + const dupMutations = getDuplicateMutations(currentMutationNames ?? [], mutationList ?? [], 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(() => { + async function setExistingAlterations() { + if (mutationToEdit?.alterations?.length !== undefined && mutationToEdit.alterations.length > 0) { + setAlterationStates?.(mutationToEdit?.alterations?.map(alt => convertAlterationToAlterationData(alt)) ?? []); + return; + } + + // at this point can be sure each alteration name does not have / character + const parsedAlterations = mutationToEdit?.name?.split(',').map(name => parseAlterationName(name.trim())[0]); + + const entityStatusAlterationsPromise = fetchAlterations?.(parsedAlterations?.map(alt => alt.alteration) ?? []); + if (!entityStatusAlterationsPromise) return; + const excludingEntityStatusAlterationsPromises: Promise[] = []; + for (const alt of parsedAlterations ?? []) { + const fetchedAlterations = fetchAlterations?.(alt.excluding); + if (fetchedAlterations) { + excludingEntityStatusAlterationsPromises.push(fetchedAlterations); + } + } + const [entityStatusAlterations, entityStatusExcludingAlterations] = await Promise.all([ + entityStatusAlterationsPromise, + Promise.all(excludingEntityStatusAlterationsPromises), + ]); + + const excludingAlterations: AlterationData[][] = []; + if (parsedAlterations) { + 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], + [], + '', + ), + ); + } + excludingAlterations.push(excluding); + } + } + + if (parsedAlterations) { + const newAlerationStates = entityStatusAlterations.map((alt, index) => + convertEntityStatusAlterationToAlterationData( + alt, + parsedAlterations[index].alteration, + excludingAlterations[index] || [], + parsedAlterations[index].comment, + parsedAlterations[index].name, + ), + ); + + setAlterationStates?.(newAlerationStates); + } + } + + if (mutationToEdit) { + setExistingAlterations(); + } + }, [mutationToEdit]); + + async function handleAlterationAdded() { + let alterationString = inputValue; + if (convertOptions?.isConverting) { + alterationString = convertOptions.alteration; + } + try { + setIsAddAlterationPending(true); + await updateAlterationStateAfterAlterationAdded?.(parseAlterationName(alterationString)); + } finally { + setIsAddAlterationPending(false); + } + setInputValue(''); + } + + 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 newAlterationCategories = await handleAlterationCategoriesConfirm(); + newMutation.alteration_categories = newAlterationCategories; + + setErrorMessagesEnabled(false); + setIsConfirmPending(true); + try { + await onConfirm(newMutation, mutationList?.length || 0); + } finally { + setErrorMessagesEnabled(true); + setIsConfirmPending(false); + } + } + + async function handleAlterationCategoriesConfirm() { + let newAlterationCategories: AlterationCategories | null = new AlterationCategories(); + if (selectedAlterationCategoryFlags?.length === 0 || alterationStates?.length === 1) { + newAlterationCategories = null; + } else { + newAlterationCategories.comment = alterationCategoryComment ?? ''; + const finalFlagArray = await saveNewFlags(); + if ((selectedAlterationCategoryFlags ?? []).length > 0) { + newAlterationCategories.flags = finalFlagArray.map(flag => convertIFlagToFlag(flag)); + } + } + + // Refresh flag entities + await getFlagsByType?.(FlagTypeEnum.ALTERATION_CATEGORY); + + return newAlterationCategories; + } + + async function saveNewFlags() { + const [newFlags, oldFlags] = _.partition(selectedAlterationCategoryFlags ?? [], newFlag => { + return !alterationCategoryFlagEntities?.some(existingFlag => { + return newFlag.type === existingFlag.type && newFlag.flag === existingFlag.flag; + }); + }); + 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); + } + } + } + + return oldFlags; + } + + const handleKeyDown: KeyboardEventHandler = event => { + if (!inputValue) return; + if (event.key === 'Enter' || event.key === 'tab') { + handleAlterationAdded(); + event.preventDefault(); + } + }; + + const handleCancel = () => { + cleanup?.(); + onCancel(); + }; + + const mutationModalAlterationInputHeader = ( + + + + setInputValue(value.target.value)} + onClick={() => setShowModifyExonForm?.(false)} + /> + + }> + + + + + + OR + + + + + + ); + + const mutationModalBody = ( +
+ {mutationModalAlterationInputHeader} + {showModifyExonForm ? ( + <> +
+ + + ) : alterationStates?.length !== 0 ? ( + <> +
+ + + + + + + ) : undefined} + {alterationStates !== undefined && + selectedAlterationStateIndex !== undefined && + selectedAlterationStateIndex > -1 && + !_.isNil(alterationStates[selectedAlterationStateIndex]) && ( + <> +
+ {EXON_ALTERATION_REGEX.test(alterationStates[selectedAlterationStateIndex].alteration) ? ( + + ) : ( + <> + + + + )} + + )} +
+ ); + + const modalErrorMessage = getModalErrorMessage(mutationAlreadyExists); + + let modalWarningMessage: string | undefined = undefined; + if (convertOptions?.isConverting && !isEqualIgnoreCase(convertOptions.alteration, (currentMutationNames ?? []).join(', '))) { + modalWarningMessage = 'Name differs from original VUS name'; + } + + return ( + Promoting Variant(s) to Mutation : undefined} + modalBody={mutationModalBody} + onCancel={handleCancel} + onConfirm={handleConfirm} + errorMessages={modalErrorMessage && errorMessagesEnabled ? [modalErrorMessage] : undefined} + warningMessages={modalWarningMessage ? [modalWarningMessage] : undefined} + confirmButtonDisabled={ + alterationStates?.length === 0 || + mutationAlreadyExists.exists || + isFetchingAlteration || + isFetchingExcludingAlteration || + alterationStates?.some(tab => tab.error || tab.excluding.some(ex => ex.error)) || + isConfirmPending + } + isConfirmPending={isConfirmPending} + /> + ); +} + +const mapStoreToProps = ({ + alterationStore, + consequenceStore, + geneStore, + firebaseAppStore, + firebaseVusStore, + firebaseMutationListStore, + flagStore, + addMutationModalStore, +}: 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, +}); + +type StoreProps = Partial>; + +export default componentInject(mapStoreToProps)(NewAddMutationModal); 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/util/utils.tsx b/src/main/webapp/app/shared/util/utils.tsx index 80ce84594..1ad9aeed5 100644 --- a/src/main/webapp/app/shared/util/utils.tsx +++ b/src/main/webapp/app/shared/util/utils.tsx @@ -9,15 +9,17 @@ import EntityActionButton from '../button/EntityActionButton'; import { SORT } from './pagination.constants'; import { PaginationState } from '../table/OncoKBAsyncTable'; import { IUser } from '../model/user.model'; -import { Alteration, CancerType } from '../model/firebase/firebase.model'; +import { Alteration, CancerType, 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 InfoIcon from '../icons/InfoIcon'; +import { AlterationData } from '../modal/NewAddMutationModal'; +import { IFlag } from '../model/flag.model'; export const getCancerTypeName = (cancerType: ICancerType | CancerType, omitCode = false): string => { if (!cancerType) return ''; @@ -303,6 +305,89 @@ export function parseAlterationName( })); } +export function getFullAlterationName(alterationData: AlterationData, includeVariantName = true) { + const variantName = includeVariantName && alterationData.name !== alterationData.alteration ? alterationData.name : ''; + const excluding = alterationData.excluding.length > 0 ? alterationData.excluding.map(ex => ex.alteration) : []; + const comment = alterationData.comment ? alterationData.comment : ''; + return buildAlterationName(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 the backend's response is different from the frontend response, set them equal to each other. + if (alteration?.alteration !== alterationName) { + alterationData.alteration = alteration?.alteration ?? ''; + } + + 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 { 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, + }; +} + +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}]`; diff --git a/src/main/webapp/app/stores/createStore.ts b/src/main/webapp/app/stores/createStore.ts index fb74611cf..9c87143bb 100644 --- a/src/main/webapp/app/stores/createStore.ts +++ b/src/main/webapp/app/stores/createStore.ts @@ -104,6 +104,7 @@ import CategoricalAlterationStore from 'app/entities/categorical-alteration/cate import { WindowStore } from './window-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; @@ -148,6 +149,7 @@ export interface IRootStore { readonly modifyTherapyModalStore: ModifyTherapyModalStore; readonly relevantCancerTypesModalStore: RelevantCancerTypesModalStore; readonly openMutationCollapsibleStore: OpenMutationCollapsibleStore; + readonly addMutationModalStore: AddMutationModalStore; readonly flagStore: FlagStore; readonly commentStore: CommentStore; /* Firebase stores */ @@ -214,6 +216,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 */ From 31927a6444877da1b04b56ce4308ee2f592eb457 Mon Sep 17 00:00:00 2001 From: Calvin Lu <59149377+calvinlu3@users.noreply.github.com> Date: Wed, 9 Oct 2024 19:18:49 -0400 Subject: [PATCH 2/8] Update with latest feedback --- src/main/webapp/app/config/colors.ts | 6 + .../webapp/app/config/constants/constants.ts | 13 +- .../MutationCollapsible.tsx | 1 - .../curation/mutation/MutationsSection.tsx | 1 - .../app/shared/icons/InputFieldIcon.tsx | 19 ++ .../modal/MutationModal/AddExonForm.tsx | 47 +++-- .../MutationModal/AlterationBadgeList.tsx | 183 ++++++++++-------- .../AlterationCategoryInputs.tsx | 2 +- .../AnnotatedAlterationContent.tsx | 4 +- .../ExcludedAlterationContent.tsx | 79 ++------ .../MutationModal/MutationListSection.tsx | 77 ++++++++ .../MutationModal/add-mutation-modal.store.ts | 82 ++++++-- .../modal/MutationModal/styles.module.scss | 39 ++-- .../app/shared/modal/NewAddMutationModal.tsx | 140 +++++++++----- src/main/webapp/app/shared/table/VusTable.tsx | 8 +- src/main/webapp/app/shared/util/utils.tsx | 2 +- src/main/webapp/app/variables.scss | 4 + 17 files changed, 459 insertions(+), 248 deletions(-) create mode 100644 src/main/webapp/app/shared/icons/InputFieldIcon.tsx create mode 100644 src/main/webapp/app/shared/modal/MutationModal/MutationListSection.tsx diff --git a/src/main/webapp/app/config/colors.ts b/src/main/webapp/app/config/colors.ts index ee1855203..27643913f 100644 --- a/src/main/webapp/app/config/colors.ts +++ b/src/main/webapp/app/config/colors.ts @@ -27,3 +27,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 4efaa83d0..00b3efc00 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 = { @@ -441,3 +441,14 @@ export const KEYCLOAK_UNAUTHORIZED_PARAM = 'unauthorized'; */ export const PRIORITY_ENTITY_MENU_ITEM_KEY = 'oncokbCuration-entityMenuPriorityKey'; export const SOMATIC_GERMLINE_SETTING_KEY = 'oncokbCuration-somaticGermlineSettingKey'; + +/** + * 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/pages/curation/collapsible/mutation-collapsible/MutationCollapsible.tsx b/src/main/webapp/app/pages/curation/collapsible/mutation-collapsible/MutationCollapsible.tsx index 4101d6b07..dffcd7937 100644 --- a/src/main/webapp/app/pages/curation/collapsible/mutation-collapsible/MutationCollapsible.tsx +++ b/src/main/webapp/app/pages/curation/collapsible/mutation-collapsible/MutationCollapsible.tsx @@ -16,7 +16,6 @@ import CommentIcon from 'app/shared/icons/CommentIcon'; import EditIcon from 'app/shared/icons/EditIcon'; import HotspotIcon from 'app/shared/icons/HotspotIcon'; 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, AlterationCategories } from 'app/shared/model/firebase/firebase.model'; diff --git a/src/main/webapp/app/pages/curation/mutation/MutationsSection.tsx b/src/main/webapp/app/pages/curation/mutation/MutationsSection.tsx index 4ba976043..5b217f0e4 100644 --- a/src/main/webapp/app/pages/curation/mutation/MutationsSection.tsx +++ b/src/main/webapp/app/pages/curation/mutation/MutationsSection.tsx @@ -1,5 +1,4 @@ import { notifyError } from 'app/oncokb-commons/components/util/NotificationUtils'; -import AddMutationModal from 'app/shared/modal/AddMutationModal'; import { Mutation } from 'app/shared/model/firebase/firebase.model'; import { FlattenedHistory } from 'app/shared/util/firebase/firebase-history-utils'; import { diff --git a/src/main/webapp/app/shared/icons/InputFieldIcon.tsx b/src/main/webapp/app/shared/icons/InputFieldIcon.tsx new file mode 100644 index 000000000..e023be2fe --- /dev/null +++ b/src/main/webapp/app/shared/icons/InputFieldIcon.tsx @@ -0,0 +1,19 @@ +import { IconDefinition } from '@fortawesome/free-regular-svg-icons'; +import React from 'react'; +import DefaultTooltip from '../tooltip/DefaultTooltip'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { Input } from 'reactstrap'; + +export interface IInputFieldIcon { + icon: IconDefinition; + onInputChange: () => {}; + inputPlaceholder: string; +} + +const InputFieldIcon = ({ icon, onInputChange, inputPlaceholder }: IInputFieldIcon) => { + return ( + }> + + + ); +}; diff --git a/src/main/webapp/app/shared/modal/MutationModal/AddExonForm.tsx b/src/main/webapp/app/shared/modal/MutationModal/AddExonForm.tsx index 5722aa927..d583f1dbf 100644 --- a/src/main/webapp/app/shared/modal/MutationModal/AddExonForm.tsx +++ b/src/main/webapp/app/shared/modal/MutationModal/AddExonForm.tsx @@ -14,8 +14,7 @@ import { notifyError } from 'app/oncokb-commons/components/util/NotificationUtil import { EXON_ALTERATION_REGEX } from 'app/config/constants/regex'; import LoadingIndicator from 'app/oncokb-commons/components/loadingIndicator/LoadingIndicator'; import classNames from 'classnames'; - -const ANY_EXON_REGEX = /Any Exon (\d+)(-\d+)? (Deletion|Insertion|Duplication)/i; +import InfoIcon from 'app/shared/icons/InfoIcon'; export interface IAddExonMutationModalBody extends StoreProps { hugoSymbol: string; @@ -36,9 +35,6 @@ const AddExonForm = ({ getProteinExons, updateAlterationStateAfterAlterationAdded, setShowModifyExonForm, - setAlterationStates, - alterationStates, - selectedAlterationStateIndex, }: IAddExonMutationModalBody) => { const [inputValue, setInputValue] = useState(''); const [selectedExons, setSelectedExons] = useState([]); @@ -95,8 +91,8 @@ const AddExonForm = ({ getProteinExons?.(hugoSymbol, ReferenceGenome.GRCh37).then(value => setProteinExons(value)); }, []); - const standardizeAnyExonInputString = (createValue: string) => { - if (ANY_EXON_REGEX.test(createValue)) { + const standardizeExonInputString = (createValue: string) => { + if (EXON_ALTERATION_REGEX.test(createValue)) { return createValue .split(' ') .map(part => _.capitalize(part)) @@ -106,7 +102,7 @@ const AddExonForm = ({ }; const onCreateOption = (createInputValue: string) => { - const value = standardizeAnyExonInputString(createInputValue); + const value = standardizeExonInputString(createInputValue); setSelectedExons(prevState => [...prevState, { label: value, value, isSelected: true }]); }; @@ -149,7 +145,7 @@ const AddExonForm = ({ hideSelectedOptions={false} isClearable isValidNewOption={createInputValue => { - return ANY_EXON_REGEX.test(createInputValue); + return EXON_ALTERATION_REGEX.test(createInputValue); }} onCreateOption={onCreateOption} /> @@ -177,9 +173,33 @@ const AddExonForm = ({ const NoOptionsMessage = props => { return ( -
No options
-
Create a new option in the correct format:
-
{'Any Exon start-end (Deletion|Insertion|Duplication)'}
+
+
No options matching text
+

+
You can also create a new option that adheres to one of the formats:
+
+
    +
  • + {'Any Exon start-end (Deletion|Insertion|Duplication)'} + +
  • +
  • + {'Exon start-end (Deletion|Insertion|Duplication)'} + +
  • +
+
+
); }; @@ -199,9 +219,6 @@ const mapStoreToProps = ({ transcriptStore, addMutationModalStore }: IRootStore) getProteinExons: flow(transcriptStore.getProteinExons), updateAlterationStateAfterAlterationAdded: addMutationModalStore.updateAlterationStateAfterAlterationAdded, setShowModifyExonForm: addMutationModalStore.setShowModifyExonForm, - alterationStates: addMutationModalStore.alterationStates, - setAlterationStates: addMutationModalStore.setAlterationStates, - selectedAlterationStateIndex: addMutationModalStore.selectedAlterationStateIndex, }); type StoreProps = Partial>; diff --git a/src/main/webapp/app/shared/modal/MutationModal/AlterationBadgeList.tsx b/src/main/webapp/app/shared/modal/MutationModal/AlterationBadgeList.tsx index bd3b39c59..7bb47dc7b 100644 --- a/src/main/webapp/app/shared/modal/MutationModal/AlterationBadgeList.tsx +++ b/src/main/webapp/app/shared/modal/MutationModal/AlterationBadgeList.tsx @@ -1,6 +1,6 @@ import DefaultTooltip from 'app/shared/tooltip/DefaultTooltip'; import classNames from 'classnames'; -import React, { useEffect } from 'react'; +import React, { useMemo, useRef, useState } from 'react'; import * as styles from './styles.module.scss'; import { faXmark } from '@fortawesome/free-solid-svg-icons'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; @@ -9,96 +9,103 @@ 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 { faComment as farComment } from '@fortawesome/free-regular-svg-icons'; -import { faComment as fasComment } from '@fortawesome/free-solid-svg-icons'; -import AlterationCategoryInputs from './AlterationCategoryInputs'; -import { Input } from 'reactstrap'; import { useOverflowDetector } from 'app/hooks/useOverflowDetector'; +import { BS_BORDER_COLOR } from 'app/config/colors'; +import _ from 'lodash'; +import { DEFAULT_ICON_SIZE } from 'app/config/constants/constants'; +import { FaCircleCheck } from 'react-icons/fa6'; export interface IAlterationBadgeList extends StoreProps { - alterationData: AlterationData[]; + isExclusionList?: boolean; + showInput?: boolean; + inputValue?: string; + onInputChange?: (newValue: string) => void; + onKeyDown?: (event: React.KeyboardEvent) => void; } const AlterationBadgeList = ({ alterationStates, - alterationCategoryComment, setAlterationStates, selectedAlterationStateIndex, setSelectedAlterationStateIndex, - setAlterationCategoryComment, - selectedAlterationCategoryFlags, -}: StoreProps) => { - useEffect(() => { - if (!alterationStates) return; - if ((selectedAlterationStateIndex ?? -1) >= alterationStates.length) { - setSelectedAlterationStateIndex?.(alterationStates.length - 1); + 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); } - }, [alterationStates?.length, selectedAlterationStateIndex]); + }; - const showAlterationCategoryDropdown = (alterationStates ?? []).length > 1; - const showAlterationCategoryComment = showAlterationCategoryDropdown && (selectedAlterationCategoryFlags ?? []).length > 0; + const handleAlterationClick = (index: number) => { + isExclusionList ? setSelectedExcludedAlterationIndex?.(index) : setSelectedAlterationStateIndex?.(index); + }; return ( - <> -
-
Current Mutation List
- {showAlterationCategoryComment && ( -
- setAlterationCategoryComment?.(event.target.value)} - /> - } - > - - -
- )} -
-
- {showAlterationCategoryDropdown && } -
- {alterationStates?.map((value, index) => { - const fullAlterationName = getFullAlterationName(value, false); - return ( - setSelectedAlterationStateIndex?.(index)} - onDelete={() => { - setAlterationStates?.( - alterationStates.filter(alterationState => getFullAlterationName(value) !== getFullAlterationName(alterationState)), - ); - }} - /> - ); - })} +
inputRef?.current?.focus()} + > + {alterationList?.map((value, index) => { + const fullAlterationName = getFullAlterationName(value, false); + return ( + handleAlterationClick(index)} + onDelete={() => handleAlterationDelete(value)} + isExludedAlteration={isExclusionList} + /> + ); + })} + {showInput && ( +
+ onInputChange?.(event.target.value)} + placeholder={alterationList.length > 0 ? undefined : 'Enter alteration(s)'} + value={inputValue} + >
-
- + )} +
); }; const mapStoreToProps = ({ addMutationModalStore }: IRootStore) => ({ alterationStates: addMutationModalStore.alterationStates, - setShowModifyExonForm: addMutationModalStore.setShowModifyExonForm, - alterationCategoryComment: addMutationModalStore.alterationCategoryComment, - selectedAlterationCategoryFlags: addMutationModalStore.selectedAlterationCategoryFlags, setAlterationStates: addMutationModalStore.setAlterationStates, selectedAlterationStateIndex: addMutationModalStore.selectedAlterationStateIndex, setSelectedAlterationStateIndex: addMutationModalStore.setSelectedAlterationStateIndex, - setAlterationCategoryComment: addMutationModalStore.setAlterationCategoryComment, + selectedExcludedAlterationIndex: addMutationModalStore.selectedExcludedAlterationIndex, + setSelectedExcludedAlterationIndex: addMutationModalStore.setSelectedExcludedAlterationIndex, }); type StoreProps = Partial>; @@ -111,38 +118,56 @@ interface IAlterationBadge { isSelected: boolean; onClick: () => void; onDelete: () => void; + isExludedAlteration?: boolean; } -const AlterationBadge = ({ alterationData, alterationName, isSelected, onClick, onDelete }: IAlterationBadge) => { +const AlterationBadge = ({ + alterationData, + alterationName, + isSelected, + onClick, + onDelete, + isExludedAlteration = false, +}: IAlterationBadge) => { const { ref, overflow } = useOverflowDetector({ handleHeight: false }); - function getBackgroundColor() { + const backgroundColor = useMemo(() => { if (alterationData.error) { return 'danger'; } if (alterationData.warning) { return 'warning'; } + if (isExludedAlteration) { + return 'secondary'; + } return 'success'; - } + }, [alterationData, isExludedAlteration]); - function getStatusIcon() { + const statusIcon = useMemo(() => { + let icon = ; if (alterationData.error) { - return ; + icon = ; } if (alterationData.warning) { - ; + icon = ; } - return <>; - } + return
{icon}
; + }, [alterationData]); const badgeComponent = (
-
- {/* {getStatusIcon()} */} +
{ + event.stopPropagation(); + onClick(); + }} + > + {statusIcon}
{ !_.isNil(excludingIndex) - ? handleExcludingFieldChange?.(newValue, field, selectedAlterationStateIndex, excludingIndex) + ? handleExcludingFieldChange?.(newValue, field) : handleNormalFieldChange?.(newValue, field, selectedAlterationStateIndex); }; diff --git a/src/main/webapp/app/shared/modal/MutationModal/ExcludedAlterationContent.tsx b/src/main/webapp/app/shared/modal/MutationModal/ExcludedAlterationContent.tsx index 07bf4b05a..afb3b335b 100644 --- a/src/main/webapp/app/shared/modal/MutationModal/ExcludedAlterationContent.tsx +++ b/src/main/webapp/app/shared/modal/MutationModal/ExcludedAlterationContent.tsx @@ -1,15 +1,12 @@ import { IRootStore } from 'app/stores'; -import React, { useEffect, useMemo, useState } from 'react'; +import React, { useState } from 'react'; import { componentInject } from '../../util/typed-inject'; import { FaChevronDown, FaChevronUp, FaPlus } from 'react-icons/fa'; import { Button, Col, Row } from 'reactstrap'; -import CreatableSelect from 'react-select/creatable'; -import { getFullAlterationName, parseAlterationName } from '../../util/utils'; -import { AlterationData } from '../NewAddMutationModal'; -import { components, MultiValueGenericProps } from 'react-select'; -import * as styles from './styles.module.scss'; +import { parseAlterationName } from '../../util/utils'; import AnnotatedAlterationContent from './AnnotatedAlterationContent'; -import { AsyncSaveButton } from 'app/shared/button/AsyncSaveButton'; +import _ from 'lodash'; +import AlterationBadgeList from './AlterationBadgeList'; export interface IExcludedAlterationContent extends StoreProps {} @@ -17,27 +14,15 @@ const ExcludedAlterationContent = ({ alterationStates, selectedAlterationStateIndex, updateAlterationStateAfterExcludedAlterationAdded, - setAlterationStates, + selectedExcludedAlterationIndex, }: IExcludedAlterationContent) => { const [excludingCollapsed, setExcludingCollapsed] = useState(false); const [excludingInputValue, setExcludingInputValue] = useState(''); - const [isAddExcludedAlterationPending, setIsAddExcludedAlterationPending] = useState(false); - const [selectedExcludedAlteration, setSelectedExcludedAlteration] = useState(null); if (alterationStates === undefined || selectedAlterationStateIndex === undefined) return <>; - const excludedAlterationIndex = useMemo(() => { - const excludingArray = alterationStates[selectedAlterationStateIndex].excluding; - return excludingArray.findIndex(ea => ea.alteration === selectedExcludedAlteration); - }, [selectedExcludedAlteration]); - - const handleAlterationAddedExcluding = async () => { - try { - setIsAddExcludedAlterationPending(true); - await updateAlterationStateAfterExcludedAlterationAdded?.(parseAlterationName(excludingInputValue)); - } finally { - setIsAddExcludedAlterationPending(false); - } + const handleAlterationAddedExcluding = () => { + updateAlterationStateAfterExcludedAlterationAdded?.(parseAlterationName(excludingInputValue)); setExcludingInputValue(''); }; @@ -67,30 +52,11 @@ const ExcludedAlterationContent = ({ )} - setSelectedExcludedAlteration(label)} />, - }} - isMulti - menuIsOpen={false} - placeholder="Enter alteration(s)" + { - if (action !== 'menu-close' && action !== 'input-blur') { - setExcludingInputValue(newInput); - } - }} - value={alterationStates[selectedAlterationStateIndex].excluding.map(state => { - const fullAlterationName = getFullAlterationName(state, false); - return { label: fullAlterationName, value: fullAlterationName, ...state }; - })} - onChange={(newAlterations: readonly AlterationData[]) => { - alterationStates[selectedAlterationStateIndex].excluding = alterationStates[selectedAlterationStateIndex].excluding.filter( - state => newAlterations.some(alt => getFullAlterationName(alt) === getFullAlterationName(state)), - ); - setAlterationStates?.(alterationStates); - }} + onInputChange={newValue => setExcludingInputValue(newValue)} onKeyDown={handleKeyDownExcluding} /> @@ -100,12 +66,12 @@ const ExcludedAlterationContent = ({
- {!isSectionEmpty && !excludingCollapsed && ( + {!isSectionEmpty && !excludingCollapsed && selectedExcludedAlterationIndex !== undefined && ( @@ -114,22 +80,6 @@ const ExcludedAlterationContent = ({ ); }; -interface CustomMultiValueLabelProps extends MultiValueGenericProps { - onClick: (label: string) => void; -} -const MultiValueLabel = (props: CustomMultiValueLabelProps) => { - return ( -
{ - props.onClick(props.data.label); - }} - > - -
- ); -}; - const mapStoreToProps = ({ addMutationModalStore }: IRootStore) => ({ alterationStates: addMutationModalStore.alterationStates, selectedAlterationStateIndex: addMutationModalStore.selectedAlterationStateIndex, @@ -137,6 +87,7 @@ const mapStoreToProps = ({ addMutationModalStore }: IRootStore) => ({ isFetchingExcludingAlteration: addMutationModalStore.isFetchingExcludingAlteration, updateAlterationStateAfterExcludedAlterationAdded: addMutationModalStore.updateAlterationStateAfterExcludedAlterationAdded, setAlterationStates: addMutationModalStore.setAlterationStates, + selectedExcludedAlterationIndex: addMutationModalStore.selectedExcludedAlterationIndex, }); type StoreProps = Partial>; 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..1fcdf8418 --- /dev/null +++ b/src/main/webapp/app/shared/modal/MutationModal/MutationListSection.tsx @@ -0,0 +1,77 @@ +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'; + +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, false); + 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 index b4aac67bd..653f08952 100644 --- 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 @@ -21,6 +21,7 @@ export class AddMutationModalStore { public alterationStates: AlterationData[] = []; public selectedAlterationStateIndex = -1; + public selectedExcludedAlterationIndex = -1; public showModifyExonForm = false; @@ -38,6 +39,7 @@ export class AddMutationModalStore { vusList: observable, alterationStates: observable, selectedAlterationStateIndex: observable, + selectedExcludedAlterationIndex: observable, showModifyExonForm: observable, isFetchingAlteration: observable, isFetchingExcludingAlteration: observable, @@ -52,9 +54,11 @@ export class AddMutationModalStore { setShowModifyExonForm: 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, @@ -92,6 +96,10 @@ export class AddMutationModalStore { this.selectedAlterationStateIndex = index; } + setSelectedExcludedAlterationIndex(index: number) { + this.selectedExcludedAlterationIndex = index; + } + setSelectedAlterationCategoryFlags(flags: SelectedFlag[]) { this.selectedAlterationCategoryFlags = flags; } @@ -165,7 +173,9 @@ export class AddMutationModalStore { convertEntityStatusAlterationToAlterationData(alt, parsedAlterations[index].alteration, [], newComment, newVariantName), ); - this.alterationStates[this.selectedAlterationStateIndex].excluding.push(...newAlterations); + const newAlterationStates = _.cloneDeep(this.alterationStates); + newAlterationStates[this.selectedAlterationStateIndex].excluding.push(...newAlterations); + this.alterationStates = newAlterationStates; } async handleAlterationChange(newValue: string, alterationIndex: number, excludingIndex?: number, isDebounced = true) { @@ -173,29 +183,31 @@ export class AddMutationModalStore { this.isFetchingExcludingAlteration = true; if (isDebounced) { - this.handleExcludingFieldChange(newValue, 'alteration', alterationIndex, excludingIndex); - _.debounce(async () => await this.fetchExcludedAlteration(newValue, alterationIndex, excludingIndex), 1000); + this.handleExcludedAlterationChange(newValue); } else { - await this.fetchExcludedAlteration(newValue, alterationIndex, excludingIndex); + 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; } - this.isFetchingAlteration = false; } } - handleExcludingFieldChange(newValue: string, field: keyof AlterationData, alterationIndex: number, excludingIndex: number) { - this.alterationStates[alterationIndex].excluding[excludingIndex][field as string] = newValue; + 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, alterationIndex: number, excludingIndex: number) { + async fetchExcludedAlteration(newAlteration: string) { + const alterationIndex = this.selectedAlterationStateIndex; + const excludingIndex = this.selectedExcludedAlterationIndex; const newParsedAlteration = parseAlterationName(newAlteration); const currentState = this.alterationStates[alterationIndex]; @@ -217,7 +229,9 @@ export class AddMutationModalStore { ) ) { notifyError(new Error('Duplicate alteration(s) removed')); - this.alterationStates[alterationIndex].excluding.splice(excludingIndex, 1); + const newAlterationStates = _.cloneDeep(this.alterationStates); + newAlterationStates[alterationIndex].excluding.splice(excludingIndex, 1); + this.alterationStates = newAlterationStates; return; } @@ -248,23 +262,47 @@ export class AddMutationModalStore { .filter(hasValue), ]; - this.alterationStates[alterationIndex].excluding.splice(excludingIndex, 1, ...newAlterations); + const newAlterationStates = _.cloneDeep(this.alterationStates); + newAlterationStates[alterationIndex].excluding.splice(excludingIndex, 1, ...newAlterations); + this.alterationStates = newAlterationStates; } handleNormalAlterationChange(newValue: string, alterationIndex: number) { - this.alterationStates[alterationIndex].alterationFieldValueWhileFetching = newValue; + const newAlterationStates = _.cloneDeep(this.alterationStates); + newAlterationStates[alterationIndex].alterationFieldValueWhileFetching = newValue; + this.alterationStates = newAlterationStates; - _.debounce(() => this.fetchNormalAlteration(newValue, alterationIndex), 1000); + _.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) { - this.alterationStates[alterationIndex][field as string] = newValue; + 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) { - this.alterationStates[alterationIndex].alterationFieldValueWhileFetching = undefined; + const newAlterationStates = _.cloneDeep(this.alterationStates); + newAlterationStates[alterationIndex].alterationFieldValueWhileFetching = undefined; + this.alterationStates = newAlterationStates; } const newComment = newParsedAlteration[0].comment; @@ -291,10 +329,11 @@ export class AddMutationModalStore { if (newParsedAlteration[0].alteration !== this.alterationStates[alterationIndex]?.alteration) { alterationPromises.push(this.fetchAlteration(newParsedAlteration[0].alteration)); } else { - this.alterationStates[alterationIndex].excluding = newExcluding; - this.alterationStates[alterationIndex].comment = newComment; - this.alterationStates[alterationIndex].name = newVariantName || newParsedAlteration[0].alteration; - newAlterations.push(this.alterationStates[alterationIndex]); + 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++) { @@ -317,7 +356,9 @@ export class AddMutationModalStore { ]; newAlterations[0].alterationFieldValueWhileFetching = undefined; - this.alterationStates.splice(alterationIndex, 1, ...newAlterations); + const newAlterationStates = _.cloneDeep(this.alterationStates); + newAlterationStates.splice(alterationIndex, 1, ...newAlterations); + this.alterationStates = newAlterationStates; } filterAlterationsAndNotify(alterations: ReturnType, alterationIndex?: number) { @@ -381,6 +422,7 @@ export class AddMutationModalStore { this.vusList = null; this.alterationStates = []; this.selectedAlterationStateIndex = -1; + this.selectedExcludedAlterationIndex = -1; this.showModifyExonForm = false; this.isFetchingAlteration = false; this.isFetchingExcludingAlteration = false; diff --git a/src/main/webapp/app/shared/modal/MutationModal/styles.module.scss b/src/main/webapp/app/shared/modal/MutationModal/styles.module.scss index a34eab4bb..1d1069a18 100644 --- a/src/main/webapp/app/shared/modal/MutationModal/styles.module.scss +++ b/src/main/webapp/app/shared/modal/MutationModal/styles.module.scss @@ -6,7 +6,7 @@ padding: 0; display: flex; align-items: center; - max-width: 98%; + max-width: 49%; width: fit-content; overflow: hidden; cursor: pointer; @@ -20,6 +20,7 @@ padding-right: 5px; display: flex; padding: 8px; + align-items: center; } .actionWrapper { @@ -28,16 +29,6 @@ border-left: 1px dashed; } -// .editButton { -// display: flex; -// flex-direction: column; -// justify-content: center; -// padding-left: 5px; -// margin-left: 5px; -// border-left: 1px dashed; -// cursor: pointer; -// } - .deleteButton { display: flex; flex-direction: column; @@ -46,8 +37,26 @@ cursor: pointer; } -.excludedAlterationValueContainer { - &:hover { - 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; + padding: 0rem 0.375rem; +} + +.alterationBadgeListInputWrapper { + visibility: visible; + display: flex; + justify-content: center; + flex: 1 1 auto; + margin: 2px; + padding-bottom: 2px; + padding-top: 2px; } diff --git a/src/main/webapp/app/shared/modal/NewAddMutationModal.tsx b/src/main/webapp/app/shared/modal/NewAddMutationModal.tsx index c1f4d1cdd..3119ddb2d 100644 --- a/src/main/webapp/app/shared/modal/NewAddMutationModal.tsx +++ b/src/main/webapp/app/shared/modal/NewAddMutationModal.tsx @@ -4,10 +4,10 @@ import _ from 'lodash'; import { flow } from 'mobx'; import React, { KeyboardEventHandler, useEffect, useState } from 'react'; import { Button, 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, getFirebaseVusPath } from '../util/firebase/firebase-utils'; -import { componentInject } from '../util/typed-inject'; +import { Mutation, AlterationCategories } from '../../shared/model/firebase/firebase.model'; +import { AlterationAnnotationStatus, AlterationTypeEnum, Gene } from '../../shared/api/generated/curation'; +import { getDuplicateMutations, getFirebaseVusPath } from '../../shared/util/firebase/firebase-utils'; +import { componentInject } from '../../shared/util/typed-inject'; import { isEqualIgnoreCase, parseAlterationName, @@ -15,21 +15,21 @@ import { convertAlterationDataToAlteration, convertAlterationToAlterationData, convertIFlagToFlag, -} from '../util/utils'; -import { DefaultAddMutationModal } from './DefaultAddMutationModal'; +} from '../../shared/util/utils'; +import { DefaultAddMutationModal } from '../../shared/modal/DefaultAddMutationModal'; import './add-mutation-modal.scss'; import { Unsubscribe } from 'firebase/database'; -import InfoIcon from '../icons/InfoIcon'; -import { FlagTypeEnum } from '../model/enumerations/flag-type.enum.model'; +import InfoIcon from '../../shared/icons/InfoIcon'; +import { FlagTypeEnum } from '../../shared/model/enumerations/flag-type.enum.model'; import AddExonForm from './MutationModal/AddExonForm'; -import AlterationBadgeList from './MutationModal/AlterationBadgeList'; -import { AddMutationInputOverlay } from './AddMutationModal'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { faPlus } from '@fortawesome/free-solid-svg-icons'; -import { AsyncSaveButton } from '../button/AsyncSaveButton'; +import { AsyncSaveButton } from '../../shared/button/AsyncSaveButton'; import AnnotatedAlterationContent from './MutationModal/AnnotatedAlterationContent'; import ExcludedAlterationContent from './MutationModal/ExcludedAlterationContent'; import { EXON_ALTERATION_REGEX } from 'app/config/constants/regex'; +import MutationListSection from './MutationModal/MutationListSection'; +import classNames from 'classnames'; function getModalErrorMessage(mutationAlreadyExists: MutationExistsMeta) { let modalErrorMessage: string | undefined = undefined; @@ -329,7 +329,7 @@ function NewAddMutationModal({ onCancel(); }; - const mutationModalAlterationInputHeader = ( + const renderInputSection = () => ( @@ -338,16 +338,16 @@ function NewAddMutationModal({ onKeyDown={handleKeyDown} style={{ borderRight: '0' }} value={inputValue} - onChange={value => setInputValue(value.target.value)} + onChange={e => setInputValue(e.target.value)} onClick={() => setShowModifyExonForm?.(false)} /> - - }> + + } /> @@ -364,43 +364,62 @@ function NewAddMutationModal({ ); - const mutationModalBody = ( -
- {mutationModalAlterationInputHeader} - {showModifyExonForm ? ( + // Helper function to render exon or mutation list section + const renderExonOrMutationListSection = () => { + if (showModifyExonForm) { + return ( <> -
+
- ) : alterationStates?.length !== 0 ? ( + ); + } + if (alterationStates?.length !== 0) { + return ( <> -
+
- + - ) : undefined} - {alterationStates !== undefined && - selectedAlterationStateIndex !== undefined && - selectedAlterationStateIndex > -1 && - !_.isNil(alterationStates[selectedAlterationStateIndex]) && ( - <> -
- {EXON_ALTERATION_REGEX.test(alterationStates[selectedAlterationStateIndex].alteration) ? ( - - ) : ( - <> - - - - )} - - )} + ); + } + return null; + }; + + // Helper function to render selected alteration state content + const renderMutationDetailSection = () => { + if ( + alterationStates !== undefined && + selectedAlterationStateIndex !== undefined && + selectedAlterationStateIndex > -1 && + !_.isNil(alterationStates[selectedAlterationStateIndex]) + ) { + const selectedAlteration = alterationStates[selectedAlterationStateIndex].alteration; + return ( + <> +
+ {EXON_ALTERATION_REGEX.test(selectedAlteration) ? ( + + ) : ( + <> + + + + )} + + ); + } + return null; + }; + + const mutationModalBody = ( +
+ {!convertOptions?.isConverting && renderInputSection()} + {renderExonOrMutationListSection()} + {renderMutationDetailSection()}
); @@ -476,3 +495,36 @@ const mapStoreToProps = ({ type StoreProps = Partial>; export default componentInject(mapStoreToProps)(NewAddMutationModal); + +const AddMutationInputOverlay = () => { + return ( +
+
+ Enter alteration(s) in input area, then press Enter key or click on{' '} + Add button to annotate alteration(s). +
+
+
String Mutation:
+
+
    +
  • + Variant alleles seperated by slash - R132C/H/G/S/L +
  • +
  • + Comma seperated list of alterations - V600E, V600K +
  • +
+
+
Exon:
+
    +
  • + Supported consequences are Insertion, Deletion and Duplication - Exon 4 Deletion +
  • +
  • + Exon range - Exon 4-8 Deletion +
  • +
+
+
+ ); +}; diff --git a/src/main/webapp/app/shared/table/VusTable.tsx b/src/main/webapp/app/shared/table/VusTable.tsx index 5a8928af3..d8ca2cd06 100644 --- a/src/main/webapp/app/shared/table/VusTable.tsx +++ b/src/main/webapp/app/shared/table/VusTable.tsx @@ -1,7 +1,7 @@ import React, { useEffect, useMemo, useRef, useState } from 'react'; -import { Mutation, Review, Vus } from 'app/shared/model/firebase/firebase.model'; +import { Vus } from 'app/shared/model/firebase/firebase.model'; import OncoKBTable, { SearchColumn } from 'app/shared/table/OncoKBTable'; -import { Button, Container, Row } from 'reactstrap'; +import { Button } from 'reactstrap'; import { SimpleConfirmModal } from 'app/shared/modal/SimpleConfirmModal'; import { getAllCommentsString, @@ -30,9 +30,9 @@ import { faSync, faTrashAlt } from '@fortawesome/free-solid-svg-icons'; import { DANGER, PRIMARY } from 'app/config/colors'; import AddVusModal from '../modal/AddVusModal'; import MutationConvertIcon from '../icons/MutationConvertIcon'; -import AddMutationModal from '../modal/AddMutationModal'; import { Unsubscribe } from 'firebase/database'; import { VUS_TABLE_ID } from 'app/config/constants/html-id'; +import NewAddMutationModal from '../modal/NewAddMutationModal'; export interface IVusTableProps extends StoreProps { hugoSymbol: string | undefined; @@ -261,7 +261,7 @@ const VusTable = ({ /> ) : undefined} {vusToPromote ? ( - { diff --git a/src/main/webapp/app/shared/util/utils.tsx b/src/main/webapp/app/shared/util/utils.tsx index 1ad9aeed5..efb983d6f 100644 --- a/src/main/webapp/app/shared/util/utils.tsx +++ b/src/main/webapp/app/shared/util/utils.tsx @@ -18,8 +18,8 @@ import { INTEGER_REGEX, REFERENCE_LINK_REGEX, SINGLE_NUCLEOTIDE_POS_REGEX, UUID_ import { AlterationAnnotationStatus, AlterationTypeEnum, ProteinExonDTO } from 'app/shared/api/generated/curation'; import { IQueryParams } from './jhipster-types'; import InfoIcon from '../icons/InfoIcon'; -import { AlterationData } from '../modal/NewAddMutationModal'; import { IFlag } from '../model/flag.model'; +import { AlterationData } from '../modal/NewAddMutationModal'; export const getCancerTypeName = (cancerType: ICancerType | CancerType, omitCode = false): string => { if (!cancerType) return ''; diff --git a/src/main/webapp/app/variables.scss b/src/main/webapp/app/variables.scss index 6d65722bc..2b7466abd 100644 --- a/src/main/webapp/app/variables.scss +++ b/src/main/webapp/app/variables.scss @@ -16,6 +16,10 @@ $success: #28a745; $warning: #ffc107; $danger: #dc3545; +// 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; From 9ef40b75a218adb29b31a6d943eccfee9c63cc08 Mon Sep 17 00:00:00 2001 From: Calvin Lu <59149377+calvinlu3@users.noreply.github.com> Date: Wed, 9 Oct 2024 19:21:01 -0400 Subject: [PATCH 3/8] Rename to MutationDetails --- .../modal/MutationModal/ExcludedAlterationContent.tsx | 4 ++-- ...AnnotatedAlterationContent.tsx => MutationDetails.tsx} | 8 ++++---- src/main/webapp/app/shared/modal/NewAddMutationModal.tsx | 4 ++-- 3 files changed, 8 insertions(+), 8 deletions(-) rename src/main/webapp/app/shared/modal/MutationModal/{AnnotatedAlterationContent.tsx => MutationDetails.tsx} (97%) diff --git a/src/main/webapp/app/shared/modal/MutationModal/ExcludedAlterationContent.tsx b/src/main/webapp/app/shared/modal/MutationModal/ExcludedAlterationContent.tsx index afb3b335b..d5dd43caa 100644 --- a/src/main/webapp/app/shared/modal/MutationModal/ExcludedAlterationContent.tsx +++ b/src/main/webapp/app/shared/modal/MutationModal/ExcludedAlterationContent.tsx @@ -4,7 +4,7 @@ import { componentInject } from '../../util/typed-inject'; import { FaChevronDown, FaChevronUp, FaPlus } from 'react-icons/fa'; import { Button, Col, Row } from 'reactstrap'; import { parseAlterationName } from '../../util/utils'; -import AnnotatedAlterationContent from './AnnotatedAlterationContent'; +import MutationDetails from './MutationDetails'; import _ from 'lodash'; import AlterationBadgeList from './AlterationBadgeList'; @@ -69,7 +69,7 @@ const ExcludedAlterationContent = ({ {!isSectionEmpty && !excludingCollapsed && selectedExcludedAlterationIndex !== undefined && ( - diff --git a/src/main/webapp/app/shared/modal/MutationModal/AnnotatedAlterationContent.tsx b/src/main/webapp/app/shared/modal/MutationModal/MutationDetails.tsx similarity index 97% rename from src/main/webapp/app/shared/modal/MutationModal/AnnotatedAlterationContent.tsx rename to src/main/webapp/app/shared/modal/MutationModal/MutationDetails.tsx index dc194024c..83f5f5be0 100644 --- a/src/main/webapp/app/shared/modal/MutationModal/AnnotatedAlterationContent.tsx +++ b/src/main/webapp/app/shared/modal/MutationModal/MutationDetails.tsx @@ -20,12 +20,12 @@ const ALTERATION_TYPE_OPTIONS: DropdownOption[] = [ AlterationTypeEnum.Any, ].map(type => ({ label: READABLE_ALTERATION[type], value: type })); -export interface IAnnotatedAlterationContent extends StoreProps { +export interface IMutationDetails extends StoreProps { alterationData: AlterationData; excludingIndex?: number; } -const AnnotatedAlterationContent = ({ +const MutationDetails = ({ alterationData, excludingIndex, getConsequences, @@ -36,7 +36,7 @@ const AnnotatedAlterationContent = ({ isFetchingAlteration, isFetchingExcludingAlteration, handleAlterationChange, -}: IAnnotatedAlterationContent) => { +}: IMutationDetails) => { useEffect(() => { getConsequences?.({}); }, []); @@ -270,4 +270,4 @@ const mapStoreToProps = ({ consequenceStore, addMutationModalStore }: IRootStore type StoreProps = Partial>; -export default componentInject(mapStoreToProps)(AnnotatedAlterationContent); +export default componentInject(mapStoreToProps)(MutationDetails); diff --git a/src/main/webapp/app/shared/modal/NewAddMutationModal.tsx b/src/main/webapp/app/shared/modal/NewAddMutationModal.tsx index 3119ddb2d..d85018c8a 100644 --- a/src/main/webapp/app/shared/modal/NewAddMutationModal.tsx +++ b/src/main/webapp/app/shared/modal/NewAddMutationModal.tsx @@ -25,7 +25,7 @@ import AddExonForm from './MutationModal/AddExonForm'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { faPlus } from '@fortawesome/free-solid-svg-icons'; import { AsyncSaveButton } from '../../shared/button/AsyncSaveButton'; -import AnnotatedAlterationContent from './MutationModal/AnnotatedAlterationContent'; +import MutationDetails from './MutationModal/MutationDetails'; import ExcludedAlterationContent from './MutationModal/ExcludedAlterationContent'; import { EXON_ALTERATION_REGEX } from 'app/config/constants/regex'; import MutationListSection from './MutationModal/MutationListSection'; @@ -405,7 +405,7 @@ function NewAddMutationModal({ ) : ( <> - + )} From 9b1fb4bd1841533b323c22b87d36bce191bba312 Mon Sep 17 00:00:00 2001 From: Calvin Lu <59149377+calvinlu3@users.noreply.github.com> Date: Thu, 10 Oct 2024 10:51:25 -0400 Subject: [PATCH 4/8] Fix eslint error --- src/main/webapp/app/shared/icons/InputFieldIcon.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/webapp/app/shared/icons/InputFieldIcon.tsx b/src/main/webapp/app/shared/icons/InputFieldIcon.tsx index e023be2fe..12f98c332 100644 --- a/src/main/webapp/app/shared/icons/InputFieldIcon.tsx +++ b/src/main/webapp/app/shared/icons/InputFieldIcon.tsx @@ -6,7 +6,7 @@ import { Input } from 'reactstrap'; export interface IInputFieldIcon { icon: IconDefinition; - onInputChange: () => {}; + onInputChange: () => void; inputPlaceholder: string; } From 48c041167689b2f16a73586d2db6f9a566514003 Mon Sep 17 00:00:00 2001 From: Calvin Lu <59149377+calvinlu3@users.noreply.github.com> Date: Tue, 15 Oct 2024 17:04:42 -0400 Subject: [PATCH 5/8] Update based on feedback --- .../oncokb/curation/service/MainService.java | 11 +- .../oncokb/curation/util/AlterationUtils.java | 9 +- .../MutationCollapsible.tsx | 4 +- .../curation/mutation/MutationsSection.tsx | 4 +- .../app/shared/modal/AddMutationModal.tsx | 1367 ++++------------- .../modal/MutationModal/AddExonForm.tsx | 129 +- .../MutationModal/AlterationBadgeList.tsx | 2 +- .../AlterationCategoryInputs.tsx | 22 - .../AnnotatedAlterationErrorContent.tsx | 2 +- .../modal/MutationModal/MutationDetails.tsx | 3 +- .../MutationModal/MutationListSection.tsx | 3 +- .../MutationModal/add-mutation-modal.store.ts | 6 +- .../app/shared/modal/NewAddMutationModal.tsx | 530 ------- src/main/webapp/app/shared/table/VusTable.tsx | 4 +- src/main/webapp/app/shared/util/utils.tsx | 2 +- 15 files changed, 387 insertions(+), 1711 deletions(-) delete mode 100644 src/main/webapp/app/shared/modal/NewAddMutationModal.tsx diff --git a/src/main/java/org/mskcc/oncokb/curation/service/MainService.java b/src/main/java/org/mskcc/oncokb/curation/service/MainService.java index 170279107..88b4f397f 100644 --- a/src/main/java/org/mskcc/oncokb/curation/service/MainService.java +++ b/src/main/java/org/mskcc/oncokb/curation/service/MainService.java @@ -296,8 +296,15 @@ public AlterationAnnotationStatus annotateAlteration(ReferenceGenome referenceGe continue; } Integer exonNumber = Integer.parseInt(exonAlterationString.replaceAll("\\D*", "")); - if (exonNumber > 0 && exonNumber < proteinExons.size() + 1) { - overlap.add(proteinExons.get(exonNumber - 1)); + + Integer minExon = proteinExons + .stream() + .min(Comparator.comparing(ProteinExonDTO::getExon)) + .map(ProteinExonDTO::getExon) + .orElse(0); + + if (exonNumber >= minExon && exonNumber < minExon + proteinExons.size() + 1) { + overlap.add(proteinExons.get(exonNumber - minExon)); } else { problematicExonAlts.add(exonAlterationString); } 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 08e60928a..c5ab48533 100644 --- a/src/main/java/org/mskcc/oncokb/curation/util/AlterationUtils.java +++ b/src/main/java/org/mskcc/oncokb/curation/util/AlterationUtils.java @@ -106,7 +106,11 @@ private Alteration parseExonAlteration(String alteration) { Set consequenceTermSet = new HashSet<>(); while (matcher.find()) { - Boolean isAnyExon = "Any".equals(matcher.group(1).trim()); // We use "Any" to denote all possible combinations of exons + Boolean isAnyExon = false; + if (matcher.group(1) != null) { + // We use "Any" to denote all possible combinations of exons + isAnyExon = "Any".equals(matcher.group(1).trim()); + } String startExonStr = matcher.group(2); // The start exon number String endExonStr = matcher.group(4); // The end exon number (if present) String consequenceTerm = matcher.group(5); // consequence term @@ -554,6 +558,9 @@ public static Boolean isAnyExon(String alteration) { Pattern p = Pattern.compile(EXON_ALT_REGEX, Pattern.CASE_INSENSITIVE); Matcher m = p.matcher(alteration); if (m.find()) { + if (m.group(1) == null) { + return false; + } return "Any".equals(m.group(1).trim()); } return false; diff --git a/src/main/webapp/app/pages/curation/collapsible/mutation-collapsible/MutationCollapsible.tsx b/src/main/webapp/app/pages/curation/collapsible/mutation-collapsible/MutationCollapsible.tsx index dffcd7937..f59120fc3 100644 --- a/src/main/webapp/app/pages/curation/collapsible/mutation-collapsible/MutationCollapsible.tsx +++ b/src/main/webapp/app/pages/curation/collapsible/mutation-collapsible/MutationCollapsible.tsx @@ -49,7 +49,7 @@ import { RemovableCollapsible } from '../RemovableCollapsible'; import { Unsubscribe } from 'firebase/database'; import { getLocationIdentifier } from 'app/components/geneHistoryTooltip/gene-history-tooltip-utils'; import MutationCollapsibleTitle from './MutationCollapsibleTitle'; -import NewAddMutationModal from 'app/shared/modal/NewAddMutationModal'; +import AddMutationModal from 'app/shared/modal/AddMutationModal'; export interface IMutationCollapsibleProps extends StoreProps { mutationPath: string; @@ -581,7 +581,7 @@ const MutationCollapsible = ({ }} /> {isEditingMutation ? ( - {showAddMutationModal && ( - { diff --git a/src/main/webapp/app/shared/modal/AddMutationModal.tsx b/src/main/webapp/app/shared/modal/AddMutationModal.tsx index 11a9ef44e..6daa9978a 100644 --- a/src/main/webapp/app/shared/modal/AddMutationModal.tsx +++ b/src/main/webapp/app/shared/modal/AddMutationModal.tsx @@ -1,42 +1,50 @@ -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, Flag, Mutation, AlterationCategories, 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 { getDuplicateMutations, getFirebaseVusPath, isFlagEqualToIFlag } from '../util/firebase/firebase-utils'; +import { flow } from 'mobx'; +import React, { KeyboardEventHandler, useEffect, useState } from 'react'; +import { Button, 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, getFirebaseVusPath } from '../util/firebase/firebase-utils'; import { componentInject } from '../util/typed-inject'; -import { hasValue, isEqualIgnoreCase, parseAlterationName, buildAlterationName } 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 { FlagTypeEnum } from '../model/enumerations/flag-type.enum.model'; -import { IFlag } from '../model/flag.model'; -import { SentryError } from 'app/config/sentry-error'; -import { InputType } from 'reactstrap/types/lib/Input'; -import { useTextareaAutoHeight } from 'app/hooks/useTextareaAutoHeight'; -import AddMutationModalField from './MutationModal/AddMutationModalField'; -import AddMutationModalDropdown, { DropdownOption } from './MutationModal/AddMutationModalDropdown'; import AddExonForm from './MutationModal/AddExonForm'; -import { useMatchGeneEntity } from 'app/hooks/useMatchGeneEntity'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { faPlus } from '@fortawesome/free-solid-svg-icons'; +import { AsyncSaveButton } from '../button/AsyncSaveButton'; +import MutationDetails from './MutationModal/MutationDetails'; +import ExcludedAlterationContent from './MutationModal/ExcludedAlterationContent'; +import { EXON_ALTERATION_REGEX } from 'app/config/constants/regex'; +import MutationListSection from './MutationModal/MutationListSection'; +import classNames from 'classnames'; + +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; @@ -68,57 +76,55 @@ 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, + showModifyExonForm, + cleanup, + fetchAlterations, + setAlterationStates, + selectedAlterationCategoryFlags, + alterationCategoryComment, + setGeneEntity, + updateAlterationStateAfterAlterationAdded, + selectedAlterationStateIndex, }: 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 [mutationAlreadyExists, setMutationAlreadyExists] = useState({ + exists: false, + inMutationList: false, + inVusList: false, + }); - const [isExonCuration, setIsExonCuration] = useState(false); + const [isAddAlterationPending, setIsAddAlterationPending] = useState(false); - 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 [flags, setFlags] = useState([]); - const [vusList, setVusList] = useState(null); - - const [alterationCategories, setAlterationCategories] = useState(null); - const [selectedStringMutationFlags, setSelectedStringMutationFlags] = useState<(IFlag | Omit)[]>([]); - const [stringMutationComment, setStringMutationComment] = useState(''); - - const inputRef = useRef> | null>(null); - - const geneEntity: IGene | undefined = useMemo(() => { - return geneEntities?.find(gene => gene.hugoSymbol === hugoSymbol); - }, [geneEntities]); useEffect(() => { if (!firebaseDb) { @@ -127,21 +133,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(); @@ -149,7 +162,7 @@ function AddMutationModal({ }, [convertOptions?.isConverting]); useEffect(() => { - const dupMutations = getDuplicateMutations(currentMutationNames, mutationList ?? [], vusList ?? {}, { + const dupMutations = getDuplicateMutations(currentMutationNames ?? [], mutationList ?? [], vusList ?? {}, { useFullAlterationName: true, excludedMutationUuid: mutationToEdit?.name_uuid, excludedVusName: convertOptions?.isConverting ? convertOptions.alteration : '', @@ -160,40 +173,25 @@ function AddMutationModal({ inMutationList: dupMutations.some(mutation => mutation.inMutationList), inVusList: dupMutations.some(mutation => mutation.inVusList), }); - }, [tabStates, mutationList, vusList]); + }, [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() { if (mutationToEdit?.alterations?.length !== undefined && mutationToEdit.alterations.length > 0) { - setTabStates(mutationToEdit?.alterations?.map(alt => convertAlterationToAlterationData(alt)) ?? []); - return; + setAlterationStates?.(mutationToEdit?.alterations?.map(alt => convertAlterationToAlterationData(alt)) ?? []); } - const parsedAlterations = mutationToEdit?.name?.split(',').map(name => parseAlterationName(name.trim())[0]); // at this point can be sure each alteration name does not have / character + // at this point can be sure each alteration name does not have / character + const parsedAlterations = mutationToEdit?.name?.split(',').map(name => parseAlterationName(name.trim())[0]); - 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, @@ -219,7 +217,7 @@ function AddMutationModal({ } if (parsedAlterations) { - const alterations = entityStatusAlterations.map((alt, index) => + const newAlerationStates = entityStatusAlterations.map((alt, index) => convertEntityStatusAlterationToAlterationData( alt, parsedAlterations[index].alteration, @@ -229,958 +227,69 @@ function AddMutationModal({ ), ); - setTabStates(alterations); + setAlterationStates?.(newAlerationStates); } } if (mutationToEdit) { setExistingAlterations(); - setAlterationCategories(mutationToEdit?.alteration_categories ?? null); } }, [mutationToEdit]); - useEffect(() => { - getConsequences?.({}); - }, []); - - useEffect(() => { - inputRef.current?.focus(); - }, []); - - useEffect(() => { - async function fetchFlags() { - const fetchedFlags = await getFlagsByType?.(FlagTypeEnum.ALTERATION_CATEGORY); - if (fetchedFlags) { - setFlags(fetchedFlags); - } - } - - fetchFlags(); - }, []); - - useEffect(() => { - if (flags) { - setSelectedStringMutationFlags( - alterationCategories?.flags?.reduce((acc: IFlag[], flag) => { - const matchedFlag = flags.find(flagEntity => isFlagEqualToIFlag(flag, flagEntity)); - - if (matchedFlag) { - acc.push(matchedFlag); - } - - return acc; - }, []) ?? [], - ); - } - setStringMutationComment(alterationCategories?.comment ?? ''); - }, [alterationCategories, flags]); - - const flagDropdownOptions = useMemo(() => { - if (!flags) return []; - return flags.map(flag => ({ label: flag.name, value: flag })); - }, [flags]); - - 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 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 []; - } - } - - 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 the backend's response is different from the frontend response, set them equal to each other. - if (alteration?.alteration !== alterationName) { - alterationData.alteration = alteration?.alteration ?? ''; - } - - return alterationData; - } - - 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; - }); - } - - const newComment = newParsedAlteration[0].comment; - const newVariantName = newParsedAlteration[0].name; - - 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], [], ''), - ) ?? []; - } - - const alterationPromises: Promise[] = []; - let newAlterations: AlterationData[] = []; - if (newParsedAlteration[0].alteration !== alterationData[alterationIndex]?.alteration) { - alterationPromises.push(fetchAlteration(newParsedAlteration[0].alteration)); - } 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)); - } - - 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], - ); - - function handleNormalAlterationChange(newValue: string, alterationIndex: number) { - setTabStates(states => { - const newStates = _.cloneDeep(states); - newStates[alterationIndex].alterationFieldValueWhileFetching = newValue; - return newStates; - }); - fetchNormalAlterationDebounced(newValue, alterationIndex, tabStates); - } - - 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; - }); - 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); - } - } - } - - 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, true), tabStates); - - if (newParsedAlteration.length === 0) { - return; + try { + setIsAddAlterationPending(true); + await updateAlterationStateAfterAlterationAdded?.(parseAlterationName(alterationString)); + } finally { + setIsAddAlterationPending(false); } - - 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(''); } - const handleKeyDown: KeyboardEventHandler = event => { - if (!inputValue) return; - if (event.key === 'Enter' || event.key === 'tab') { - handleAlterationAdded(); - event.preventDefault(); - } - }; - - async function handleAlterationAddedExcluding(alterationIndex: number) { - const newParsedAlteration = parseAlterationName(excludingInputValue, true); - - 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(); - } - }; - - function getFullAlterationName(alterationData: AlterationData, includeVariantName = true) { - const variantName = includeVariantName && alterationData.name !== alterationData.alteration ? alterationData.name : ''; - const excluding = alterationData.excluding.length > 0 ? alterationData.excluding.map(ex => ex.alteration) : []; - const comment = alterationData.comment ? alterationData.comment : ''; - return buildAlterationName(alterationData.alteration, variantName, excluding, comment); - } - - function getTabTitle(tabAlterationData: AlterationData, isExcluding = false) { - if (!tabAlterationData) { - // loading state - return <>; - } - - const fullAlterationName = getFullAlterationName(tabAlterationData, isExcluding ? false : true); - - if (tabAlterationData.error) { - return ( - - - {fullAlterationName} - - ); - } - - if (tabAlterationData.warning) { - return ( - - - {fullAlterationName} - - ); - } - - return fullAlterationName; - } - - function getTabContent(alterationData: AlterationData, alterationIndex: number, excludingIndex?: number) { - const excludingSection = !_.isNil(excludingIndex) ? <> : 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)} - /> - handleFieldChange(newValue, 'proteinChange', 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 && ( -
- - -
- )} -
- ); - } + 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; - function 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, - }; - setSelectedStringMutationFlags(prevState => [...prevState, newSelectedFlag]); - } + const newAlterationCategories = await handleAlterationCategoriesConfirm(); + newMutation.alteration_categories = newAlterationCategories; - function handleAlterationCategoriesField(field: keyof AlterationCategories, value: unknown) { - if (field === 'comment') { - setStringMutationComment(value as string); - } else if (field === 'flags') { - const flagOptions = value as DropdownOption[]; - setSelectedStringMutationFlags(flagOptions.map(option => option.value)); + setErrorMessagesEnabled(false); + setIsConfirmPending(true); + try { + await onConfirm(newMutation, mutationList?.length || 0); + } finally { + setErrorMessagesEnabled(true); + setIsConfirmPending(false); } } - 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 > 1 && ( - <> - - -
- - String Name - - - handleAlterationCategoriesField('flags', newFlags)} - onCreateOption={handleMutationFlagAdded} - value={selectedStringMutationFlags.map(newFlag => ({ label: newFlag.name, value: newFlag }))} - /> - -
- -
- - - { - handleAlterationCategoriesField('comment', value); - }} - disabled={selectedStringMutationFlags.length === 0} - /> - - - - )} - {tabStates.length > 0 && ( -
- { - return { - title: getTabTitle(alterationData), - content: getTabContent(alterationData, index), - }; - })} - /> -
- )} - - ); - - 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'; + async function handleAlterationCategoriesConfirm() { + let newAlterationCategories: AlterationCategories | null = new AlterationCategories(); + if (selectedAlterationCategoryFlags?.length === 0 || alterationStates?.length === 1) { + newAlterationCategories = null; } else { - modalErrorMessage = 'Mutation already in VUS list'; + newAlterationCategories.comment = alterationCategoryComment ?? ''; + const finalFlagArray = await saveNewFlags(); + if ((selectedAlterationCategoryFlags ?? []).length > 0) { + newAlterationCategories.flags = finalFlagArray.map(flag => convertIFlagToFlag(flag)); + } } - } - let modalWarningMessage: string | undefined = undefined; - if (convertOptions?.isConverting && !isEqualIgnoreCase(convertOptions.alteration, currentMutationNames.join(', '))) { - modalWarningMessage = 'Name differs from original VUS name'; - } + // Refresh flag entities + await getFlagsByType?.(FlagTypeEnum.ALTERATION_CATEGORY); - function convertIFlagToFlag(flagEntity: IFlag | Omit): Flag { - return { - flag: flagEntity.flag, - type: flagEntity.type, - }; + return newAlterationCategories; } async function saveNewFlags() { - const [newFlags, oldFlags] = _.partition(selectedStringMutationFlags ?? [], newFlag => { - return !flags.some(existingFlag => { + const [newFlags, oldFlags] = _.partition(selectedAlterationCategoryFlags ?? [], newFlag => { + return !alterationCategoryFlagEntities?.some(existingFlag => { return newFlag.type === existingFlag.type && newFlag.flag === existingFlag.flag; }); }); @@ -1206,75 +315,135 @@ function AddMutationModal({ return oldFlags; } - async function handleAlterationCategoriesConfirm() { - let newAlterationCategories: AlterationCategories | null = new AlterationCategories(); - if (selectedStringMutationFlags.length === 0 || tabStates.length === 1) { - newAlterationCategories = null; - } else { - newAlterationCategories.comment = stringMutationComment; - const finalFlagArray = await saveNewFlags(); - if (selectedStringMutationFlags.length > 0) { - newAlterationCategories.flags = finalFlagArray.map(flag => convertIFlagToFlag(flag)); - } + const handleKeyDown: KeyboardEventHandler = event => { + if (!inputValue) return; + if (event.key === 'Enter' || event.key === 'tab') { + handleAlterationAdded(); + event.preventDefault(); } + }; - // Refresh flag entities - await getFlagsByType?.(FlagTypeEnum.ALTERATION_CATEGORY); + const handleCancel = () => { + cleanup?.(); + onCancel(); + }; - return newAlterationCategories; - } + const renderInputSection = () => ( + + + + setInputValue(e.target.value)} + onClick={() => setShowModifyExonForm?.(false)} + /> + + } /> + + + + + + OR + + + + + + ); - 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; - } + // Helper function to render exon or mutation list section + const renderExonOrMutationListSection = () => { + if (showModifyExonForm) { + return ( + <> +
+ + + ); + } + if (alterationStates?.length !== 0) { + return ( + <> +
+ + + + + + + ); + } + return null; + }; - async function handleConfirm() { - 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; + // Helper function to render selected alteration state content + const renderMutationDetailSection = () => { + if ( + alterationStates !== undefined && + selectedAlterationStateIndex !== undefined && + selectedAlterationStateIndex > -1 && + !_.isNil(alterationStates[selectedAlterationStateIndex]) + ) { + const selectedAlteration = alterationStates[selectedAlterationStateIndex].alteration; + return ( + <> +
+ {EXON_ALTERATION_REGEX.test(selectedAlteration) ? ( + + ) : ( + <> + + + + )} + + ); + } + return null; + }; - const newAlterationCategories = await handleAlterationCategoriesConfirm(); - newMutation.alteration_categories = newAlterationCategories; + const mutationModalBody = ( +
+ {!convertOptions?.isConverting && renderInputSection()} + {renderExonOrMutationListSection()} + {renderMutationDetailSection()} +
+ ); - setErrorMessagesEnabled(false); - setIsConfirmPending(true); - try { - await onConfirm(newMutation, mutationList?.length || 0); - } finally { - setErrorMessagesEnabled(true); - setIsConfirmPending(false); - } + const modalErrorMessage = getModalErrorMessage(mutationAlreadyExists); + + let modalWarningMessage: string | undefined = undefined; + if (convertOptions?.isConverting && !isEqualIgnoreCase(convertOptions.alteration, (currentMutationNames ?? []).join(', '))) { + modalWarningMessage = 'Name differs from original VUS name'; } return ( Promoting Variant(s) to Mutation
: undefined} - modalBody={isExonCuration ? : modalBody} - onCancel={onCancel} + modalBody={mutationModalBody} + onCancel={handleCancel} onConfirm={handleConfirm} errorMessages={modalErrorMessage && errorMessagesEnabled ? [modalErrorMessage] : 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)) || + alterationStates?.some(tab => tab.error || tab.excluding.some(ex => ex.error)) || isConfirmPending } isConfirmPending={isConfirmPending} @@ -1282,7 +451,51 @@ function AddMutationModal({ ); } -export const AddMutationInputOverlay = () => { +const mapStoreToProps = ({ + alterationStore, + consequenceStore, + geneStore, + firebaseAppStore, + firebaseVusStore, + firebaseMutationListStore, + flagStore, + addMutationModalStore, +}: 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, +}); + +type StoreProps = Partial>; + +export default componentInject(mapStoreToProps)(AddMutationModal); + +const AddMutationInputOverlay = () => { return (
@@ -1314,27 +527,3 @@ export const AddMutationInputOverlay = () => {
); }; - -const mapStoreToProps = ({ - alterationStore, - consequenceStore, - geneStore, - firebaseAppStore, - firebaseVusStore, - firebaseMutationListStore, - flagStore, -}: 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, - createFlagEntity: flagStore.createEntity, -}); - -type StoreProps = Partial>; - -export default componentInject(mapStoreToProps)(AddMutationModal); diff --git a/src/main/webapp/app/shared/modal/MutationModal/AddExonForm.tsx b/src/main/webapp/app/shared/modal/MutationModal/AddExonForm.tsx index d583f1dbf..573cd0215 100644 --- a/src/main/webapp/app/shared/modal/MutationModal/AddExonForm.tsx +++ b/src/main/webapp/app/shared/modal/MutationModal/AddExonForm.tsx @@ -4,7 +4,7 @@ import { flow } from 'mobx'; import { componentInject } from 'app/shared/util/typed-inject'; import { ReferenceGenome } from 'app/shared/model/enumerations/reference-genome.model'; import { ProteinExonDTO } from 'app/shared/api/generated/curation'; -import { Col, Row } from 'reactstrap'; +import { Button, Col, Row } from 'reactstrap'; import { components, OptionProps } from 'react-select'; import CreatableSelect from 'react-select/creatable'; import _ from 'lodash'; @@ -15,6 +15,9 @@ import { EXON_ALTERATION_REGEX } from 'app/config/constants/regex'; import LoadingIndicator from 'app/oncokb-commons/components/loadingIndicator/LoadingIndicator'; import classNames from 'classnames'; import InfoIcon from 'app/shared/icons/InfoIcon'; +import { FaArrowLeft, FaStar } from 'react-icons/fa'; +import { faArrowLeft, faCross, faX } from '@fortawesome/free-solid-svg-icons'; +import ActionIcon from 'app/shared/icons/ActionIcon'; export interface IAddExonMutationModalBody extends StoreProps { hugoSymbol: string; @@ -41,6 +44,7 @@ const AddExonForm = ({ const [proteinExons, setProteinExons] = useState([]); const [isPendingAddAlteration, setIsPendingAddAlteration] = useState(false); + const [didRemoveProblematicAlt, setDidRemoveProblematicAlt] = useState(false); const exonOptions = useMemo(() => { const options: ProteinExonDropdownOption[] = EXON_CONSEQUENCES.flatMap(consequence => { @@ -58,7 +62,7 @@ const AddExonForm = ({ return exonAltStrings.reduce((acc, exonString) => { const match = exonString.match(EXON_ALTERATION_REGEX); if (match) { - if (match[1].trim() === 'Any') { + if (match[1]?.trim() === 'Any') { acc.push({ label: exonString, value: exonString, isSelected: true }); return acc; } @@ -69,16 +73,19 @@ const AddExonForm = ({ for (let exonNum = startExon; exonNum <= endExon; exonNum++) { const targetOption = exonOptions.find(option => option.label === `Exon ${exonNum} ${consequence}`); if (!targetOption) { - notifyError(`Error parsing alteration: ${defaultExonAlterationName}`); - return acc; + notifyError(`Removed exon that does not exist: ${defaultExonAlterationName}`); + setDidRemoveProblematicAlt(true); + } else { + acc.push({ ...targetOption, isSelected: true }); } - acc.push(targetOption); } } return acc; }, [] as ProteinExonDropdownOption[]); }, [defaultExonAlterationName, exonOptions]); + const isUpdate = defaultSelectedExons.length > 0 || didRemoveProblematicAlt; + useEffect(() => { setSelectedExons(defaultSelectedExons ?? []); }, [defaultSelectedExons]); @@ -110,7 +117,7 @@ const AddExonForm = ({ const parsedAlterations = parseAlterationName(finalExonName); try { setIsPendingAddAlteration(true); - await updateAlterationStateAfterAlterationAdded?.(parsedAlterations, (defaultSelectedExons ?? []).length > 0); + await updateAlterationStateAfterAlterationAdded?.(parsedAlterations, isUpdate); } finally { setIsPendingAddAlteration(false); } @@ -123,39 +130,49 @@ const AddExonForm = ({ return ( <> - + -
{(defaultSelectedExons?.length ?? 0) > 0 ? 'Modify Selected Exons' : 'Selected Exons'}
+
{isUpdate ? 'Modify Selected Exons' : 'Selected Exons'}
+ + + setShowModifyExonForm?.(false)} tooltipProps={{ overlay: 'Cancel' }} />
- 0 ? 'col-9' : 'col-10')}> - setInputValue(newValue)} - options={exonOptions} - value={selectedExons} - onChange={newOptions => setSelectedExons(newOptions.map(option => ({ ...option, isSelected: true })))} - components={{ - Option: MultiSelectOption, - NoOptionsMessage, - }} - isMulti - closeMenuOnSelect={false} - hideSelectedOptions={false} - isClearable - isValidNewOption={createInputValue => { - return EXON_ALTERATION_REGEX.test(createInputValue); - }} - onCreateOption={onCreateOption} - /> + + + + setInputValue(newValue)} + options={exonOptions} + value={selectedExons} + onChange={newOptions => setSelectedExons(newOptions.map(option => ({ ...option, isSelected: true })))} + components={{ + Option: MultiSelectOption, + NoOptionsMessage, + }} + isMulti + closeMenuOnSelect={false} + hideSelectedOptions={false} + isClearable + isValidNewOption={createInputValue => { + return EXON_ALTERATION_REGEX.test(createInputValue); + }} + onCreateOption={onCreateOption} + /> + + + + + 0 ? 'Update' : 'Add'} - disabled={isPendingAddAlteration || selectedExons.length === 0} + confirmText={isUpdate ? 'Update' : 'Add'} + disabled={isPendingAddAlteration || selectedExons.length === 0 || _.isEqual(defaultSelectedExons, selectedExons)} /> @@ -170,35 +187,41 @@ const AddExonForm = ({ ); }; +const EXON_CREATE_INFO = ( + <> +
You can create a new option that adheres to one of the formats:
+
+
    +
  • + {'Any Exon start-end (Deletion|Insertion|Duplication)'} + +
  • +
  • + {'Exon start-end (Deletion|Insertion|Duplication)'} + +
  • +
+
+ +); + const NoOptionsMessage = props => { return (
No options matching text


-
You can also create a new option that adheres to one of the formats:
-
-
    -
  • - {'Any Exon start-end (Deletion|Insertion|Duplication)'} - -
  • -
  • - {'Exon start-end (Deletion|Insertion|Duplication)'} - -
  • -
-
+ {EXON_CREATE_INFO}
); diff --git a/src/main/webapp/app/shared/modal/MutationModal/AlterationBadgeList.tsx b/src/main/webapp/app/shared/modal/MutationModal/AlterationBadgeList.tsx index 7bb47dc7b..c188b71c4 100644 --- a/src/main/webapp/app/shared/modal/MutationModal/AlterationBadgeList.tsx +++ b/src/main/webapp/app/shared/modal/MutationModal/AlterationBadgeList.tsx @@ -4,7 +4,7 @@ import React, { useMemo, useRef, useState } 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 '../NewAddMutationModal'; +import { AlterationData } from '../AddMutationModal'; import { getFullAlterationName } from 'app/shared/util/utils'; import { IRootStore } from 'app/stores'; import { componentInject } from 'app/shared/util/typed-inject'; diff --git a/src/main/webapp/app/shared/modal/MutationModal/AlterationCategoryInputs.tsx b/src/main/webapp/app/shared/modal/MutationModal/AlterationCategoryInputs.tsx index 7abfc6f70..f64a310fc 100644 --- a/src/main/webapp/app/shared/modal/MutationModal/AlterationCategoryInputs.tsx +++ b/src/main/webapp/app/shared/modal/MutationModal/AlterationCategoryInputs.tsx @@ -76,27 +76,6 @@ const AlterationCategoryInputs = ({ } }; - const reactSelectStyles = { - container: (provided, state) => ({ - ...provided, - padding: 0, - height: 'fit-content', - }), - control: (provided, state) => ({ - ...provided, - minHeight: 'fit-content', - height: 'fit-content', - }), - indicatorsContainer: (provided, state) => ({ - ...provided, - height: '29px', - }), - input: (provided, state) => ({ - ...provided, - height: '21px', - }), - }; - return ( <> @@ -107,7 +86,6 @@ const AlterationCategoryInputs = ({ handleFieldChange(newValue?.value, 'type')} /> )}
-
+
{showAlterationCategoryDropdown && }
Name preview: {finalMutationName}
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 index 653f08952..15f57cc43 100644 --- 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 @@ -1,6 +1,5 @@ import { Mutation, VusObjList } from 'app/shared/model/firebase/firebase.model'; import { action, computed, flow, flowResult, makeObservable, observable } from 'mobx'; -import { AlterationData } from '../AddMutationModal'; import { convertEntityStatusAlterationToAlterationData, getFullAlterationName, hasValue, parseAlterationName } from 'app/shared/util/utils'; import _ from 'lodash'; import { notifyError } from 'app/oncokb-commons/components/util/NotificationUtils'; @@ -9,6 +8,7 @@ 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; @@ -140,7 +140,9 @@ export class AddMutationModalStore { ); if (isUpdate) { - this.alterationStates[this.selectedAlterationStateIndex] = newAlterations[0]; + const newAlterationStates = _.cloneDeep(this.alterationStates); + newAlterationStates[this.selectedAlterationStateIndex] = newAlterations[0]; + this.alterationStates = newAlterationStates; } else { this.alterationStates = this.alterationStates.concat(newAlterations); } diff --git a/src/main/webapp/app/shared/modal/NewAddMutationModal.tsx b/src/main/webapp/app/shared/modal/NewAddMutationModal.tsx deleted file mode 100644 index d85018c8a..000000000 --- a/src/main/webapp/app/shared/modal/NewAddMutationModal.tsx +++ /dev/null @@ -1,530 +0,0 @@ -import { IRootStore } from 'app/stores'; -import { onValue, ref } from 'firebase/database'; -import _ from 'lodash'; -import { flow } from 'mobx'; -import React, { KeyboardEventHandler, useEffect, useState } from 'react'; -import { Button, Col, Input, InputGroup, InputGroupText, Row } from 'reactstrap'; -import { Mutation, AlterationCategories } from '../../shared/model/firebase/firebase.model'; -import { AlterationAnnotationStatus, AlterationTypeEnum, Gene } from '../../shared/api/generated/curation'; -import { getDuplicateMutations, getFirebaseVusPath } from '../../shared/util/firebase/firebase-utils'; -import { componentInject } from '../../shared/util/typed-inject'; -import { - isEqualIgnoreCase, - parseAlterationName, - convertEntityStatusAlterationToAlterationData, - convertAlterationDataToAlteration, - convertAlterationToAlterationData, - convertIFlagToFlag, -} from '../../shared/util/utils'; -import { DefaultAddMutationModal } from '../../shared/modal/DefaultAddMutationModal'; -import './add-mutation-modal.scss'; -import { Unsubscribe } from 'firebase/database'; -import InfoIcon from '../../shared/icons/InfoIcon'; -import { FlagTypeEnum } from '../../shared/model/enumerations/flag-type.enum.model'; -import AddExonForm from './MutationModal/AddExonForm'; -import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; -import { faPlus } from '@fortawesome/free-solid-svg-icons'; -import { AsyncSaveButton } from '../../shared/button/AsyncSaveButton'; -import MutationDetails from './MutationModal/MutationDetails'; -import ExcludedAlterationContent from './MutationModal/ExcludedAlterationContent'; -import { EXON_ALTERATION_REGEX } from 'app/config/constants/regex'; -import MutationListSection from './MutationModal/MutationListSection'; -import classNames from 'classnames'; - -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; - isGermline: boolean; - onConfirm: (mutation: Mutation, mutationFirebaseIndex: number) => Promise; - onCancel: () => void; - mutationToEditPath?: string | null; - convertOptions?: { - alteration: string; - isConverting: boolean; - }; -} - -type MutationExistsMeta = { - exists: boolean; - inMutationList: boolean; - inVusList: boolean; -}; - -function NewAddMutationModal({ - hugoSymbol, - isGermline, - mutationToEditPath, - mutationList, - geneEntities, - onConfirm, - onCancel, - firebaseDb, - convertOptions, - getFlagsByType, - createFlagEntity, - alterationCategoryFlagEntities, - setVusList, - setMutationToEdit, - alterationStates, - vusList, - mutationToEdit, - setShowModifyExonForm, - isFetchingAlteration, - isFetchingExcludingAlteration, - currentMutationNames, - showModifyExonForm, - cleanup, - fetchAlterations, - setAlterationStates, - selectedAlterationCategoryFlags, - alterationCategoryComment, - setGeneEntity, - updateAlterationStateAfterAlterationAdded, - selectedAlterationStateIndex, -}: IAddMutationModalProps) { - const [inputValue, setInputValue] = useState(''); - const [mutationAlreadyExists, setMutationAlreadyExists] = useState({ - exists: false, - inMutationList: false, - inVusList: false, - }); - - const [isAddAlterationPending, setIsAddAlterationPending] = useState(false); - - const [errorMessagesEnabled, setErrorMessagesEnabled] = useState(true); - const [isConfirmPending, setIsConfirmPending] = useState(false); - - useEffect(() => { - if (!firebaseDb) { - return; - } - const callbacks: Unsubscribe[] = []; - callbacks.push( - onValue(ref(firebaseDb, getFirebaseVusPath(isGermline, hugoSymbol)), snapshot => { - setVusList?.(snapshot.val()); - }), - ); - - if (mutationToEditPath) { - callbacks.push( - onValue(ref(firebaseDb, mutationToEditPath), snapshot => { - 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(); - } - }, [convertOptions?.isConverting]); - - useEffect(() => { - const dupMutations = getDuplicateMutations(currentMutationNames ?? [], mutationList ?? [], 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(() => { - async function setExistingAlterations() { - if (mutationToEdit?.alterations?.length !== undefined && mutationToEdit.alterations.length > 0) { - setAlterationStates?.(mutationToEdit?.alterations?.map(alt => convertAlterationToAlterationData(alt)) ?? []); - return; - } - - // at this point can be sure each alteration name does not have / character - const parsedAlterations = mutationToEdit?.name?.split(',').map(name => parseAlterationName(name.trim())[0]); - - const entityStatusAlterationsPromise = fetchAlterations?.(parsedAlterations?.map(alt => alt.alteration) ?? []); - if (!entityStatusAlterationsPromise) return; - const excludingEntityStatusAlterationsPromises: Promise[] = []; - for (const alt of parsedAlterations ?? []) { - const fetchedAlterations = fetchAlterations?.(alt.excluding); - if (fetchedAlterations) { - excludingEntityStatusAlterationsPromises.push(fetchedAlterations); - } - } - const [entityStatusAlterations, entityStatusExcludingAlterations] = await Promise.all([ - entityStatusAlterationsPromise, - Promise.all(excludingEntityStatusAlterationsPromises), - ]); - - const excludingAlterations: AlterationData[][] = []; - if (parsedAlterations) { - 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], - [], - '', - ), - ); - } - excludingAlterations.push(excluding); - } - } - - if (parsedAlterations) { - const newAlerationStates = entityStatusAlterations.map((alt, index) => - convertEntityStatusAlterationToAlterationData( - alt, - parsedAlterations[index].alteration, - excludingAlterations[index] || [], - parsedAlterations[index].comment, - parsedAlterations[index].name, - ), - ); - - setAlterationStates?.(newAlerationStates); - } - } - - if (mutationToEdit) { - setExistingAlterations(); - } - }, [mutationToEdit]); - - async function handleAlterationAdded() { - let alterationString = inputValue; - if (convertOptions?.isConverting) { - alterationString = convertOptions.alteration; - } - try { - setIsAddAlterationPending(true); - await updateAlterationStateAfterAlterationAdded?.(parseAlterationName(alterationString)); - } finally { - setIsAddAlterationPending(false); - } - setInputValue(''); - } - - 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 newAlterationCategories = await handleAlterationCategoriesConfirm(); - newMutation.alteration_categories = newAlterationCategories; - - setErrorMessagesEnabled(false); - setIsConfirmPending(true); - try { - await onConfirm(newMutation, mutationList?.length || 0); - } finally { - setErrorMessagesEnabled(true); - setIsConfirmPending(false); - } - } - - async function handleAlterationCategoriesConfirm() { - let newAlterationCategories: AlterationCategories | null = new AlterationCategories(); - if (selectedAlterationCategoryFlags?.length === 0 || alterationStates?.length === 1) { - newAlterationCategories = null; - } else { - newAlterationCategories.comment = alterationCategoryComment ?? ''; - const finalFlagArray = await saveNewFlags(); - if ((selectedAlterationCategoryFlags ?? []).length > 0) { - newAlterationCategories.flags = finalFlagArray.map(flag => convertIFlagToFlag(flag)); - } - } - - // Refresh flag entities - await getFlagsByType?.(FlagTypeEnum.ALTERATION_CATEGORY); - - return newAlterationCategories; - } - - async function saveNewFlags() { - const [newFlags, oldFlags] = _.partition(selectedAlterationCategoryFlags ?? [], newFlag => { - return !alterationCategoryFlagEntities?.some(existingFlag => { - return newFlag.type === existingFlag.type && newFlag.flag === existingFlag.flag; - }); - }); - 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); - } - } - } - - return oldFlags; - } - - const handleKeyDown: KeyboardEventHandler = event => { - if (!inputValue) return; - if (event.key === 'Enter' || event.key === 'tab') { - handleAlterationAdded(); - event.preventDefault(); - } - }; - - const handleCancel = () => { - cleanup?.(); - onCancel(); - }; - - const renderInputSection = () => ( - - - - setInputValue(e.target.value)} - onClick={() => setShowModifyExonForm?.(false)} - /> - - } /> - - - - - - OR - - - - - - ); - - // Helper function to render exon or mutation list section - const renderExonOrMutationListSection = () => { - if (showModifyExonForm) { - return ( - <> -
- - - ); - } - if (alterationStates?.length !== 0) { - return ( - <> -
- - - - - - - ); - } - return null; - }; - - // Helper function to render selected alteration state content - const renderMutationDetailSection = () => { - if ( - alterationStates !== undefined && - selectedAlterationStateIndex !== undefined && - selectedAlterationStateIndex > -1 && - !_.isNil(alterationStates[selectedAlterationStateIndex]) - ) { - const selectedAlteration = alterationStates[selectedAlterationStateIndex].alteration; - return ( - <> -
- {EXON_ALTERATION_REGEX.test(selectedAlteration) ? ( - - ) : ( - <> - - - - )} - - ); - } - return null; - }; - - const mutationModalBody = ( -
- {!convertOptions?.isConverting && renderInputSection()} - {renderExonOrMutationListSection()} - {renderMutationDetailSection()} -
- ); - - const modalErrorMessage = getModalErrorMessage(mutationAlreadyExists); - - let modalWarningMessage: string | undefined = undefined; - if (convertOptions?.isConverting && !isEqualIgnoreCase(convertOptions.alteration, (currentMutationNames ?? []).join(', '))) { - modalWarningMessage = 'Name differs from original VUS name'; - } - - return ( - Promoting Variant(s) to Mutation
: undefined} - modalBody={mutationModalBody} - onCancel={handleCancel} - onConfirm={handleConfirm} - errorMessages={modalErrorMessage && errorMessagesEnabled ? [modalErrorMessage] : undefined} - warningMessages={modalWarningMessage ? [modalWarningMessage] : undefined} - confirmButtonDisabled={ - alterationStates?.length === 0 || - mutationAlreadyExists.exists || - isFetchingAlteration || - isFetchingExcludingAlteration || - alterationStates?.some(tab => tab.error || tab.excluding.some(ex => ex.error)) || - isConfirmPending - } - isConfirmPending={isConfirmPending} - /> - ); -} - -const mapStoreToProps = ({ - alterationStore, - consequenceStore, - geneStore, - firebaseAppStore, - firebaseVusStore, - firebaseMutationListStore, - flagStore, - addMutationModalStore, -}: 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, -}); - -type StoreProps = Partial>; - -export default componentInject(mapStoreToProps)(NewAddMutationModal); - -const AddMutationInputOverlay = () => { - return ( -
-
- Enter alteration(s) in input area, then press Enter key or click on{' '} - Add button to annotate alteration(s). -
-
-
String Mutation:
-
-
    -
  • - Variant alleles seperated by slash - R132C/H/G/S/L -
  • -
  • - Comma seperated list of alterations - V600E, V600K -
  • -
-
-
Exon:
-
    -
  • - Supported consequences are Insertion, Deletion and Duplication - Exon 4 Deletion -
  • -
  • - Exon range - Exon 4-8 Deletion -
  • -
-
-
- ); -}; diff --git a/src/main/webapp/app/shared/table/VusTable.tsx b/src/main/webapp/app/shared/table/VusTable.tsx index d8ca2cd06..d21ca117e 100644 --- a/src/main/webapp/app/shared/table/VusTable.tsx +++ b/src/main/webapp/app/shared/table/VusTable.tsx @@ -32,7 +32,7 @@ import AddVusModal from '../modal/AddVusModal'; import MutationConvertIcon from '../icons/MutationConvertIcon'; import { Unsubscribe } from 'firebase/database'; import { VUS_TABLE_ID } from 'app/config/constants/html-id'; -import NewAddMutationModal from '../modal/NewAddMutationModal'; +import AddMutationModal from '../modal/AddMutationModal'; export interface IVusTableProps extends StoreProps { hugoSymbol: string | undefined; @@ -261,7 +261,7 @@ const VusTable = ({ /> ) : undefined} {vusToPromote ? ( - { diff --git a/src/main/webapp/app/shared/util/utils.tsx b/src/main/webapp/app/shared/util/utils.tsx index efb983d6f..e6af357b6 100644 --- a/src/main/webapp/app/shared/util/utils.tsx +++ b/src/main/webapp/app/shared/util/utils.tsx @@ -19,7 +19,7 @@ import { AlterationAnnotationStatus, AlterationTypeEnum, ProteinExonDTO } from ' import { IQueryParams } from './jhipster-types'; import InfoIcon from '../icons/InfoIcon'; import { IFlag } from '../model/flag.model'; -import { AlterationData } from '../modal/NewAddMutationModal'; +import { AlterationData } from '../modal/AddMutationModal'; export const getCancerTypeName = (cancerType: ICancerType | CancerType, omitCode = false): string => { if (!cancerType) return ''; From cf48f96500086345f87ce271e466b6542440304e Mon Sep 17 00:00:00 2001 From: Calvin Lu <59149377+calvinlu3@users.noreply.github.com> Date: Mon, 4 Nov 2024 16:57:44 -0500 Subject: [PATCH 6/8] Update based on feedback --- .../domain/enumeration/SVConsequence.java | 24 +++++-- .../oncokb/curation/util/AlterationUtils.java | 67 +++++++++++++++---- src/main/webapp/app/app.scss | 8 +++ .../webapp/app/config/constants/regex.spec.ts | 6 ++ src/main/webapp/app/config/constants/regex.ts | 3 +- .../app/hooks/useTextareaAutoHeight.tsx | 3 +- .../MutationCollapsibleTitle.tsx | 18 ++++- .../app/shared/icons/InputFieldIcon.tsx | 19 ------ .../app/shared/modal/AddMutationModal.tsx | 25 ++++--- .../modal/MutationModal/AddExonForm.tsx | 36 +++++++--- .../MutationModal/AddMutationModalField.tsx | 12 ++-- .../MutationModal/AlterationBadgeList.tsx | 17 +++-- .../modal/MutationModal/MutationDetails.tsx | 2 +- .../MutationModal/add-mutation-modal.store.ts | 43 +++++------- .../modal/MutationModal/styles.module.scss | 10 ++- src/main/webapp/app/shared/util/utils.tsx | 12 ++-- 16 files changed, 191 insertions(+), 114 deletions(-) delete mode 100644 src/main/webapp/app/shared/icons/InputFieldIcon.tsx 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 e8db80387..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 { - SV_DELETION, - SV_TRANSLOCATION, - SV_DUPLICATION, - SV_INSERTION, - SV_INVERSION, - SV_FUSION, - SV_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 c5ab48533..af4f0de00 100644 --- a/src/main/java/org/mskcc/oncokb/curation/util/AlterationUtils.java +++ b/src/main/java/org/mskcc/oncokb/curation/util/AlterationUtils.java @@ -103,7 +103,10 @@ private Alteration parseExonAlteration(String alteration) { Pattern pattern = Pattern.compile(EXON_ALT_REGEX, Pattern.CASE_INSENSITIVE); Matcher matcher = pattern.matcher(alteration); List splitResults = new ArrayList<>(); - Set consequenceTermSet = new HashSet<>(); + Map> exonsByConsequence = new HashMap<>(); + exonsByConsequence.put(SVConsequence.SV_INSERTION, new HashSet<>()); + exonsByConsequence.put(SVConsequence.SV_DELETION, new HashSet<>()); + exonsByConsequence.put(SVConsequence.SV_DUPLICATION, new HashSet<>()); while (matcher.find()) { Boolean isAnyExon = false; @@ -114,29 +117,24 @@ private Alteration parseExonAlteration(String alteration) { String startExonStr = matcher.group(2); // The start exon number String endExonStr = matcher.group(4); // The end exon number (if present) String consequenceTerm = matcher.group(5); // consequence term - + SVConsequence svConsequence = SVConsequence.SV_UNKNOWN; switch (consequenceTerm.toLowerCase()) { case "insertion": - consequenceTerm = "Insertion"; + svConsequence = SVConsequence.SV_INSERTION; consequence.setTerm(SVConsequence.SV_INSERTION.name()); break; case "duplication": - consequenceTerm = "Duplication"; + svConsequence = SVConsequence.SV_DUPLICATION; consequence.setTerm(SVConsequence.SV_DUPLICATION.name()); break; case "deletion": - consequenceTerm = "Deletion"; + svConsequence = SVConsequence.SV_DELETION; consequence.setTerm(SVConsequence.SV_DELETION.name()); break; default: break; } - consequenceTermSet.add(consequenceTerm); - if (consequenceTermSet.size() > 1) { - consequence.setTerm(SVConsequence.SV_UNKNOWN.name()); - } - if (isAnyExon) { splitResults.add(alteration); continue; @@ -146,12 +144,57 @@ private Alteration parseExonAlteration(String alteration) { int endExon = (endExonStr != null) ? Integer.parseInt(endExonStr) : startExon; for (int exon = startExon; exon <= endExon; exon++) { - splitResults.add("Exon " + exon + " " + consequenceTerm); + String exonAlteration = "Exon " + exon + " " + consequenceTerm; + splitResults.add(exonAlteration); + exonsByConsequence.get(svConsequence).add(exonAlteration); } } alt.setAlteration(splitResults.stream().collect(Collectors.joining(" + "))); - alt.setName(alteration); + + StringBuilder formattedName = new StringBuilder(); + for (SVConsequence consequenceKey : new SVConsequence[] { + SVConsequence.SV_INSERTION, + SVConsequence.SV_DELETION, + SVConsequence.SV_DUPLICATION, + }) { + List sortedExonAlterations = new ArrayList<>(exonsByConsequence.get(consequenceKey)); + sortedExonAlterations.sort(Comparator.comparingInt(exon -> Integer.parseInt(exon.split(" ")[1]))); + String consequenceTerm = consequenceKey.getName(); + + List result = new ArrayList<>(); + int start = -1; + int end = -1; + + for (int i = 0; i < sortedExonAlterations.size(); i++) { + String exon = sortedExonAlterations.get(i); + int exonNumber = Integer.parseInt(exon.split(" ")[1]); + + if (start == -1) { + start = exonNumber; + end = exonNumber; + } else if (exonNumber == end + 1) { + end = exonNumber; + } else { + if (start == end) { + result.add("Exon " + start + " " + consequenceTerm); + } else { + result.add("Exon " + start + "-" + end + " " + consequenceTerm); + } + start = exonNumber; + end = exonNumber; + } + } + if (start != -1) { + if (start == end) { + result.add("Exon " + start + " " + consequenceTerm); + } else { + result.add("Exon " + start + "-" + end + " " + consequenceTerm); + } + } + formattedName.append(result.stream().collect(Collectors.joining(" + "))); + } + alt.setName(formattedName.toString()); return alt; } diff --git a/src/main/webapp/app/app.scss b/src/main/webapp/app/app.scss index 2eae997d7..bf209a1fd 100644 --- a/src/main/webapp/app/app.scss +++ b/src/main/webapp/app/app.scss @@ -101,6 +101,10 @@ Generic styles margin-right: 0.5rem; } +.error-message > svg { + flex-shrink: 0; +} + .warning-message { display: flex; align-items: center; @@ -108,6 +112,10 @@ Generic styles margin-right: 0.5rem; } +.warning-message > svg { + flex-shrink: 0; +} + .break { white-space: normal; word-break: break-all; diff --git a/src/main/webapp/app/config/constants/regex.spec.ts b/src/main/webapp/app/config/constants/regex.spec.ts index 707893ed4..b52d5d7dd 100644 --- a/src/main/webapp/app/config/constants/regex.spec.ts +++ b/src/main/webapp/app/config/constants/regex.spec.ts @@ -87,8 +87,14 @@ describe('Regex constants test', () => { ['Exon 4 Deletion + Exon 5 Deletion + Exon 6 Deletion', true], ['Exon 4-8 Deletion + Exon 10 Deletion', true], ['Exon 4 Deletion+Exon 5 Deletion', true], + ['Any Exon 2-4 Deletion', true], + ['Any Exon 2-4 Deletion + Exon 5 Deletion', true], + ['Any Exon 2-4 Deletion + Any Exon 7-9 Deletion', true], ['Exon 14 Del', false], ['Exon 4 8 Insertion', false], + ['Exon 4 Deletion +', false], + ['Exon 2-4 Deletion + Any', false], + ['Exon 4 Insertion + Exon 6', false], ])('should return %b for %s', (alteration, expected) => { expect(EXON_ALTERATION_REGEX.test(alteration)).toEqual(expected); }); diff --git a/src/main/webapp/app/config/constants/regex.ts b/src/main/webapp/app/config/constants/regex.ts index 4d808db9e..596fc501c 100644 --- a/src/main/webapp/app/config/constants/regex.ts +++ b/src/main/webapp/app/config/constants/regex.ts @@ -10,4 +10,5 @@ export const WHOLE_NUMBER_REGEX = new RegExp('^\\d+$'); export const INTEGER_REGEX = /^-?\d+$/; -export const EXON_ALTERATION_REGEX = /^(Any\s+)?(Exon\s+(\d+)(-(\d+))?\s+(Deletion|Insertion|Duplication))(\s*\+\s*(\1))*/i; +export const EXON_ALTERATION_REGEX = + /^(Any\s+)?(Exon\s+(\d+)(-(\d+))?\s+(Deletion|Insertion|Duplication))(\s*\+\s*(Any\s+)?Exon\s+\d+(-\d+)?\s+(Deletion|Insertion|Duplication))*$/i; diff --git a/src/main/webapp/app/hooks/useTextareaAutoHeight.tsx b/src/main/webapp/app/hooks/useTextareaAutoHeight.tsx index 922a502d4..b62ad812c 100644 --- a/src/main/webapp/app/hooks/useTextareaAutoHeight.tsx +++ b/src/main/webapp/app/hooks/useTextareaAutoHeight.tsx @@ -1,9 +1,8 @@ import React, { useEffect } from 'react'; -import { InputType } from 'zlib'; export const useTextareaAutoHeight = ( inputRef: React.MutableRefObject, - type: InputType | undefined, + type: string | undefined, ) => { useEffect(() => { const input = inputRef.current; 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 index 00f00ca87..211eee642 100644 --- a/src/main/webapp/app/pages/curation/collapsible/mutation-collapsible/MutationCollapsibleTitle.tsx +++ b/src/main/webapp/app/pages/curation/collapsible/mutation-collapsible/MutationCollapsibleTitle.tsx @@ -3,13 +3,19 @@ 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 { + buildAlterationName, + getAlterationNameComponent, + getMutationRenameValueFromName, + 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 { EXON_ALTERATION_REGEX } from 'app/config/constants/regex'; export interface IMutationCollapsibleTitle extends StoreProps { name: string | undefined; @@ -42,6 +48,16 @@ const MutationCollapsibleTitle = ({ name, mutationAlterations, alterationCategor ); } + const mutationRenameValue = getMutationRenameValueFromName(name ?? ''); + if (EXON_ALTERATION_REGEX.test(mutationRenameValue ?? '')) { + return ( + <> + {mutationRenameValue} + {stringMutationBadges} + + ); + } + if (mutationAlterations) { return ( <> diff --git a/src/main/webapp/app/shared/icons/InputFieldIcon.tsx b/src/main/webapp/app/shared/icons/InputFieldIcon.tsx deleted file mode 100644 index 12f98c332..000000000 --- a/src/main/webapp/app/shared/icons/InputFieldIcon.tsx +++ /dev/null @@ -1,19 +0,0 @@ -import { IconDefinition } from '@fortawesome/free-regular-svg-icons'; -import React from 'react'; -import DefaultTooltip from '../tooltip/DefaultTooltip'; -import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; -import { Input } from 'reactstrap'; - -export interface IInputFieldIcon { - icon: IconDefinition; - onInputChange: () => void; - inputPlaceholder: string; -} - -const InputFieldIcon = ({ icon, onInputChange, inputPlaceholder }: IInputFieldIcon) => { - return ( - }> - - - ); -}; diff --git a/src/main/webapp/app/shared/modal/AddMutationModal.tsx b/src/main/webapp/app/shared/modal/AddMutationModal.tsx index 6daa9978a..1e6026cbf 100644 --- a/src/main/webapp/app/shared/modal/AddMutationModal.tsx +++ b/src/main/webapp/app/shared/modal/AddMutationModal.tsx @@ -113,6 +113,7 @@ function AddMutationModal({ setGeneEntity, updateAlterationStateAfterAlterationAdded, selectedAlterationStateIndex, + hasUncommitedExonFormChanges, }: IAddMutationModalProps) { const [inputValue, setInputValue] = useState(''); const [mutationAlreadyExists, setMutationAlreadyExists] = useState({ @@ -203,14 +204,7 @@ 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); } @@ -220,7 +214,6 @@ function AddMutationModal({ const newAlerationStates = entityStatusAlterations.map((alt, index) => convertEntityStatusAlterationToAlterationData( alt, - parsedAlterations[index].alteration, excludingAlterations[index] || [], parsedAlterations[index].comment, parsedAlterations[index].name, @@ -266,6 +259,7 @@ function AddMutationModal({ } finally { setErrorMessagesEnabled(true); setIsConfirmPending(false); + cleanup?.(); } } @@ -424,9 +418,12 @@ function AddMutationModal({ const modalErrorMessage = getModalErrorMessage(mutationAlreadyExists); - let modalWarningMessage: string | undefined = undefined; + const modalWarningMessage: string[] = []; if (convertOptions?.isConverting && !isEqualIgnoreCase(convertOptions.alteration, (currentMutationNames ?? []).join(', '))) { - modalWarningMessage = 'Name differs from original VUS name'; + modalWarningMessage.push('Name differs from original VUS name'); + } + if (hasUncommitedExonFormChanges) { + modalWarningMessage.push('You made some changes to Exon dropdown. Please click update button.'); } return ( @@ -437,14 +434,15 @@ function AddMutationModal({ onCancel={handleCancel} onConfirm={handleConfirm} errorMessages={modalErrorMessage && errorMessagesEnabled ? [modalErrorMessage] : undefined} - warningMessages={modalWarningMessage ? [modalWarningMessage] : undefined} + warningMessages={modalWarningMessage ? modalWarningMessage : undefined} confirmButtonDisabled={ alterationStates?.length === 0 || mutationAlreadyExists.exists || isFetchingAlteration || isFetchingExcludingAlteration || alterationStates?.some(tab => tab.error || tab.excluding.some(ex => ex.error)) || - isConfirmPending + isConfirmPending || + (hasUncommitedExonFormChanges ?? false) } isConfirmPending={isConfirmPending} /> @@ -489,6 +487,7 @@ const mapStoreToProps = ({ setGeneEntity: addMutationModalStore.setGeneEntity, updateAlterationStateAfterAlterationAdded: addMutationModalStore.updateAlterationStateAfterAlterationAdded, selectedAlterationStateIndex: addMutationModalStore.selectedAlterationStateIndex, + hasUncommitedExonFormChanges: addMutationModalStore.hasUncommitedExonFormChanges, }); type StoreProps = Partial>; diff --git a/src/main/webapp/app/shared/modal/MutationModal/AddExonForm.tsx b/src/main/webapp/app/shared/modal/MutationModal/AddExonForm.tsx index 573cd0215..d0908db46 100644 --- a/src/main/webapp/app/shared/modal/MutationModal/AddExonForm.tsx +++ b/src/main/webapp/app/shared/modal/MutationModal/AddExonForm.tsx @@ -4,7 +4,7 @@ import { flow } from 'mobx'; import { componentInject } from 'app/shared/util/typed-inject'; import { ReferenceGenome } from 'app/shared/model/enumerations/reference-genome.model'; import { ProteinExonDTO } from 'app/shared/api/generated/curation'; -import { Button, Col, Row } from 'reactstrap'; +import { Col, Row } from 'reactstrap'; import { components, OptionProps } from 'react-select'; import CreatableSelect from 'react-select/creatable'; import _ from 'lodash'; @@ -15,9 +15,8 @@ import { EXON_ALTERATION_REGEX } from 'app/config/constants/regex'; import LoadingIndicator from 'app/oncokb-commons/components/loadingIndicator/LoadingIndicator'; import classNames from 'classnames'; import InfoIcon from 'app/shared/icons/InfoIcon'; -import { FaArrowLeft, FaStar } from 'react-icons/fa'; -import { faArrowLeft, faCross, faX } from '@fortawesome/free-solid-svg-icons'; -import ActionIcon from 'app/shared/icons/ActionIcon'; +import { FaArrowLeft } from 'react-icons/fa'; +import * as styles from './styles.module.scss'; export interface IAddExonMutationModalBody extends StoreProps { hugoSymbol: string; @@ -38,6 +37,7 @@ const AddExonForm = ({ getProteinExons, updateAlterationStateAfterAlterationAdded, setShowModifyExonForm, + setHasUncommitedExonFormChanges, }: IAddExonMutationModalBody) => { const [inputValue, setInputValue] = useState(''); const [selectedExons, setSelectedExons] = useState([]); @@ -98,6 +98,13 @@ const AddExonForm = ({ getProteinExons?.(hugoSymbol, ReferenceGenome.GRCh37).then(value => setProteinExons(value)); }, []); + useEffect(() => { + const updateDisabled = isPendingAddAlteration || selectedExons.length === 0 || _.isEqual(defaultSelectedExons, selectedExons); + if (!updateDisabled) { + setHasUncommitedExonFormChanges?.(true); + } + }, [isPendingAddAlteration, selectedExons, defaultSelectedExons]); + const standardizeExonInputString = (createValue: string) => { if (EXON_ALTERATION_REGEX.test(createValue)) { return createValue @@ -130,13 +137,23 @@ const AddExonForm = ({ return ( <> + + +
{ + setShowModifyExonForm?.(false); + setHasUncommitedExonFormChanges?.(false); + }} + className={classNames('d-inline-flex align-items-center', styles.link)} + > + Mutation List +
+ +
{isUpdate ? 'Modify Selected Exons' : 'Selected Exons'}
- - setShowModifyExonForm?.(false)} tooltipProps={{ overlay: 'Cancel' }} /> -
@@ -193,7 +210,7 @@ const EXON_CREATE_INFO = (
  • - {'Any Exon start-end (Deletion|Insertion|Duplication)'} + {`Any Exon start-end (${EXON_CONSEQUENCES.join('|')})`}
  • - {'Exon start-end (Deletion|Insertion|Duplication)'} + {`Exon start-end (${EXON_CONSEQUENCES.join('|')})`} >; diff --git a/src/main/webapp/app/shared/modal/MutationModal/AddMutationModalField.tsx b/src/main/webapp/app/shared/modal/MutationModal/AddMutationModalField.tsx index cf008b479..e19fe5fff 100644 --- a/src/main/webapp/app/shared/modal/MutationModal/AddMutationModalField.tsx +++ b/src/main/webapp/app/shared/modal/MutationModal/AddMutationModalField.tsx @@ -1,7 +1,7 @@ import React from 'react'; import { useTextareaAutoHeight } from 'app/hooks/useTextareaAutoHeight'; import { useRef } from 'react'; -import { Col, Spinner } from 'reactstrap'; +import { Col, Row, Spinner } from 'reactstrap'; import classNames from 'classnames'; import { InputType } from 'reactstrap/types/lib/Input'; import { Input } from 'reactstrap'; @@ -22,12 +22,10 @@ const AddMutationModalField = ({ label, value: value, placeholder, onChange, isL useTextareaAutoHeight(inputRef, type); return ( -
    + -
    - {label} - {isLoading && } -
    + {label} + {isLoading && } -
    + ); }; diff --git a/src/main/webapp/app/shared/modal/MutationModal/AlterationBadgeList.tsx b/src/main/webapp/app/shared/modal/MutationModal/AlterationBadgeList.tsx index c188b71c4..b58880eee 100644 --- a/src/main/webapp/app/shared/modal/MutationModal/AlterationBadgeList.tsx +++ b/src/main/webapp/app/shared/modal/MutationModal/AlterationBadgeList.tsx @@ -5,7 +5,7 @@ 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 { getFullAlterationName, getMutationRenameValueFromName } 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'; @@ -14,6 +14,7 @@ import { BS_BORDER_COLOR } from 'app/config/colors'; import _ from 'lodash'; import { DEFAULT_ICON_SIZE } from 'app/config/constants/constants'; import { FaCircleCheck } from 'react-icons/fa6'; +import { EXON_ALTERATION_REGEX } from 'app/config/constants/regex'; export interface IAlterationBadgeList extends StoreProps { isExclusionList?: boolean; @@ -56,6 +57,10 @@ const AlterationBadgeList = ({ }; const handleAlterationClick = (index: number) => { + const currentIndex = isExclusionList ? selectedExcludedAlterationIndex : selectedAlterationStateIndex; + if (currentIndex === index) { + index = -1; + } isExclusionList ? setSelectedExcludedAlterationIndex?.(index) : setSelectedAlterationStateIndex?.(index); }; @@ -80,7 +85,7 @@ const AlterationBadgeList = ({ isSelected={index === (isExclusionList ? selectedExcludedAlterationIndex : selectedAlterationStateIndex)} onClick={() => handleAlterationClick(index)} onDelete={() => handleAlterationDelete(value)} - isExludedAlteration={isExclusionList} + isExcludedAlteration={isExclusionList} /> ); })} @@ -118,7 +123,7 @@ interface IAlterationBadge { isSelected: boolean; onClick: () => void; onDelete: () => void; - isExludedAlteration?: boolean; + isExcludedAlteration?: boolean; } const AlterationBadge = ({ @@ -127,7 +132,7 @@ const AlterationBadge = ({ isSelected, onClick, onDelete, - isExludedAlteration = false, + isExcludedAlteration = false, }: IAlterationBadge) => { const { ref, overflow } = useOverflowDetector({ handleHeight: false }); @@ -138,11 +143,11 @@ const AlterationBadge = ({ if (alterationData.warning) { return 'warning'; } - if (isExludedAlteration) { + if (isExcludedAlteration) { return 'secondary'; } return 'success'; - }, [alterationData, isExludedAlteration]); + }, [alterationData, isExcludedAlteration]); const statusIcon = useMemo(() => { let icon = ; diff --git a/src/main/webapp/app/shared/modal/MutationModal/MutationDetails.tsx b/src/main/webapp/app/shared/modal/MutationModal/MutationDetails.tsx index 9b3f634f0..c2e7144dc 100644 --- a/src/main/webapp/app/shared/modal/MutationModal/MutationDetails.tsx +++ b/src/main/webapp/app/shared/modal/MutationModal/MutationDetails.tsx @@ -80,7 +80,7 @@ const MutationDetails = ({ onChange={newValue => handleFieldChange(newValue, 'refResidues')} /> handleFieldChange(newValue, 'varResidues')} 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 index 15f57cc43..b2b6abac4 100644 --- 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 @@ -24,6 +24,7 @@ export class AddMutationModalStore { public selectedExcludedAlterationIndex = -1; public showModifyExonForm = false; + public hasUncommitedExonFormChanges = false; public isFetchingAlteration = false; public isFetchingExcludingAlteration = false; @@ -41,6 +42,7 @@ export class AddMutationModalStore { selectedAlterationStateIndex: observable, selectedExcludedAlterationIndex: observable, showModifyExonForm: observable, + hasUncommitedExonFormChanges: observable, isFetchingAlteration: observable, isFetchingExcludingAlteration: observable, selectedAlterationCategoryFlags: observable, @@ -52,6 +54,7 @@ export class AddMutationModalStore { setVusList: action.bound, setGeneEntity: action.bound, setShowModifyExonForm: action.bound, + setHasUncommitedExonFormChanges: action.bound, setAlterationStates: action.bound, setSelectedAlterationStateIndex: action.bound, setSelectedExcludedAlterationIndex: action.bound, @@ -71,8 +74,8 @@ export class AddMutationModalStore { }); } - setMutationToEdit(mutationtoEdit: Mutation | null) { - this.mutationToEdit = mutationtoEdit; + setMutationToEdit(mutationToEdit: Mutation | null) { + this.mutationToEdit = mutationToEdit; } setVusList(vusList: VusObjList | null) { @@ -88,6 +91,10 @@ export class AddMutationModalStore { this.selectedAlterationStateIndex = -1; } + setHasUncommitedExonFormChanges(value: boolean) { + this.hasUncommitedExonFormChanges = value; + } + setAlterationStates(newAlterationStates: AlterationData[]) { this.alterationStates = newAlterationStates; } @@ -127,12 +134,11 @@ export class AddMutationModalStore { ]); const newExcludingAlterations = newEntityStatusExcludingAlterations.map((alt, index) => - convertEntityStatusAlterationToAlterationData(alt, newParsedAlteration[0].excluding[index], [], ''), + convertEntityStatusAlterationToAlterationData(alt, [], ''), ); const newAlterations = newEntityStatusAlterations.map((alt, index) => convertEntityStatusAlterationToAlterationData( alt, - newParsedAlteration[index].alteration, _.cloneDeep(newExcludingAlterations), newParsedAlteration[index].comment, newParsedAlteration[index].name, @@ -172,7 +178,7 @@ export class AddMutationModalStore { const newEntityStatusAlterations = await this.fetchAlterations(parsedAlterations.map(alt => alt.alteration)); const newAlterations = newEntityStatusAlterations.map((alt, index) => - convertEntityStatusAlterationToAlterationData(alt, parsedAlterations[index].alteration, [], newComment, newVariantName), + convertEntityStatusAlterationToAlterationData(alt, [], newComment, newVariantName), ); const newAlterationStates = _.cloneDeep(this.alterationStates); @@ -251,16 +257,7 @@ export class AddMutationModalStore { newAlterations = [ ...newAlterations, ...(await Promise.all(alterationPromises)) - .map((alt, index) => - alt - ? convertEntityStatusAlterationToAlterationData( - alt, - newParsedAlteration[index].alteration, - [], - newParsedAlteration[index].comment, - ) - : undefined, - ) + .map((alt, index) => (alt ? convertEntityStatusAlterationToAlterationData(alt, [], newParsedAlteration[index].comment) : undefined)) .filter(hasValue), ]; @@ -320,10 +317,7 @@ export class AddMutationModalStore { newExcluding = this.alterationStates[alterationIndex].excluding; } else { const excludingEntityStatusAlterations = await this.fetchAlterations(newParsedAlteration[0].excluding); - newExcluding = - excludingEntityStatusAlterations?.map((ex, index) => - convertEntityStatusAlterationToAlterationData(ex, newParsedAlteration[0].excluding[index], [], ''), - ) ?? []; + newExcluding = excludingEntityStatusAlterations?.map((ex, index) => convertEntityStatusAlterationToAlterationData(ex, [], '')) ?? []; } const alterationPromises: Promise[] = []; @@ -346,15 +340,7 @@ export class AddMutationModalStore { ...newAlterations, ...(await Promise.all(alterationPromises)) .filter(hasValue) - .map((alt, index) => - convertEntityStatusAlterationToAlterationData( - alt, - newParsedAlteration[index + newAlterations.length].alteration, - newExcluding, - newComment, - newVariantName, - ), - ), + .map((alt, index) => convertEntityStatusAlterationToAlterationData(alt, newExcluding, newComment, newVariantName)), ]; newAlterations[0].alterationFieldValueWhileFetching = undefined; @@ -426,6 +412,7 @@ export class AddMutationModalStore { this.selectedAlterationStateIndex = -1; this.selectedExcludedAlterationIndex = -1; this.showModifyExonForm = false; + this.hasUncommitedExonFormChanges = false; this.isFetchingAlteration = false; this.isFetchingExcludingAlteration = false; this.selectedAlterationCategoryFlags = []; diff --git a/src/main/webapp/app/shared/modal/MutationModal/styles.module.scss b/src/main/webapp/app/shared/modal/MutationModal/styles.module.scss index 1d1069a18..2a75c60d7 100644 --- a/src/main/webapp/app/shared/modal/MutationModal/styles.module.scss +++ b/src/main/webapp/app/shared/modal/MutationModal/styles.module.scss @@ -48,7 +48,6 @@ border: 0px; margin: 0px; outline: 0px; - padding: 0rem 0.375rem; } .alterationBadgeListInputWrapper { @@ -60,3 +59,12 @@ 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/util/utils.tsx b/src/main/webapp/app/shared/util/utils.tsx index e6af357b6..42b0280b8 100644 --- a/src/main/webapp/app/shared/util/utils.tsx +++ b/src/main/webapp/app/shared/util/utils.tsx @@ -312,9 +312,12 @@ export function getFullAlterationName(alterationData: AlterationData, includeVar return buildAlterationName(alterationData.alteration, variantName, excluding, comment); } +export function getMutationRenameValueFromName(name: string) { + return name.match(/\[([^\]]+)\]/)?.[1]; +} + export function convertEntityStatusAlterationToAlterationData( entityStatusAlteration: AlterationAnnotationStatus, - alterationName: string, excluding: AlterationData[], comment: string, variantName?: string, @@ -322,7 +325,7 @@ export function convertEntityStatusAlterationToAlterationData( const alteration = entityStatusAlteration.entity; const alterationData: AlterationData = { type: alteration?.type ?? AlterationTypeEnum.Unknown, - alteration: alterationName, + alteration: alteration?.alteration ?? '', name: (variantName || alteration?.name) ?? '', consequence: alteration?.consequence?.name ?? '', comment, @@ -337,11 +340,6 @@ export function convertEntityStatusAlterationToAlterationData( error: entityStatusAlteration.error ? entityStatusAlteration.message : undefined, }; - // if the backend's response is different from the frontend response, set them equal to each other. - if (alteration?.alteration !== alterationName) { - alterationData.alteration = alteration?.alteration ?? ''; - } - return alterationData; } From 62305ef7dd56b50c0af19166f649dbb8a9231869 Mon Sep 17 00:00:00 2001 From: Calvin Lu <59149377+calvinlu3@users.noreply.github.com> Date: Tue, 5 Nov 2024 15:22:18 -0500 Subject: [PATCH 7/8] Use Firebase alteration if available --- src/main/webapp/app/shared/modal/AddMutationModal.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/main/webapp/app/shared/modal/AddMutationModal.tsx b/src/main/webapp/app/shared/modal/AddMutationModal.tsx index 1e6026cbf..10b65f28d 100644 --- a/src/main/webapp/app/shared/modal/AddMutationModal.tsx +++ b/src/main/webapp/app/shared/modal/AddMutationModal.tsx @@ -179,7 +179,9 @@ function AddMutationModal({ useEffect(() => { async function setExistingAlterations() { if (mutationToEdit?.alterations?.length !== undefined && mutationToEdit.alterations.length > 0) { + // Use the alteration model in Firebase instead of annotation from API setAlterationStates?.(mutationToEdit?.alterations?.map(alt => convertAlterationToAlterationData(alt)) ?? []); + return; } // at this point can be sure each alteration name does not have / character From 3234ba6f79d5c4d0bb63c6e9342e68211e6da358 Mon Sep 17 00:00:00 2001 From: Calvin Lu <59149377+calvinlu3@users.noreply.github.com> Date: Fri, 8 Nov 2024 13:59:03 -0500 Subject: [PATCH 8/8] Allow user to hold control to select multiple options --- .../modal/MutationModal/AddExonForm.tsx | 76 +++++++++++++++---- 1 file changed, 61 insertions(+), 15 deletions(-) diff --git a/src/main/webapp/app/shared/modal/MutationModal/AddExonForm.tsx b/src/main/webapp/app/shared/modal/MutationModal/AddExonForm.tsx index d0908db46..41abb65ef 100644 --- a/src/main/webapp/app/shared/modal/MutationModal/AddExonForm.tsx +++ b/src/main/webapp/app/shared/modal/MutationModal/AddExonForm.tsx @@ -15,7 +15,7 @@ import { EXON_ALTERATION_REGEX } from 'app/config/constants/regex'; import LoadingIndicator from 'app/oncokb-commons/components/loadingIndicator/LoadingIndicator'; import classNames from 'classnames'; import InfoIcon from 'app/shared/icons/InfoIcon'; -import { FaArrowLeft } from 'react-icons/fa'; +import { FaArrowLeft, FaRegLightbulb } from 'react-icons/fa'; import * as styles from './styles.module.scss'; export interface IAddExonMutationModalBody extends StoreProps { @@ -27,6 +27,7 @@ type ProteinExonDropdownOption = { label: string; value: { exon: ProteinExonDTO; name: string } | string; isSelected: boolean; + onMouseOverOption: (data: ProteinExonDropdownOption) => void; }; const EXON_CONSEQUENCES = ['Deletion', 'Insertion', 'Duplication']; @@ -45,12 +46,33 @@ const AddExonForm = ({ const [isPendingAddAlteration, setIsPendingAddAlteration] = useState(false); const [didRemoveProblematicAlt, setDidRemoveProblematicAlt] = useState(false); + const [isShiftPressed, setIsShiftPressed] = useState(false); + + const onMouseOverOption = (option: ProteinExonDropdownOption) => { + if (isShiftPressed) { + setSelectedExons(prevSelected => { + const isAlreadySelected = prevSelected.some(selectedOption => selectedOption.label === option.label); + return isAlreadySelected ? prevSelected : [...prevSelected, option]; + }); + } + }; + + const MultiSelectOption = (props: OptionProps) => { + return ( +
    onMouseOverOption(props.data)}> + + {(props.data as any).__isNew__ ? <> : null} />}{' '} + + +
    + ); + }; const exonOptions = useMemo(() => { const options: ProteinExonDropdownOption[] = EXON_CONSEQUENCES.flatMap(consequence => { return proteinExons.map(exon => { const name = `Exon ${exon.exon} ${consequence}`; - return { label: `Exon ${exon.exon} ${consequence}`, value: { exon, name }, isSelected: false }; + return { label: `Exon ${exon.exon} ${consequence}`, value: { exon, name }, isSelected: false, onMouseOverOption }; }); }); return options; @@ -63,7 +85,12 @@ const AddExonForm = ({ const match = exonString.match(EXON_ALTERATION_REGEX); if (match) { if (match[1]?.trim() === 'Any') { - acc.push({ label: exonString, value: exonString, isSelected: true }); + acc.push({ + label: exonString, + value: exonString, + isSelected: true, + onMouseOverOption, + }); return acc; } const startExon = parseInt(match[3], 10); @@ -117,7 +144,7 @@ const AddExonForm = ({ const onCreateOption = (createInputValue: string) => { const value = standardizeExonInputString(createInputValue); - setSelectedExons(prevState => [...prevState, { label: value, value, isSelected: true }]); + setSelectedExons(prevState => [...prevState, { label: value, value, isSelected: true, onMouseOverOption }]); }; async function handleAlterationAdded() { @@ -135,6 +162,28 @@ const AddExonForm = ({ return ; } + useEffect(() => { + window.addEventListener('keydown', handleShiftDown); + window.addEventListener('keyup', handleShiftUp); + + return () => { + window.removeEventListener('keydown', handleShiftDown); + window.removeEventListener('keyup', handleShiftUp); + }; + }, []); + + const handleShiftDown = (event: KeyboardEvent) => { + if (event.key === 'Control') { + setIsShiftPressed(true); + } + }; + + const handleShiftUp = (event: KeyboardEvent) => { + if (event.key === 'Control') { + setIsShiftPressed(false); + } + }; + return ( <> @@ -155,6 +204,14 @@ const AddExonForm = ({
    {isUpdate ? 'Modify Selected Exons' : 'Selected Exons'}
    + + +
    + + Tip: Hold control and drag to select multiple options +
    + +
    @@ -244,17 +301,6 @@ const NoOptionsMessage = props => { ); }; -const MultiSelectOption = (props: OptionProps) => { - return ( -
    - - {(props.data as any).__isNew__ ? <> : null} />}{' '} - - -
    - ); -}; - const mapStoreToProps = ({ transcriptStore, addMutationModalStore }: IRootStore) => ({ getProteinExons: flow(transcriptStore.getProteinExons), updateAlterationStateAfterAlterationAdded: addMutationModalStore.updateAlterationStateAfterAlterationAdded,