From 426b887f553cc22ee140da1d7023c20312a45747 Mon Sep 17 00:00:00 2001 From: Dillan Cooke Date: Mon, 4 Nov 2024 14:51:40 -0500 Subject: [PATCH] [GLT-4274] show omissions on the Run Details page --- changes/add_run_omissions.md | 2 + pom.xml | 4 +- .../ca/on/oicr/gsi/dimsum/CaseLoader.java | 30 ++++-- .../dimsum/controller/mvc/RunController.java | 5 +- .../controller/rest/RunRestController.java | 14 ++- .../ca/on/oicr/gsi/dimsum/data/CaseData.java | 1 - .../oicr/gsi/dimsum/data/RunAndLibraries.java | 77 ++++++++++++++ .../oicr/gsi/dimsum/service/CaseService.java | 21 +++- .../dimsum/service/NotificationManager.java | 2 +- .../filtering/OmittedRunSampleSort.java | 55 ++++++++++ .../ca/on/oicr/gsi/dimsum/util/DataUtils.java | 5 + .../templates/{run.html => run-detail.html} | 7 +- .../service/NotificationManagerTest.java | 2 +- .../filtering/OmittedRunSampleSortTest.java | 100 ++++++++++++++++++ ts/data/case.ts | 4 +- ts/data/notification.ts | 6 +- ts/data/run.ts | 70 +++++++++++- ts/data/sample.ts | 22 +--- ts/{run.ts => run-details.ts} | 25 +++++ ts/util/requests.ts | 16 +++ ts/util/urls.ts | 3 +- webpack.config.js | 2 +- 22 files changed, 427 insertions(+), 46 deletions(-) create mode 100644 changes/add_run_omissions.md create mode 100644 src/main/java/ca/on/oicr/gsi/dimsum/data/RunAndLibraries.java create mode 100644 src/main/java/ca/on/oicr/gsi/dimsum/service/filtering/OmittedRunSampleSort.java rename src/main/resources/templates/{run.html => run-detail.html} (89%) create mode 100644 src/test/java/ca/on/oicr/gsi/dimsum/service/filtering/OmittedRunSampleSortTest.java rename ts/{run.ts => run-details.ts} (81%) diff --git a/changes/add_run_omissions.md b/changes/add_run_omissions.md new file mode 100644 index 00000000..6a14ecc7 --- /dev/null +++ b/changes/add_run_omissions.md @@ -0,0 +1,2 @@ +Omissions table on the Run Details page, showing libraries that are included in the run but not a +part of any case diff --git a/pom.xml b/pom.xml index 452afa88..a4ff0d8c 100644 --- a/pom.xml +++ b/pom.xml @@ -138,7 +138,7 @@ ca.on.oicr.gsi.cardea cardea-data - 1.14.0 + 1.15.1-SNAPSHOT @@ -184,4 +184,4 @@ - + \ No newline at end of file diff --git a/src/main/java/ca/on/oicr/gsi/dimsum/CaseLoader.java b/src/main/java/ca/on/oicr/gsi/dimsum/CaseLoader.java index 32a47ddd..f3523a0d 100644 --- a/src/main/java/ca/on/oicr/gsi/dimsum/CaseLoader.java +++ b/src/main/java/ca/on/oicr/gsi/dimsum/CaseLoader.java @@ -20,13 +20,14 @@ import org.springframework.web.reactive.function.client.WebClient; import ca.on.oicr.gsi.cardea.data.Assay; import ca.on.oicr.gsi.cardea.data.Case; +import ca.on.oicr.gsi.cardea.data.OmittedRunSample; import ca.on.oicr.gsi.cardea.data.OmittedSample; import ca.on.oicr.gsi.cardea.data.Project; -import ca.on.oicr.gsi.cardea.data.RunAndLibraries; import ca.on.oicr.gsi.cardea.data.Sample; import ca.on.oicr.gsi.cardea.data.Test; import ca.on.oicr.gsi.dimsum.data.CaseData; import ca.on.oicr.gsi.dimsum.data.ProjectSummary; +import ca.on.oicr.gsi.dimsum.data.RunAndLibraries; import ca.on.oicr.gsi.dimsum.service.filtering.CompletedGate; import ca.on.oicr.gsi.dimsum.service.filtering.PendingState; import io.micrometer.core.instrument.MeterRegistry; @@ -72,7 +73,8 @@ public CaseData load(ZonedDateTime previousTimestamp) throws IOException { ca.on.oicr.gsi.cardea.data.CaseData cardeaCaseData = loadCardeaData(builder); Map assaysById = cardeaCaseData.getAssaysById(); - Map runsByName = sortRuns(cardeaCaseData.getCases()); + Map runsByName = + sortRuns(cardeaCaseData.getCases(), cardeaCaseData.getOmittedRunSamples()); List omittedSamples = cardeaCaseData.getOmittedSamples(); Set requisitionNames = loadRequisitionNames(cardeaCaseData.getCases()); Set projectsNames = loadProjectsNames(cardeaCaseData.getCases()); @@ -97,7 +99,7 @@ requisitionNames, projectsNames, donorNames, getRunNames(runsByName), testNames, * * @param builder WebClient builder state used to fetch data from Cardea API `/dimsum` endpoint */ - public ca.on.oicr.gsi.cardea.data.CaseData loadCardeaData(WebClient.Builder builder) + private ca.on.oicr.gsi.cardea.data.CaseData loadCardeaData(WebClient.Builder builder) throws IOException { ca.on.oicr.gsi.cardea.data.CaseData data = builder .codecs(configurer -> configurer.defaultCodecs().maxInMemorySize(LIMIT_FOR_DATA_LOAD)) @@ -145,8 +147,9 @@ private static Set getRunNames(Map runsByName) return runsByName.keySet(); } - private Map sortRuns(List cases) { - Map map = new HashMap<>(); + private Map sortRuns(List cases, + List omittedRunSamples) { + Map map = new HashMap<>(); for (Case kase : cases) { for (Test test : kase.getTests()) { for (Sample sample : test.getLibraryQualifications()) { @@ -158,19 +161,26 @@ private Map sortRuns(List cases) { addRunLibrary(map, sample, RunAndLibraries.Builder::addFullDepthSequencing); } } + for (OmittedRunSample sample : omittedRunSamples) { + if (map.containsKey(sample.getRunId())) { + map.get(sample.getRunId()).addOmittedSample(sample); + } else { + // TODO: error, log, or ignore? + } + } } return map.values().stream() .map(RunAndLibraries.Builder::build) .collect(Collectors.toMap(x -> x.getRun().getName(), Function.identity())); } - private void addRunLibrary(Map map, Sample sample, + private void addRunLibrary(Map map, Sample sample, BiConsumer addSample) { - String runName = sample.getRun().getName(); - if (!map.containsKey(runName)) { - map.put(runName, new RunAndLibraries.Builder().run(sample.getRun())); + long runId = sample.getRun().getId(); + if (!map.containsKey(runId)) { + map.put(runId, new RunAndLibraries.Builder().run(sample.getRun())); } - addSample.accept(map.get(runName), sample); + addSample.accept(map.get(runId), sample); } @FunctionalInterface diff --git a/src/main/java/ca/on/oicr/gsi/dimsum/controller/mvc/RunController.java b/src/main/java/ca/on/oicr/gsi/dimsum/controller/mvc/RunController.java index ad27e686..310ae90e 100644 --- a/src/main/java/ca/on/oicr/gsi/dimsum/controller/mvc/RunController.java +++ b/src/main/java/ca/on/oicr/gsi/dimsum/controller/mvc/RunController.java @@ -9,9 +9,9 @@ import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.RequestMapping; import ca.on.oicr.gsi.cardea.data.Run; -import ca.on.oicr.gsi.cardea.data.RunAndLibraries; import ca.on.oicr.gsi.cardea.data.Sample; import ca.on.oicr.gsi.dimsum.controller.NotFoundException; +import ca.on.oicr.gsi.dimsum.data.RunAndLibraries; import ca.on.oicr.gsi.dimsum.service.CaseService; @Controller @@ -40,7 +40,8 @@ public String getRunPage(@PathVariable String runName, ModelMap model) { .collect(Collectors.joining(","))); model.put("showLibraryQualifications", !runAndLibraries.getLibraryQualifications().isEmpty()); model.put("showFullDepthSequencings", !runAndLibraries.getFullDepthSequencings().isEmpty()); - return "run"; + model.put("showOmitted", !runAndLibraries.getOmittedSamples().isEmpty()); + return "run-detail"; } private static String getRunStatus(Run run) { diff --git a/src/main/java/ca/on/oicr/gsi/dimsum/controller/rest/RunRestController.java b/src/main/java/ca/on/oicr/gsi/dimsum/controller/rest/RunRestController.java index d531832a..2c08ec60 100644 --- a/src/main/java/ca/on/oicr/gsi/dimsum/controller/rest/RunRestController.java +++ b/src/main/java/ca/on/oicr/gsi/dimsum/controller/rest/RunRestController.java @@ -8,13 +8,15 @@ import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; +import ca.on.oicr.gsi.cardea.data.OmittedRunSample; import ca.on.oicr.gsi.cardea.data.Run; -import ca.on.oicr.gsi.cardea.data.RunAndLibraries; import ca.on.oicr.gsi.cardea.data.Sample; import ca.on.oicr.gsi.dimsum.controller.NotFoundException; import ca.on.oicr.gsi.dimsum.controller.rest.request.DataQuery; +import ca.on.oicr.gsi.dimsum.data.RunAndLibraries; import ca.on.oicr.gsi.dimsum.service.CaseService; import ca.on.oicr.gsi.dimsum.service.filtering.CaseFilter; +import ca.on.oicr.gsi.dimsum.service.filtering.OmittedRunSampleSort; import ca.on.oicr.gsi.dimsum.service.filtering.RunFilter; import ca.on.oicr.gsi.dimsum.service.filtering.RunSort; import ca.on.oicr.gsi.dimsum.service.filtering.SampleSort; @@ -59,6 +61,16 @@ public TableData getFullDepthSequencings(@PathVariable String runName, query.getPageNumber(), sort, descending, filters); } + @PostMapping("/{runName}/omissions") + public TableData getOmitted(@PathVariable String runName, + @RequestBody DataQuery query) { + checkRunExists(runName); + OmittedRunSampleSort sort = parseSort(query, OmittedRunSampleSort::getByLabel); + boolean descending = parseDescending(query); + return caseService.getOmittedSamplesForRun(runName, query.getPageSize(), + query.getPageNumber(), sort, descending); + } + private void checkRunExists(String runName) { RunAndLibraries runAndLibraries = caseService.getRunAndLibraries(runName); if (runAndLibraries == null) { diff --git a/src/main/java/ca/on/oicr/gsi/dimsum/data/CaseData.java b/src/main/java/ca/on/oicr/gsi/dimsum/data/CaseData.java index e2d27126..74c9575c 100644 --- a/src/main/java/ca/on/oicr/gsi/dimsum/data/CaseData.java +++ b/src/main/java/ca/on/oicr/gsi/dimsum/data/CaseData.java @@ -13,7 +13,6 @@ import ca.on.oicr.gsi.cardea.data.Assay; import ca.on.oicr.gsi.cardea.data.Case; import ca.on.oicr.gsi.cardea.data.OmittedSample; -import ca.on.oicr.gsi.cardea.data.RunAndLibraries; @Immutable public class CaseData { diff --git a/src/main/java/ca/on/oicr/gsi/dimsum/data/RunAndLibraries.java b/src/main/java/ca/on/oicr/gsi/dimsum/data/RunAndLibraries.java new file mode 100644 index 00000000..30da3a3f --- /dev/null +++ b/src/main/java/ca/on/oicr/gsi/dimsum/data/RunAndLibraries.java @@ -0,0 +1,77 @@ +package ca.on.oicr.gsi.dimsum.data; + +import static java.util.Objects.requireNonNull; +import java.util.Collections; +import java.util.HashSet; +import java.util.Set; +import ca.on.oicr.gsi.cardea.data.OmittedRunSample; +import ca.on.oicr.gsi.cardea.data.Run; +import ca.on.oicr.gsi.cardea.data.Sample; + +/** + * Immutable RunAndLibraries + */ +public class RunAndLibraries { + + private final Set fullDepthSequencings; + private final Set libraryQualifications; + private final Set omittedSamples; + private final Run run; + + private RunAndLibraries(Builder builder) { + this.run = requireNonNull(builder.run); + this.libraryQualifications = Collections.unmodifiableSet(builder.libraryQualifications); + this.fullDepthSequencings = Collections.unmodifiableSet(builder.fullDepthSequencings); + this.omittedSamples = Collections.unmodifiableSet(builder.omittedSamples); + } + + public Set getFullDepthSequencings() { + return fullDepthSequencings; + } + + public Set getLibraryQualifications() { + return libraryQualifications; + } + + public Set getOmittedSamples() { + return omittedSamples; + } + + public Run getRun() { + return run; + } + + public static class Builder { + + private Set fullDepthSequencings = new HashSet<>(); + private Set libraryQualifications = new HashSet<>(); + private Set omittedSamples = new HashSet<>(); + private Run run; + + public Builder addFullDepthSequencing(Sample sample) { + fullDepthSequencings.add(sample); + return this; + } + + public Builder addLibraryQualification(Sample sample) { + libraryQualifications.add(sample); + return this; + } + + public Builder addOmittedSample(OmittedRunSample sample) { + omittedSamples.add(sample); + return this; + } + + public RunAndLibraries build() { + return new RunAndLibraries(this); + } + + public Builder run(Run run) { + this.run = run; + return this; + } + + } + +} diff --git a/src/main/java/ca/on/oicr/gsi/dimsum/service/CaseService.java b/src/main/java/ca/on/oicr/gsi/dimsum/service/CaseService.java index 46b37095..1cd4c3c0 100644 --- a/src/main/java/ca/on/oicr/gsi/dimsum/service/CaseService.java +++ b/src/main/java/ca/on/oicr/gsi/dimsum/service/CaseService.java @@ -28,10 +28,10 @@ import ca.on.oicr.gsi.cardea.data.Case; import ca.on.oicr.gsi.cardea.data.CaseRelease; import ca.on.oicr.gsi.cardea.data.MetricCategory; +import ca.on.oicr.gsi.cardea.data.OmittedRunSample; import ca.on.oicr.gsi.cardea.data.OmittedSample; import ca.on.oicr.gsi.cardea.data.Project; import ca.on.oicr.gsi.cardea.data.Run; -import ca.on.oicr.gsi.cardea.data.RunAndLibraries; import ca.on.oicr.gsi.cardea.data.Sample; import ca.on.oicr.gsi.cardea.data.Test; import ca.on.oicr.gsi.dimsum.CaseLoader; @@ -42,11 +42,13 @@ import ca.on.oicr.gsi.dimsum.data.ProjectSummary; import ca.on.oicr.gsi.dimsum.data.ProjectSummaryField; import ca.on.oicr.gsi.dimsum.data.ProjectSummaryRow; +import ca.on.oicr.gsi.dimsum.data.RunAndLibraries; import ca.on.oicr.gsi.dimsum.data.TestTableView; import ca.on.oicr.gsi.dimsum.service.filtering.CaseFilter; import ca.on.oicr.gsi.dimsum.service.filtering.CaseFilterKey; import ca.on.oicr.gsi.dimsum.service.filtering.CaseSort; import ca.on.oicr.gsi.dimsum.service.filtering.CompletedGate; +import ca.on.oicr.gsi.dimsum.service.filtering.OmittedRunSampleSort; import ca.on.oicr.gsi.dimsum.service.filtering.OmittedSampleFilter; import ca.on.oicr.gsi.dimsum.service.filtering.OmittedSampleFilterKey; import ca.on.oicr.gsi.dimsum.service.filtering.OmittedSampleSort; @@ -558,6 +560,23 @@ public TableData getFullDepthSequencingsForRun(String runName, int pageS RunAndLibraries::getFullDepthSequencings, MetricCategory.FULL_DEPTH_SEQUENCING); } + public TableData getOmittedSamplesForRun(String runName, int pageSize, + int pageNumber, OmittedRunSampleSort sort, boolean descending) { + RunAndLibraries runAndLibraries = caseData.getRunAndLibraries(runName); + Set samples = + runAndLibraries == null ? Collections.emptySet() : runAndLibraries.getOmittedSamples(); + + TableData data = new TableData<>(); + data.setTotalCount(samples.size()); + data.setFilteredCount(samples.size()); + data.setItems(samples.stream() + .sorted(descending ? sort.comparator().reversed() : sort.comparator()) + .skip(pageSize * (pageNumber - 1)) + .limit(pageSize) + .toList()); + return data; + } + private TableData getRunLibraries(String runName, int pageSize, int pageNumber, SampleSort sort, boolean descending, Collection filters, Function> getSamples, MetricCategory requestCategory) { diff --git a/src/main/java/ca/on/oicr/gsi/dimsum/service/NotificationManager.java b/src/main/java/ca/on/oicr/gsi/dimsum/service/NotificationManager.java index e982e406..ff5216c2 100644 --- a/src/main/java/ca/on/oicr/gsi/dimsum/service/NotificationManager.java +++ b/src/main/java/ca/on/oicr/gsi/dimsum/service/NotificationManager.java @@ -19,10 +19,10 @@ import ca.on.oicr.gsi.cardea.data.MetricCategory; import ca.on.oicr.gsi.cardea.data.MetricSubcategory; import ca.on.oicr.gsi.cardea.data.Run; -import ca.on.oicr.gsi.cardea.data.RunAndLibraries; import ca.on.oicr.gsi.cardea.data.Sample; import ca.on.oicr.gsi.dimsum.data.IssueState; import ca.on.oicr.gsi.dimsum.data.Notification; +import ca.on.oicr.gsi.dimsum.data.RunAndLibraries; import ca.on.oicr.gsi.dimsum.data.RunQcCommentSummary; import ca.on.oicr.gsi.dimsum.service.filtering.NotificationSort; import ca.on.oicr.gsi.dimsum.service.filtering.TableData; diff --git a/src/main/java/ca/on/oicr/gsi/dimsum/service/filtering/OmittedRunSampleSort.java b/src/main/java/ca/on/oicr/gsi/dimsum/service/filtering/OmittedRunSampleSort.java new file mode 100644 index 00000000..d0fd3659 --- /dev/null +++ b/src/main/java/ca/on/oicr/gsi/dimsum/service/filtering/OmittedRunSampleSort.java @@ -0,0 +1,55 @@ +package ca.on.oicr.gsi.dimsum.service.filtering; + +import java.util.Comparator; +import java.util.Map; +import java.util.function.Function; +import java.util.stream.Collectors; +import java.util.stream.Stream; +import ca.on.oicr.gsi.cardea.data.OmittedRunSample; +import ca.on.oicr.gsi.dimsum.util.DataUtils; + +public enum OmittedRunSampleSort { + + // @formatter:off + NAME("Name", Comparator.comparing(OmittedRunSample::getName)), + QC_STATUS("QC Status", Comparator.comparing(OmittedRunSampleSort::getQcStatusSortPriority)); + // @formatter:on + + private static final Map map = + Stream.of(OmittedRunSampleSort.values()) + .collect(Collectors.toMap(OmittedRunSampleSort::getLabel, Function.identity())); + + public static OmittedRunSampleSort getByLabel(String label) { + return map.get(label); + } + + private final String label; + private final Comparator comparator; + + private OmittedRunSampleSort(String label, Comparator comparator) { + this.label = label; + this.comparator = comparator; + } + + public String getLabel() { + return label; + } + + public Comparator comparator() { + return comparator; + } + + protected static int getQcStatusSortPriority(OmittedRunSample sample) { + if (sample.getQcDate() == null) { + return 1; + } else if (sample.getDataReviewDate() == null) { + return 2; + } else if (DataUtils.isTopUpRequired(sample)) { + return 3; + } else if (Boolean.TRUE.equals(sample.getQcPassed())) { + return 4; + } else { + return 5; + } + } +} diff --git a/src/main/java/ca/on/oicr/gsi/dimsum/util/DataUtils.java b/src/main/java/ca/on/oicr/gsi/dimsum/util/DataUtils.java index 759bcb09..7af4dfec 100644 --- a/src/main/java/ca/on/oicr/gsi/dimsum/util/DataUtils.java +++ b/src/main/java/ca/on/oicr/gsi/dimsum/util/DataUtils.java @@ -7,6 +7,7 @@ import ca.on.oicr.gsi.cardea.data.Case; import ca.on.oicr.gsi.cardea.data.CaseDeliverable; import ca.on.oicr.gsi.cardea.data.CaseRelease; +import ca.on.oicr.gsi.cardea.data.OmittedRunSample; import ca.on.oicr.gsi.cardea.data.Sample; public class DataUtils { @@ -64,6 +65,10 @@ public static boolean isTopUpRequired(Sample sample) { return TOP_UP_REASON.equals(sample.getQcReason()); } + public static boolean isTopUpRequired(OmittedRunSample sample) { + return TOP_UP_REASON.equals(sample.getQcReason()); + } + public static boolean isAnalysisReviewSkipped(Case kase) { return kase.getProjects().stream().allMatch(project -> project.isAnalysisReviewSkipped()); } diff --git a/src/main/resources/templates/run.html b/src/main/resources/templates/run-detail.html similarity index 89% rename from src/main/resources/templates/run.html rename to src/main/resources/templates/run-detail.html index 2a7dac65..8aa886b4 100644 --- a/src/main/resources/templates/run.html +++ b/src/main/resources/templates/run-detail.html @@ -51,7 +51,12 @@

Full Depth Sequencings

- +

+ Omissions +

+
+ + diff --git a/src/test/java/ca/on/oicr/gsi/dimsum/service/NotificationManagerTest.java b/src/test/java/ca/on/oicr/gsi/dimsum/service/NotificationManagerTest.java index d7d0d6f1..721f607b 100644 --- a/src/test/java/ca/on/oicr/gsi/dimsum/service/NotificationManagerTest.java +++ b/src/test/java/ca/on/oicr/gsi/dimsum/service/NotificationManagerTest.java @@ -24,9 +24,9 @@ import ca.on.oicr.gsi.cardea.data.MetricCategory; import ca.on.oicr.gsi.cardea.data.MetricSubcategory; import ca.on.oicr.gsi.cardea.data.Run; -import ca.on.oicr.gsi.cardea.data.RunAndLibraries; import ca.on.oicr.gsi.cardea.data.Sample; import ca.on.oicr.gsi.dimsum.data.IssueState; +import ca.on.oicr.gsi.dimsum.data.RunAndLibraries; public class NotificationManagerTest { diff --git a/src/test/java/ca/on/oicr/gsi/dimsum/service/filtering/OmittedRunSampleSortTest.java b/src/test/java/ca/on/oicr/gsi/dimsum/service/filtering/OmittedRunSampleSortTest.java new file mode 100644 index 00000000..6e259ff8 --- /dev/null +++ b/src/test/java/ca/on/oicr/gsi/dimsum/service/filtering/OmittedRunSampleSortTest.java @@ -0,0 +1,100 @@ +package ca.on.oicr.gsi.dimsum.service.filtering; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; +import java.util.Comparator; +import java.util.List; +import java.util.function.Function; +import java.util.stream.IntStream; +import org.junit.jupiter.api.Test; +import ca.on.oicr.gsi.cardea.data.OmittedRunSample; +import ca.on.oicr.gsi.dimsum.util.DataUtils; + +public class OmittedRunSampleSortTest { + + private static final String[] namesOrdered = + {"Sample A", "Sample B", "Sample C", "Sample D", "Sample E"}; + private static final String[] sampleNames = + {namesOrdered[4], namesOrdered[1], namesOrdered[3], namesOrdered[0], namesOrdered[2]}; + private static final Integer[] qcStatusOrdered = {1, 2, 3, 4, 5}; + private static final Integer[] qcStatuses = {5, 1, 4, 2, 3}; + + @Test + public void testSortByNameAscending() { + List samples = getSamplesSorted(OmittedRunSampleSort.NAME, false); + assertOrder(samples, OmittedRunSample::getName, namesOrdered, false); + } + + @Test + public void testSortByNameDescending() { + List samples = getSamplesSorted(OmittedRunSampleSort.NAME, true); + assertOrder(samples, OmittedRunSample::getName, namesOrdered, true); + } + + @Test + public void testSortByQcStatusAscending() { + List samples = getSamplesSorted(OmittedRunSampleSort.QC_STATUS, false); + assertOrder(samples, OmittedRunSampleSort::getQcStatusSortPriority, qcStatusOrdered, false); + } + + @Test + public void testSortByQcStatusDescending() { + List samples = getSamplesSorted(OmittedRunSampleSort.QC_STATUS, true); + assertOrder(samples, OmittedRunSampleSort::getQcStatusSortPriority, qcStatusOrdered, true); + } + + private static List getSamplesSorted(OmittedRunSampleSort sort, + boolean descending) { + Comparator comparator = + descending ? sort.comparator().reversed() : sort.comparator(); + List samples = mockSamples().stream().sorted(comparator).toList(); + return samples; + } + + private static List mockSamples() { + return IntStream.range(0, 5).mapToObj(OmittedRunSampleSortTest::mockSample).toList(); + } + + private static OmittedRunSample mockSample(int sampleNumber) { + OmittedRunSample sample = mock(OmittedRunSample.class); + when(sample.getName()).thenReturn(sampleNames[sampleNumber]); + + switch (qcStatuses[sampleNumber]) { + case 1: // Pending QC + // nothing needs set + break; + case 2: // Pending Data Review + when(sample.getQcPassed()).thenReturn(true); + when(sample.getQcUser()).thenReturn("user1"); + break; + case 3: // Top-Up Required + when(sample.getQcPassed()).thenReturn(null); + when(sample.getDataReviewPassed()).thenReturn(true); + when(sample.getQcUser()).thenReturn("user2"); + when(sample.getQcReason()).thenReturn(DataUtils.TOP_UP_REASON); + break; + case 4: // Passed + when(sample.getQcPassed()).thenReturn(true); + when(sample.getQcUser()).thenReturn("user3"); + break; + case 5: // Other (failed) + when(sample.getQcPassed()).thenReturn(false); + when(sample.getQcUser()).thenReturn("user4"); + break; + } + return sample; + } + + private static void assertOrder(List samples, + Function getter, + T[] expectedOrder, boolean reversed) { + assertNotNull(samples); + assertEquals(samples.size(), expectedOrder.length); + for (int i = 0; i < samples.size(); i++) { + int index = reversed ? samples.size() - 1 - i : i; + assertEquals(expectedOrder[index], getter.apply(samples.get(i)), + "The sample at index " + i + " is not in the correct order."); + } + } + +} diff --git a/ts/data/case.ts b/ts/data/case.ts index a0871fff..9fbf4bea 100644 --- a/ts/data/case.ts +++ b/ts/data/case.ts @@ -17,7 +17,7 @@ import { urls } from "../util/urls"; import { siteConfig } from "../util/site-config"; import { getQcStatus, - getRunQcStatus, + getQcStatusWithDataReview, getSampleQcStatus, Sample, } from "./sample"; @@ -1380,7 +1380,7 @@ export function makeSampleTooltip(sample: Sample) { sample.run.name ) ); - const runStatus = getRunQcStatus(sample.run); + const runStatus = getQcStatusWithDataReview(sample.run); addStatusTooltipText( topContainer, runStatus, diff --git a/ts/data/notification.ts b/ts/data/notification.ts index 0a0515f4..e47a296d 100644 --- a/ts/data/notification.ts +++ b/ts/data/notification.ts @@ -2,7 +2,7 @@ import { legendAction, TableDefinition } from "../component/table-builder"; import { addLink, makeIcon, makeNameDiv } from "../util/html-utils"; import { urls } from "../util/urls"; import { Run } from "./case"; -import { getRunQcStatus } from "./sample"; +import { getQcStatusWithDataReview } from "./sample"; import { siteConfig } from "../util/site-config"; interface Notification { @@ -51,13 +51,13 @@ export const notificationDefinition: TableDefinition = { { title: "Run QC", addParentContents(notification, fragment) { - const status = getRunQcStatus(notification.run); + const status = getQcStatusWithDataReview(notification.run); const icon = makeIcon(status.icon); icon.title = status.label; fragment.appendChild(icon); }, getCellHighlight(notification) { - const status = getRunQcStatus(notification.run); + const status = getQcStatusWithDataReview(notification.run); return status.cellStatus || null; }, }, diff --git a/ts/data/run.ts b/ts/data/run.ts index cfde76cf..62caf194 100644 --- a/ts/data/run.ts +++ b/ts/data/run.ts @@ -1,7 +1,17 @@ import { legendAction, TableDefinition } from "../component/table-builder"; -import { makeNameDiv } from "../util/html-utils"; +import { Tooltip } from "../component/tooltip"; +import { makeIcon, makeNameDiv } from "../util/html-utils"; +import { postNavigate } from "../util/requests"; import { urls } from "../util/urls"; -import { Run } from "./case"; +import { addStatusTooltipText, Qcable, Run } from "./case"; +import { extractLibraryName, getQcStatusWithDataReview } from "./sample"; + +export interface OmittedRunSample extends Qcable { + id: string; + name: string; + runId: number; + sequencingLane: number; +} export const runDefinition: TableDefinition = { queryUrl: urls.rest.runs.list, @@ -47,3 +57,59 @@ export const runDefinition: TableDefinition = { ]; }, }; + +export function getOmissionsDefinition( + queryUrl: string +): TableDefinition { + return { + queryUrl: queryUrl, + defaultSort: { + columnTitle: "Name", + descending: true, + type: "text", + }, + staticActions: [legendAction], + generateColumns(data) { + return [ + { + title: "QC Status", + sortType: "custom", + addParentContents(sample, fragment) { + const status = getQcStatusWithDataReview(sample); + const icon = makeIcon(status.icon); + const tooltipInstance = Tooltip.getInstance(); + tooltipInstance.addTarget(icon, (tooltip) => { + addStatusTooltipText( + tooltip, + status, + sample.qcReason, + sample.qcUser, + sample.qcNote + ); + }); + fragment.appendChild(icon); + }, + getCellHighlight(sample) { + const status = getQcStatusWithDataReview(sample); + return status.cellStatus || null; + }, + }, + { + title: "Name", + sortType: "text", + addParentContents(sample, fragment) { + const text = `${sample.name} (L${sample.sequencingLane})`; + fragment.appendChild( + makeNameDiv( + text, + urls.miso.sample(sample.id), + undefined, + sample.name + ) + ); + }, + }, + ]; + }, + }; +} diff --git a/ts/data/sample.ts b/ts/data/sample.ts index 852d56c2..75fd7c58 100644 --- a/ts/data/sample.ts +++ b/ts/data/sample.ts @@ -37,6 +37,7 @@ import { latestActivitySort, runLibraryFilters, } from "../component/table-components"; +import { postNavigate } from "../util/requests"; const METRIC_LABEL_Q30 = "Bases Over Q30"; const METRIC_LABEL_CLUSTERS_PF_1 = "Min Clusters (PF)"; @@ -424,20 +425,7 @@ function openQcInMiso(samples: Sample[], category: MetricCategory) { report: "Dimsum", library_aliquots: generateMetricData(category, samples), }; - - const form = document.createElement("form"); - form.style.display = "none"; - form.action = urls.miso.qcRunLibraries; - form.method = "POST"; - form.target = "_blank"; - const data = document.createElement("input"); - data.type = "hidden"; - data.name = "data"; - data.value = JSON.stringify(request); - form.appendChild(data); - document.body.appendChild(form); - form.submit(); - form.remove(); + postNavigate(urls.miso.qcRunLibraries, request, true); } function generateMetricData( @@ -1193,7 +1181,7 @@ function getMetricValue(metricName: string, sample: Sample): number | null { export function getQcStatus(sample: Sample): QcStatus { const sampleStatus = getSampleQcStatus(sample); if (sample.run) { - const runStatus = getRunQcStatus(sample.run); + const runStatus = getQcStatusWithDataReview(sample.run); return runStatus.priority < sampleStatus.priority ? runStatus : sampleStatus; @@ -1216,7 +1204,7 @@ export function getSampleQcStatus(sample: Sample): QcStatus { return firstStatus; } -export function getRunQcStatus(run: Qcable): QcStatus { +export function getQcStatusWithDataReview(run: Qcable): QcStatus { const firstStatus = getFirstReviewStatus(run); if (firstStatus.qcComplete) { if (!run.dataReviewDate) { @@ -1252,7 +1240,7 @@ function nullOrUndefined(value: unknown): value is null | undefined { return value === undefined || value === null; } -function extractLibraryName(runLibraryId: string): string { +export function extractLibraryName(runLibraryId: string): string { const match = runLibraryId.match("^\\d+_\\d+_(LDI\\d+)$"); if (!match) { throw new Error(`Sample ${runLibraryId} is not a run-library`); diff --git a/ts/run.ts b/ts/run-details.ts similarity index 81% rename from ts/run.ts rename to ts/run-details.ts index 44ccedbb..d657f960 100644 --- a/ts/run.ts +++ b/ts/run-details.ts @@ -16,6 +16,8 @@ import { import { TableBuilder, TableDefinition } from "./component/table-builder"; import { urls } from "./util/urls"; import { Tooltip } from "./component/tooltip"; +import { getOmissionsDefinition } from "./data/run"; +import { showAlertDialog } from "./component/dialog"; const misoRunLink = getRequiredElementById("misoRunLink"); const runName = getRequiredDataAttribute(misoRunLink, "data-run-name"); @@ -40,6 +42,27 @@ function makeTable( } } +function makeOmissionsTable(runName: string) { + const containerId = "omissionsTableContainer"; + const container = document.getElementById(containerId); + if (container) { + addOmissionsHelp(); + const definition = getOmissionsDefinition( + urls.rest.runs.omissions(runName) + ); + new TableBuilder(definition, containerId).build(); + } +} + +function addOmissionsHelp() { + document.getElementById("omissionsInfo")?.addEventListener("click", () => { + showAlertDialog( + "Omissions", + "The libraries listed here were included in the run, but are not a part of any case." + ); + }); +} + const runQcCell = getRequiredElementById("runStatus"); const statusString = runQcCell.getAttribute("data-run-status"); const status = getQcStatus(statusString); @@ -64,6 +87,8 @@ makeTable( ) ); +makeOmissionsTable(runName); + function makeDashiRunLinksTooltip( element: HTMLElement, runName: string, diff --git a/ts/util/requests.ts b/ts/util/requests.ts index cfe7be6c..01c37179 100644 --- a/ts/util/requests.ts +++ b/ts/util/requests.ts @@ -67,6 +67,22 @@ export function postDownload(url: string, body: any) { }); } +export function postNavigate(url: string, data: any, newTab: boolean) { + const form = document.createElement("form"); + form.style.display = "none"; + form.action = url; + form.method = "POST"; + form.target = "_blank"; + const input = document.createElement("input"); + input.type = "hidden"; + input.name = "data"; + input.value = JSON.stringify(data); + form.appendChild(input); + document.body.appendChild(form); + form.submit(); + form.remove(); +} + export function get(url: string, params?: Record) { let headers: any = { "Content-Type": "application/json", diff --git a/ts/util/urls.ts b/ts/util/urls.ts index 0d966f8a..4759f6eb 100644 --- a/ts/util/urls.ts +++ b/ts/util/urls.ts @@ -1,5 +1,4 @@ import { siteConfig } from "./site-config"; -import { toTitleCase } from "./html-utils"; import { Pair } from "./pair"; const restBaseUrl = "/rest"; @@ -30,6 +29,8 @@ export const urls = { `${restBaseUrl}/runs/${runName}/library-qualifications`, fullDepthSequencings: (runName: string) => `${restBaseUrl}/runs/${runName}/full-depth-sequencings`, + omissions: (runName: string) => + `${restBaseUrl}/runs/${runName}/omissions`, list: `${restBaseUrl}/runs`, }, autocomplete: { diff --git a/webpack.config.js b/webpack.config.js index 1962fd27..24f51005 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -9,7 +9,7 @@ module.exports = { projectDetails: "./ts/project-details.ts", notifications: "./ts/notifications.ts", runList: "./ts/run-list.ts", - run: "./ts/run.ts", + runDetails: "./ts/run-details.ts", omissions: "./ts/omissions.ts", projectList: "./ts/project-list.ts", caseReport: "./ts/case-report.ts",