diff --git a/src/main/java/org/mskcc/oncokb/curation/domain/enumeration/CNAConsequence.java b/src/main/java/org/mskcc/oncokb/curation/domain/enumeration/CNAConsequence.java index fc3a29e2d..781f4ee72 100644 --- a/src/main/java/org/mskcc/oncokb/curation/domain/enumeration/CNAConsequence.java +++ b/src/main/java/org/mskcc/oncokb/curation/domain/enumeration/CNAConsequence.java @@ -1,9 +1,9 @@ package org.mskcc.oncokb.curation.domain.enumeration; public enum CNAConsequence { - AMPLIFICATION, - DELETION, - GAIN, - LOSS, - UNKNOWN, + CNA_AMPLIFICATION, + CNA_DELETION, + CNA_GAIN, + CNA_LOSS, + CNA_UNKNOWN, } diff --git a/src/main/java/org/mskcc/oncokb/curation/domain/enumeration/FlagType.java b/src/main/java/org/mskcc/oncokb/curation/domain/enumeration/FlagType.java index 9ea1db8f2..f07f816ee 100644 --- a/src/main/java/org/mskcc/oncokb/curation/domain/enumeration/FlagType.java +++ b/src/main/java/org/mskcc/oncokb/curation/domain/enumeration/FlagType.java @@ -9,4 +9,5 @@ public enum FlagType { TRANSCRIPT, DRUG, HOTSPOT, + ALTERATION_CATEGORY, } diff --git a/src/main/java/org/mskcc/oncokb/curation/domain/enumeration/SVConsequence.java b/src/main/java/org/mskcc/oncokb/curation/domain/enumeration/SVConsequence.java index aea1fd65b..c0d0c00e0 100644 --- a/src/main/java/org/mskcc/oncokb/curation/domain/enumeration/SVConsequence.java +++ b/src/main/java/org/mskcc/oncokb/curation/domain/enumeration/SVConsequence.java @@ -1,11 +1,21 @@ package org.mskcc.oncokb.curation.domain.enumeration; public enum SVConsequence { - DELETION, - TRANSLOCATION, - DUPLICATION, - INSERTION, - INVERSION, - FUSION, - UNKNOWN, + SV_DELETION("Deletion"), + SV_TRANSLOCATION("Translocation"), + SV_DUPLICATION("Duplication"), + SV_INSERTION("Insertion"), + SV_INVERSION("Inversion"), + SV_FUSION("Fusion"), + SV_UNKNOWN("Unknown"); + + private final String name; + + SVConsequence(String name) { + this.name = name; + } + + public String getName() { + return name; + } } diff --git a/src/main/java/org/mskcc/oncokb/curation/service/MainService.java b/src/main/java/org/mskcc/oncokb/curation/service/MainService.java index 484de97e8..1852948cc 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,61 @@ 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*", "")); + + 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 { - 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 d1d1b9394..544a61f5d 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; @@ -30,6 +30,7 @@ import org.springframework.data.domain.Page; import org.springframework.data.domain.PageImpl; import org.springframework.data.domain.Pageable; +import org.springframework.security.access.method.P; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -587,6 +588,77 @@ public List getAlignmentResult( } } + public List getExons(Gene gene, ReferenceGenome referenceGenome) { + Optional transcriptOptional = this.findByGeneAndReferenceGenomeAndCanonicalIsTrue(gene, referenceGenome); + if (transcriptOptional.isEmpty()) { + return new ArrayList<>(); + } + 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; + } + 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 336bb9e4f..5e93f92c9 100644 --- a/src/main/java/org/mskcc/oncokb/curation/util/AlterationUtils.java +++ b/src/main/java/org/mskcc/oncokb/curation/util/AlterationUtils.java @@ -9,6 +9,7 @@ import java.util.regex.Pattern; import java.util.stream.Collectors; import org.apache.commons.lang3.StringUtils; +import org.apache.commons.text.similarity.JaroWinklerSimilarity; import org.mskcc.oncokb.curation.domain.*; import org.mskcc.oncokb.curation.domain.enumeration.*; import org.mskcc.oncokb.curation.util.parser.ParsingStatus; @@ -22,11 +23,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); @@ -50,7 +55,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()) { @@ -204,6 +209,133 @@ public static void parseProteinChange(EntityStatus alterationEntityS alterationEntityStatus.setMessage(parsedAlteration.getMessage()); } + 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<>(); + 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<>()); + + List anyExonAlterations = new ArrayList<>(); + while (matcher.find()) { + 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 + SVConsequence svConsequence = SVConsequence.SV_UNKNOWN; + switch (consequenceTerm.toLowerCase()) { + case "insertion": + svConsequence = SVConsequence.SV_INSERTION; + consequence.setTerm(SVConsequence.SV_INSERTION.name()); + break; + case "duplication": + svConsequence = SVConsequence.SV_DUPLICATION; + consequence.setTerm(SVConsequence.SV_DUPLICATION.name()); + break; + case "deletion": + svConsequence = SVConsequence.SV_DELETION; + consequence.setTerm(SVConsequence.SV_DELETION.name()); + break; + default: + break; + } + + if (isAnyExon) { + String normalizedAnyExonName = buildExonName( + Integer.parseInt(startExonStr), + Integer.parseInt(endExonStr), + consequenceTerm, + true + ); + splitResults.add(normalizedAnyExonName); + anyExonAlterations.add(normalizedAnyExonName); + continue; + } + + int startExon = Integer.parseInt(startExonStr); + int endExon = (endExonStr != null) ? Integer.parseInt(endExonStr) : startExon; + + for (int exon = startExon; exon <= endExon; exon++) { + String exonAlteration = "Exon " + exon + " " + consequenceTerm; + splitResults.add(exonAlteration); + exonsByConsequence.get(svConsequence).add(exonAlteration); + } + } + + alt.setAlteration(splitResults.stream().collect(Collectors.joining(" + "))); + + List formattedNameByConsequence = new ArrayList<>(); + for (SVConsequence consequenceKey : new SVConsequence[] { + SVConsequence.SV_DELETION, + SVConsequence.SV_INSERTION, + 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 { + result.add(buildExonName(start, end, consequenceTerm, false)); + start = exonNumber; + end = exonNumber; + } + } + if (start != -1) { + result.add(buildExonName(start, end, consequenceTerm, false)); + } + if (!result.isEmpty()) { + formattedNameByConsequence.add(result.stream().collect(Collectors.joining(" + "))); + } + } + formattedNameByConsequence.addAll(anyExonAlterations); + alt.setName(formattedNameByConsequence.stream().collect(Collectors.joining(" + "))); + + return alt; + } + + private String buildExonName(int start, int end, String consequenceTerm, Boolean isAny) { + StringBuilder nameBuilder = new StringBuilder(); + if (isAny) { + nameBuilder.append("Any "); + } + nameBuilder.append("Exon "); + String range = ""; + if (start == end) { + range = Integer.toString(start); + } else { + range = Integer.toString(start) + "-" + Integer.toString(end); + } + nameBuilder.append(range); + nameBuilder.append(" "); + nameBuilder.append(consequenceTerm); + return nameBuilder.toString(); + } + public EntityStatus parseAlteration(String alteration) { EntityStatus entityWithStatus = new EntityStatus<>(); String message = ""; @@ -244,6 +376,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; + } + parseProteinChange(entityWithStatus, alteration); return entityWithStatus; @@ -329,6 +469,24 @@ 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()) { + if (m.group(1) == null) { + return false; + } + 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 cfa78ecb8..bf209a1fd 100644 --- a/src/main/webapp/app/app.scss +++ b/src/main/webapp/app/app.scss @@ -112,6 +112,10 @@ Generic styles margin-right: 0.5rem; } +.warning-message > svg { + flex-shrink: 0; +} + .break { white-space: normal; word-break: break-all; @@ -444,3 +448,39 @@ a { .scrollbar-wrapper:focus { visibility: visible; } + +// Custom badge outline styles +@mixin badge-outline-variant( + $color, + $color-hover: color-contrast($color), + $active-background: $color, + $active-border: $color, + $active-color: color-contrast($active-background) +) { + color: $color; + border: 1px solid; + border-color: $color; + + &:hover { + color: $color-hover; + background-color: $active-background; + border-color: $active-border; + } + + &.active { + color: $active-color; + background-color: $active-background; + border-color: $active-border; + } + + &.disabled { + color: $color; + background-color: transparent; + } +} + +@each $color, $value in $theme-colors { + .badge-outline-#{$color} { + @include badge-outline-variant($value); + } +} diff --git a/src/main/webapp/app/config/colors.ts b/src/main/webapp/app/config/colors.ts index 354840fd2..6cad216c7 100644 --- a/src/main/webapp/app/config/colors.ts +++ b/src/main/webapp/app/config/colors.ts @@ -30,3 +30,9 @@ export const COLLAPSIBLE_LEVELS = { }; export const HOTSPOT = '#ff9900'; + +/* + * Bootstrap colors + */ + +export const BS_BORDER_COLOR = '#dee2e6'; diff --git a/src/main/webapp/app/config/constants/constants.ts b/src/main/webapp/app/config/constants/constants.ts index a81b5e72f..a7ad10131 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 = { @@ -455,3 +455,13 @@ export const SOMATIC_GERMLINE_SETTING_KEY = 'oncokbCuration-somaticGermlineSetti export const DUPLICATE_THERAPY_ERROR_MESSAGE = 'Each therapy must be unique'; export const EMPTY_THERAPY_ERROR_MESSAGE = 'You must include at least one drug for each therapy'; export const THERAPY_ALREADY_EXISTS_ERROR_MESSAGE = 'Therapy already exists'; + +/** + * React select styles based on Bootstrap theme + */ +export const REACT_SELECT_STYLES = { + control: (base, state) => ({ + ...base, + borderColor: BS_BORDER_COLOR, + }), +}; diff --git a/src/main/webapp/app/config/constants/html-id.ts b/src/main/webapp/app/config/constants/html-id.ts index 67c041cb6..74ae9ce32 100644 --- a/src/main/webapp/app/config/constants/html-id.ts +++ b/src/main/webapp/app/config/constants/html-id.ts @@ -27,6 +27,12 @@ export const RCT_MODAL_ID = 'relevant-cancer-type-modal'; export const DEFAULT_ADD_MUTATION_MODAL_ID = 'default-add-mutation-modal'; export const ADD_MUTATION_MODAL_INPUT_ID = 'add-mutation-modal-input'; +export const ADD_MUTATION_MODAL_EXCLUDED_ALTERATION_INPUT_ID = 'add-mutation-modal-excluded-alteration-input'; +export const ADD_MUTATION_MODAL_ADD_EXCLUDED_ALTERATION_BUTTON_ID = 'add-mutation-modal-add-excluded-alteration-button'; +export const ADD_MUTATION_MODAL_ADD_EXON_BUTTON_ID = 'add-mutation-modal-add-exon-button'; +export const ADD_MUTATION_MODAL_FLAG_DROPDOWN_ID = 'add-mutation-modal-flag-input'; +export const ADD_MUTATION_MODAL_FLAG_COMMENT_ID = 'add-mutation-modal-flag-comment'; +export const ADD_MUTATION_MODAL_FLAG_COMMENT_INPUT_ID = 'add-mutation-modal-flag-comment-input'; export const SIMPLE_CONFIRM_MODAL_CONTENT_ID = 'simple-confirm-modal-content'; diff --git a/src/main/webapp/app/config/constants/regex.spec.ts b/src/main/webapp/app/config/constants/regex.spec.ts index 1a664984a..b52d5d7dd 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,28 @@ 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], + ['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 070f36ed3..596fc501c 100644 --- a/src/main/webapp/app/config/constants/regex.ts +++ b/src/main/webapp/app/config/constants/regex.ts @@ -9,3 +9,6 @@ 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*(Any\s+)?Exon\s+\d+(-\d+)?\s+(Deletion|Insertion|Duplication))*$/i; diff --git a/src/main/webapp/app/entities/flag/flag.store.ts b/src/main/webapp/app/entities/flag/flag.store.ts index 7da51e311..da91b889d 100644 --- a/src/main/webapp/app/entities/flag/flag.store.ts +++ b/src/main/webapp/app/entities/flag/flag.store.ts @@ -5,15 +5,21 @@ import PaginationCrudStore from 'app/shared/util/pagination-crud-store'; import axios, { AxiosResponse } from 'axios'; import { ENTITY_TYPE } from 'app/config/constants/constants'; import { getEntityResourcePath } from 'app/shared/util/RouteUtils'; +import { FlagTypeEnum } from 'app/shared/model/enumerations/flag-type.enum.model'; export class FlagStore extends PaginationCrudStore { public oncokbGeneEntity: IFlag | null = null; + public alterationCategoryFlags: IFlag[] = []; getOncokbEntity = this.readHandler(this.getOncokbGeneFlag); + getFlagsByType = this.readHandler(this.getFlagsByTypeGen); + constructor(protected rootStore: IRootStore) { super(rootStore, ENTITY_TYPE.FLAG); makeObservable(this, { oncokbGeneEntity: observable, + alterationCategoryFlags: observable, getOncokbEntity: action, + getFlagsByType: action, }); } @@ -25,6 +31,14 @@ export class FlagStore extends PaginationCrudStore { } return result; } + + *getFlagsByTypeGen(type: FlagTypeEnum) { + const result: AxiosResponse = yield axios.get(`${getEntityResourcePath(ENTITY_TYPE.FLAG)}?type.equals=${type}`); + if (type === FlagTypeEnum.ALTERATION_CATEGORY) { + this.alterationCategoryFlags = result.data; + } + return result.data; + } } export default FlagStore; diff --git a/src/main/webapp/app/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..fd4eabd07 --- /dev/null +++ b/src/main/webapp/app/hooks/useOverflowDetector.tsx @@ -0,0 +1,37 @@ +import { useEffect, useRef, useState } from 'react'; + +export interface useOverflowDetectorProps { + onChange?: (overflow: boolean) => void; + detectHeight?: boolean; + detectWidth?: boolean; +} + +export function useOverflowDetector(props: useOverflowDetectorProps = {}) { + const [isOverflow, setIsOverflow] = useState(false); + const ref = useRef(null); + + useEffect(() => { + const updateState = () => { + if (ref.current === null) { + return; + } + + const { detectWidth: handleWidth = true, detectHeight: handleHeight = true } = props; + + const newState = + (handleWidth && ref.current.offsetWidth < ref.current.scrollWidth) || + (handleHeight && ref.current.offsetHeight < ref.current.scrollHeight); + + if (newState === isOverflow) { + return; + } + setIsOverflow(newState); + if (props.onChange) { + props.onChange(newState); + } + }; + updateState(); + }, [ref.current, props.detectWidth, props.detectHeight, props.onChange]); + + return [isOverflow, ref] as const; +} diff --git a/src/main/webapp/app/hooks/useTextareaAutoHeight.tsx b/src/main/webapp/app/hooks/useTextareaAutoHeight.tsx new file mode 100644 index 000000000..b62ad812c --- /dev/null +++ b/src/main/webapp/app/hooks/useTextareaAutoHeight.tsx @@ -0,0 +1,25 @@ +import React, { useEffect } from 'react'; + +export const useTextareaAutoHeight = ( + inputRef: React.MutableRefObject, + type: string | undefined, +) => { + useEffect(() => { + const input = inputRef.current; + if (!input || type !== 'textarea') { + return; + } + + const resizeObserver = new ResizeObserver(() => { + window.requestAnimationFrame(() => { + input.style.height = 'auto'; + input.style.height = `${input.scrollHeight}px`; + }); + }); + resizeObserver.observe(input); + + return () => { + resizeObserver.disconnect(); + }; + }, []); +}; diff --git a/src/main/webapp/app/pages/curation/BadgeGroup.tsx b/src/main/webapp/app/pages/curation/BadgeGroup.tsx index e0893a7c2..db3d21bba 100644 --- a/src/main/webapp/app/pages/curation/BadgeGroup.tsx +++ b/src/main/webapp/app/pages/curation/BadgeGroup.tsx @@ -58,19 +58,11 @@ const BadgeGroup = (props: IBadgeGroupProps) => { }, [sectionData, props.firebasePath]); if (props.showDemotedBadge) { - return ( - - Demoted - - ); + return ; } if (props.showDeletedBadge) { - return ( - - Deleted - - ); + return ; } if (props.showNotCuratableBadge?.show) { diff --git a/src/main/webapp/app/pages/curation/collapsible/BaseCollapsible.tsx b/src/main/webapp/app/pages/curation/collapsible/BaseCollapsible.tsx index c0a9618e5..8b20b21a5 100644 --- a/src/main/webapp/app/pages/curation/collapsible/BaseCollapsible.tsx +++ b/src/main/webapp/app/pages/curation/collapsible/BaseCollapsible.tsx @@ -135,10 +135,10 @@ export default function BaseCollapsible({ > {isOpen ? : } - +
{title} {badge} - +
diff --git a/src/main/webapp/app/pages/curation/collapsible/ReviewCollapsible.tsx b/src/main/webapp/app/pages/curation/collapsible/ReviewCollapsible.tsx index 77ec65b9c..e4edecb50 100644 --- a/src/main/webapp/app/pages/curation/collapsible/ReviewCollapsible.tsx +++ b/src/main/webapp/app/pages/curation/collapsible/ReviewCollapsible.tsx @@ -295,9 +295,7 @@ export const ReviewCollapsible = ({ if (isUnderCreationOrDeletion) { return undefined; } - return ( - {ReviewActionLabels[reviewAction ?? '']} - ); + return ; }; const getReviewableContent = () => { diff --git a/src/main/webapp/app/pages/curation/collapsible/MutationCollapsible.tsx b/src/main/webapp/app/pages/curation/collapsible/mutation-collapsible/MutationCollapsible.tsx similarity index 95% rename from src/main/webapp/app/pages/curation/collapsible/MutationCollapsible.tsx rename to src/main/webapp/app/pages/curation/collapsible/mutation-collapsible/MutationCollapsible.tsx index 5d863548f..bd90b403b 100644 --- a/src/main/webapp/app/pages/curation/collapsible/MutationCollapsible.tsx +++ b/src/main/webapp/app/pages/curation/collapsible/mutation-collapsible/MutationCollapsible.tsx @@ -16,10 +16,9 @@ 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 } from 'app/shared/model/firebase/firebase.model'; +import { Alteration, Review, AlterationCategories } from 'app/shared/model/firebase/firebase.model'; import DefaultTooltip from 'app/shared/tooltip/DefaultTooltip'; import { FlattenedHistory } from 'app/shared/util/firebase/firebase-history-utils'; import { @@ -30,26 +29,28 @@ import { isSectionRemovableWithoutReview, } from 'app/shared/util/firebase/firebase-utils'; import { componentInject } from 'app/shared/util/typed-inject'; -import { getExonRanges } from 'app/shared/util/utils'; +import { getExonRanges, parseAlterationName } from 'app/shared/util/utils'; import { IRootStore } from 'app/stores'; import { get, onValue, ref } from 'firebase/database'; import _ from 'lodash'; import { observer } from 'mobx-react'; import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { Button } from 'reactstrap'; -import BadgeGroup from '../BadgeGroup'; -import { DeleteSectionButton } from '../button/DeleteSectionButton'; -import FirebaseList from '../list/FirebaseList'; -import MutationLastModified from '../mutation/mutation-last-modified'; -import MutationLevelSummary from '../nestLevelSummary/MutationLevelSummary'; import * as styles from '../styles.module.scss'; -import CancerTypeCollapsible from './CancerTypeCollapsible'; -import Collapsible from './Collapsible'; -import { NestLevelColor, NestLevelMapping, NestLevelType } from './NestLevel'; -import { RemovableCollapsible } from './RemovableCollapsible'; +import CancerTypeCollapsible from '../CancerTypeCollapsible'; +import Collapsible from '../Collapsible'; +import { NestLevelColor, NestLevelMapping, NestLevelType } from '../NestLevel'; +import { RemovableCollapsible } from '../RemovableCollapsible'; import { Unsubscribe } from 'firebase/database'; import { getLocationIdentifier } from 'app/components/geneHistoryTooltip/gene-history-tooltip-utils'; import { SimpleConfirmModal } from 'app/shared/modal/SimpleConfirmModal'; +import MutationCollapsibleTitle from './MutationCollapsibleTitle'; +import AddMutationModal from 'app/shared/modal/AddMutationModal'; +import BadgeGroup from '../../BadgeGroup'; +import { DeleteSectionButton } from '../../button/DeleteSectionButton'; +import FirebaseList from '../../list/FirebaseList'; +import MutationLastModified from '../../mutation/mutation-last-modified'; +import MutationLevelSummary from '../../nestLevelSummary/MutationLevelSummary'; export interface IMutationCollapsibleProps extends StoreProps { mutationPath: string; @@ -88,6 +89,7 @@ const MutationCollapsible = ({ const [mutationNameReview, setMutationNameReview] = useState(null); const [mutationSummary, setMutationSummary] = useState(''); const [mutationAlterations, setMutationAlterations] = useState(null); + const [alterationCategories, setAlterationCategories] = useState(null); const [isRemovableWithoutReview, setIsRemovableWithoutReview] = useState(false); const [relatedAnnotationResult, setRelatedAnnotationResult] = useState([]); const [oncogenicity, setOncogenicity] = useState(''); @@ -187,7 +189,12 @@ const MutationCollapsible = ({ setOncogenicity(snapshot.val()); }), ); - + callbacks.push( + onValue(ref(firebaseDb, `${mutationPath}/alteration_categories`), snapshot => { + const info = snapshot.val() as AlterationCategories; + setAlterationCategories(info); + }), + ); onValue( ref(firebaseDb, `${mutationPath}/name_uuid`), snapshot => { @@ -269,7 +276,13 @@ const MutationCollapsible = ({ <> + } defaultOpen={open} collapsibleClassName="mb-1" colorOptions={{ borderLeftColor: NestLevelColor[NestLevelMapping[NestLevelType.MUTATION]] }} diff --git a/src/main/webapp/app/pages/curation/collapsible/mutation-collapsible/MutationCollapsibleTitle.tsx b/src/main/webapp/app/pages/curation/collapsible/mutation-collapsible/MutationCollapsibleTitle.tsx new file mode 100644 index 000000000..01e1c1e85 --- /dev/null +++ b/src/main/webapp/app/pages/curation/collapsible/mutation-collapsible/MutationCollapsibleTitle.tsx @@ -0,0 +1,95 @@ +import DefaultBadge from 'app/shared/badge/DefaultBadge'; +import InfoIcon from 'app/shared/icons/InfoIcon'; +import { Alteration, AlterationCategories } from 'app/shared/model/firebase/firebase.model'; +import { getAlterationName, isFlagEqualToIFlag } from 'app/shared/util/firebase/firebase-utils'; +import { componentInject } from 'app/shared/util/typed-inject'; +import { + buildAlterationName, + getAlterationNameComponent, + 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'; +import _ from 'lodash'; + +export interface IMutationCollapsibleTitle extends StoreProps { + name: string | undefined; + mutationAlterations: Alteration[] | null | undefined; + alterationCategories: AlterationCategories | null; +} +const MutationCollapsibleTitle = ({ name, mutationAlterations, alterationCategories, flagEntities }: IMutationCollapsibleTitle) => { + const defaultName = 'No Name'; + let stringMutationBadges: JSX.Element | undefined; + const shouldGroupBadges = + (alterationCategories?.flags?.length || 0) > 1 || (alterationCategories?.flags?.length === 1 && alterationCategories.comment !== ''); + + if (alterationCategories?.flags && flagEntities) { + const tooltipOverlay = alterationCategories.comment ? {alterationCategories.comment} : undefined; + stringMutationBadges = ( +
+ {alterationCategories.flags.map(flag => { + const matchedFlagEntity = flagEntities.find(flagEntity => isFlagEqualToIFlag(flag, flagEntity)); + return ( + + ); + })} + {tooltipOverlay ? : undefined} +
+ ); + } + + if (mutationAlterations) { + return ( + <> + + {mutationAlterations.map((alteration, index) => { + if (EXON_ALTERATION_REGEX.test(alteration.alteration)) { + if (alteration.name) { + const exonRangeName = getMutationRenameValueFromName(alteration.name); + return _.isEmpty(exonRangeName) ? alteration.name : exonRangeName; + } + return alteration.alteration; + } + return getAlterationNameComponent(getAlterationName(alteration, true), alteration.comment); + })} + + {stringMutationBadges} + + ); + } + + if (name) { + const parsedAlterations = parseAlterationName(name, true); + return ( + <> + + {parsedAlterations.map(pAlt => + getAlterationNameComponent(buildAlterationName(pAlt.alteration, pAlt.name, pAlt.excluding), pAlt.comment), + )} + + {stringMutationBadges} + + ); + } + + return {defaultName}; +}; + +const mapStoreToProps = ({ flagStore }: IRootStore) => ({ + flagEntities: flagStore.alterationCategoryFlags, +}); + +type StoreProps = Partial>; + +export default componentInject(mapStoreToProps)(observer(MutationCollapsibleTitle)); diff --git a/src/main/webapp/app/pages/curation/collapsible/mutation-collapsible/styles.module.scss b/src/main/webapp/app/pages/curation/collapsible/mutation-collapsible/styles.module.scss new file mode 100644 index 000000000..746b8438e --- /dev/null +++ b/src/main/webapp/app/pages/curation/collapsible/mutation-collapsible/styles.module.scss @@ -0,0 +1,10 @@ +.flagWrapper { + background-color: #e5e5e5; + border-radius: 5px; + border: 1px solid #e5e5e5; + margin-left: 0.5rem; + padding-bottom: 0.1rem; + padding-top: 0.1rem; + display: flex; + align-items: center; +} diff --git a/src/main/webapp/app/pages/curation/geneticTypeTabs/GeneticTypeTabs.tsx b/src/main/webapp/app/pages/curation/geneticTypeTabs/GeneticTypeTabs.tsx index cfe02e058..345ed2096 100644 --- a/src/main/webapp/app/pages/curation/geneticTypeTabs/GeneticTypeTabs.tsx +++ b/src/main/webapp/app/pages/curation/geneticTypeTabs/GeneticTypeTabs.tsx @@ -69,25 +69,17 @@ const GeneticTypeTabs = ({ geneEntity, geneticType, firebaseDb, createGene }: IG }; if (needsReview[type]) { badges.push( - - Needs Review - , + , ); } const isGeneReleased = geneReleaseStatus[type]; if (isGeneReleased) { // Todo: In tooltip show when gene was released - badges.push( - - Released - , - ); + badges.push(); } else { badges.push( - - Pending Release - , + , ); } diff --git a/src/main/webapp/app/pages/curation/mutation/MutationsSection.tsx b/src/main/webapp/app/pages/curation/mutation/MutationsSection.tsx index 62d2316f7..dde1d9e04 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 { @@ -8,7 +7,6 @@ import { compareMutationsByProteinChangePosition, compareMutationsDefault, getFirebaseGenePath, - getMutationName, } from 'app/shared/util/firebase/firebase-utils'; import { componentInject } from 'app/shared/util/typed-inject'; import { IRootStore } from 'app/stores'; @@ -18,13 +16,15 @@ import _ from 'lodash'; import { observer } from 'mobx-react'; import React, { useCallback, useEffect, useRef, useState } from 'react'; import { Col, Row } from 'reactstrap'; -import MutationCollapsible from '../collapsible/MutationCollapsible'; +import MutationCollapsible from '../collapsible/mutation-collapsible/MutationCollapsible'; import MutationsSectionHeader, { SortOptions } from '../header/MutationsSectionHeader'; import FirebaseList from '../list/FirebaseList'; import * as styles from '../styles.module.scss'; import MutationName from './MutationName'; import { extractPositionFromSingleNucleotideAlteration } from 'app/shared/util/utils'; import { MUTATION_LIST_ID, SINGLE_MUTATION_VIEW_ID } from 'app/config/constants/html-id'; +import { FlagTypeEnum } from 'app/shared/model/enumerations/flag-type.enum.model'; +import AddMutationModal from 'app/shared/modal/AddMutationModal'; export interface IMutationsSectionProps extends StoreProps { mutationsPath: string; @@ -48,6 +48,7 @@ function MutationsSection({ firebaseDb, annotatedAltsCache, fetchMutationListForConvertIcon, + getFlagsByType, }: IMutationsSectionProps) { const [showAddMutationModal, setShowAddMutationModal] = useState(false); const [filteredIndices, setFilteredIndices] = useState([]); @@ -55,6 +56,10 @@ function MutationsSection({ const mutationSectionRef = useRef(null); + useEffect(() => { + getFlagsByType?.(FlagTypeEnum.ALTERATION_CATEGORY); + }, []); + useEffect(() => { fetchMutationListForConvertIcon?.(mutationsPath); }, []); @@ -246,6 +251,7 @@ const mapStoreToProps = ({ firebaseAppStore, curationPageStore, firebaseMutationConvertIconStore, + flagStore, }: IRootStore) => ({ addMutation: firebaseGeneService.addMutation, openMutationCollapsibleIndex: openMutationCollapsibleStore.index, @@ -253,6 +259,7 @@ const mapStoreToProps = ({ firebaseDb: firebaseAppStore.firebaseDb, annotatedAltsCache: curationPageStore.annotatedAltsCache, fetchMutationListForConvertIcon: firebaseMutationConvertIconStore.fetchData, + getFlagsByType: flagStore.getFlagsByType, }); type StoreProps = Partial>; diff --git a/src/main/webapp/app/shared/badge/DefaultBadge.tsx b/src/main/webapp/app/shared/badge/DefaultBadge.tsx index 37720f013..5a2ff8c2b 100644 --- a/src/main/webapp/app/shared/badge/DefaultBadge.tsx +++ b/src/main/webapp/app/shared/badge/DefaultBadge.tsx @@ -4,22 +4,23 @@ import DefaultTooltip from '../tooltip/DefaultTooltip'; export interface IDefaultBadgeProps { color: string; - children: React.ReactNode; + text: string; tooltipOverlay?: (() => React.ReactNode) | React.ReactNode; className?: string; style?: React.CSSProperties; - square?: boolean; + isRoundedPill?: boolean; + onDeleteCallback?: () => void; } const DefaultBadge: React.FunctionComponent = props => { - const { className, style, color, square, tooltipOverlay } = props; + const { className, style, color, text, tooltipOverlay, isRoundedPill = true } = props; + + const badgeClassNames = ['badge', 'mx-1', `text-bg-${color}`]; + if (isRoundedPill) badgeClassNames.push('rounded-pill'); const badge = ( - - {props.children} + + {text} ); @@ -31,14 +32,7 @@ const DefaultBadge: React.FunctionComponent = props => { ); } - return ( - - {props.children} - - ); + return badge; }; export default DefaultBadge; diff --git a/src/main/webapp/app/shared/badge/NoEntryBadge.tsx b/src/main/webapp/app/shared/badge/NoEntryBadge.tsx index b6c88f715..41489bfb2 100644 --- a/src/main/webapp/app/shared/badge/NoEntryBadge.tsx +++ b/src/main/webapp/app/shared/badge/NoEntryBadge.tsx @@ -2,11 +2,7 @@ import React from 'react'; import DefaultBadge from './DefaultBadge'; const NoEntryBadge: React.FunctionComponent> = props => { - return ( - - No Entry - - ); + return ; }; export default NoEntryBadge; diff --git a/src/main/webapp/app/shared/badge/NotCuratableBadge.tsx b/src/main/webapp/app/shared/badge/NotCuratableBadge.tsx index ef83da083..51730b43c 100644 --- a/src/main/webapp/app/shared/badge/NotCuratableBadge.tsx +++ b/src/main/webapp/app/shared/badge/NotCuratableBadge.tsx @@ -69,9 +69,8 @@ const NotCuratableBadge: React.FunctionComponent = ({ m )}
} - > - Not Curatable -
+ text="Not Curatable" + /> ); }; diff --git a/src/main/webapp/app/shared/firebase/input/RealtimeBasicInput.tsx b/src/main/webapp/app/shared/firebase/input/RealtimeBasicInput.tsx index fa7f823b6..b76f61673 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', @@ -125,24 +126,7 @@ const RealtimeBasicInput: React.FunctionComponent = (props: }; }, [firebasePath, db]); - useEffect(() => { - if (!inputValueLoaded) return; - const input = inputRef.current; - if (!input || type !== RealtimeInputType.TEXTAREA) { - return; - } - - const resizeObserver = new ResizeObserver(() => { - window.requestAnimationFrame(() => { - resizeTextArea(input); - }); - }); - resizeObserver.observe(input); - - return () => { - resizeObserver.disconnect(); - }; - }, [inputValueLoaded]); + useTextareaAutoHeight(inputRef, type); const labelComponent = label && ( diff --git a/src/main/webapp/app/shared/icons/ActionIcon.tsx b/src/main/webapp/app/shared/icons/ActionIcon.tsx index b9b3d4fb7..e5a6f12ee 100644 --- a/src/main/webapp/app/shared/icons/ActionIcon.tsx +++ b/src/main/webapp/app/shared/icons/ActionIcon.tsx @@ -10,6 +10,7 @@ export type SpanProps = JSX.IntrinsicElements['span']; export interface IActionIcon extends SpanProps { icon: IconDefinition; + text?: string; compact?: boolean; size?: 'sm' | 'lg'; color?: string; @@ -18,7 +19,7 @@ export interface IActionIcon extends SpanProps { } const ActionIcon: React.FunctionComponent = (props: IActionIcon) => { - const { icon, compact, size, color, className, onMouseLeave, onMouseEnter, tooltipProps, ...rest } = props; + const { icon, compact, size, color, className, onMouseLeave, onMouseEnter, tooltipProps, text, ...rest } = props; const defaultCompact = compact || false; const fontSize = size === 'lg' ? '1.5rem' : '1.2rem'; const defaultColor = props.disabled ? SECONDARY : color || PRIMARY; @@ -61,7 +62,7 @@ const ActionIcon: React.FunctionComponent = (props: IActionIcon) => } }; - const iconComponent = defaultCompact ? ( + let iconComponent = defaultCompact ? ( @@ -80,6 +81,17 @@ const ActionIcon: React.FunctionComponent = (props: IActionIcon) => onClick={handleClick} /> ); + + if (text) { + iconComponent = ( +
+ {iconComponent} + + {text ? {text} : undefined} +
+ ); + } + if (!tooltipProps) { return iconComponent; } diff --git a/src/main/webapp/app/shared/modal/AddMutationModal.tsx b/src/main/webapp/app/shared/modal/AddMutationModal.tsx index 50e77e962..7dec4249d 100644 --- a/src/main/webapp/app/shared/modal/AddMutationModal.tsx +++ b/src/main/webapp/app/shared/modal/AddMutationModal.tsx @@ -1,37 +1,59 @@ -import Tabs from 'app/components/tabs/tabs'; -import { notifyError } from 'app/oncokb-commons/components/util/NotificationUtils'; import { IRootStore } from 'app/stores'; import { onValue, ref } from 'firebase/database'; import _ from 'lodash'; -import { flow, flowResult } from 'mobx'; -import React, { KeyboardEventHandler, useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { FaChevronDown, FaChevronUp, FaExclamationTriangle, FaPlus } from 'react-icons/fa'; -import ReactSelect, { GroupBase, MenuPlacement } from 'react-select'; -import CreatableSelect from 'react-select/creatable'; -import { Alert, Button, Col, Input, Row, Spinner } from 'reactstrap'; -import { Alteration, Mutation, VusObjList } from '../model/firebase/firebase.model'; -import { - AlterationAnnotationStatus, - AlterationTypeEnum, - AnnotateAlterationBody, - Gene, - Alteration as ApiAlteration, -} from '../api/generated/curation'; -import { IGene } from '../model/gene.model'; +import { flow } from 'mobx'; +import React, { KeyboardEventHandler, useEffect, useState } from 'react'; +import { 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 } 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 { Linkout } from '../links/Linkout'; import { SopPageLink } from '../links/SopPageLink'; +import { FlagTypeEnum } from '../model/enumerations/flag-type.enum.model'; +import AddExonForm, { ExonCreateInfo } from './MutationModal/AddExonForm'; +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'; +import { ReferenceGenome } from '../model/enumerations/reference-genome.model'; +import { notifyError } from 'app/oncokb-commons/components/util/NotificationUtils'; +import DefaultTooltip from '../tooltip/DefaultTooltip'; +import { ADD_MUTATION_MODAL_ADD_EXON_BUTTON_ID, ADD_MUTATION_MODAL_INPUT_ID } from 'app/config/constants/html-id'; -type AlterationData = { +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; @@ -61,49 +83,60 @@ 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, + hasUncommitedExonFormChanges, + unCommittedExonFormChangesWarning, + getProteinExons, + setProteinExons, + proteinExons, }: IAddMutationModalProps) { - const typeOptions: DropdownOption[] = [ - AlterationTypeEnum.ProteinChange, - AlterationTypeEnum.CopyNumberAlteration, - AlterationTypeEnum.StructuralVariant, - AlterationTypeEnum.CdnaChange, - AlterationTypeEnum.GenomicChange, - AlterationTypeEnum.Any, - ].map(type => ({ label: READABLE_ALTERATION[type], value: type })); - const consequenceOptions: DropdownOption[] = - consequences?.map((consequence): DropdownOption => ({ label: consequence.name, value: consequence.id })) ?? []; - const [inputValue, setInputValue] = useState(''); - const [tabStates, setTabStates] = useState([]); - const [excludingInputValue, setExcludingInputValue] = useState(''); - const [excludingCollapsed, setExcludingCollapsed] = useState(true); - const [mutationAlreadyExists, setMutationAlreadyExists] = useState({ exists: false, inMutationList: false, inVusList: false }); - const [mutationToEdit, setMutationToEdit] = useState(null); - const [errorMessagesEnabled, setErrorMessagesEnabled] = useState(true); - const [isFetchingAlteration, setIsFetchingAlteration] = useState(false); - const [isFetchingExcludingAlteration, setIsFetchingExcludingAlteration] = useState(false); - const [isConfirmPending, setIsConfirmPending] = useState(false); + const [mutationAlreadyExists, setMutationAlreadyExists] = useState({ + exists: false, + inMutationList: false, + inVusList: false, + }); - const [vusList, setVusList] = useState(null); + const [isAddAlterationPending, setIsAddAlterationPending] = useState(false); - const inputRef = useRef> | null>(null); - - const geneEntity: IGene | undefined = useMemo(() => { - return geneEntities?.find(gene => gene.hugoSymbol === hugoSymbol); - }, [geneEntities]); + const [errorMessagesEnabled, setErrorMessagesEnabled] = useState(true); + const [isConfirmPending, setIsConfirmPending] = useState(false); useEffect(() => { if (!firebaseDb) { @@ -112,21 +145,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(); @@ -134,7 +174,13 @@ function AddMutationModal({ }, [convertOptions?.isConverting]); useEffect(() => { - const dupMutations = getDuplicateMutations(currentMutationNames, mutationList ?? [], vusList ?? {}, { + if (hugoSymbol) { + getProteinExons?.(hugoSymbol, ReferenceGenome.GRCh37).then(value => setProteinExons?.(value)); + } + }, [hugoSymbol]); + + useEffect(() => { + const dupMutations = getDuplicateMutations(currentMutationNames ?? [], mutationList ?? [], vusList ?? {}, { useFullAlterationName: true, excludedMutationUuid: mutationToEdit?.name_uuid, excludedVusName: convertOptions?.isConverting ? convertOptions.alteration : '', @@ -145,31 +191,13 @@ 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)) ?? []); + // Use the alteration model in Firebase instead of annotation from API + setAlterationStates?.(mutationToEdit?.alterations?.map(alt => convertAlterationToAlterationData(alt)) ?? []); return; } @@ -181,10 +209,14 @@ function AddMutationModal({ [] as ReturnType, ); - const entityStatusAlterationsPromise = fetchAlterations(parsedAlterations?.map(alt => alt.alteration) ?? []); + const entityStatusAlterationsPromise = fetchAlterations?.(parsedAlterations?.map(alt => alt.alteration) ?? []); + if (!entityStatusAlterationsPromise) return; const excludingEntityStatusAlterationsPromises: Promise[] = []; for (const alt of parsedAlterations ?? []) { - excludingEntityStatusAlterationsPromises.push(fetchAlterations(alt.excluding)); + const fetchedAlterations = fetchAlterations?.(alt.excluding); + if (fetchedAlterations) { + excludingEntityStatusAlterationsPromises.push(fetchedAlterations); + } } const [entityStatusAlterations, entityStatusExcludingAlterations] = await Promise.all([ entityStatusAlterationsPromise, @@ -196,31 +228,23 @@ function AddMutationModal({ for (let i = 0; i < parsedAlterations.length; i++) { const excluding: AlterationData[] = []; for (let exIndex = 0; exIndex < parsedAlterations[i].excluding.length; exIndex++) { - excluding.push( - convertEntityStatusAlterationToAlterationData( - entityStatusExcludingAlterations[i][exIndex], - parsedAlterations[i].excluding[exIndex], - [], - '', - ), - ); + excluding.push(convertEntityStatusAlterationToAlterationData(entityStatusExcludingAlterations[i][exIndex], [], '')); } excludingAlterations.push(excluding); } } if (parsedAlterations) { - const alterations = entityStatusAlterations.map((alt, index) => + const newAlerationStates = entityStatusAlterations.map((alt, index) => convertEntityStatusAlterationToAlterationData( alt, - parsedAlterations[index].alteration, excludingAlterations[index] || [], parsedAlterations[index].comment, parsedAlterations[index].name, ), ); - setTabStates(alterations); + setAlterationStates?.(newAlerationStates); } } @@ -229,349 +253,90 @@ function AddMutationModal({ } }, [mutationToEdit]); - useEffect(() => { - getConsequences?.({}); - }, []); - - useEffect(() => { - inputRef.current?.focus(); - }, []); - - const currentMutationNames = useMemo(() => { - return tabStates.map(state => getFullAlterationName({ ...state, comment: '' }).toLowerCase()).sort(); - }, [tabStates]); - - function filterAlterationsAndNotify( - alterations: ReturnType, - alterationData: AlterationData[], - alterationIndex?: number, - ) { - // remove alterations that already exist in modal - const newAlterations = alterations.filter(alt => { - return !alterationData.some((state, index) => { - if (index === alterationIndex) { - return false; - } - - const stateName = state.alteration.toLowerCase(); - const stateExcluding = state.excluding.map(ex => ex.alteration.toLowerCase()).sort(); - const altName = alt.alteration.toLowerCase(); - const altExcluding = alt.excluding.map(ex => ex.toLowerCase()).sort(); - return stateName === altName && _.isEqual(stateExcluding, altExcluding); - }); - }); - - if (alterations.length !== newAlterations.length) { - notifyError(new Error('Duplicate alteration(s) removed')); - } - - return newAlterations; - } - - async function fetchAlteration(alterationName: string): Promise { - try { - const request: AnnotateAlterationBody[] = [ - { - referenceGenome: REFERENCE_GENOME.GRCH37, - alteration: { alteration: alterationName, genes: [{ id: geneEntity?.id } as Gene] } as ApiAlteration, - }, - ]; - const alts = await flowResult(annotateAlterations?.(request)); - return alts[0]; - } catch (error) { - notifyError(error); + async function handleAlterationAdded() { + let alterationString = inputValue; + if (convertOptions?.isConverting) { + alterationString = convertOptions.alteration; } - } - - async function fetchAlterations(alterationNames: string[]) { - try { - const alterationPromises = alterationNames.map(name => fetchAlteration(name)); - const alterations = await Promise.all(alterationPromises); - const filtered: AlterationAnnotationStatus[] = []; - for (const alteration of alterations) { - if (alteration !== undefined) { - filtered.push(alteration); - } + if (EXON_ALTERATION_REGEX.test(alterationString) && proteinExons?.length === 0) { + notifyError( + new Error('Removed exons alterations because gene does not have an associated oncokb canonical transcript. Reach out to dev team.'), + ); + } else { + try { + setIsAddAlterationPending(true); + await updateAlterationStateAfterAlterationAdded?.(parseAlterationName(alterationString, true)); + } finally { + setIsAddAlterationPending(false); } - 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; + setInputValue(''); } - async function fetchNormalAlteration(newAlteration: string, alterationIndex: number, alterationData: AlterationData[]) { - const newParsedAlteration = filterAlterationsAndNotify(parseAlterationName(newAlteration), alterationData, alterationIndex); - if (newParsedAlteration.length === 0) { - setTabStates(states => { - const newStates = _.cloneDeep(states); - newStates[alterationIndex].alterationFieldValueWhileFetching = undefined; - return newStates; - }); - } + async function handleConfirm() { + const newMutation = mutationToEdit ? _.cloneDeep(mutationToEdit) : new Mutation(''); + const newAlterations = alterationStates?.map(state => convertAlterationDataToAlteration(state)) ?? []; + newMutation.name = newAlterations.map(alteration => alteration.name).join(', '); + newMutation.alterations = newAlterations; - const newComment = newParsedAlteration[0].comment; - const newVariantName = newParsedAlteration[0].name; + const newAlterationCategories = await handleAlterationCategoriesConfirm(); + newMutation.alteration_categories = newAlterationCategories; - let newExcluding: AlterationData[]; - if ( - _.isEqual( - newParsedAlteration[0].excluding, - alterationData[alterationIndex]?.excluding.map(ex => ex.alteration), - ) - ) { - newExcluding = alterationData[alterationIndex].excluding; - } else { - const excludingEntityStatusAlterations = await fetchAlterations(newParsedAlteration[0].excluding); - newExcluding = - excludingEntityStatusAlterations?.map((ex, index) => - convertEntityStatusAlterationToAlterationData(ex, newParsedAlteration[0].excluding[index], [], ''), - ) ?? []; + setErrorMessagesEnabled(false); + setIsConfirmPending(true); + try { + await onConfirm(newMutation, mutationList?.length || 0); + } finally { + setErrorMessagesEnabled(true); + setIsConfirmPending(false); + cleanup?.(); } + } - const alterationPromises: Promise[] = []; - let newAlterations: AlterationData[] = []; - if (newParsedAlteration[0].alteration !== alterationData[alterationIndex]?.alteration) { - alterationPromises.push(fetchAlteration(newParsedAlteration[0].alteration)); + async function handleAlterationCategoriesConfirm() { + let newAlterationCategories: AlterationCategories | null = new AlterationCategories(); + if (selectedAlterationCategoryFlags?.length === 0 || alterationStates?.length === 1) { + newAlterationCategories = null; } else { - alterationData[alterationIndex].excluding = newExcluding; - alterationData[alterationIndex].comment = newComment; - alterationData[alterationIndex].name = newVariantName || newParsedAlteration[0].alteration; - newAlterations.push(alterationData[alterationIndex]); - } - - for (let i = 1; i < newParsedAlteration.length; i++) { - alterationPromises.push(fetchAlteration(newParsedAlteration[i].alteration)); + newAlterationCategories.comment = alterationCategoryComment ?? ''; + const finalFlagArray = await saveNewFlags(); + if ((selectedAlterationCategoryFlags ?? []).length > 0) { + newAlterationCategories.flags = finalFlagArray.map(flag => convertIFlagToFlag(flag)); + } } - newAlterations = [ - ...newAlterations, - ...(await Promise.all(alterationPromises)) - .filter(hasValue) - .map((alt, index) => - convertEntityStatusAlterationToAlterationData( - alt, - newParsedAlteration[index + newAlterations.length].alteration, - newExcluding, - newComment, - newVariantName, - ), - ), - ]; - newAlterations[0].alterationFieldValueWhileFetching = undefined; + // Refresh flag entities + await getFlagsByType?.(FlagTypeEnum.ALTERATION_CATEGORY); - 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); + return newAlterationCategories; } - async function fetchExcludedAlteration( - newAlteration: string, - alterationIndex: number, - excludingIndex: number, - alterationData: AlterationData[], - ) { - const newParsedAlteration = parseAlterationName(newAlteration); - - const currentState = alterationData[alterationIndex]; - const alteration = currentState.alteration.toLowerCase(); - let excluding: string[] = []; - for (let i = 0; i < currentState.excluding.length; i++) { - if (i === excludingIndex) { - excluding.push(...newParsedAlteration.map(alt => alt.alteration.toLowerCase())); - } else { - excluding.push(currentState.excluding[excludingIndex].alteration.toLowerCase()); - } - } - excluding = excluding.sort(); - if ( - alterationData.some( - state => - state.alteration.toLowerCase() === alteration && - _.isEqual(state.excluding.map(ex => ex.alteration.toLowerCase()).sort(), excluding), - ) - ) { - notifyError(new Error('Duplicate alteration(s) removed')); - setTabStates(states => { - const newStates = _.cloneDeep(states); - newStates[alterationIndex].excluding.splice(excludingIndex, 1); - return newStates; + async function saveNewFlags() { + const [newFlags, oldFlags] = _.partition(selectedAlterationCategoryFlags ?? [], newFlag => { + return !alterationCategoryFlagEntities?.some(existingFlag => { + return newFlag.type === existingFlag.type && newFlag.flag === existingFlag.flag; }); - return; - } - - const alterationPromises: Promise[] = []; - let newAlterations: AlterationData[] = []; - if (newParsedAlteration[0].alteration !== alterationData[alterationIndex]?.excluding[excludingIndex].alteration) { - alterationPromises.push(fetchAlteration(newParsedAlteration[0].alteration)); - } else { - newAlterations.push(alterationData[alterationIndex].excluding[excludingIndex]); - } - - for (let i = 1; i < newParsedAlteration.length; i++) { - alterationPromises.push(fetchAlteration(newParsedAlteration[i].alteration)); - } - newAlterations = [ - ...newAlterations, - ...(await Promise.all(alterationPromises)) - .map((alt, index) => - alt - ? convertEntityStatusAlterationToAlterationData( - alt, - newParsedAlteration[index].alteration, - [], - newParsedAlteration[index].comment, - ) - : undefined, - ) - .filter(hasValue), - ]; - - setTabStates(states => { - const newStates = _.cloneDeep(states); - newStates[alterationIndex].excluding.splice(excludingIndex, 1, ...newAlterations); - return newStates; }); - } - - const fetchExcludedAlterationDebounced = useCallback( - _.debounce(async (newAlteration: string, alterationIndex: number, excludingIndex: number, alterationData: AlterationData[]) => { - await fetchExcludedAlteration(newAlteration, alterationIndex, excludingIndex, alterationData); - setIsFetchingExcludingAlteration(false); - }, 1000), - [], - ); - - async function handleAlterationChange(newValue: string, alterationIndex: number, excludingIndex?: number, isDebounced = true) { - if (!_.isNil(excludingIndex)) { - setIsFetchingExcludingAlteration(true); - - if (isDebounced) { - handleExcludingFieldChange(newValue, 'alteration', alterationIndex, excludingIndex); - fetchExcludedAlterationDebounced(newValue, alterationIndex, excludingIndex, tabStates); - } else { - await fetchExcludedAlteration(newValue, alterationIndex, excludingIndex, tabStates); - setIsFetchingExcludingAlteration(false); - } - } else { - setIsFetchingAlteration(true); - - if (isDebounced) { - handleNormalAlterationChange(newValue, alterationIndex); - } else { - await fetchNormalAlteration(newValue, alterationIndex, tabStates); - setIsFetchingAlteration(false); + if (newFlags.length > 0) { + for (const newFlag of newFlags) { + const savedFlagEntity = await createFlagEntity?.({ + type: FlagTypeEnum.ALTERATION_CATEGORY, + flag: newFlag.flag, + name: newFlag.name, + description: '', + alterations: null, + genes: null, + transcripts: null, + articles: null, + drugs: null, + }); + if (savedFlagEntity?.data) { + oldFlags.push(savedFlagEntity.data); + } } } - } - - function handleNormalFieldChange(newValue: string, field: keyof AlterationData, alterationIndex: number) { - setTabStates(states => { - const newStates = _.cloneDeep(states); - newStates[alterationIndex][field as string] = newValue; - return newStates; - }); - } - function handleExcludingFieldChange(newValue: string, field: keyof AlterationData, alterationIndex: number, excludingIndex: number) { - setTabStates(states => { - const newStates = _.cloneDeep(states); - newStates[alterationIndex].excluding[excludingIndex][field as string] = newValue; - return newStates; - }); - } - - function handleFieldChange(newValue: string, field: keyof AlterationData, alterationIndex: number, excludingIndex?: number) { - !_.isNil(excludingIndex) - ? handleExcludingFieldChange(newValue, field, alterationIndex, excludingIndex) - : handleNormalFieldChange(newValue, field, alterationIndex); - } - - async function handleAlterationAdded() { - let alterationString = inputValue; - if (convertOptions?.isConverting) { - alterationString = convertOptions.alteration; - } - const newParsedAlteration = filterAlterationsAndNotify(parseAlterationName(alterationString), tabStates); - - if (newParsedAlteration.length === 0) { - return; - } - - const newEntityStatusAlterationsPromise = fetchAlterations(newParsedAlteration.map(alt => alt.alteration)); - const newEntityStatusExcludingAlterationsPromise = fetchAlterations(newParsedAlteration[0].excluding); - const [newEntityStatusAlterations, newEntityStatusExcludingAlterations] = await Promise.all([ - newEntityStatusAlterationsPromise, - newEntityStatusExcludingAlterationsPromise, - ]); - - const newExcludingAlterations = newEntityStatusExcludingAlterations.map((alt, index) => - convertEntityStatusAlterationToAlterationData(alt, newParsedAlteration[0].excluding[index], [], ''), - ); - const newAlterations = newEntityStatusAlterations.map((alt, index) => - convertEntityStatusAlterationToAlterationData( - alt, - newParsedAlteration[index].alteration, - _.cloneDeep(newExcludingAlterations), - newParsedAlteration[index].comment, - newParsedAlteration[index].name, - ), - ); - - setTabStates(states => [...states, ...newAlterations]); - setInputValue(''); + return oldFlags; } const handleKeyDown: KeyboardEventHandler = event => { @@ -582,596 +347,203 @@ function AddMutationModal({ } }; - async function handleAlterationAddedExcluding(alterationIndex: number) { - const newParsedAlteration = parseAlterationName(excludingInputValue); - - const currentState = tabStates[alterationIndex]; - const alteration = currentState.alteration.toLowerCase(); - let excluding = currentState.excluding.map(ex => ex.alteration.toLowerCase()); - excluding.push(...newParsedAlteration.map(alt => alt.alteration.toLowerCase())); - excluding = excluding.sort(); - - if ( - tabStates.some( - state => - state.alteration.toLowerCase() === alteration && - _.isEqual(state.excluding.map(ex => ex.alteration.toLowerCase()).sort(), excluding), - ) - ) { - notifyError(new Error('Duplicate alteration(s) removed')); - return; - } - - const newComment = newParsedAlteration[0].comment; - const newVariantName = newParsedAlteration[0].name; - - const newEntityStatusAlterations = await fetchAlterations(newParsedAlteration.map(alt => alt.alteration)); - - const newAlterations = newEntityStatusAlterations.map((alt, index) => - convertEntityStatusAlterationToAlterationData(alt, newParsedAlteration[index].alteration, [], newComment, newVariantName), - ); - - setTabStates(states => { - const newStates = _.cloneDeep(states); - newStates[alterationIndex].excluding.push(...newAlterations); - return newStates; - }); - - setExcludingInputValue(''); - } - - const handleKeyDownExcluding = (event: React.KeyboardEvent, alterationIndex: number) => { - if (!excludingInputValue) return; - if (event.key === 'Enter' || event.key === 'tab') { - handleAlterationAddedExcluding(alterationIndex); - event.preventDefault(); - } + const handleCancel = () => { + cleanup?.(); + onCancel(); }; - function getFullAlterationName(alterationData: AlterationData, includeVariantName = true) { - const variantName = includeVariantName && alterationData.name !== alterationData.alteration ? ` [${alterationData.name}]` : ''; - const excluding = - alterationData.excluding.length > 0 ? ` {excluding ${alterationData.excluding.map(ex => ex.alteration).join(' ; ')}}` : ''; - const comment = alterationData.comment ? ` (${alterationData.comment})` : ''; - return `${alterationData.alteration}${variantName}${excluding}${comment}`; - } - - function getTabTitle(tabAlterationData: AlterationData, isExcluding = false) { - if (!tabAlterationData) { - // loading state - return <>; - } - - const fullAlterationName = getFullAlterationName(tabAlterationData, isExcluding ? false : true); + const renderInputSection = () => ( + + + + setInputValue(e.target.value)} + onClick={() => setShowModifyExonForm?.(false)} + /> + + } /> + + + + + + OR + + + + + + + + + + ); - if (tabAlterationData.error) { + // Helper function to render exon or mutation list section + const renderExonOrMutationListSection = () => { + if (showModifyExonForm) { return ( - - - {fullAlterationName} - + <> +
+ + ); } - - if (tabAlterationData.warning) { + if (alterationStates?.length !== 0) { return ( - - - {fullAlterationName} - + <> +
+ + + + + + ); } + return null; + }; - 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); + // 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; + }; - 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 && ( -
- - -
- )} -
- ); - } - - const modalBody = ( - <> - - - { - if (action !== 'menu-close' && action !== 'input-blur') { - setInputValue(newInput); - } - }} - value={tabStates.map(state => { - const fullAlterationName = getFullAlterationName(state); - return { label: fullAlterationName, value: fullAlterationName, ...state }; - })} - onChange={(newAlterations: readonly AlterationData[]) => - setTabStates(states => - states.filter(state => newAlterations.some(alt => getFullAlterationName(alt) === getFullAlterationName(state))), - ) - } - onKeyDown={handleKeyDown} - /> - - {!convertOptions?.isConverting ? ( - <> - -
- - }> -
- - - ) : undefined} -
- {tabStates.length > 0 && ( -
- { - return { - title: getTabTitle(alterationData), - content: getTabContent(alterationData, index), - }; - })} - /> -
- )} - + const mutationModalBody = ( +
+ {!convertOptions?.isConverting && renderInputSection()} + {renderExonOrMutationListSection()} + {renderMutationDetailSection()} +
); - let modalErrorMessage: string | undefined = undefined; - if (mutationAlreadyExists.exists) { - modalErrorMessage = 'Mutation already exists in'; - if (mutationAlreadyExists.inMutationList && mutationAlreadyExists.inVusList) { - modalErrorMessage = 'Mutation already in mutation list and VUS list'; - } else if (mutationAlreadyExists.inMutationList) { - modalErrorMessage = 'Mutation already in mutation list'; - } else { - modalErrorMessage = 'Mutation already in VUS list'; - } - } + const modalErrorMessage = getModalErrorMessage(mutationAlreadyExists); - let modalWarningMessage: string | undefined = undefined; - if (convertOptions?.isConverting && !isEqualIgnoreCase(convertOptions.alteration, currentMutationNames.join(', '))) { - modalWarningMessage = 'Name differs from original VUS name'; + const modalWarningMessage: string[] = []; + if (convertOptions?.isConverting && !isEqualIgnoreCase(convertOptions.alteration, (currentMutationNames ?? []).join(', '))) { + modalWarningMessage.push('Name differs from original VUS name'); + } + if (hasUncommitedExonFormChanges && unCommittedExonFormChangesWarning) { + modalWarningMessage.push(unCommittedExonFormChangesWarning); } return ( Promoting Variant(s) to Mutation : undefined} - modalBody={modalBody} - onCancel={onCancel} - onConfirm={async () => { - 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; - - setErrorMessagesEnabled(false); - setIsConfirmPending(true); - try { - await onConfirm(newMutation, mutationList?.length || 0); - } finally { - setErrorMessagesEnabled(true); - setIsConfirmPending(false); - } - }} + modalBody={mutationModalBody} + onCancel={handleCancel} + onConfirm={handleConfirm} errorMessages={modalErrorMessage && errorMessagesEnabled ? [modalErrorMessage] : undefined} - warningMessages={modalWarningMessage ? [modalWarningMessage] : undefined} + warningMessages={modalWarningMessage ? modalWarningMessage : undefined} confirmButtonDisabled={ - tabStates.length === 0 || + alterationStates?.length === 0 || mutationAlreadyExists.exists || isFetchingAlteration || isFetchingExcludingAlteration || - tabStates.some(tab => tab.error || tab.excluding.some(ex => ex.error)) || - isConfirmPending + alterationStates?.some(tab => tab.error || tab.excluding.some(ex => ex.error)) || + isConfirmPending || + (hasUncommitedExonFormChanges ?? false) } isConfirmPending={isConfirmPending} /> ); } -interface IAddMutationModalFieldProps { - label: string; - value: string; - placeholder: string; - onChange: (newValue: string) => void; - isLoading?: boolean; - disabled?: boolean; -} - -function AddMutationModalField({ label, value: value, placeholder, onChange, isLoading, disabled }: IAddMutationModalFieldProps) { - return ( -
- -
- {label} - {isLoading && } -
- - - { - onChange(event.target.value); - }} - placeholder={placeholder} - /> - -
- ); -} +const mapStoreToProps = ({ + alterationStore, + consequenceStore, + geneStore, + firebaseAppStore, + firebaseVusStore, + firebaseMutationListStore, + flagStore, + addMutationModalStore, + transcriptStore, +}: IRootStore) => ({ + annotateAlterations: flow(alterationStore.annotateAlterations), + geneEntities: geneStore.entities, + consequences: consequenceStore.entities, + getConsequences: consequenceStore.getEntities, + firebaseDb: firebaseAppStore.firebaseDb, + vusList: firebaseVusStore.data, + mutationList: firebaseMutationListStore.data, + getFlagsByType: flagStore.getFlagsByType, + alterationCategoryFlagEntities: flagStore.alterationCategoryFlags, + createFlagEntity: flagStore.createEntity, + setVusList: addMutationModalStore.setVusList, + setMutationToEdit: addMutationModalStore.setMutationToEdit, + alterationStates: addMutationModalStore.alterationStates, + mutationToEdit: addMutationModalStore.mutationToEdit, + setShowModifyExonForm: addMutationModalStore.setShowModifyExonForm, + showModifyExonForm: addMutationModalStore.showModifyExonForm, + isFetchingAlteration: addMutationModalStore.isFetchingAlteration, + isFetchingExcludingAlteration: addMutationModalStore.isFetchingExcludingAlteration, + currentMutationNames: addMutationModalStore.currentMutationNames, + cleanup: addMutationModalStore.cleanup, + filterAlterationsAndNotify: addMutationModalStore.filterAlterationsAndNotify, + fetchAlterations: addMutationModalStore.fetchAlterations, + setAlterationStates: addMutationModalStore.setAlterationStates, + selectedAlterationCategoryFlags: addMutationModalStore.selectedAlterationCategoryFlags, + alterationCategoryComment: addMutationModalStore.alterationCategoryComment, + setGeneEntity: addMutationModalStore.setGeneEntity, + updateAlterationStateAfterAlterationAdded: addMutationModalStore.updateAlterationStateAfterAlterationAdded, + selectedAlterationStateIndex: addMutationModalStore.selectedAlterationStateIndex, + hasUncommitedExonFormChanges: addMutationModalStore.hasUncommitedExonFormChanges, + unCommittedExonFormChangesWarning: addMutationModalStore.unCommittedExonFormChangesWarning, + getProteinExons: flow(transcriptStore.getProteinExons), + setProteinExons: addMutationModalStore.setProteinExons, + proteinExons: addMutationModalStore.proteinExons, +}); -type DropdownOption = { - label: string; - value: any; -}; -interface IAddMutationModalDropdownProps { - label: string; - value: DropdownOption; - options: DropdownOption[]; - menuPlacement?: MenuPlacement; - onChange: (newValue: DropdownOption | null) => void; -} +type StoreProps = Partial>; -function AddMutationModalDropdown({ label, value, options, menuPlacement, onChange }: IAddMutationModalDropdownProps) { - return ( -
- - {label} - - - - -
- ); -} +export default componentInject(mapStoreToProps)(AddMutationModal); const AddMutationInputOverlay = () => { return ( @@ -1181,7 +553,7 @@ const AddMutationInputOverlay = () => { Add button to annotate alteration(s).
-
Supported inputs:
+
String Mutation:
  • @@ -1192,6 +564,16 @@ const AddMutationInputOverlay = () => {
+
Exon:
+
    +
  • + Supported consequences are Insertion, Deletion and Duplication - Exon 4 Deletion +
  • +
  • + Exon range - Exon 4-8 Deletion +
  • +
+
For detailed list, refer to:{' '} OncoKB SOP - Chapter 6: Table 3.1: OncoKB alteration nomenclature, style and formatting @@ -1200,24 +582,3 @@ const AddMutationInputOverlay = () => {
); }; - -const mapStoreToProps = ({ - alterationStore, - consequenceStore, - geneStore, - firebaseAppStore, - firebaseVusStore, - firebaseMutationListStore, -}: IRootStore) => ({ - annotateAlterations: flow(alterationStore.annotateAlterations), - geneEntities: geneStore.entities, - consequences: consequenceStore.entities, - getConsequences: consequenceStore.getEntities, - firebaseDb: firebaseAppStore.firebaseDb, - vusList: firebaseVusStore.data, - mutationList: firebaseMutationListStore.data, -}); - -type StoreProps = Partial>; - -export default componentInject(mapStoreToProps)(AddMutationModal); diff --git a/src/main/webapp/app/shared/modal/DefaultAddMutationModal.tsx b/src/main/webapp/app/shared/modal/DefaultAddMutationModal.tsx index a64456c66..0a0c0da98 100644 --- a/src/main/webapp/app/shared/modal/DefaultAddMutationModal.tsx +++ b/src/main/webapp/app/shared/modal/DefaultAddMutationModal.tsx @@ -18,7 +18,7 @@ export interface IDefaultAddMutationModal { export const DefaultAddMutationModal = (props: IDefaultAddMutationModal) => { return ( - + {props.modalHeader ? {props.modalHeader} : undefined}
{props.modalBody}
diff --git a/src/main/webapp/app/shared/modal/MutationModal/AddExonForm.tsx b/src/main/webapp/app/shared/modal/MutationModal/AddExonForm.tsx new file mode 100644 index 000000000..7e1a31255 --- /dev/null +++ b/src/main/webapp/app/shared/modal/MutationModal/AddExonForm.tsx @@ -0,0 +1,318 @@ +import React, { useEffect, useMemo, useState } from 'react'; +import { IRootStore } from 'app/stores'; +import { componentInject } from 'app/shared/util/typed-inject'; +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'; +import InfoIcon from 'app/shared/icons/InfoIcon'; +import { FaArrowLeft, FaRegLightbulb } from 'react-icons/fa'; +import * as styles from './styles.module.scss'; +import { flow } from 'mobx'; +import { AddMutationModalDataTestIdType, getAddMutationModalDataTestId } from 'app/shared/util/test-id-utils'; + +export interface IAddExonMutationModalBody extends StoreProps { + hugoSymbol: string; + defaultExonAlterationName?: string; +} + +type ProteinExonDropdownOption = { + label: string; + value: string; + exon?: ProteinExonDTO; + isSelected: boolean; + onMouseOverOption: (data: ProteinExonDropdownOption) => void; +}; + +const EXON_CONSEQUENCES = ['Deletion', 'Insertion', 'Duplication']; + +const AddExonForm = ({ + defaultExonAlterationName, + updateAlterationStateAfterAlterationAdded, + setShowModifyExonForm, + setHasUncommitedExonFormChanges, + proteinExons, +}: IAddExonMutationModalBody) => { + const [inputValue, setInputValue] = useState(''); + const [selectedExons, setSelectedExons] = useState([]); + + const [isPendingAddAlteration, setIsPendingAddAlteration] = useState(false); + const [didRemoveProblematicAlt, setDidRemoveProblematicAlt] = useState(false); + const [isControlPressed, setIsControlPressed] = useState(false); + + const onMouseOverOption = (option: ProteinExonDropdownOption) => { + if (isControlPressed) { + setSelectedExons(prevSelected => { + const isAlreadySelected = prevSelected.some(selectedOption => selectedOption.label === option.label); + return isAlreadySelected ? prevSelected : [...prevSelected, option]; + }); + } + }; + + const MultiSelectOption = (props: OptionProps) => { + return ( +
onMouseOverOption(props.data)}> + + { + // Cast to any due to https://github.com/JedWatson/react-select/issues/5064 + (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: name, exon, isSelected: false, onMouseOverOption }; + }) ?? [] + ); + }); + 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, + onMouseOverOption, + }); + 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(`Removed exon that does not exist: ${defaultExonAlterationName}`); + setDidRemoveProblematicAlt(true); + } else { + acc.push({ ...targetOption, isSelected: true }); + } + } + } + return acc; + }, [] as ProteinExonDropdownOption[]); + }, [defaultExonAlterationName, exonOptions]); + + const isUpdate = defaultSelectedExons.length > 0 || didRemoveProblematicAlt; + + useEffect(() => { + setSelectedExons(defaultSelectedExons ?? []); + }, [defaultSelectedExons]); + + const finalExonName = useMemo(() => { + return selectedExons.map(option => option.label).join(' + '); + }, [selectedExons]); + + useEffect(() => { + const updateDisabled = + isPendingAddAlteration || selectedExons.length === 0 || _.isEqual(defaultSelectedExons, selectedExons) || proteinExons?.length === 0; + setHasUncommitedExonFormChanges?.(!updateDisabled, isUpdate); + }, [isPendingAddAlteration, selectedExons, defaultSelectedExons]); + + const standardizeExonInputString = (createValue: string) => { + if (EXON_ALTERATION_REGEX.test(createValue)) { + return createValue + .split(' ') + .map(part => _.capitalize(part)) + .join(' '); + } + return createValue; + }; + + const onCreateOption = (createInputValue: string) => { + const value = standardizeExonInputString(createInputValue); + setSelectedExons(prevState => [...prevState, { label: value, value, isSelected: true, onMouseOverOption }]); + }; + + async function handleAlterationAdded() { + const parsedAlterations = parseAlterationName(finalExonName); + try { + setIsPendingAddAlteration(true); + await updateAlterationStateAfterAlterationAdded?.(parsedAlterations, isUpdate); + } finally { + setIsPendingAddAlteration(false); + setSelectedExons([]); + } + setShowModifyExonForm?.(false); + } + + if (_.isNil(defaultSelectedExons)) { + return ; + } + + const NoOptionsMessage = props => { + return ( + +
+
No options matching text
+

+ +
+
+ ); + }; + + useEffect(() => { + window.addEventListener('keydown', handleControlKeyDown); + window.addEventListener('keyup', handleControlKeyUp); + + return () => { + window.removeEventListener('keydown', handleControlKeyDown); + window.removeEventListener('keyup', handleControlKeyUp); + }; + }, []); + + const handleControlKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Control') { + setIsControlPressed(true); + } + }; + + const handleControlKeyUp = (event: KeyboardEvent) => { + if (event.key === 'Control') { + setIsControlPressed(false); + } + }; + + return ( +
+ {!defaultExonAlterationName ? ( + + +
{ + setShowModifyExonForm?.(false); + setHasUncommitedExonFormChanges?.(false, isUpdate); + }} + className={classNames('d-inline-flex align-items-center', styles.link)} + > + Mutation List +
+ +
+ ) : undefined} + + +
{isUpdate ? 'Modify Selected Exons' : 'Selected Exons'}
+ +
+ + +
+ + Tip: Hold control and drag to select multiple options +
+ +
+ + + + + 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} + /> + + + } /> + + + + + + + + {selectedExons.length > 0 && ( + + + Name preview: {finalExonName} + + + )} +
+ ); +}; + +export const ExonCreateInfo = ({ listView }: { listView?: boolean }) => { + return ( + <> + {!listView ?
You can create a new option that adheres to one of the formats:
: undefined} +
+
    +
  • + {`Any Exon start-end (${EXON_CONSEQUENCES.join('|')})`} + +
  • +
  • + {`Exon start-end (${EXON_CONSEQUENCES.join('|')})`} + +
  • +
+
+ + ); +}; + +const mapStoreToProps = ({ transcriptStore, addMutationModalStore }: IRootStore) => ({ + getProteinExons: flow(transcriptStore.getProteinExons), + updateAlterationStateAfterAlterationAdded: addMutationModalStore.updateAlterationStateAfterAlterationAdded, + setShowModifyExonForm: addMutationModalStore.setShowModifyExonForm, + setHasUncommitedExonFormChanges: addMutationModalStore.setHasUncommitedExonFormChanges, + proteinExons: addMutationModalStore.proteinExons, +}); + +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..e19fe5fff --- /dev/null +++ b/src/main/webapp/app/shared/modal/MutationModal/AddMutationModalField.tsx @@ -0,0 +1,47 @@ +import React from 'react'; +import { useTextareaAutoHeight } from 'app/hooks/useTextareaAutoHeight'; +import { useRef } from 'react'; +import { Col, Row, Spinner } from 'reactstrap'; +import classNames from 'classnames'; +import { InputType } from 'reactstrap/types/lib/Input'; +import { Input } from 'reactstrap'; + +interface IAddMutationModalFieldProps { + label: string; + value: string; + placeholder: string; + onChange: (newValue: string) => void; + isLoading?: boolean; + disabled?: boolean; + type?: InputType; +} + +const AddMutationModalField = ({ label, value: value, placeholder, onChange, isLoading, disabled, type }: IAddMutationModalFieldProps) => { + const inputRef = useRef(null); + + useTextareaAutoHeight(inputRef, type); + + return ( + + + {label} + {isLoading && } + + + { + onChange(event.target.value); + }} + placeholder={placeholder} + type={type} + className={classNames(type === 'textarea' ? 'alteration-modal-textarea-field' : undefined)} + /> + + + ); +}; + +export default AddMutationModalField; diff --git a/src/main/webapp/app/shared/modal/MutationModal/AlterationBadgeList.tsx b/src/main/webapp/app/shared/modal/MutationModal/AlterationBadgeList.tsx new file mode 100644 index 000000000..7473ca583 --- /dev/null +++ b/src/main/webapp/app/shared/modal/MutationModal/AlterationBadgeList.tsx @@ -0,0 +1,208 @@ +import DefaultTooltip from 'app/shared/tooltip/DefaultTooltip'; +import classNames from 'classnames'; +import React, { useMemo, useRef } from 'react'; +import * as styles from './styles.module.scss'; +import { faXmark } from '@fortawesome/free-solid-svg-icons'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { AlterationData } from '../AddMutationModal'; +import { getFullAlterationName } from 'app/shared/util/utils'; +import { IRootStore } from 'app/stores'; +import { componentInject } from 'app/shared/util/typed-inject'; +import { FaExclamationCircle, FaExclamationTriangle } from 'react-icons/fa'; +import { useOverflowDetector } from 'app/hooks/useOverflowDetector'; +import { BS_BORDER_COLOR } from 'app/config/colors'; +import _ from 'lodash'; +import { FaCircleCheck } from 'react-icons/fa6'; +import { ADD_MUTATION_MODAL_EXCLUDED_ALTERATION_INPUT_ID } from 'app/config/constants/html-id'; +import { AddMutationModalDataTestIdType, getAddMutationModalDataTestId } from 'app/shared/util/test-id-utils'; + +export interface IAlterationBadgeList extends StoreProps { + isExclusionList?: boolean; + showInput?: boolean; + inputValue?: string; + onInputChange?: (newValue: string) => void; + onKeyDown?: (event: React.KeyboardEvent) => void; +} + +const AlterationBadgeList = ({ + alterationStates, + setAlterationStates, + selectedAlterationStateIndex, + setSelectedAlterationStateIndex, + selectedExcludedAlterationIndex, + setSelectedExcludedAlterationIndex, + onInputChange, + inputValue, + isExclusionList = false, + showInput = false, +}: IAlterationBadgeList) => { + const inputRef = useRef(null); + if (alterationStates === undefined || selectedAlterationStateIndex === undefined) return <>; + + const alterationList = isExclusionList ? alterationStates[selectedAlterationStateIndex].excluding : alterationStates; + + const handleAlterationDelete = (value: AlterationData) => { + const filteredAlterationList = alterationList?.filter( + alterationState => getFullAlterationName(value) !== getFullAlterationName(alterationState), + ); + if (!isExclusionList) { + setAlterationStates?.(filteredAlterationList); + } else { + const newAlterationStates = _.cloneDeep(alterationStates); + newAlterationStates[selectedAlterationStateIndex].excluding = newAlterationStates[selectedAlterationStateIndex].excluding.filter( + state => getFullAlterationName(value) !== getFullAlterationName(state), + ); + setAlterationStates?.(newAlterationStates); + } + }; + + const handleAlterationClick = (index: number) => { + const currentIndex = isExclusionList ? selectedExcludedAlterationIndex : selectedAlterationStateIndex; + if (currentIndex === index) { + index = -1; + } + isExclusionList ? setSelectedExcludedAlterationIndex?.(index) : setSelectedAlterationStateIndex?.(index); + }; + + return ( +
inputRef?.current?.focus()} + > + {alterationList?.map((value, index) => { + const fullAlterationName = getFullAlterationName(value, false); + return ( + handleAlterationClick(index)} + onDelete={() => handleAlterationDelete(value)} + isExcludedAlteration={isExclusionList} + /> + ); + })} + {showInput && ( +
+ onInputChange?.(event.target.value)} + placeholder={alterationList.length > 0 ? undefined : 'Enter alteration(s)'} + value={inputValue} + > +
+ )} +
+ ); +}; + +const mapStoreToProps = ({ addMutationModalStore }: IRootStore) => ({ + alterationStates: addMutationModalStore.alterationStates, + setAlterationStates: addMutationModalStore.setAlterationStates, + selectedAlterationStateIndex: addMutationModalStore.selectedAlterationStateIndex, + setSelectedAlterationStateIndex: addMutationModalStore.setSelectedAlterationStateIndex, + selectedExcludedAlterationIndex: addMutationModalStore.selectedExcludedAlterationIndex, + setSelectedExcludedAlterationIndex: addMutationModalStore.setSelectedExcludedAlterationIndex, +}); + +type StoreProps = Partial>; + +export default componentInject(mapStoreToProps)(AlterationBadgeList); + +interface IAlterationBadge { + alterationData: AlterationData; + alterationName: string; + isSelected: boolean; + onClick: () => void; + onDelete: () => void; + isExcludedAlteration?: boolean; +} + +const AlterationBadge = ({ + alterationData, + alterationName, + isSelected, + onClick, + onDelete, + isExcludedAlteration = false, +}: IAlterationBadge) => { + const [isOverflow, ref] = useOverflowDetector({ detectHeight: false }); + + const backgroundColor = useMemo(() => { + if (alterationData.error) { + return 'danger'; + } + if (alterationData.warning) { + return 'warning'; + } + if (isExcludedAlteration) { + return 'secondary'; + } + return 'success'; + }, [alterationData, isExcludedAlteration]); + + const statusIcon = useMemo(() => { + let icon = ; + if (alterationData.error) { + icon = ; + } + if (alterationData.warning) { + icon = ; + } + return
{icon}
; + }, [alterationData]); + + const badgeComponent = ( +
+
{ + event.stopPropagation(); + onClick(); + }} + > + {statusIcon} +
+ {alterationName} +
+
+
+ +
+
+ ); + + if (isOverflow) { + return ( + + {badgeComponent} + + ); + } + + return badgeComponent; +}; diff --git a/src/main/webapp/app/shared/modal/MutationModal/AlterationCategoryInputs.tsx b/src/main/webapp/app/shared/modal/MutationModal/AlterationCategoryInputs.tsx new file mode 100644 index 000000000..62e6d1b36 --- /dev/null +++ b/src/main/webapp/app/shared/modal/MutationModal/AlterationCategoryInputs.tsx @@ -0,0 +1,117 @@ +import React, { useEffect, useMemo, useState } from 'react'; +import CreatableSelect from 'react-select/creatable'; +import { Col, Row } from 'reactstrap'; +import { IFlag } from 'app/shared/model/flag.model'; +import { FlagTypeEnum } from 'app/shared/model/enumerations/flag-type.enum.model'; +import { AlterationCategories } from 'app/shared/model/firebase/firebase.model'; +import { isFlagEqualToIFlag } from 'app/shared/util/firebase/firebase-utils'; +import { componentInject } from 'app/shared/util/typed-inject'; +import { IRootStore } from 'app/stores'; +import { DropdownOption } from './AddMutationModalDropdown'; +import { ADD_MUTATION_MODAL_FLAG_DROPDOWN_ID } from 'app/config/constants/html-id'; + +const AlterationCategoryInputs = ({ + getFlagsByType, + alterationCategoryFlagEntities, + mutationToEdit, + setSelectedAlterationCategoryFlags, + selectedAlterationCategoryFlags, + setAlterationCategoryComment, +}: StoreProps) => { + const [alterationCategories, setAlterationCategories] = useState(null); + + useEffect(() => { + getFlagsByType?.(FlagTypeEnum.ALTERATION_CATEGORY); + + setAlterationCategories(mutationToEdit?.alteration_categories ?? null); + }, [mutationToEdit]); + + useEffect(() => { + if (alterationCategoryFlagEntities) { + setSelectedAlterationCategoryFlags?.( + alterationCategories?.flags?.reduce((acc: IFlag[], flag) => { + const matchedFlag = alterationCategoryFlagEntities.find(flagEntity => isFlagEqualToIFlag(flag, flagEntity)); + + if (matchedFlag) { + acc.push(matchedFlag); + } + + return acc; + }, []) ?? [], + ); + } + setAlterationCategoryComment?.(alterationCategories?.comment ?? ''); + }, [alterationCategories, alterationCategoryFlagEntities]); + + const flagDropdownOptions = useMemo(() => { + if (!alterationCategoryFlagEntities) return []; + return alterationCategoryFlagEntities.map(flag => ({ label: flag.name, value: flag })); + }, [alterationCategoryFlagEntities]); + + const handleMutationFlagAdded = (newFlagName: string) => { + // The flag name entered by user can be converted to flag by remove any non alphanumeric characters + const newFlagFlag = newFlagName + .replace(/[^a-zA-Z0-9\s]/g, ' ') + .replace(/\s+/g, '_') + .toUpperCase(); + const newSelectedFlag: Omit = { + type: FlagTypeEnum.ALTERATION_CATEGORY, + flag: newFlagFlag, + name: newFlagName, + description: '', + alterations: null, + articles: null, + drugs: null, + genes: null, + transcripts: null, + }; + setSelectedAlterationCategoryFlags?.([...(selectedAlterationCategoryFlags ?? []), newSelectedFlag]); + }; + + const handleAlterationCategoriesField = (field: keyof AlterationCategories, value: unknown) => { + if (field === 'comment') { + setAlterationCategoryComment?.(value as string); + } else if (field === 'flags') { + const flagOptions = value as DropdownOption[]; + setSelectedAlterationCategoryFlags?.(flagOptions.map(option => option.value)); + } + }; + + return ( + <> + + +
+ + String Name + + + handleAlterationCategoriesField('flags', newFlags)} + onCreateOption={handleMutationFlagAdded} + value={selectedAlterationCategoryFlags?.map(newFlag => ({ label: newFlag.name, value: newFlag }))} + /> + +
+ +
+ + ); +}; + +const mapStoreToProps = ({ flagStore, addMutationModalStore }: IRootStore) => ({ + getFlagsByType: flagStore.getFlagsByType, + createFlagEntity: flagStore.createEntity, + alterationCategoryFlagEntities: flagStore.alterationCategoryFlags, + mutationToEdit: addMutationModalStore.mutationToEdit, + setSelectedAlterationCategoryFlags: addMutationModalStore.setSelectedAlterationCategoryFlags, + selectedAlterationCategoryFlags: addMutationModalStore.selectedAlterationCategoryFlags, + setAlterationCategoryComment: addMutationModalStore.setAlterationCategoryComment, +}); + +type StoreProps = Partial>; + +export default componentInject(mapStoreToProps)(AlterationCategoryInputs); diff --git a/src/main/webapp/app/shared/modal/MutationModal/AnnotatedAlterationErrorContent.tsx b/src/main/webapp/app/shared/modal/MutationModal/AnnotatedAlterationErrorContent.tsx new file mode 100644 index 000000000..cf7a30b11 --- /dev/null +++ b/src/main/webapp/app/shared/modal/MutationModal/AnnotatedAlterationErrorContent.tsx @@ -0,0 +1,69 @@ +import React from 'react'; +import { AlterationData } from '../AddMutationModal'; +import { IRootStore } from 'app/stores'; +import { componentInject } from 'app/shared/util/typed-inject'; +import { Alert, Button } from 'reactstrap'; +import _ from 'lodash'; + +const ERROR_SUGGGESTION_REGEX = new RegExp('The alteration name is invalid, do you mean (.+)\\?'); + +export interface IAnnotatedAlterationErrorContent extends StoreProps { + alterationData: AlterationData; + alterationIndex: number; + excludingIndex?: number; + declineSuggestionCallback?: () => void; +} + +const AnnotatedAlterationErrorContent = ({ + alterationData, + alterationIndex, + excludingIndex, + declineSuggestionCallback, + addMutationModalStore, +}: IAnnotatedAlterationErrorContent) => { + const suggestion = ERROR_SUGGGESTION_REGEX.exec(alterationData.error ?? '')?.[1]; + + function handleNoClick() { + const newAlterationStates = _.cloneDeep(addMutationModalStore?.alterationStates ?? []); + if (!_.isNil(excludingIndex)) { + newAlterationStates[alterationIndex].excluding.splice(excludingIndex, 1); + } else { + newAlterationStates.splice(alterationIndex, 1); + } + addMutationModalStore?.setAlterationStates(newAlterationStates); + + declineSuggestionCallback?.(); + } + + function handleYesClick() { + if (!suggestion) return; + const newAlterationData = _.cloneDeep(alterationData); + newAlterationData.alteration = suggestion; + } + + return ( +
+ + {alterationData.error} + + {suggestion && ( +
+ + +
+ )} +
+ ); +}; + +const mapStoreToProps = ({ addMutationModalStore }: IRootStore) => ({ + addMutationModalStore, +}); + +type StoreProps = Partial>; + +export default componentInject(mapStoreToProps)(AnnotatedAlterationErrorContent); diff --git a/src/main/webapp/app/shared/modal/MutationModal/ExcludedAlterationContent.tsx b/src/main/webapp/app/shared/modal/MutationModal/ExcludedAlterationContent.tsx new file mode 100644 index 000000000..921f1514e --- /dev/null +++ b/src/main/webapp/app/shared/modal/MutationModal/ExcludedAlterationContent.tsx @@ -0,0 +1,92 @@ +import { IRootStore } from 'app/stores'; +import React, { useState } from 'react'; +import { componentInject } from '../../util/typed-inject'; +import { FaChevronDown, FaChevronUp, FaPlus } from 'react-icons/fa'; +import { Button, Col, Row } from 'reactstrap'; +import { parseAlterationName } from '../../util/utils'; +import MutationDetails from './MutationDetails'; +import _ from 'lodash'; +import AlterationBadgeList from './AlterationBadgeList'; +import { ADD_MUTATION_MODAL_ADD_EXCLUDED_ALTERATION_BUTTON_ID } from 'app/config/constants/html-id'; + +export interface IExcludedAlterationContent extends StoreProps {} + +const ExcludedAlterationContent = ({ + alterationStates, + selectedAlterationStateIndex, + updateAlterationStateAfterExcludedAlterationAdded, + selectedExcludedAlterationIndex, +}: IExcludedAlterationContent) => { + const [excludingCollapsed, setExcludingCollapsed] = useState(false); + const [excludingInputValue, setExcludingInputValue] = useState(''); + + if (alterationStates === undefined || selectedAlterationStateIndex === undefined) return <>; + + const handleAlterationAddedExcluding = () => { + updateAlterationStateAfterExcludedAlterationAdded?.(parseAlterationName(excludingInputValue)); + setExcludingInputValue(''); + }; + + const handleKeyDownExcluding = (event: React.KeyboardEvent) => { + if (!excludingInputValue) return; + if (event.key === 'Enter' || event.key === 'tab') { + handleAlterationAddedExcluding(); + event.preventDefault(); + } + }; + + const isSectionEmpty = alterationStates[selectedAlterationStateIndex].excluding.length === 0; + + return ( + <> +
+ + Excluding + + + setExcludingInputValue(newValue)} + onKeyDown={handleKeyDownExcluding} + /> + + + + +
+ {!isSectionEmpty && !excludingCollapsed && selectedExcludedAlterationIndex !== undefined && ( + + + + + + )} + + ); +}; + +const mapStoreToProps = ({ addMutationModalStore }: IRootStore) => ({ + alterationStates: addMutationModalStore.alterationStates, + selectedAlterationStateIndex: addMutationModalStore.selectedAlterationStateIndex, + handleExcludingFieldChange: addMutationModalStore.handleExcludingFieldChange, + isFetchingExcludingAlteration: addMutationModalStore.isFetchingExcludingAlteration, + updateAlterationStateAfterExcludedAlterationAdded: addMutationModalStore.updateAlterationStateAfterExcludedAlterationAdded, + setAlterationStates: addMutationModalStore.setAlterationStates, + selectedExcludedAlterationIndex: addMutationModalStore.selectedExcludedAlterationIndex, +}); + +type StoreProps = Partial>; + +export default componentInject(mapStoreToProps)(ExcludedAlterationContent); diff --git a/src/main/webapp/app/shared/modal/MutationModal/MutationDetails.tsx b/src/main/webapp/app/shared/modal/MutationModal/MutationDetails.tsx new file mode 100644 index 000000000..2030aed36 --- /dev/null +++ b/src/main/webapp/app/shared/modal/MutationModal/MutationDetails.tsx @@ -0,0 +1,273 @@ +import React, { useEffect } from 'react'; +import { AlterationData } from '../AddMutationModal'; +import { AlterationTypeEnum } from 'app/shared/api/generated/curation'; +import AddMutationModalField from './AddMutationModalField'; +import AddMutationModalDropdown, { DropdownOption } from './AddMutationModalDropdown'; +import { componentInject } from 'app/shared/util/typed-inject'; +import { IRootStore } from 'app/stores'; +import _ from 'lodash'; +import { Alert } from 'reactstrap'; +import { READABLE_ALTERATION } from 'app/config/constants/constants'; +import { getFullAlterationName } from 'app/shared/util/utils'; +import AnnotatedAlterationErrorContent from './AnnotatedAlterationErrorContent'; +import { AddMutationModalDataTestIdType, getAddMutationModalDataTestId } from 'app/shared/util/test-id-utils'; + +const ALTERATION_TYPE_OPTIONS: DropdownOption[] = [ + AlterationTypeEnum.ProteinChange, + AlterationTypeEnum.CopyNumberAlteration, + AlterationTypeEnum.StructuralVariant, + AlterationTypeEnum.CdnaChange, + AlterationTypeEnum.GenomicChange, + AlterationTypeEnum.Any, +].map(type => ({ label: READABLE_ALTERATION[type], value: type })); + +export interface IMutationDetails extends StoreProps { + alterationData: AlterationData; + excludingIndex?: number; +} + +const MutationDetails = ({ + alterationData, + excludingIndex, + getConsequences, + consequences, + selectedAlterationStateIndex, + handleExcludingFieldChange, + handleNormalFieldChange, + isFetchingAlteration, + isFetchingExcludingAlteration, + handleAlterationChange, +}: IMutationDetails) => { + useEffect(() => { + getConsequences?.({}); + }, []); + + const consequenceOptions: DropdownOption[] = + consequences?.map((consequence): DropdownOption => ({ label: consequence.name, value: consequence.id })) ?? []; + + if (alterationData === undefined || selectedAlterationStateIndex === undefined) return <>; + + const 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) + : handleNormalFieldChange?.(newValue, field, selectedAlterationStateIndex); + }; + + let content: JSX.Element; + + switch (alterationData.type) { + case AlterationTypeEnum.ProteinChange: + content = getProteinChangeContent(); + break; + case AlterationTypeEnum.CopyNumberAlteration: + content = getCopyNumberAlterationContent(); + break; + case AlterationTypeEnum.CdnaChange: + content = getCdnaChangeContent(); + break; + case AlterationTypeEnum.GenomicChange: + content = getGenomicChangeContent(); + break; + case AlterationTypeEnum.StructuralVariant: + content = getStructuralVariantContent(); + break; + default: + content = getOtherContent(); + break; + } + + if (alterationData.error) { + return ( + + ); + } + + return ( +
+
{excludingIndex !== undefined && excludingIndex > -1 ? 'Excluded Mutation Details' : 'Mutation Details'}
+ {alterationData.warning && ( + + {alterationData.warning} + + )} + option.value === alterationData.type) ?? { label: '', value: undefined }} + onChange={newValue => handleFieldChange(newValue?.value, 'type')} + /> + handleAlterationChange?.(newValue, selectedAlterationStateIndex, excludingIndex)} + /> + {content} + handleFieldChange(newValue, 'comment')} + /> +
+ ); +}; + +const mapStoreToProps = ({ consequenceStore, addMutationModalStore }: IRootStore) => ({ + consequences: consequenceStore.entities, + getConsequences: consequenceStore.getEntities, + alterationStates: addMutationModalStore.alterationStates, + selectedAlterationStateIndex: addMutationModalStore.selectedAlterationStateIndex, + handleExcludingFieldChange: addMutationModalStore.handleExcludingFieldChange, + handleNormalFieldChange: addMutationModalStore.handleNormalFieldChange, + isFetchingAlteration: addMutationModalStore.isFetchingAlteration, + isFetchingExcludingAlteration: addMutationModalStore.isFetchingExcludingAlteration, + handleAlterationChange: addMutationModalStore.handleAlterationChange, +}); + +type StoreProps = Partial>; + +export default componentInject(mapStoreToProps)(MutationDetails); diff --git a/src/main/webapp/app/shared/modal/MutationModal/MutationListSection.tsx b/src/main/webapp/app/shared/modal/MutationModal/MutationListSection.tsx new file mode 100644 index 000000000..a834f19f2 --- /dev/null +++ b/src/main/webapp/app/shared/modal/MutationModal/MutationListSection.tsx @@ -0,0 +1,81 @@ +import React, { useMemo } from 'react'; +import { IRootStore } from 'app/stores'; +import { componentInject } from '../../util/typed-inject'; +import { getFullAlterationName } from '../../util/utils'; +import DefaultTooltip from '../../tooltip/DefaultTooltip'; +import { Input } from 'reactstrap'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { faComment as farComment } from '@fortawesome/free-regular-svg-icons'; +import { faComment as fasComment } from '@fortawesome/free-solid-svg-icons'; +import AlterationCategoryInputs from './AlterationCategoryInputs'; +import AlterationBadgeList from './AlterationBadgeList'; +import { ADD_MUTATION_MODAL_FLAG_COMMENT_ID, ADD_MUTATION_MODAL_FLAG_COMMENT_INPUT_ID } from 'app/config/constants/html-id'; + +const MutationListSection = ({ + alterationStates, + alterationCategoryComment, + setAlterationCategoryComment, + selectedAlterationCategoryFlags, +}: StoreProps) => { + const showAlterationCategoryDropdown = (alterationStates ?? []).length > 1; + const showAlterationCategoryComment = showAlterationCategoryDropdown && (selectedAlterationCategoryFlags ?? []).length > 0; + + const finalMutationName = useMemo(() => { + return alterationStates + ?.map(alterationState => { + const altName = getFullAlterationName(alterationState, 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 new file mode 100644 index 000000000..369823969 --- /dev/null +++ b/src/main/webapp/app/shared/modal/MutationModal/add-mutation-modal.store.ts @@ -0,0 +1,440 @@ +import { Mutation, VusObjList } from 'app/shared/model/firebase/firebase.model'; +import { action, computed, flow, flowResult, makeObservable, observable } from 'mobx'; +import { convertEntityStatusAlterationToAlterationData, getFullAlterationName, hasValue, parseAlterationName } from 'app/shared/util/utils'; +import _ from 'lodash'; +import { notifyError } from 'app/oncokb-commons/components/util/NotificationUtils'; +import { + AlterationAnnotationStatus, + AnnotateAlterationBody, + Gene, + Alteration as ApiAlteration, + ProteinExonDTO, +} from 'app/shared/api/generated/curation'; +import { REFERENCE_GENOME } from 'app/config/constants/constants'; +import AlterationStore from 'app/entities/alteration/alteration.store'; +import { IGene } from 'app/shared/model/gene.model'; +import { IFlag } from 'app/shared/model/flag.model'; +import { AlterationData } from '../AddMutationModal'; + +type SelectedFlag = IFlag | Omit; + +export class AddMutationModalStore { + private alterationStore: AlterationStore; + + public proteinExons: ProteinExonDTO[] = []; + public geneEntity: IGene | null = null; + public mutationToEdit: Mutation | null = null; + public vusList: VusObjList | null = null; + public alterationStates: AlterationData[] = []; + + public selectedAlterationStateIndex = -1; + public selectedExcludedAlterationIndex = -1; + + public showModifyExonForm = false; + public hasUncommitedExonFormChanges = false; + public unCommittedExonFormChangesWarning = ''; + + public isFetchingAlteration = false; + public isFetchingExcludingAlteration = false; + + public selectedAlterationCategoryFlags: SelectedFlag[] = []; + public alterationCategoryComment: string = ''; + + constructor(alterationStore: AlterationStore) { + this.alterationStore = alterationStore; + makeObservable(this, { + proteinExons: observable, + geneEntity: observable, + mutationToEdit: observable, + vusList: observable, + alterationStates: observable, + selectedAlterationStateIndex: observable, + selectedExcludedAlterationIndex: observable, + showModifyExonForm: observable, + hasUncommitedExonFormChanges: observable, + unCommittedExonFormChangesWarning: observable, + isFetchingAlteration: observable, + isFetchingExcludingAlteration: observable, + selectedAlterationCategoryFlags: observable, + alterationCategoryComment: observable, + currentMutationNames: computed, + updateAlterationStateAfterAlterationAdded: action.bound, + updateAlterationStateAfterExcludedAlterationAdded: action.bound, + setMutationToEdit: action.bound, + setVusList: action.bound, + setGeneEntity: action.bound, + setShowModifyExonForm: action.bound, + setHasUncommitedExonFormChanges: action.bound, + setAlterationStates: action.bound, + setSelectedAlterationStateIndex: action.bound, + setSelectedExcludedAlterationIndex: action.bound, + setSelectedAlterationCategoryFlags: action.bound, + setAlterationCategoryComment: action.bound, + handleAlterationChange: action.bound, + handleExcludedAlterationChange: action.bound, + handleExcludingFieldChange: action.bound, + fetchExcludedAlteration: action.bound, + handleNormalAlterationChange: action.bound, + handleNormalFieldChange: action.bound, + fetchNormalAlteration: action.bound, + filterAlterationsAndNotify: action.bound, + fetchAlteration: action.bound, + fetchAlterations: action.bound, + cleanup: action.bound, + setProteinExons: action.bound, + }); + } + + setProteinExons(proteinExons: ProteinExonDTO[]) { + this.proteinExons = proteinExons; + } + + setMutationToEdit(mutationToEdit: Mutation | null) { + this.mutationToEdit = mutationToEdit; + } + + setVusList(vusList: VusObjList | null) { + this.vusList = vusList; + } + + setGeneEntity(geneEntity: IGene | null) { + this.geneEntity = geneEntity; + } + + setShowModifyExonForm(show: boolean) { + this.showModifyExonForm = show; + this.selectedAlterationStateIndex = -1; + } + + setHasUncommitedExonFormChanges(value: boolean, isUpdate: boolean) { + this.hasUncommitedExonFormChanges = value; + if (value) { + this.unCommittedExonFormChangesWarning = `You made some changes to Exon dropdown. Please click ${isUpdate ? 'update' : 'add'} button.`; + } + } + + setAlterationStates(newAlterationStates: AlterationData[]) { + this.alterationStates = newAlterationStates; + } + + setSelectedAlterationStateIndex(index: number) { + this.selectedAlterationStateIndex = index; + } + + setSelectedExcludedAlterationIndex(index: number) { + this.selectedExcludedAlterationIndex = index; + } + + setSelectedAlterationCategoryFlags(flags: SelectedFlag[]) { + this.selectedAlterationCategoryFlags = flags; + } + + setAlterationCategoryComment(comment: string) { + this.alterationCategoryComment = comment; + } + + get currentMutationNames() { + return this.alterationStates.map(state => getFullAlterationName({ ...state, comment: '' }).toLowerCase()).sort(); + } + + async updateAlterationStateAfterAlterationAdded(parsedAlterations: ReturnType, isUpdate = false) { + const newParsedAlteration = this.filterAlterationsAndNotify(parsedAlterations) ?? []; + + if (newParsedAlteration.length === 0) { + return; + } + + const newEntityStatusAlterationsPromise = this.fetchAlterations(newParsedAlteration.map(alt => alt.alteration)) ?? []; + const newEntityStatusExcludingAlterationsPromise = this.fetchAlterations(newParsedAlteration[0].excluding) ?? []; + const [newEntityStatusAlterations, newEntityStatusExcludingAlterations] = await Promise.all([ + newEntityStatusAlterationsPromise, + newEntityStatusExcludingAlterationsPromise, + ]); + + const newExcludingAlterations = newEntityStatusExcludingAlterations.map((alt, index) => + convertEntityStatusAlterationToAlterationData(alt, [], ''), + ); + const newAlterations = newEntityStatusAlterations.map((alt, index) => + convertEntityStatusAlterationToAlterationData( + alt, + _.cloneDeep(newExcludingAlterations), + newParsedAlteration[index].comment, + newParsedAlteration[index].name, + ), + ); + + if (isUpdate) { + const newAlterationStates = _.cloneDeep(this.alterationStates); + newAlterationStates[this.selectedAlterationStateIndex] = newAlterations[0]; + this.alterationStates = newAlterationStates; + } else { + this.alterationStates = this.alterationStates.concat(newAlterations); + } + } + + async updateAlterationStateAfterExcludedAlterationAdded(parsedAlterations: ReturnType) { + const currentState = this.alterationStates[this.selectedAlterationStateIndex]; + const alteration = currentState.alteration.toLowerCase(); + let excluding = currentState.excluding.map(ex => ex.alteration.toLowerCase()); + excluding.push(...parsedAlterations.map(alt => alt.alteration.toLowerCase())); + excluding = excluding.sort(); + + if ( + this.alterationStates.some( + state => + state.alteration.toLowerCase() === alteration && + _.isEqual(state.excluding.map(ex => ex.alteration.toLowerCase()).sort(), excluding), + ) + ) { + notifyError(new Error('Duplicate alteration(s) removed')); + return; + } + + const newComment = parsedAlterations[0].comment; + const newVariantName = parsedAlterations[0].name; + + const newEntityStatusAlterations = await this.fetchAlterations(parsedAlterations.map(alt => alt.alteration)); + + const newAlterations = newEntityStatusAlterations.map((alt, index) => + convertEntityStatusAlterationToAlterationData(alt, [], newComment, newVariantName), + ); + + const newAlterationStates = _.cloneDeep(this.alterationStates); + newAlterationStates[this.selectedAlterationStateIndex].excluding.push(...newAlterations); + this.alterationStates = newAlterationStates; + } + + async handleAlterationChange(newValue: string, alterationIndex: number, excludingIndex?: number, isDebounced = true) { + if (!_.isNil(excludingIndex)) { + this.isFetchingExcludingAlteration = true; + + if (isDebounced) { + this.handleExcludedAlterationChange(newValue); + } else { + await this.fetchExcludedAlteration(newValue); + this.isFetchingExcludingAlteration = false; + } + } else { + this.isFetchingAlteration = true; + if (isDebounced) { + this.handleNormalAlterationChange(newValue, alterationIndex); + } else { + await this.fetchNormalAlteration(newValue, alterationIndex); + this.isFetchingAlteration = false; + } + } + } + + handleExcludingFieldChange(newValue: string, field: keyof AlterationData) { + const newAlterationStates = _.cloneDeep(this.alterationStates); + newAlterationStates[this.selectedAlterationStateIndex].excluding[this.selectedExcludedAlterationIndex][field as string] = newValue; + this.alterationStates = newAlterationStates; + } + + async fetchExcludedAlteration(newAlteration: string) { + const alterationIndex = this.selectedAlterationStateIndex; + const excludingIndex = this.selectedExcludedAlterationIndex; + const newParsedAlteration = parseAlterationName(newAlteration); + + const currentState = this.alterationStates[alterationIndex]; + const alteration = currentState.alteration.toLowerCase(); + let excluding: string[] = []; + for (let i = 0; i < currentState.excluding.length; i++) { + if (i === excludingIndex) { + excluding.push(...newParsedAlteration.map(alt => alt.alteration.toLowerCase())); + } else { + excluding.push(currentState.excluding[excludingIndex].alteration.toLowerCase()); + } + } + excluding = excluding.sort(); + if ( + this.alterationStates.some( + state => + state.alteration.toLowerCase() === alteration && + _.isEqual(state.excluding.map(ex => ex.alteration.toLowerCase()).sort(), excluding), + ) + ) { + notifyError(new Error('Duplicate alteration(s) removed')); + const newAlterationStates = _.cloneDeep(this.alterationStates); + newAlterationStates[alterationIndex].excluding.splice(excludingIndex, 1); + this.alterationStates = newAlterationStates; + return; + } + + const alterationPromises: Promise[] = []; + let newAlterations: AlterationData[] = []; + if (newParsedAlteration[0].alteration !== this.alterationStates[alterationIndex]?.excluding[excludingIndex].alteration) { + alterationPromises.push(this.fetchAlteration(newParsedAlteration[0].alteration)); + } else { + newAlterations.push(this.alterationStates[alterationIndex].excluding[excludingIndex]); + } + + for (let i = 1; i < newParsedAlteration.length; i++) { + alterationPromises.push(this.fetchAlteration(newParsedAlteration[i].alteration)); + } + newAlterations = [ + ...newAlterations, + ...(await Promise.all(alterationPromises)) + .map((alt, index) => (alt ? convertEntityStatusAlterationToAlterationData(alt, [], newParsedAlteration[index].comment) : undefined)) + .filter(hasValue), + ]; + + const newAlterationStates = _.cloneDeep(this.alterationStates); + newAlterationStates[alterationIndex].excluding.splice(excludingIndex, 1, ...newAlterations); + this.alterationStates = newAlterationStates; + } + + handleNormalAlterationChange(newValue: string, alterationIndex: number) { + const newAlterationStates = _.cloneDeep(this.alterationStates); + newAlterationStates[alterationIndex].alterationFieldValueWhileFetching = newValue; + this.alterationStates = newAlterationStates; + + _.debounce(async () => { + await this.fetchNormalAlteration(newValue, alterationIndex); + this.isFetchingAlteration = false; + }, 1000)(); + } + + handleExcludedAlterationChange(newValue: string) { + const newAlterationStates = _.cloneDeep(this.alterationStates); + newAlterationStates[this.selectedAlterationStateIndex].excluding[ + this.selectedExcludedAlterationIndex + ].alterationFieldValueWhileFetching = newValue; + this.alterationStates = newAlterationStates; + + _.debounce(async () => { + await this.fetchExcludedAlteration(newValue); + this.isFetchingExcludingAlteration = false; + }, 1000)(); + } + + handleNormalFieldChange(newValue: string, field: keyof AlterationData, alterationIndex: number) { + const newAlterationStates = _.cloneDeep(this.alterationStates); + newAlterationStates[alterationIndex][field as string] = newValue; + this.alterationStates = newAlterationStates; + } + + async fetchNormalAlteration(newAlteration: string, alterationIndex: number) { + const newParsedAlteration = this.filterAlterationsAndNotify(parseAlterationName(newAlteration), alterationIndex); + if (newParsedAlteration.length === 0) { + const newAlterationStates = _.cloneDeep(this.alterationStates); + newAlterationStates[alterationIndex].alterationFieldValueWhileFetching = undefined; + this.alterationStates = newAlterationStates; + } + + const newComment = newParsedAlteration[0].comment; + const newVariantName = newParsedAlteration[0].name; + + let newExcluding: AlterationData[]; + if ( + _.isEqual( + newParsedAlteration[0].excluding, + this.alterationStates[alterationIndex]?.excluding.map(ex => ex.alteration), + ) + ) { + newExcluding = this.alterationStates[alterationIndex].excluding; + } else { + const excludingEntityStatusAlterations = await this.fetchAlterations(newParsedAlteration[0].excluding); + newExcluding = excludingEntityStatusAlterations?.map((ex, index) => convertEntityStatusAlterationToAlterationData(ex, [], '')) ?? []; + } + + const alterationPromises: Promise[] = []; + let newAlterations: AlterationData[] = []; + if (newParsedAlteration[0].alteration !== this.alterationStates[alterationIndex]?.alteration) { + alterationPromises.push(this.fetchAlteration(newParsedAlteration[0].alteration)); + } else { + const newAlterationState = _.cloneDeep(this.alterationStates[alterationIndex]); + newAlterationState.excluding = newExcluding; + newAlterationState.comment = newComment; + newAlterationState.name = newVariantName || newParsedAlteration[0].alteration; + newAlterations.push(newAlterationState); + } + + for (let i = 1; i < newParsedAlteration.length; i++) { + alterationPromises.push(this.fetchAlteration(newParsedAlteration[i].alteration)); + } + + newAlterations = [ + ...newAlterations, + ...(await Promise.all(alterationPromises)) + .filter(hasValue) + .map((alt, index) => convertEntityStatusAlterationToAlterationData(alt, newExcluding, newComment, newVariantName)), + ]; + newAlterations[0].alterationFieldValueWhileFetching = undefined; + + const newAlterationStates = _.cloneDeep(this.alterationStates); + newAlterationStates.splice(alterationIndex, 1, ...newAlterations); + this.alterationStates = newAlterationStates; + } + + filterAlterationsAndNotify(alterations: ReturnType, alterationIndex?: number) { + // remove alterations that already exist in modal + const newAlterations = alterations.filter(alt => { + return !this.alterationStates.some((state, index) => { + if (index === alterationIndex) { + return false; + } + + const stateName = state.alteration.toLowerCase(); + const stateExcluding = state.excluding.map(ex => ex.alteration.toLowerCase()).sort(); + const altName = alt.alteration.toLowerCase(); + const altExcluding = alt.excluding.map(ex => ex.toLowerCase()).sort(); + return stateName === altName && _.isEqual(stateExcluding, altExcluding); + }); + }); + + if (alterations.length !== newAlterations.length) { + notifyError(new Error('Duplicate alteration(s) removed')); + } + + return newAlterations; + } + + async fetchAlteration(alterationName: string): Promise { + try { + const request: AnnotateAlterationBody[] = [ + { + referenceGenome: REFERENCE_GENOME.GRCH37, + alteration: { alteration: alterationName, genes: [{ id: this.geneEntity?.id } as Gene] } as ApiAlteration, + }, + ]; + const alts = await flowResult(flow(this.alterationStore.annotateAlterations)(request)); + return alts[0]; + } catch (error) { + notifyError(error); + } + } + + async fetchAlterations(alterationNames: string[]) { + try { + const alterationPromises = alterationNames.map(name => this.fetchAlteration(name)); + const alterations = await Promise.all(alterationPromises); + const filtered: AlterationAnnotationStatus[] = []; + for (const alteration of alterations) { + if (alteration !== undefined) { + filtered.push(alteration); + } + } + return filtered; + } catch (error) { + notifyError(error); + return []; + } + } + + cleanup() { + this.geneEntity = null; + this.mutationToEdit = null; + this.vusList = null; + this.alterationStates = []; + this.selectedAlterationStateIndex = -1; + this.selectedExcludedAlterationIndex = -1; + this.showModifyExonForm = false; + this.hasUncommitedExonFormChanges = false; + this.isFetchingAlteration = false; + this.isFetchingExcludingAlteration = false; + this.selectedAlterationCategoryFlags = []; + this.alterationCategoryComment = ''; + this.proteinExons = []; + } +} diff --git a/src/main/webapp/app/shared/modal/MutationModal/styles.module.scss b/src/main/webapp/app/shared/modal/MutationModal/styles.module.scss new file mode 100644 index 000000000..2a75c60d7 --- /dev/null +++ b/src/main/webapp/app/shared/modal/MutationModal/styles.module.scss @@ -0,0 +1,70 @@ +@import '../../../variables.scss'; + +.alterationBadge { + font-size: 14px; + font-weight: normal; + padding: 0; + display: flex; + align-items: center; + max-width: 49%; + width: fit-content; + overflow: hidden; + cursor: pointer; +} + +.alterationBadgeName { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + flex-grow: 1; + padding-right: 5px; + display: flex; + padding: 8px; + align-items: center; +} + +.actionWrapper { + flex-shrink: 0; + display: flex; + border-left: 1px dashed; +} + +.deleteButton { + display: flex; + flex-direction: column; + justify-content: center; + padding: 7px; + cursor: pointer; +} + +.alterationBadgeListInput { + color: inherit; + background: 0px center; + opacity: 1; + width: 100%; + grid-area: 1 / 2; + font: inherit; + min-width: 2px; + border: 0px; + margin: 0px; + outline: 0px; +} + +.alterationBadgeListInputWrapper { + visibility: visible; + display: flex; + justify-content: center; + flex: 1 1 auto; + margin: 2px; + padding-bottom: 2px; + padding-top: 2px; +} + +.link { + color: $oncokb-blue; + cursor: pointer; +} + +.link:hover { + text-decoration: underline; +} diff --git a/src/main/webapp/app/shared/modal/RelevantCancerTypesModal.tsx b/src/main/webapp/app/shared/modal/RelevantCancerTypesModal.tsx index 38f6386ff..c372c73a2 100644 --- a/src/main/webapp/app/shared/modal/RelevantCancerTypesModal.tsx +++ b/src/main/webapp/app/shared/modal/RelevantCancerTypesModal.tsx @@ -115,9 +115,8 @@ const RelevantCancerTypesModalContent = observer( This RCT is deleted. Press the revert button to undo the deletion.} - > - Deleted - + text="Deleted" + /> )}
); diff --git a/src/main/webapp/app/shared/modal/add-mutation-modal.scss b/src/main/webapp/app/shared/modal/add-mutation-modal.scss index 3fcac187e..b532a46bc 100644 --- a/src/main/webapp/app/shared/modal/add-mutation-modal.scss +++ b/src/main/webapp/app/shared/modal/add-mutation-modal.scss @@ -3,3 +3,9 @@ justify-content: center; align-items: center; } + +.alteration-modal-textarea-field { + min-height: 20px !important; + overflow-y: hidden; + resize: none; +} diff --git a/src/main/webapp/app/shared/model/enumerations/flag-type.enum.model.ts b/src/main/webapp/app/shared/model/enumerations/flag-type.enum.model.ts new file mode 100644 index 000000000..5536c7f26 --- /dev/null +++ b/src/main/webapp/app/shared/model/enumerations/flag-type.enum.model.ts @@ -0,0 +1,8 @@ +export enum FlagTypeEnum { + GENE_TYPE = 'GENE_TYPE', + GENE_PANEL = 'GENE_PANEL', + TRANSCRIPT = 'TRANSCRIPT', + DRUG = 'DRUG', + HOTSPOT = 'HOTSPOT', + ALTERATION_CATEGORY = 'ALTERATION_CATEGORY', +} diff --git a/src/main/webapp/app/shared/model/firebase/firebase.model.ts b/src/main/webapp/app/shared/model/firebase/firebase.model.ts index a5b90cfa5..622845ce1 100644 --- a/src/main/webapp/app/shared/model/firebase/firebase.model.ts +++ b/src/main/webapp/app/shared/model/firebase/firebase.model.ts @@ -215,6 +215,7 @@ export class Mutation { mutation_effect: MutationEffect = new MutationEffect(); mutation_effect_uuid: string = generateUuid(); mutation_effect_comments?: Comment[] = []; // used for somatic + alteration_categories?: AlterationCategories | null; name: string = ''; name_comments?: Comment[] = []; name_review?: Review; @@ -238,6 +239,16 @@ export class Mutation { } } +export type Flag = { + type: string; + flag: string; +}; + +export class AlterationCategories { + flags?: Flag[]; + comment = ''; +} + export class MutationEffect { description = ''; description_review?: Review; diff --git a/src/main/webapp/app/shared/table/GenomicIndicatorsTable.tsx b/src/main/webapp/app/shared/table/GenomicIndicatorsTable.tsx index 5f79610ac..babc47fb9 100644 --- a/src/main/webapp/app/shared/table/GenomicIndicatorsTable.tsx +++ b/src/main/webapp/app/shared/table/GenomicIndicatorsTable.tsx @@ -274,9 +274,7 @@ const GenomicIndicatorsTable = ({ firebaseDb={firebaseDb!} buildCell={genomicIndicator => { return genomicIndicator.name_review?.removed ? ( - - Deleted - + ) : ( - - {color && ( - - Outdated - - )} - + {color && } ); }, diff --git a/src/main/webapp/app/shared/util/firebase/firebase-utils.tsx b/src/main/webapp/app/shared/util/firebase/firebase-utils.tsx index 4be3c97dd..24715e6d7 100644 --- a/src/main/webapp/app/shared/util/firebase/firebase-utils.tsx +++ b/src/main/webapp/app/shared/util/firebase/firebase-utils.tsx @@ -9,6 +9,7 @@ import { Comment, DX_LEVELS, FIREBASE_ONCOGENICITY, + Flag, Gene, Meta, MetaReview, @@ -31,6 +32,7 @@ import { parseFirebaseGenePath } from './firebase-path-utils'; import { hasReview } from './firebase-review-utils'; import { Database, ref, get } from 'firebase/database'; import { FirebaseGeneService } from 'app/service/firebase/firebase-gene-service'; +import { IFlag } from 'app/shared/model/flag.model'; export const getValueByNestedKey = (obj: any, nestedKey = '') => { return nestedKey.split('/').reduce((currObj, currKey) => { @@ -42,12 +44,15 @@ export const isDnaVariant = (alteration: Alteration) => { return alteration.alteration && alteration.alteration.startsWith('c.'); }; -export const getAlterationName = (alteration: Alteration) => { +export const getAlterationName = (alteration: Alteration, omitComment = false) => { if (alteration.name) { let name = alteration.name; if (alteration.proteinChange && alteration.proteinChange !== alteration.alteration) { name += ` (p.${alteration.proteinChange})`; } + if (omitComment) { + name = name.replace(/\(.*?\)/g, ''); + } return name; } else if (alteration.proteinChange) { return alteration.proteinChange; @@ -208,7 +213,7 @@ export const isSectionEmpty = (sectionValue: any, fullPath: string) => { return true; } - const ignoredKeySuffixes = ['_review', '_uuid', 'TIs', 'cancerTypes', 'name', 'alterations']; + const ignoredKeySuffixes = ['_review', '_uuid', 'TIs', 'cancerTypes', 'name', 'alterations', 'alteration_categories']; const isEmpty = isNestedObjectEmpty(sectionValue, ignoredKeySuffixes); if (!isEmpty) { @@ -963,3 +968,7 @@ export function areCancerTypePropertiesEqual(a: string | undefined, b: string | export function isStringEmpty(string: string | undefined | null) { return string === '' || _.isNil(string); } + +export function isFlagEqualToIFlag(flag: Flag, flagEntity: IFlag) { + return flag.flag === flagEntity.flag && flag.type === flagEntity.type; +} diff --git a/src/main/webapp/app/shared/util/test-id-utils.ts b/src/main/webapp/app/shared/util/test-id-utils.ts index af8bff7c8..40af47152 100644 --- a/src/main/webapp/app/shared/util/test-id-utils.ts +++ b/src/main/webapp/app/shared/util/test-id-utils.ts @@ -8,3 +8,14 @@ export enum CollapsibleDataTestIdType { export function getCollapsibleDataTestId(dataTestid: CollapsibleDataTestIdType, identifier: string | undefined) { return `${identifier}-${dataTestid}`; } + +export enum AddMutationModalDataTestIdType { + ALTERATION_BADGE_NAME = 'alteration-badge-name', + ALTERATION_BADGE_DELETE = 'alteration-badge-delete', + MUTATION_DETAILS = 'mutation-details', + EXON_FORM = 'exon-form', +} + +export function getAddMutationModalDataTestId(dataTestid: AddMutationModalDataTestIdType, identifier?: string) { + return `add-mutation-modal-${identifier}-${dataTestid}`; +} diff --git a/src/main/webapp/app/shared/util/utils.spec.ts b/src/main/webapp/app/shared/util/utils.spec.ts index aa690819b..4576535b4 100644 --- a/src/main/webapp/app/shared/util/utils.spec.ts +++ b/src/main/webapp/app/shared/util/utils.spec.ts @@ -1,5 +1,5 @@ import 'jest-expect-message'; -import { getCancerTypeName, expandAlterationName, generateUuid, isUuid, parseAlterationName } from './utils'; +import { getCancerTypeName, expandAlterationName, generateUuid, isUuid, parseAlterationName, buildAlterationName } from './utils'; describe('Utils', () => { describe('getCancerTypeName', () => { @@ -128,6 +128,18 @@ describe('Utils', () => { }); }); + describe('buildAlterationName', () => { + test.each([ + ['V600E', undefined, undefined, undefined, 'V600E'], + ['V600E', 'Renamed', undefined, undefined, 'V600E [Renamed]'], + ['V600E', 'Renamed', ['A', 'B'], undefined, 'V600E [Renamed] {excluding A; B}'], + ['V600E', 'Renamed', ['A', 'B'], 'Test mutation', 'V600E [Renamed] {excluding A; B} (Test mutation)'], + ['V600E', '', [], '', 'V600E'], + ])('should build alteration name', (alteration, name, excluding, comment, expected) => { + expect(buildAlterationName(alteration, name, excluding, comment)).toEqual(expected); + }); + }); + describe('isUuid', () => { it('should indentify uuids', () => { const uuid = generateUuid(); diff --git a/src/main/webapp/app/shared/util/utils.tsx b/src/main/webapp/app/shared/util/utils.tsx index 5a9c3c3ae..a2f2641c8 100644 --- a/src/main/webapp/app/shared/util/utils.tsx +++ b/src/main/webapp/app/shared/util/utils.tsx @@ -9,14 +9,23 @@ import EntityActionButton from '../button/EntityActionButton'; import { SORT } from './pagination.constants'; import { PaginationState } from '../table/OncoKBAsyncTable'; import { IUser } from '../model/user.model'; -import { CancerType, DrugCollection } from '../model/firebase/firebase.model'; +import { Alteration, CancerType, Flag, DrugCollection } 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 { + EXON_ALTERATION_REGEX, + INTEGER_REGEX, + REFERENCE_LINK_REGEX, + SINGLE_NUCLEOTIDE_POS_REGEX, + UUID_REGEX, +} from 'app/config/constants/regex'; +import { AlterationAnnotationStatus, AlterationTypeEnum, ProteinExonDTO } from 'app/shared/api/generated/curation'; import { IQueryParams } from './jhipster-types'; +import InfoIcon from '../icons/InfoIcon'; +import { IFlag } from '../model/flag.model'; +import { AlterationData } from '../modal/AddMutationModal'; export const getCancerTypeName = (cancerType: ICancerType | CancerType, omitCode = false): string => { if (!cancerType) return ''; @@ -302,6 +311,119 @@ export function parseAlterationName( })); } +export function getFullAlterationName(alterationData: AlterationData, includeVariantName = true) { + let alterationName = alterationData.alteration; + let 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 : ''; + if (EXON_ALTERATION_REGEX.test(alterationData.alteration)) { + // Use the variant name as the display name for Exons + variantName = ''; + alterationName = alterationData.name; + } + return buildAlterationName(alterationName, variantName, excluding, comment); +} + +export function getMutationRenameValueFromName(name: string) { + return name.match(/\[([^\]]+)\]/)?.[1]; +} + +export function convertEntityStatusAlterationToAlterationData( + entityStatusAlteration: AlterationAnnotationStatus, + excluding: AlterationData[], + comment: string, + variantName?: string, +): AlterationData { + const alteration = entityStatusAlteration.entity; + const alterationData: AlterationData = { + type: alteration?.type ?? AlterationTypeEnum.Unknown, + alteration: alteration?.alteration ?? '', + name: (variantName || alteration?.name) ?? '', + consequence: alteration?.consequence?.name ?? '', + comment, + excluding, + genes: alteration?.genes, + proteinChange: alteration?.proteinChange, + proteinStart: alteration?.start, + proteinEnd: alteration?.end, + refResidues: alteration?.refResidues, + varResidues: alteration?.variantResidues, + warning: entityStatusAlteration.warning ? entityStatusAlteration.message : undefined, + error: entityStatusAlteration.error ? entityStatusAlteration.message : undefined, + }; + + return alterationData; +} + +export function convertAlterationDataToAlteration(alterationData: AlterationData) { + const alteration = new Alteration(); + alteration.type = alterationData.type; + alteration.alteration = alterationData.alteration; + alteration.name = getFullAlterationName(alterationData); + alteration.proteinChange = alterationData.proteinChange || ''; + alteration.proteinStart = alterationData.proteinStart || -1; + alteration.proteinEnd = alterationData.proteinEnd || -1; + alteration.refResidues = alterationData.refResidues || ''; + alteration.varResidues = alterationData.varResidues || ''; + alteration.consequence = alterationData.consequence; + alteration.comment = alterationData.comment; + alteration.excluding = alterationData.excluding.map(ex => convertAlterationDataToAlteration(ex)); + alteration.genes = alterationData.genes || []; + return alteration; +} + +export function convertAlterationToAlterationData(alteration: Alteration): AlterationData { + let variantName = alteration.name; + if (!EXON_ALTERATION_REGEX.test(alteration.name)) { + variantName = parseAlterationName(alteration.name)[0].name; + } + + return { + type: alteration.type, + alteration: alteration.alteration, + name: variantName || alteration.alteration, + consequence: alteration.consequence, + comment: alteration.comment, + excluding: alteration.excluding?.map(ex => convertAlterationToAlterationData(ex)) || [], + genes: alteration?.genes || [], + proteinChange: alteration?.proteinChange, + proteinStart: alteration?.proteinStart === -1 ? undefined : alteration?.proteinStart, + proteinEnd: alteration?.proteinEnd === -1 ? undefined : alteration?.proteinEnd, + refResidues: alteration?.refResidues, + varResidues: alteration?.varResidues, + }; +} + +export function convertIFlagToFlag(flagEntity: IFlag | Omit): Flag { + return { + flag: flagEntity.flag, + type: flagEntity.type, + }; +} + +export function buildAlterationName(alteration: string, name = '', excluding = [] as string[], comment = '') { + if (name) { + name = ` [${name}]`; + } + let exclusionString = ''; + if (excluding.length > 0) { + exclusionString = ` {excluding ${excluding.join('; ')}}`; + } + if (comment) { + comment = ` (${comment})`; + } + return `${alteration}${name}${exclusionString}${comment}`; +} + +export function getAlterationNameComponent(alterationName: string, comment?: string) { + return ( + <> + {alterationName} + {comment && } + + ); +} + export function findIndexOfFirstCapital(str: string) { for (let i = 0; i < str.length; i++) { if (str[i] >= 'A' && str[i] <= 'Z') { diff --git a/src/main/webapp/app/stores/createStore.ts b/src/main/webapp/app/stores/createStore.ts index e1456ba01..02d3b92e1 100644 --- a/src/main/webapp/app/stores/createStore.ts +++ b/src/main/webapp/app/stores/createStore.ts @@ -106,6 +106,7 @@ import { WindowStore } from './window-store'; /* jhipster-needle-add-store-import - JHipster will add store here */ import ManagementStore from 'app/stores/management.store'; import { GeneApi } from 'app/shared/api/manual/gene-api'; +import { AddMutationModalStore } from 'app/shared/modal/MutationModal/add-mutation-modal.store'; export interface IRootStore { readonly loadingStore: LoadingBarStore; @@ -150,6 +151,7 @@ export interface IRootStore { readonly modifyTherapyModalStore: ModifyTherapyModalStore; readonly relevantCancerTypesModalStore: RelevantCancerTypesModalStore; readonly openMutationCollapsibleStore: OpenMutationCollapsibleStore; + readonly addMutationModalStore: AddMutationModalStore; readonly flagStore: FlagStore; readonly commentStore: CommentStore; /* Firebase stores */ @@ -219,6 +221,7 @@ export function createStores(history: History): IRootStore { rootStore.modifyTherapyModalStore = new ModifyTherapyModalStore(); rootStore.relevantCancerTypesModalStore = new RelevantCancerTypesModalStore(); rootStore.openMutationCollapsibleStore = new OpenMutationCollapsibleStore(); + rootStore.addMutationModalStore = new AddMutationModalStore(rootStore.alterationStore); rootStore.commentStore = new CommentStore(); /* Firebase Stores */ diff --git a/src/main/webapp/app/variables.scss b/src/main/webapp/app/variables.scss index ed22a628a..5625432e6 100644 --- a/src/main/webapp/app/variables.scss +++ b/src/main/webapp/app/variables.scss @@ -17,6 +17,10 @@ $warning: #ffc107; $danger: #dc3545; $inactive: #f2f4f8; // from design team +// The contrast ratio to reach against white, to determine if color changes from "light" to "dark". Acceptable values for WCAG 2.0 are 3, 4.5 and 7. +// See https://www.w3.org/TR/WCAG20/#visual-audio-contrast-contrast +$min-contrast-ratio: 3; + $link-hover-color: $oncokb-darker-blue; $nav-bg-color: $oncokb-blue; diff --git a/src/test/javascript/constants.ts b/src/test/javascript/constants.ts index 80c95ef7b..ea2975f6e 100644 --- a/src/test/javascript/constants.ts +++ b/src/test/javascript/constants.ts @@ -21,3 +21,8 @@ export const SCREENSHOT_METHOD_OPTIONS: WdioCheckElementMethodOptions = { }; export const ALLOWED_MISMATCH_PERCENTAGE = 0; + +export const WDIO_DEFAULT_DIMENSIONS = { + width: 1920, + height: 1080, +}; diff --git a/src/test/javascript/data/api-annotate-alterations-exon-2-4-deletion.json b/src/test/javascript/data/api-annotate-alterations-exon-2-4-deletion.json new file mode 100644 index 000000000..98c3eff4f --- /dev/null +++ b/src/test/javascript/data/api-annotate-alterations-exon-2-4-deletion.json @@ -0,0 +1,68 @@ +[ + { + "entity": { + "id": null, + "type": "STRUCTURAL_VARIANT", + "name": "Exon 2-4 Deletion", + "alteration": "Exon 2 Deletion + Exon 3 Deletion + Exon 4 Deletion", + "proteinChange": "", + "start": 1, + "end": 150, + "refResidues": null, + "variantResidues": null, + "flags": [], + "genes": [ + { + "id": 30432, + "entrezGeneId": 3845, + "hugoSymbol": "KRAS", + "hgncId": "6407" + } + ], + "transcripts": [], + "consequence": { + "id": 39, + "term": "UNKNOWN", + "name": "Unknown", + "isGenerallyTruncating": false, + "description": "Unknown status" + }, + "associations": [] + }, + "message": "", + "type": "OK", + "queryId": null, + "annotation": { + "hotspot": { + "associatedHotspots": [], + "hotspot": false + }, + "exons": [ + { + "range": { + "start": 1, + "end": 37 + }, + "exon": 2 + }, + { + "range": { + "start": 38, + "end": 97 + }, + "exon": 3 + }, + { + "range": { + "start": 97, + "end": 150 + }, + "exon": 4 + } + ] + }, + "warning": false, + "ok": true, + "error": false + } +] diff --git a/src/test/javascript/data/api-annotate-alterations-oncogenic-mutations.json b/src/test/javascript/data/api-annotate-alterations-oncogenic-mutations.json new file mode 100644 index 000000000..d09b4a689 --- /dev/null +++ b/src/test/javascript/data/api-annotate-alterations-oncogenic-mutations.json @@ -0,0 +1,46 @@ +[ + { + "entity": { + "id": null, + "type": "ANY", + "name": "Oncogenic Mutations", + "alteration": "Oncogenic Mutations", + "proteinChange": "", + "start": null, + "end": null, + "refResidues": null, + "variantResidues": null, + "flags": [], + "genes": [ + { + "id": 41135, + "entrezGeneId": null, + "hugoSymbol": null, + "hgncId": null + } + ], + "transcripts": [], + "consequence": { + "id": 3, + "term": "ANY", + "name": "Any", + "isGenerallyTruncating": false, + "description": "Any variant" + }, + "associations": [] + }, + "message": "", + "type": "OK", + "queryId": null, + "annotation": { + "hotspot": { + "associatedHotspots": [], + "hotspot": false + }, + "exons": [] + }, + "warning": false, + "ok": true, + "error": false + } +] diff --git a/src/test/javascript/data/api-annotate-alterations-v600k.json b/src/test/javascript/data/api-annotate-alterations-v600k.json new file mode 100644 index 000000000..1ea413256 --- /dev/null +++ b/src/test/javascript/data/api-annotate-alterations-v600k.json @@ -0,0 +1,71 @@ +[ + { + "entity": { + "id": null, + "type": "PROTEIN_CHANGE", + "name": "V600K", + "alteration": "V600K", + "proteinChange": "V600K", + "start": 600, + "end": 600, + "refResidues": "V", + "variantResidues": "K", + "flags": [], + "genes": [ + { + "id": 41135, + "entrezGeneId": 673, + "hugoSymbol": "BRAF", + "hgncId": "1097" + } + ], + "transcripts": [], + "consequence": { + "id": 16, + "term": "MISSENSE_VARIANT", + "name": "Missense Variant", + "isGenerallyTruncating": false, + "description": "A sequence variant, that changes one or more bases, resulting in a different amino acid sequence but where the length is preserved" + }, + "associations": [] + }, + "message": "", + "type": "OK", + "queryId": null, + "annotation": { + "hotspot": { + "associatedHotspots": [ + { + "type": "HOTSPOT_V1", + "alteration": "V600" + }, + { + "type": "THREE_D", + "alteration": "V600" + }, + { + "type": "HOTSPOT_V1", + "alteration": "V600" + }, + { + "type": "THREE_D", + "alteration": "V600" + } + ], + "hotspot": true + }, + "exons": [ + { + "range": { + "start": 581, + "end": 620 + }, + "exon": 15 + } + ] + }, + "error": false, + "warning": false, + "ok": true + } +] diff --git a/src/test/javascript/data/api-flags-type-alteration-category.json b/src/test/javascript/data/api-flags-type-alteration-category.json new file mode 100644 index 000000000..1dffccde4 --- /dev/null +++ b/src/test/javascript/data/api-flags-type-alteration-category.json @@ -0,0 +1,26 @@ +[ + { + "id": 1, + "type": "ALTERATION_CATEGORY", + "flag": "TEST_FLAG", + "name": "Test Flag", + "description": "", + "alterations": null, + "articles": null, + "drugs": null, + "genes": null, + "transcripts": null + }, + { + "id": 1, + "type": "ALTERATION_CATEGORY", + "flag": "TEST_FLAG_2", + "name": "Test Flag 2", + "description": "", + "alterations": null, + "articles": null, + "drugs": null, + "genes": null, + "transcripts": null + } +] diff --git a/src/test/javascript/data/api-protein-exons.json b/src/test/javascript/data/api-protein-exons.json new file mode 100644 index 000000000..ca3103e9b --- /dev/null +++ b/src/test/javascript/data/api-protein-exons.json @@ -0,0 +1,128 @@ +[ + { + "range": { + "start": 1, + "end": 46 + }, + "exon": 1 + }, + { + "range": { + "start": 47, + "end": 80 + }, + "exon": 2 + }, + { + "range": { + "start": 81, + "end": 168 + }, + "exon": 3 + }, + { + "range": { + "start": 169, + "end": 203 + }, + "exon": 4 + }, + { + "range": { + "start": 203, + "end": 237 + }, + "exon": 5 + }, + { + "range": { + "start": 238, + "end": 287 + }, + "exon": 6 + }, + { + "range": { + "start": 287, + "end": 327 + }, + "exon": 7 + }, + { + "range": { + "start": 327, + "end": 380 + }, + "exon": 8 + }, + { + "range": { + "start": 381, + "end": 393 + }, + "exon": 9 + }, + { + "range": { + "start": 393, + "end": 438 + }, + "exon": 10 + }, + { + "range": { + "start": 439, + "end": 478 + }, + "exon": 11 + }, + { + "range": { + "start": 478, + "end": 506 + }, + "exon": 12 + }, + { + "range": { + "start": 506, + "end": 565 + }, + "exon": 13 + }, + { + "range": { + "start": 565, + "end": 581 + }, + "exon": 14 + }, + { + "range": { + "start": 581, + "end": 620 + }, + "exon": 15 + }, + { + "range": { + "start": 621, + "end": 664 + }, + "exon": 16 + }, + { + "range": { + "start": 665, + "end": 709 + }, + "exon": 17 + }, + { + "range": { + "start": 710, + "end": 767 + }, + "exon": 18 + } +] diff --git a/src/test/javascript/firebase/mock-data.json b/src/test/javascript/firebase/mock-data.json index fade37bc4..9a03d913a 100644 --- a/src/test/javascript/firebase/mock-data.json +++ b/src/test/javascript/firebase/mock-data.json @@ -2772,7 +2772,17 @@ "short": "" }, "mutation_effect_uuid": "45f48fdd-a2fa-44dc-86cb-b3fd8e4bc8c1", - "name": "V600E, V600K", + "name": "V600E (comment), V600K", + "alteration_categories": { + "flags": [ + { + "type": "ALTERATION_CATEGORY", + "flag": "TEST_FLAG", + "name": "Test Flag" + } + ], + "comment": "This is a test flag" + }, "name_review": { "updateTime": 1493057315640 }, diff --git a/src/test/javascript/screenshots/baseline/desktop_chrome/add-mutation-modal--1920x1080.png b/src/test/javascript/screenshots/baseline/desktop_chrome/add-mutation-modal--1920x1080.png index 07296827c..e16c616c9 100644 Binary files a/src/test/javascript/screenshots/baseline/desktop_chrome/add-mutation-modal--1920x1080.png and b/src/test/javascript/screenshots/baseline/desktop_chrome/add-mutation-modal--1920x1080.png differ diff --git a/src/test/javascript/screenshots/baseline/desktop_chrome/add-mutation-modal-add-exon-form--1920x1080.png b/src/test/javascript/screenshots/baseline/desktop_chrome/add-mutation-modal-add-exon-form--1920x1080.png new file mode 100644 index 000000000..8f037dcd6 Binary files /dev/null and b/src/test/javascript/screenshots/baseline/desktop_chrome/add-mutation-modal-add-exon-form--1920x1080.png differ diff --git a/src/test/javascript/screenshots/baseline/desktop_chrome/add-mutation-modal-modify-exon-form--1920x1080.png b/src/test/javascript/screenshots/baseline/desktop_chrome/add-mutation-modal-modify-exon-form--1920x1080.png new file mode 100644 index 000000000..bd53ba0fb Binary files /dev/null and b/src/test/javascript/screenshots/baseline/desktop_chrome/add-mutation-modal-modify-exon-form--1920x1080.png differ diff --git a/src/test/javascript/screenshots/baseline/desktop_chrome/add-mutation-modal-opened-alteration-info--1920x1440.png b/src/test/javascript/screenshots/baseline/desktop_chrome/add-mutation-modal-opened-alteration-info--1920x1440.png new file mode 100644 index 000000000..059368a9f Binary files /dev/null and b/src/test/javascript/screenshots/baseline/desktop_chrome/add-mutation-modal-opened-alteration-info--1920x1440.png differ diff --git a/src/test/javascript/screenshots/baseline/desktop_chrome/add-mutation-modal-with-excluded-mutation--1920x1440.png b/src/test/javascript/screenshots/baseline/desktop_chrome/add-mutation-modal-with-excluded-mutation--1920x1440.png new file mode 100644 index 000000000..e928ab40e Binary files /dev/null and b/src/test/javascript/screenshots/baseline/desktop_chrome/add-mutation-modal-with-excluded-mutation--1920x1440.png differ diff --git a/src/test/javascript/screenshots/baseline/desktop_chrome/deleted-mutation-review-collapsible--1920x1080.png b/src/test/javascript/screenshots/baseline/desktop_chrome/deleted-mutation-review-collapsible--1920x1080.png index 2881b05b2..39f1f266d 100644 Binary files a/src/test/javascript/screenshots/baseline/desktop_chrome/deleted-mutation-review-collapsible--1920x1080.png and b/src/test/javascript/screenshots/baseline/desktop_chrome/deleted-mutation-review-collapsible--1920x1080.png differ diff --git a/src/test/javascript/screenshots/baseline/desktop_chrome/gene-type-review-collapsible--1920x1080.png b/src/test/javascript/screenshots/baseline/desktop_chrome/gene-type-review-collapsible--1920x1080.png index 6e3ff6696..87ec8c4af 100644 Binary files a/src/test/javascript/screenshots/baseline/desktop_chrome/gene-type-review-collapsible--1920x1080.png and b/src/test/javascript/screenshots/baseline/desktop_chrome/gene-type-review-collapsible--1920x1080.png differ diff --git a/src/test/javascript/screenshots/baseline/desktop_chrome/mutation-collapsible--1920x1080.png b/src/test/javascript/screenshots/baseline/desktop_chrome/mutation-collapsible--1920x1080.png index 2efc7ced1..8f4a36899 100644 Binary files a/src/test/javascript/screenshots/baseline/desktop_chrome/mutation-collapsible--1920x1080.png and b/src/test/javascript/screenshots/baseline/desktop_chrome/mutation-collapsible--1920x1080.png differ diff --git a/src/test/javascript/screenshots/baseline/desktop_chrome/mutation-collapsible-with-flag--1920x1080.png b/src/test/javascript/screenshots/baseline/desktop_chrome/mutation-collapsible-with-flag--1920x1080.png new file mode 100644 index 000000000..09bc05b6b Binary files /dev/null and b/src/test/javascript/screenshots/baseline/desktop_chrome/mutation-collapsible-with-flag--1920x1080.png differ diff --git a/src/test/javascript/screenshots/baseline/desktop_chrome/mutation-effect-not-curatable--1920x1080.png b/src/test/javascript/screenshots/baseline/desktop_chrome/mutation-effect-not-curatable--1920x1080.png index 7fc9adc4c..4f3fc3a8f 100644 Binary files a/src/test/javascript/screenshots/baseline/desktop_chrome/mutation-effect-not-curatable--1920x1080.png and b/src/test/javascript/screenshots/baseline/desktop_chrome/mutation-effect-not-curatable--1920x1080.png differ diff --git a/src/test/javascript/screenshots/baseline/desktop_chrome/new-mutation-review-collapsible--1920x1080.png b/src/test/javascript/screenshots/baseline/desktop_chrome/new-mutation-review-collapsible--1920x1080.png index e2e5d394d..6a8ccefb9 100644 Binary files a/src/test/javascript/screenshots/baseline/desktop_chrome/new-mutation-review-collapsible--1920x1080.png and b/src/test/javascript/screenshots/baseline/desktop_chrome/new-mutation-review-collapsible--1920x1080.png differ diff --git a/src/test/javascript/screenshots/baseline/desktop_chrome/review-page--1920x1080.png b/src/test/javascript/screenshots/baseline/desktop_chrome/review-page--1920x1080.png index ec224aac8..698314a49 100644 Binary files a/src/test/javascript/screenshots/baseline/desktop_chrome/review-page--1920x1080.png and b/src/test/javascript/screenshots/baseline/desktop_chrome/review-page--1920x1080.png differ diff --git a/src/test/javascript/screenshots/baseline/desktop_chrome/updated-gene-summary-review-collapsible--1920x1080.png b/src/test/javascript/screenshots/baseline/desktop_chrome/updated-gene-summary-review-collapsible--1920x1080.png index bf55e711f..139a1a6b9 100644 Binary files a/src/test/javascript/screenshots/baseline/desktop_chrome/updated-gene-summary-review-collapsible--1920x1080.png and b/src/test/javascript/screenshots/baseline/desktop_chrome/updated-gene-summary-review-collapsible--1920x1080.png differ diff --git a/src/test/javascript/screenshots/baseline/desktop_chrome/updated-rct-review-collapsible--1920x1080.png b/src/test/javascript/screenshots/baseline/desktop_chrome/updated-rct-review-collapsible--1920x1080.png index 3bcbcdb26..e064b5d05 100644 Binary files a/src/test/javascript/screenshots/baseline/desktop_chrome/updated-rct-review-collapsible--1920x1080.png and b/src/test/javascript/screenshots/baseline/desktop_chrome/updated-rct-review-collapsible--1920x1080.png differ diff --git a/src/test/javascript/setup-mocks.ts b/src/test/javascript/setup-mocks.ts index 73eaf8aaf..c46f43e6a 100644 --- a/src/test/javascript/setup-mocks.ts +++ b/src/test/javascript/setup-mocks.ts @@ -11,8 +11,15 @@ const getAlterationMockResponse = (requestBody: string, readFile = true) => { let filePath = `${DATA_DIR}api-annotate-alterations-`; switch (alterationName) { case 'v600e': + case 'v600k': filePath += alterationName; break; + case 'oncogenic mutations': + filePath += 'oncogenic-mutations'; + break; + case 'exon 2-4 deletion': + filePath += 'exon-2-4-deletion'; + break; default: break; } @@ -111,6 +118,13 @@ export default async function setUpMocks() { fetchResponse: false, }); + const fetchAlterationCategoryFlagsMock = await browser.mock('**/api/flags?type.equals=ALTERATION_CATEGORY'); + const alterationCategoryFlags = JSON.parse(fs.readFileSync(`${DATA_DIR}api-flags-type-alteration-category.json`).toString()); + fetchAlterationCategoryFlagsMock.respond(alterationCategoryFlags, { + statusCode: 200, + fetchResponse: false, + }); + const pubMedArticleMock = await browser.mock('**/api/articles/pubmed/15520807'); const pubmedArticle = JSON.parse(fs.readFileSync(`${DATA_DIR}api-articles-pubmed-15520807.json`).toString()); pubMedArticleMock.respond(pubmedArticle, { @@ -131,4 +145,11 @@ export default async function setUpMocks() { statusCode: 200, fetchResponse: false, }); + + const proteinExonMock = await browser.mock('**/api/transcripts/protein-exons**'); + const proteinExon = JSON.parse(fs.readFileSync(`${DATA_DIR}api-protein-exons.json`).toString()); + proteinExonMock.respond(proteinExon, { + statusCode: 200, + fetchResponse: false, + }); } diff --git a/src/test/javascript/spec/add-mutation-modal.e2e.ts b/src/test/javascript/spec/add-mutation-modal.e2e.ts new file mode 100644 index 000000000..aa7b83165 --- /dev/null +++ b/src/test/javascript/spec/add-mutation-modal.e2e.ts @@ -0,0 +1,116 @@ +import { BASE_URL, DATABASE_EMULATOR_URL, MOCK_DATA_JSON_FILE_PATH } from '../constants'; +import setUpMocks from '../setup-mocks'; +import * as fs from 'fs'; +import * as admin from 'firebase-admin'; +import { + ADD_MUTATION_MODAL_FLAG_COMMENT_ID, + ADD_MUTATION_MODAL_FLAG_DROPDOWN_ID, + ADD_MUTATION_MODAL_INPUT_ID, +} from '../../../main/webapp/app/config/constants/html-id'; +import { + AddMutationModalDataTestIdType, + CollapsibleDataTestIdType, + getAddMutationModalDataTestId, + getCollapsibleDataTestId, +} from '../../../main/webapp/app/shared/util/test-id-utils'; + +describe('Add Mutation Modal Screenshot Tests', () => { + let adminApp: admin.app.App; + + const backup = JSON.parse(fs.readFileSync(MOCK_DATA_JSON_FILE_PATH).toString()); + + before(async () => { + await setUpMocks(); + adminApp = admin.initializeApp({ + databaseURL: DATABASE_EMULATOR_URL, + }); + }); + + beforeEach(async () => { + // Reset database to a clean state before each test + await adminApp.database().ref('/').set(backup); + }); + + it('should support adding multiple alterations via input box', async () => { + await browser.url(`${BASE_URL}/curation/EMPTYGENE/somatic`); + + // Click to open mutation modal + const addMutationButton = await $('button=Add Mutation'); + await addMutationButton.waitForClickable(); + await addMutationButton.click(); + + // Add mutations + const mutationNameInput = await $(`input#${ADD_MUTATION_MODAL_INPUT_ID}`); + await mutationNameInput.setValue('V600E/K'); + await browser.keys('Enter'); + + const firstMutationBadge = await $( + `div[data-testid='${getAddMutationModalDataTestId(AddMutationModalDataTestIdType.ALTERATION_BADGE_NAME, 'V600E')}']`, + ); + const secondMutationBadge = await $( + `div[data-testid='${getAddMutationModalDataTestId(AddMutationModalDataTestIdType.ALTERATION_BADGE_NAME, 'V600K')}']`, + ); + expect(firstMutationBadge).toExist(); + expect(secondMutationBadge).toExist(); + }); + + it('should support adding exon mutation via input box', async () => { + await browser.url(`${BASE_URL}/curation/EMPTYGENE/somatic`); + + // Click to open mutation modal + const addMutationButton = await $('button=Add Mutation'); + await addMutationButton.waitForClickable(); + await addMutationButton.click(); + + // Add Exon mutation + const mutationNameInput = await $(`input#${ADD_MUTATION_MODAL_INPUT_ID}`); + await mutationNameInput.setValue('Exon 2-4 Deletion'); + await browser.keys('Enter'); + + const badge = await $( + `div[data-testid='${getAddMutationModalDataTestId(AddMutationModalDataTestIdType.ALTERATION_BADGE_NAME, 'Exon 2-4 Deletion')}']`, + ); + expect(badge).toExist(); + }); + + it('should remove deleted alteration from list', async () => { + await browser.url(`${BASE_URL}/curation/BRAF/somatic`); + + const mutation = 'V600E'; + + // Edit mutation name modal + const mutationNameEditBtn = await $(`div[data-testid='${getCollapsibleDataTestId(CollapsibleDataTestIdType.CARD, mutation)}']`).$( + "svg[data-icon='pen']", + ); + await mutationNameEditBtn.click(); + const alterationBadgeDeleteBtn = await $( + `div[data-testid='${getAddMutationModalDataTestId(AddMutationModalDataTestIdType.ALTERATION_BADGE_DELETE, mutation)}']`, + ); + await alterationBadgeDeleteBtn.click(); + + const badge = await $( + `div[data-testid='${getAddMutationModalDataTestId(AddMutationModalDataTestIdType.ALTERATION_BADGE_NAME, 'V600E')}']`, + ); + expect(await badge.isExisting()).toBe(false); + }); + + it('should show string name dropdown when there is more than 1 alteration', async () => { + await browser.url(`${BASE_URL}/curation/EMPTYGENE/somatic`); + + // Click to open mutation modal + const addMutationButton = await $('button=Add Mutation'); + await addMutationButton.waitForClickable(); + await addMutationButton.click(); + + // Add mutations + const mutationNameInput = await $(`input#${ADD_MUTATION_MODAL_INPUT_ID}`); + await mutationNameInput.setValue('V600E, V600K'); + await browser.keys('Enter'); + + const stringNameDropdown = await $(`input#${ADD_MUTATION_MODAL_FLAG_DROPDOWN_ID}`); + expect(stringNameDropdown).toExist(); + + const stringNameComment = await $(`svg#${ADD_MUTATION_MODAL_FLAG_COMMENT_ID}`); + expect(stringNameComment).toExist(); + }); +}); diff --git a/src/test/javascript/spec/add-mutation-modal.screenshot.ts b/src/test/javascript/spec/add-mutation-modal.screenshot.ts new file mode 100644 index 000000000..28d6eb7a0 --- /dev/null +++ b/src/test/javascript/spec/add-mutation-modal.screenshot.ts @@ -0,0 +1,183 @@ +import { WdioCheckElementMethodOptions } from '@wdio/visual-service/dist/types'; +import * as path from 'path'; +import { BASE_URL, DATABASE_EMULATOR_URL, MOCK_DATA_JSON_FILE_PATH, WDIO_DEFAULT_DIMENSIONS } from '../constants'; +import setUpMocks from '../setup-mocks'; +import * as fs from 'fs'; +import * as admin from 'firebase-admin'; +import { + ADD_MUTATION_MODAL_ADD_EXCLUDED_ALTERATION_BUTTON_ID, + ADD_MUTATION_MODAL_ADD_EXON_BUTTON_ID, + ADD_MUTATION_MODAL_EXCLUDED_ALTERATION_INPUT_ID, + ADD_MUTATION_MODAL_INPUT_ID, + DEFAULT_ADD_MUTATION_MODAL_ID, +} from '../../../main/webapp/app/config/constants/html-id'; +import { assertScreenShotMatch } from '../shared/test-utils'; +import { + AddMutationModalDataTestIdType, + CollapsibleDataTestIdType, + getAddMutationModalDataTestId, + getCollapsibleDataTestId, +} from '../../../main/webapp/app/shared/util/test-id-utils'; + +describe('Add Mutation Modal Screenshot Tests', () => { + let adminApp: admin.app.App; + + const backup = JSON.parse(fs.readFileSync(MOCK_DATA_JSON_FILE_PATH).toString()); + + const methodOptions: WdioCheckElementMethodOptions = { + actualFolder: path.join(process.cwd(), 'src/test/javascript/screenshots/actual'), + baselineFolder: path.join(process.cwd(), 'src/test/javascript/screenshots/baseline'), + diffFolder: path.join(process.cwd(), 'src/test/javascript/screenshots/diff'), + disableCSSAnimation: true, + }; + + before(async () => { + await setUpMocks(); + adminApp = admin.initializeApp({ + databaseURL: DATABASE_EMULATOR_URL, + }); + }); + + beforeEach(async () => { + // Reset database to a clean state before each test + await adminApp.database().ref('/').set(backup); + await browser.setWindowSize(WDIO_DEFAULT_DIMENSIONS.width, WDIO_DEFAULT_DIMENSIONS.height); + }); + + //comment + it('should compare modal with newly added mutations', async () => { + await browser.url(`${BASE_URL}/curation/EMPTYGENE/somatic`); + + // Click to open mutation modal + const addMutationButton = await $('button=Add Mutation'); + await addMutationButton.waitForClickable(); + await addMutationButton.click(); + + // Add a new mutation + const mutationNameInput = await $(`input#${ADD_MUTATION_MODAL_INPUT_ID}`); + await mutationNameInput.setValue('V600E, V600K'); + await browser.keys('Enter'); + + const addMutationModal = await $(`div[id="${DEFAULT_ADD_MUTATION_MODAL_ID}"]`); + await addMutationModal.waitForDisplayed(); + + const result = await browser.checkElement(addMutationModal, 'add-mutation-modal', methodOptions); + assertScreenShotMatch(result); + }); + + it('should compare modal with excluded mutations', async () => { + await browser.url(`${BASE_URL}/curation/EMPTYGENE/somatic`); + + const mutation = 'Oncogenic Mutations'; + + // Click to open mutation modal + const addMutationButton = await $('button=Add Mutation'); + await addMutationButton.waitForClickable(); + await addMutationButton.click(); + + // Add a new mutation + const mutationNameInput = await $(`input#${ADD_MUTATION_MODAL_INPUT_ID}`); + await mutationNameInput.setValue(mutation); + await browser.keys('Enter'); + + // Open up mutation details + const alterationBadgeName = await $( + `div[data-testid='${getAddMutationModalDataTestId(AddMutationModalDataTestIdType.ALTERATION_BADGE_NAME, mutation)}']`, + ); + await alterationBadgeName.click(); + + // Add a new excluded mutation + const excludedMutationInput = await $(`input#${ADD_MUTATION_MODAL_EXCLUDED_ALTERATION_INPUT_ID}`); + await excludedMutationInput.setValue('V600K'); + const addExcludedAlterationBtn = await $(`button#${ADD_MUTATION_MODAL_ADD_EXCLUDED_ALTERATION_BUTTON_ID}`); + await addExcludedAlterationBtn.waitForClickable(); + await addExcludedAlterationBtn.click(); + + const addMutationModal = await $(`div[id="${DEFAULT_ADD_MUTATION_MODAL_ID}"]`); + await addMutationModal.waitForDisplayed(); + + // Resize window to fit element + await browser.setWindowSize(WDIO_DEFAULT_DIMENSIONS.width, 1440); + + const result = await browser.checkElement(addMutationModal, 'add-mutation-modal-with-excluded-mutation', methodOptions); + assertScreenShotMatch(result); + }); + + it('should compare modal with alteration info opened', async () => { + await browser.url(`${BASE_URL}/curation/BRAF/somatic`); + + const mutation = 'V600K'; + + const mutationNameEditBtn = await $(`div[data-testid='${getCollapsibleDataTestId(CollapsibleDataTestIdType.CARD, mutation)}']`).$( + "svg[data-icon='pen']", + ); + await mutationNameEditBtn.click(); + + const alterationBadgeName = await $( + `div[data-testid='${getAddMutationModalDataTestId(AddMutationModalDataTestIdType.ALTERATION_BADGE_NAME, mutation)}']`, + ); + await alterationBadgeName.click(); + + const mutationDetails = await $( + `div[data-testid="${getAddMutationModalDataTestId(AddMutationModalDataTestIdType.MUTATION_DETAILS, mutation)}"]`, + ); + await mutationDetails.waitForDisplayed(); + + const addMutationModal = await $(`div[id="${DEFAULT_ADD_MUTATION_MODAL_ID}"]`); + await addMutationModal.waitForDisplayed(); + + // Resize window to fit element + await browser.setWindowSize(WDIO_DEFAULT_DIMENSIONS.width, 1440); + + const result = await browser.checkElement(addMutationModal, 'add-mutation-modal-opened-alteration-info', methodOptions); + assertScreenShotMatch(result); + }); + + it('should compare add exon form', async () => { + await browser.url(`${BASE_URL}/curation/EMPTYGENE/somatic`); + + // Click to open mutation modal + const addMutationButton = await $('button=Add Mutation'); + await addMutationButton.waitForClickable(); + await addMutationButton.click(); + + const addExonButton = await $(`button[id="${ADD_MUTATION_MODAL_ADD_EXON_BUTTON_ID}"]`); + await addExonButton.waitForClickable(); + await addExonButton.click(); + + const addMutationModal = await $(`div[id="${DEFAULT_ADD_MUTATION_MODAL_ID}"]`); + await addMutationModal.waitForDisplayed(); + await addMutationModal.moveTo(); + + const result = await browser.checkElement(addMutationModal, 'add-mutation-modal-add-exon-form', methodOptions); + assertScreenShotMatch(result); + }); + + it('should compare modify exon form', async () => { + await browser.url(`${BASE_URL}/curation/EMPTYGENE/somatic`); + + const mutation = 'Exon 2-4 Deletion'; + + // Click to open mutation modal + const addMutationButton = await $('button=Add Mutation'); + await addMutationButton.waitForClickable(); + await addMutationButton.click(); + + // Add a new mutation + const mutationNameInput = await $(`input#${ADD_MUTATION_MODAL_INPUT_ID}`); + await mutationNameInput.setValue(mutation); + await browser.keys('Enter'); + + // Open up exon form + const alterationBadgeName = await $( + `div[data-testid='${getAddMutationModalDataTestId(AddMutationModalDataTestIdType.ALTERATION_BADGE_NAME, mutation)}']`, + ); + await alterationBadgeName.click(); + + const addMutationModal = await $(`div[id="${DEFAULT_ADD_MUTATION_MODAL_ID}"]`); + await addMutationModal.waitForDisplayed(); + + const result = await browser.checkElement(addMutationModal, 'add-mutation-modal-modify-exon-form', methodOptions); + assertScreenShotMatch(result); + }); +}); diff --git a/src/test/javascript/spec/screenshot.ts b/src/test/javascript/spec/screenshot.ts index e005468a2..7f91c05c9 100644 --- a/src/test/javascript/spec/screenshot.ts +++ b/src/test/javascript/spec/screenshot.ts @@ -8,9 +8,7 @@ import * as admin from 'firebase-admin'; import { assertScreenShotMatch } from '../shared/test-utils'; import { CollapsibleDataTestIdType, getCollapsibleDataTestId } from '../../../main/webapp/app/shared/util/test-id-utils'; import { - ADD_MUTATION_MODAL_INPUT_ID, ADD_THERAPY_BUTTON_ID, - DEFAULT_ADD_MUTATION_MODAL_ID, GENE_HEADER_REVIEW_BUTTON_ID, GENE_HEADER_REVIEW_COMPLETE_BUTTON_ID, GENE_LIST_TABLE_ID, @@ -75,10 +73,22 @@ describe('Screenshot Tests', () => { assertScreenShotMatch(result); }); + it('should compare mutation collapsible with alteration flag', async () => { + await browser.url(`${BASE_URL}/curation/BRAF/somatic`); + + const mutationCollapsible = await $( + `div[data-testid="${getCollapsibleDataTestId(CollapsibleDataTestIdType.COLLAPSIBLE, 'V600E (comment), V600K')}"]`, + ); + await mutationCollapsible.waitForDisplayed(); + + const result = await browser.checkElement(mutationCollapsible, 'mutation-collapsible-with-flag', SCREENSHOT_METHOD_OPTIONS); + assertScreenShotMatch(result); + }); + it('should compare mutation effect not curatable', async () => { await browser.url(`${BASE_URL}/curation/BRAF/somatic`); - const mutation = 'V600E, V600K'; + const mutation = 'V600E (comment), V600K'; const mutationCollapsibleButton = await $( `div[data-testid="${getCollapsibleDataTestId(CollapsibleDataTestIdType.TITLE_WRAPPER, mutation)}"]`, @@ -171,26 +181,6 @@ describe('Screenshot Tests', () => { await reviewCompleteButton.click(); }); - it('should compare add mutation modal', async () => { - await browser.url(`${BASE_URL}/curation/EMPTYGENE/somatic`); - - // Click to open mutation modal - const addMutationButton = await $('button=Add Mutation'); - await addMutationButton.waitForClickable(); - await addMutationButton.click(); - - // Add a new mutation - const mutationNameInput = await $(`input#${ADD_MUTATION_MODAL_INPUT_ID}`); - await mutationNameInput.setValue('V600E'); - await browser.keys('Enter'); - - const addMutationModal = await $(`div[id="${DEFAULT_ADD_MUTATION_MODAL_ID}"]`); - await addMutationModal.waitForDisplayed(); - - const result = await browser.checkElement(addMutationModal, 'add-mutation-modal', methodOptions); - assertScreenShotMatch(result); - }); - it('should compare gene type on review page', async () => { await browser.url(`${BASE_URL}/curation/EMPTYGENE/somatic`);