From 46f03b2cccffd14db0f2130a6f9c942c066e4b35 Mon Sep 17 00:00:00 2001 From: Marty Pradere Date: Wed, 29 Jul 2026 20:50:00 -0600 Subject: [PATCH 1/3] Use shared treatment link display column factory --- .../nirc_ehr/table/NIRC_EHRCustomizer.java | 8 +- .../table/TreatmentDisplayColumnFactory.java | 130 ------------------ 2 files changed, 6 insertions(+), 132 deletions(-) delete mode 100644 nirc_ehr/src/org/labkey/nirc_ehr/table/TreatmentDisplayColumnFactory.java diff --git a/nirc_ehr/src/org/labkey/nirc_ehr/table/NIRC_EHRCustomizer.java b/nirc_ehr/src/org/labkey/nirc_ehr/table/NIRC_EHRCustomizer.java index d2d5777b..31997901 100644 --- a/nirc_ehr/src/org/labkey/nirc_ehr/table/NIRC_EHRCustomizer.java +++ b/nirc_ehr/src/org/labkey/nirc_ehr/table/NIRC_EHRCustomizer.java @@ -36,6 +36,8 @@ import org.labkey.api.ehr.security.EHRDataEntryPermission; import org.labkey.api.ehr.security.EHRVeterinarianPermission; import org.labkey.api.ehr.table.FixedWidthDisplayColumn; +import org.labkey.api.ehr.table.TreatmentLinkConfig; +import org.labkey.api.ehr.table.TreatmentLinkDisplayColumnFactory; import org.labkey.api.exp.api.StorageProvisioner; import org.labkey.api.exp.property.Domain; import org.labkey.api.gwt.client.FacetingBehaviorType; @@ -67,6 +69,8 @@ public class NIRC_EHRCustomizer extends AbstractTableCustomizer { + private static final TreatmentLinkConfig RECORD_TREATMENT = TreatmentLinkConfig.builder().build(); + public UserSchema getEHRUserSchema(AbstractTableInfo ds, String name) { Container ehrContainer = EHRService.get().getEHRStudyContainer(ds.getUserSchema().getContainer()); @@ -1072,7 +1076,7 @@ private void customizeTreatmentOrder(AbstractTableInfo ti) { WrappedColumn col = new WrappedColumn(ti.getColumn("objectid"), "treatmentRecord"); col.setLabel("Record Treatment"); - col.setDisplayColumnFactory(new TreatmentDisplayColumnFactory(false)); + col.setDisplayColumnFactory(TreatmentLinkDisplayColumnFactory.forOrder(RECORD_TREATMENT)); ti.addColumn(col); } } @@ -1083,7 +1087,7 @@ private void customizeTreatmentSchedule(AbstractTableInfo ti) { WrappedColumn col = new WrappedColumn(ti.getColumn("objectid"), "treatmentRecord"); col.setLabel("Record Treatment"); - col.setDisplayColumnFactory(new TreatmentDisplayColumnFactory(true)); + col.setDisplayColumnFactory(TreatmentLinkDisplayColumnFactory.forSchedule(RECORD_TREATMENT)); ti.addColumn(col); } } diff --git a/nirc_ehr/src/org/labkey/nirc_ehr/table/TreatmentDisplayColumnFactory.java b/nirc_ehr/src/org/labkey/nirc_ehr/table/TreatmentDisplayColumnFactory.java deleted file mode 100644 index 56ffb38b..00000000 --- a/nirc_ehr/src/org/labkey/nirc_ehr/table/TreatmentDisplayColumnFactory.java +++ /dev/null @@ -1,130 +0,0 @@ -/* - * Copyright (c) 2026 LabKey Corporation - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.labkey.nirc_ehr.table; - -import org.labkey.api.data.ColumnInfo; -import org.labkey.api.data.DataColumn; -import org.labkey.api.data.DisplayColumn; -import org.labkey.api.data.DisplayColumnFactory; -import org.labkey.api.data.RenderContext; -import org.labkey.api.ehr.security.EHRClinicalEntryPermission; -import org.labkey.api.query.FieldKey; -import org.labkey.api.util.DateUtil; -import org.labkey.api.util.LinkBuilder; -import org.labkey.api.view.ActionURL; -import org.labkey.api.writer.HtmlWriter; - -import java.util.Date; -import java.util.Set; - -/** - * Display column factory for creating Record Treatment links. When includeScheduledDate is set, the row's date is - * passed as the scheduledDate URL parameter, so it should only be set on tables whose date column is the scheduled - * slot being recorded (e.g. treatmentSchedule), not the treatment order's start date. - */ -public class TreatmentDisplayColumnFactory implements DisplayColumnFactory -{ - private final boolean _includeScheduledDate; - - public TreatmentDisplayColumnFactory(boolean includeScheduledDate) - { - _includeScheduledDate = includeScheduledDate; - } - - @Override - public DisplayColumn createRenderer(final ColumnInfo colInfo) - { - return new DataColumn(colInfo){ - - @Override - public void renderGridCellContents(RenderContext ctx, HtmlWriter out) - { - String objectid = (String)getBoundColumn().getValue(ctx); - Date date = (Date)ctx.get("date"); - String caseid = (String)ctx.get("caseid"); - String category = (String)ctx.get("category"); - ActionURL url = new ActionURL("ehr", "dataEntryForm", colInfo.getParentTable().getUserSchema().getContainer()); - if (!colInfo.getParentTable().getUserSchema().getContainer().hasPermission(colInfo.getParentTable().getUserSchema().getUser(), EHRClinicalEntryPermission.class)) - return; - - if (category == null) - return; - - if (category.equals("Behavior")) - { - if (caseid != null) - { - url.addParameter("formType", "Behavioral Rounds"); - url.addParameter("caseid", caseid); - } - else - { - url.addParameter("formType", "Bulk Behavior Entry"); - } - } - else - { - if (caseid != null) - { - url.addParameter("formType", "Clinical Rounds"); - url.addParameter("caseid", caseid); - } - else - { - url.addParameter("formType", "medicationTreatment"); - } - } - - url.addParameter("treatmentid", objectid); - if (_includeScheduledDate && date != null) - url.addParameter("scheduledDate", DateUtil.formatIsoDateShortTime(date)); - - String returnUrl = new ActionURL("ehr", "animalHistory", colInfo.getParentTable().getUserSchema().getContainer()) + "#inputType:none&showReport:0&activeReport:clinMedicationSchedule"; - url.addParameter("returnUrl", returnUrl); - - out.write(LinkBuilder.labkeyLink("Record Treatment", url).target("_blank")); - } - - @Override - public void addQueryFieldKeys(Set keys) - { - super.addQueryFieldKeys(keys); - keys.add(getBoundColumn().getFieldKey()); - keys.add(FieldKey.fromString("date")); - keys.add(FieldKey.fromString("caseid")); - keys.add(FieldKey.fromString("category")); - } - - @Override - public boolean isSortable() - { - return false; - } - - @Override - public boolean isFilterable() - { - return false; - } - - @Override - public boolean isEditable() - { - return false; - } - }; - } -} From 9dee146835f29a5865f5cc8e2c86906adeb6a383 Mon Sep 17 00:00:00 2001 From: Marty Pradere Date: Thu, 30 Jul 2026 05:59:45 -0600 Subject: [PATCH 2/3] Declare this center's Behavior treatment link routing The shared config no longer seeds it. Same form names as before, so the rendered links are unchanged. --- .../src/org/labkey/nirc_ehr/table/NIRC_EHRCustomizer.java | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/nirc_ehr/src/org/labkey/nirc_ehr/table/NIRC_EHRCustomizer.java b/nirc_ehr/src/org/labkey/nirc_ehr/table/NIRC_EHRCustomizer.java index 31997901..31cb9df5 100644 --- a/nirc_ehr/src/org/labkey/nirc_ehr/table/NIRC_EHRCustomizer.java +++ b/nirc_ehr/src/org/labkey/nirc_ehr/table/NIRC_EHRCustomizer.java @@ -69,7 +69,9 @@ public class NIRC_EHRCustomizer extends AbstractTableCustomizer { - private static final TreatmentLinkConfig RECORD_TREATMENT = TreatmentLinkConfig.builder().build(); + private static final TreatmentLinkConfig RECORD_TREATMENT = TreatmentLinkConfig.builder() + .formTypes("Behavior", "Behavioral Rounds", "Bulk Behavior Entry") + .build(); public UserSchema getEHRUserSchema(AbstractTableInfo ds, String name) { From fc39909ae350f2523f784052682bead54bc020bb Mon Sep 17 00:00:00 2001 From: Marty Pradere Date: Wed, 5 Aug 2026 12:37:20 -0600 Subject: [PATCH 3/3] Add test for treatment link routing and scheduled date Covers each treatment category's form type routing and the scheduledDate distinction between the treatment order and treatment schedule links, neither of which had coverage in this module. --- .../tests.nirc_ehr/NIRC_EHRTest.java | 126 ++++++++++++++++++ 1 file changed, 126 insertions(+) diff --git a/nirc_ehr/test/src/org.labkey.test/tests.nirc_ehr/NIRC_EHRTest.java b/nirc_ehr/test/src/org.labkey.test/tests.nirc_ehr/NIRC_EHRTest.java index 6bf96be4..4cf09864 100644 --- a/nirc_ehr/test/src/org.labkey.test/tests.nirc_ehr/NIRC_EHRTest.java +++ b/nirc_ehr/test/src/org.labkey.test/tests.nirc_ehr/NIRC_EHRTest.java @@ -68,6 +68,9 @@ import java.io.BufferedReader; import java.io.File; import java.io.IOException; +import java.net.URI; +import java.net.URLDecoder; +import java.nio.charset.StandardCharsets; import java.nio.file.FileVisitResult; import java.nio.file.Files; import java.nio.file.Path; @@ -1572,6 +1575,129 @@ public void testBehavioralCases() Assert.assertEquals("Case was not closed", 1, activeCase.getDataRowCount()); } + // Verifies the URL rendered by the treatment link display column on both tables that carry it. The category to + // form type routing, the treatmentid parameter name and the return report are all configuration declared in + // NIRC_EHRCustomizer, so a typo there is otherwise invisible until a user clicks the link. The presence of + // scheduledDate is what separates the two tables: the schedule's date is the slot being recorded, while a + // treatment order's date is the order's start date, so passing it would make every recording against an order + // look like the first one. + @Test + public void testTreatmentRecordLinks() throws Exception + { + String animalId = "TRTLINK1"; + String behaviorCaseId = UUID.randomUUID().toString(); + String clinicalCaseId = UUID.randomUUID().toString(); + + // objectids are supplied rather than server-generated so each rendered link can be matched back to the order + // it came from without depending on grid row order. + String behaviorWithCase = UUID.randomUUID().toString(); + String behaviorNoCase = UUID.randomUUID().toString(); + String clinicalWithCase = UUID.randomUUID().toString(); + String surgicalNoCase = UUID.randomUUID().toString(); + + String orderStart = LocalDateTime.now().minusDays(1).format(_dateFormat); + String today = LocalDateTime.now().format(_dateFormat); + + goToEHRFolder(); + + log("Creating a live animal with one active treatment order per routing case being verified"); + getApiHelper().deleteAllRecords("study", "treatment_order", new Filter("Id", animalId)); + getApiHelper().deleteAllRecords("study", "demographics", new Filter("Id", animalId)); + + String[] demographicsFields = {"Id", "Species", "Birth", "Gender", "date", "calculated_status", "objectid", "performedby"}; + Object[][] demographicsData = {{animalId, "Rhesus", (new Date()).toString(), getMale(), new Date(), "Alive", UUID.randomUUID().toString(), 1004}}; + getApiHelper().doSaveRows(DATA_ADMIN.getEmail(), getApiHelper().prepareInsertCommand("study", "demographics", "lsid", demographicsFields, demographicsData), getExtraContext()); + + // SID yields exactly one scheduled slot per order per day, at the 8:00 AM hourofday in + // treatment_frequency_times, so each order below contributes exactly one row to the schedule. + // Surgical is included because it has no routing of its own: it must fall through to the same forms as + // Clinical, proving the fallback is not keyed to the Clinical category. + String[] orderFields = {"Id", "date", "code", "frequency", "route", "category", "caseid", FIELD_QCSTATELABEL, FIELD_OBJECTID, FIELD_LSID, "_recordid", "performedby"}; + Object[][] orderData = { + {animalId, orderStart, "NIRC-001", "SID", "IV", "Behavior", behaviorCaseId, EHRQCState.COMPLETED.label, behaviorWithCase, null, "recordID1", 1004}, + {animalId, orderStart, "NIRC-001", "SID", "IV", "Behavior", null, EHRQCState.COMPLETED.label, behaviorNoCase, null, "recordID2", 1004}, + {animalId, orderStart, "NIRC-001", "SID", "IV", "Clinical", clinicalCaseId, EHRQCState.COMPLETED.label, clinicalWithCase, null, "recordID3", 1004}, + {animalId, orderStart, "NIRC-001", "SID", "IV", "Surgical", null, EHRQCState.COMPLETED.label, surgicalNoCase, null, "recordID4", 1004} + }; + getApiHelper().doSaveRows(DATA_ADMIN.getEmail(), getApiHelper().prepareInsertCommand("study", "treatment_order", "lsid", orderFields, orderData), getExtraContext()); + + log("Verifying the treatment order links, which must not pass a scheduled date"); + beginAt(String.format("%s/query-executeQuery.view?schemaName=study&query.queryName=treatment_order&query.columns=objectid,Id,category,caseid,treatmentRecord&query.Id~eq=%s", + getContainerPath(), animalId)); + DataRegionTable orderTable = new DataRegionTable("query", this); + assertEquals("Incorrect number of treatment orders", 4, orderTable.getDataRowCount()); + + Map> orderLinks = readTreatmentLinkParams(orderTable); + verifyTreatmentLink(orderLinks, behaviorWithCase, "a Behavior order with a case", "Behavioral Rounds", behaviorCaseId, null); + verifyTreatmentLink(orderLinks, behaviorNoCase, "a Behavior order with no case", "Bulk Behavior Entry", null, null); + verifyTreatmentLink(orderLinks, clinicalWithCase, "a Clinical order with a case", "Clinical Rounds", clinicalCaseId, null); + verifyTreatmentLink(orderLinks, surgicalNoCase, "a Surgical order with no case", "medicationTreatment", null, null); + + log("Verifying the treatment schedule links, which must pass the slot's own date as the scheduled date"); + beginAt(String.format("%s/query-executeQuery.view?schemaName=study&query.queryName=treatmentSchedule&query.columns=objectid,Id,category,caseid,date,treatmentRecord&query.Id~eq=%s&query.param.StartDate=%s", + getContainerPath(), animalId, today)); + DataRegionTable scheduleTable = new DataRegionTable("query", this); + assertEquals("Incorrect number of scheduled slots", 4, scheduleTable.getDataRowCount()); + + String expectedScheduledDate = today + " 08:00"; + Map> scheduleLinks = readTreatmentLinkParams(scheduleTable); + verifyTreatmentLink(scheduleLinks, behaviorWithCase, "a Behavior slot with a case", "Behavioral Rounds", behaviorCaseId, expectedScheduledDate); + verifyTreatmentLink(scheduleLinks, behaviorNoCase, "a Behavior slot with no case", "Bulk Behavior Entry", null, expectedScheduledDate); + verifyTreatmentLink(scheduleLinks, clinicalWithCase, "a Clinical slot with a case", "Clinical Rounds", clinicalCaseId, expectedScheduledDate); + verifyTreatmentLink(scheduleLinks, surgicalNoCase, "a Surgical slot with no case", "medicationTreatment", null, expectedScheduledDate); + + checker().screenShotIfNewError("treatmentRecordLinks"); + + // The URL assertions above cannot tell a correct form type name from a plausible misspelling, so open the one + // routing no other test reaches: a Behavior order with no case, which goes to the bulk entry form. + log("Verifying the Behavior no-case link opens the bulk entry form"); + scheduleTable.link(scheduleRowForOrder(scheduleTable, behaviorNoCase), "treatmentRecord").click(); + switchToWindow(1); + waitForText("Bulk Behavior Entry"); + waitForText(animalId); + switchToMainWindow(); + } + + // Maps the URL parameters of each rendered treatmentRecord link, keyed by the objectid of the row it was + // rendered from. + private Map> readTreatmentLinkParams(DataRegionTable table) + { + Map> byOrderId = new HashMap<>(); + for (int row = 0; row < table.getDataRowCount(); row++) + { + String href = table.link(row, "treatmentRecord").getAttribute("href"); + Assert.assertNotNull("Treatment link in row " + row + " has no href", href); + Map params = new HashMap<>(); + WebTestHelper.parseUrlQueryString(URI.create(href).getRawQuery()) + .forEach((key, value) -> params.put(key, value == null ? null : URLDecoder.decode(value, StandardCharsets.UTF_8))); + byOrderId.put(table.getDataAsText(row, "objectid"), params); + } + return byOrderId; + } + + private int scheduleRowForOrder(DataRegionTable table, String objectid) + { + int row = table.getColumnDataAsText("objectid").indexOf(objectid); + Assert.assertNotEquals("No schedule row for treatment order " + objectid, -1, row); + return row; + } + + // expectedCaseId and expectedScheduledDate are null when the parameter must be absent entirely. An empty value is + // a failure, not a pass: the link is meant to omit the parameter rather than send it blank. + private void verifyTreatmentLink(Map> linksByOrderId, String objectid, String scenario, + String expectedFormType, @Nullable String expectedCaseId, @Nullable String expectedScheduledDate) + { + Map params = linksByOrderId.get(objectid); + Assert.assertNotNull("No treatment link rendered for " + scenario, params); + + checker().verifyEquals("Incorrect formType for " + scenario, expectedFormType, params.get("formType")); + checker().verifyEquals("Incorrect caseid for " + scenario, expectedCaseId, params.get("caseid")); + checker().verifyEquals("Incorrect scheduledDate for " + scenario, expectedScheduledDate, params.get("scheduledDate")); + checker().verifyEquals("Incorrect treatmentid for " + scenario, objectid, params.get("treatmentid")); + checker().verifyTrue("returnUrl should return to the medication schedule report for " + scenario + ": " + params.get("returnUrl"), + params.get("returnUrl") != null && params.get("returnUrl").endsWith("activeReport:clinMedicationSchedule")); + } + private int countLines(File file) throws Exception { try (BufferedReader reader = Readers.getReader(file))