diff --git a/EHR_App/test/sampledata/EHR_App/ehr_lookups/areas.tsv b/EHR_App/resources/data/areas.tsv similarity index 100% rename from EHR_App/test/sampledata/EHR_App/ehr_lookups/areas.tsv rename to EHR_App/resources/data/areas.tsv diff --git a/EHR_App/test/sampledata/EHR_App/ehr_lookups/buildings.tsv b/EHR_App/resources/data/buildings.tsv similarity index 100% rename from EHR_App/test/sampledata/EHR_App/ehr_lookups/buildings.tsv rename to EHR_App/resources/data/buildings.tsv diff --git a/EHR_App/test/sampledata/EHR_App/ehr_lookups/cage.tsv b/EHR_App/resources/data/cage.tsv similarity index 100% rename from EHR_App/test/sampledata/EHR_App/ehr_lookups/cage.tsv rename to EHR_App/resources/data/cage.tsv diff --git a/EHR_App/test/sampledata/EHR_App/ehr_lookups/cage_type.tsv b/EHR_App/resources/data/cage_type.tsv similarity index 100% rename from EHR_App/test/sampledata/EHR_App/ehr_lookups/cage_type.tsv rename to EHR_App/resources/data/cage_type.tsv diff --git a/EHR_App/resources/data/calculated_status_codes.tsv b/EHR_App/resources/data/calculated_status_codes.tsv new file mode 100644 index 000000000..dcc907e1a --- /dev/null +++ b/EHR_App/resources/data/calculated_status_codes.tsv @@ -0,0 +1,6 @@ +code +Alive +Dead +ERROR +No Record +Shipped \ No newline at end of file diff --git a/EHR_App/resources/data/editable_lookups.tsv b/EHR_App/resources/data/editable_lookups.tsv new file mode 100644 index 000000000..20b401521 --- /dev/null +++ b/EHR_App/resources/data/editable_lookups.tsv @@ -0,0 +1,2 @@ +sch query category title description + diff --git a/EHR_App/test/sampledata/EHR_App/ehr_lookups/gender_codes.tsv b/EHR_App/resources/data/gender_codes.tsv similarity index 100% rename from EHR_App/test/sampledata/EHR_App/ehr_lookups/gender_codes.tsv rename to EHR_App/resources/data/gender_codes.tsv diff --git a/EHR_App/resources/data/lookup_sets.tsv b/EHR_App/resources/data/lookup_sets.tsv new file mode 100644 index 000000000..d0d1ae6d5 --- /dev/null +++ b/EHR_App/resources/data/lookup_sets.tsv @@ -0,0 +1,2 @@ +setname label keyfield titleColumn + diff --git a/EHR_App/resources/data/lookupsManifest.tsv b/EHR_App/resources/data/lookupsManifest.tsv new file mode 100644 index 000000000..a86dc78f4 --- /dev/null +++ b/EHR_App/resources/data/lookupsManifest.tsv @@ -0,0 +1,11 @@ +name +editable_lookups +areas +buildings +cage +cage_type +calculated_status_codes +gender_codes +rooms +source +species \ No newline at end of file diff --git a/EHR_App/resources/data/lookupsManifestTest.tsv b/EHR_App/resources/data/lookupsManifestTest.tsv new file mode 100644 index 000000000..d3a021336 --- /dev/null +++ b/EHR_App/resources/data/lookupsManifestTest.tsv @@ -0,0 +1,11 @@ +name +editable_lookups +areas +buildings +cage +cage_type +calculated_status_codes +gender_codes +rooms +source +species diff --git a/EHR_App/resources/data/reports/reports.tsv b/EHR_App/resources/data/reports/reports.tsv deleted file mode 100644 index 2c54fa06e..000000000 --- a/EHR_App/resources/data/reports/reports.tsv +++ /dev/null @@ -1,19 +0,0 @@ -reportname category reporttype reporttitle visible containerpath schemaname queryname viewname report datefieldname todayonly queryhaslocation sort_order QCStateLabelFieldName description -activeHousing Colony Management query Housing - Active TRUE study housing Active Housing date FALSE TRUE qcstate/publicdata This report shows the active housing record for each animal -birth Colony Management query Birth Records TRUE study birth date FALSE FALSE qcstate/publicdata Birth records -housing Colony Management query Housing History TRUE study housing date FALSE TRUE qcstate/publicdata This report contains the housing history of each animal -roommateHistory Colony Management query Cagemate History TRUE study housingRoommates StartDate FALSE FALSE qcstate/publicdata This report displays all animals that shared a cage, including the start date, stop date and days co-housed -weight Colony Management js Weights TRUE study weightGraph date FALSE FALSE qcstate/publicdata This report contains a summary of the animal\'s weight, including a graph -Flags Colony Management query Flags true study flags StartDate false false qcstate/publicdata Animal attribute flags -demographics General query Demographics TRUE study demographics FALSE FALSE qcstate/publicdata This report displays the demographics data about each animal including species, sex and birth -snapshot General js Snapshot TRUE study snapshot FALSE FALSE qcstate/publicdata This report contains a summary of the animal, including demographics, assignments and weight -death Pathology query Death Records true study deaths date false false qcstate/publicdata Death records -arrival General query Arrivals true study arrival date false false qcstate/publicdata Displays arrival dates -departure General query Departures true study departure date false false qcstate/publicdata Displays departure dates -currentBlood Clinical js Current Blood true study currentBlood date false false qcstate/publicdata This report contains a summary of the current available blood for each animal -bloodDraws Clinical query Blood Draws TRUE study blood date FALSE FALSE qcstate/publicdata This report displays blood draw data for the selected animal -vitals Clinical query Vital Signs TRUE study vitals date FALSE FALSE qcstate/publicdata This report displays vitals data for the selected animal -procedures Clinical query Procedures TRUE study prc date FALSE FALSE qcstate/publicdata This report displays procedures data for the selected animal -drugAdministration Clinical query Drug Administration TRUE study drug date FALSE FALSE qcstate/publicdata This report displays drug administration data for the selected animal -drug Behavior query Behavior Treatments true study Drug Administration Behavior Treatments date false false qcstate/publicdata This report contains the behavior treatments entered about each animal -cases Daily Reports query Active Clinical Cases true study Cases Active Clinical Cases date false false qcstate/publicdata This displays active clinical cases \ No newline at end of file diff --git a/EHR_App/test/sampledata/EHR_App/ehr_lookups/rooms.tsv b/EHR_App/resources/data/rooms.tsv similarity index 100% rename from EHR_App/test/sampledata/EHR_App/ehr_lookups/rooms.tsv rename to EHR_App/resources/data/rooms.tsv diff --git a/EHR_App/test/sampledata/EHR_App/ehr_lookups/source.tsv b/EHR_App/resources/data/source.tsv similarity index 100% rename from EHR_App/test/sampledata/EHR_App/ehr_lookups/source.tsv rename to EHR_App/resources/data/source.tsv diff --git a/EHR_App/test/sampledata/EHR_App/ehr_lookups/species.tsv b/EHR_App/resources/data/species.tsv similarity index 100% rename from EHR_App/test/sampledata/EHR_App/ehr_lookups/species.tsv rename to EHR_App/resources/data/species.tsv diff --git a/EHR_App/resources/queries/study/Pedigree.sql b/EHR_App/resources/queries/study/Pedigree.sql new file mode 100644 index 000000000..2ba0f1e2e --- /dev/null +++ b/EHR_App/resources/queries/study/Pedigree.sql @@ -0,0 +1,34 @@ +/* + * Copyright (c) 2015-2018 LabKey Corporation + * + * Licensed under the Apache License, Version 2.0: http://www.apache.org/licenses/LICENSE-2.0 + */ +SELECT + + d.id as Id, + d.dam as Dam, + d.sire as Sire, + + CASE (d.id.demographics.gender.code) + WHEN 'e' THEN 1 + WHEN 'm' THEN 1 + WHEN 'v' THEN 1 + WHEN 'c' THEN 2 + WHEN 'f' THEN 2 + WHEN 's' THEN 2 + ELSE 3 + END AS gender, + d.id.demographics.gender.meaning as gender_code, + CASE (d.id.demographics.calculated_status) + WHEN 'Alive' THEN 0 + ELSE 1 + END + AS status, + d.id.demographics.calculated_status as status_code, + d.id.demographics.species.common as species, + '' as Display, + 'Demographics' as source, + d.modified + +FROM study.demographics d +WHERE d.Dam IS NOT NULL OR d.Sire IS NOT NULL diff --git a/EHR_App/resources/reports/additionalReports.tsv b/EHR_App/resources/reports/additionalReports.tsv new file mode 100644 index 000000000..caf77c873 --- /dev/null +++ b/EHR_App/resources/reports/additionalReports.tsv @@ -0,0 +1,25 @@ +reportname category reporttype reporttitle visible containerpath schemaname queryname viewname report datefieldname todayonly queryhaslocation sort_order QCStateLabelFieldName description supportsNonIdFilters +behaviorRemarks +clinObsBehavior +pairingsBehavior +pairingHousingSummary +pairingHistory +alopecia +biopsy +clinremarks +currentBlood +obs +physicalExam +serology +breeder +exemptions +pairings +bloodSchedule +clinMedicationSchedule +dietSchedule +incompleteTreatments +surgMedicationSchedule +surgMedicationScheduleDaily +inbreeding +necropsy +pregnancy \ No newline at end of file diff --git a/EHR_App/test/sampledata/EHR_App/ehr_lookups/calculated_status_codes.tsv b/EHR_App/test/sampledata/EHR_App/ehr_lookups/calculated_status_codes.tsv deleted file mode 100644 index febf086c0..000000000 --- a/EHR_App/test/sampledata/EHR_App/ehr_lookups/calculated_status_codes.tsv +++ /dev/null @@ -1,6 +0,0 @@ -code meaning -ALIVE ALIVE -DEAD DEAD -TRANS TRANSFERRED -MISS MISSING - UNKNOWN DISPOSITION -WOODS IN THE WOODS \ No newline at end of file diff --git a/EHR_App/test/sampledata/EHR_App/study/study/datasets/datasetDemographics.tsv b/EHR_App/test/sampledata/EHR_App/study/study/datasets/datasetDemographics.tsv index 9d2289a78..6674ae2fd 100644 --- a/EHR_App/test/sampledata/EHR_App/study/study/datasets/datasetDemographics.tsv +++ b/EHR_App/test/sampledata/EHR_App/study/study/datasets/datasetDemographics.tsv @@ -1,65 +1,65 @@ objectid Id date birth death calculated_status gender sire dam species origin geographic_origin -1 44444 -1381d -1381d ALIVE 1 44442 44443 171 00001 -2 TSTCP -1382d -1382d ALIVE 1 44442 44443 171 00002 -3 44446 -1406d -1406d ALIVE 1 44442 44443 171 00003 -4 44445 -1414d -1414d -726d DEAD 2 44442 44443 171 00004 -5 TEST3844307 -2454d -2454d ALIVE 1 5748235 TEST2312318 171 00005 -6 TEST8976544 -2470d -2470d ALIVE 2 9516255 8739374 20 00001 CHINESE -7 TEST9195996 -2473d -2473d ALIVE 1 TEST6390238 TEST2312318 171 00002 -8 TEST6208376 -2500d -2500d ALIVE 2 2568235 5961588 20 00003 CHINESE -9 TEST6700530 -2507d -2507d -2132d DEAD 1 9516255 7773678 20 00004 CHINESE -10 TEST3621582 -2759d -2759d ALIVE 2 TEST6390238 TEST2312318 171 00005 -11 TEST3224553 -2779d -2779d -2189d DEAD 2 4434585 7877112 20 00001 CHINESE -12 TEST727088 -2815d -2815d ALIVE 2 3686702 9719847 20 00002 CHINESE -13 TEST1112911 -2819d -2819d ALIVE 2 731544 6914348 20 00003 CHINESE -14 TEST1020148 -2908d -2908d -2235d DEAD 2 57307 7166552 171 00004 -15 TEST1099252 -2916d -2916d -2244d DEAD 2 TEST6390238 TEST2312318 171 00005 -16 TEST1441142 -2918d -2918d -2231d DEAD 2 731544 1256797 20 00001 CHINESE -17 TEST4037096 -2930d -2930d ALIVE 2 4597773 20 00002 CHINESE -18 TEST3771679 -2958d -2958d ALIVE 2 TEST6390238 TEST2312318 171 00003 -19 TEST9118022 -2980d -2980d -2262d DEAD 2 2042908 7257197 20 00004 CHINESE -20 TEST5409620 -2989d -2989d ALIVE 1 9468964 6954679 20 00005 CHINESE -21 TEST5131891 -2993d -2993d ALIVE 1 2480492 2001039 20 00001 CHINESE -22 TEST5158984 -3005d -3005d ALIVE 1 TEST6390238 TEST2312318 171 00002 -23 TEST7151371 -3022d -3022d ALIVE 1 2042908 838626 20 00003 CHINESE -24 TEST3137998 -3025d -3025d ALIVE 1 4778953 690232 20 00004 CHINESE -25 TEST4945025 -3082d -3082d ALIVE 1 9819418 3066209 20 00005 CHINESE -26 TEST1684145 -3087d -3087d ALIVE 2 4891303 8982969 20 00001 CHINESE -27 TEST5598475 -3091d -3091d ALIVE 1 TEST6390238 TEST2312318 171 00002 -28 TEST7407382 -3178d -3178d ALIVE 1 731451 1712704 20 00003 INDIAN -29 TEST8533200 -3209d -3209d ALIVE 1 9468964 1636539 20 00004 INDIAN -30 TEST3843301 -3219d -3219d ALIVE 1 731544 TEST2312318 171 00005 -31 TEST4935165 -3240d -3240d ALIVE 2 6974794 4343642 20 00001 INDIAN -32 TEST2227135 -3242d -3242d ALIVE 2 TEST6390238 TEST2312318 171 00002 -33 TEST5292692 -3310d -3310d ALIVE 1 5542511 4076261 20 00003 INDIAN -34 TEST4013108 -3334d -3334d ALIVE 1 4891303 7524224 171 00004 -35 TEST3935154 -3338d -3338d ALIVE 1 7785547 7579363 171 00005 -36 TEST4710248 -3341d -3341d ALIVE 1 9819418 642333 20 00001 INDIAN -37 TEST2950014 -3358d -3358d -2224d DEAD 1 4778953 3386291 20 00002 INDIAN -38 TEST5171727 -3360d -3360d ALIVE 1 731544 116526 20 00003 INDIAN -39 TEST499022 -3566d -3566d ALIVE 2 731451 532430 171 00004 -40 TEST2008446 -3618d -3618d ALIVE 2 4884340 7405528 20 00005 INDIAN -41 TEST3997535 -3637d -3637d ALIVE 2 4778953 9681212 20 00001 INDIAN -42 TEST6390238 -3923d -3923d ALIVE 2 3565069 5250080 171 00002 -43 TEST4564246 -4622d -4622d ALIVE 2 8296075 7877112 171 00003 -44 TEST5904521 -5431d -5431d ALIVE 1 8377984 20 00004 INDIAN -45 TEST3804589 -5806d -5806d ALIVE 1 493957 9749422 20 00005 INDIAN -46 TEST4551032 -6362d -6362d ALIVE 1 5030167 8416939 20 00001 INDIAN -47 TEST2312318 -8069d -8069d ALIVE 1 5748235 8739374 171 00002 -48 TEST1993532 -11808d -11808d -2259d DEAD 2 5409336 3784452 20 00003 INDIAN -49 TESTMICE101 -2200d -2200d ALIVE 1 200 00001 INDIAN -50 TESTMICE102 -500d -500d ALIVE 2 TESTMICE101 200 00001 -51 TESTRAT101 -1500d -1500d ALIVE 1 300 00002 CHINESE -52 TESTRAT102 -700d -700d ALIVE 2 TESTRAT101 300 00002 -53 TESTGPIG101 -1750d -1750d ALIVE 1 400 00003 INDIAN -54 TESTGPIG102 -750d -750d ALIVE 2 TESTGPIG101 400 00003 -55 TESTGRBL101 -2275d -2275d ALIVE 1 500 00004 INDIAN -56 TESTGRBL102 -1000d -1000d ALIVE 2 TESTGRBL101 500 00004 -57 TESTRBT101 -3100d -3100d ALIVE 1 600 00005 CHINESE -58 TESTRBT102 -500d -500d ALIVE 2 TESTRBT101 600 00005 -59 TESTHMSTR101 -1100d -1100d ALIVE 1 700 00001 INDIAN -60 TESTHMSTR102 -375d -375d ALIVE 2 TESTHMSTR101 700 00001 -61 TESTCAT101 -2612d -2612d ALIVE 1 800 00002 CHINESE -62 TESTCAT102 -1125d -1125d ALIVE 2 TESTCAT101 800 00002 -63 AnimalTx01 -1125d -1125d ALIVE 1 44442 44443 171 00001 -64 AnimalTx02 -1125d -1125d ALIVE 2 44442 44443 171 00001 \ No newline at end of file +1 44444 -1381d -1381d Alive 1 44442 44443 171 00001 +2 TSTCP -1382d -1382d Alive 1 44442 44443 171 00002 +3 44446 -1406d -1406d Alive 1 44442 44443 171 00003 +4 44445 -1414d -1414d -726d Dead 2 44442 44443 171 00004 +5 TEST3844307 -2454d -2454d Alive 1 5748235 TEST2312318 171 00005 +6 TEST8976544 -2470d -2470d Alive 2 9516255 8739374 20 00001 CHINESE +7 TEST9195996 -2473d -2473d Alive 1 TEST6390238 TEST2312318 171 00002 +8 TEST6208376 -2500d -2500d Alive 2 2568235 5961588 20 00003 CHINESE +9 TEST6700530 -2507d -2507d -2132d Dead 1 9516255 7773678 20 00004 CHINESE +10 TEST3621582 -2759d -2759d Alive 2 TEST6390238 TEST2312318 171 00005 +11 TEST3224553 -2779d -2779d -2189d Dead 2 4434585 7877112 20 00001 CHINESE +12 TEST727088 -2815d -2815d Alive 2 3686702 9719847 20 00002 CHINESE +13 TEST1112911 -2819d -2819d Alive 2 731544 6914348 20 00003 CHINESE +14 TEST1020148 -2908d -2908d -2235d Dead 2 57307 7166552 171 00004 +15 TEST1099252 -2916d -2916d -2244d Dead 2 TEST6390238 TEST2312318 171 00005 +16 TEST1441142 -2918d -2918d -2231d Dead 2 731544 1256797 20 00001 CHINESE +17 TEST4037096 -2930d -2930d Alive 2 4597773 20 00002 CHINESE +18 TEST3771679 -2958d -2958d Alive 2 TEST6390238 TEST2312318 171 00003 +19 TEST9118022 -2980d -2980d -2262d Dead 2 2042908 7257197 20 00004 CHINESE +20 TEST5409620 -2989d -2989d Alive 1 9468964 6954679 20 00005 CHINESE +21 TEST5131891 -2993d -2993d Alive 1 2480492 2001039 20 00001 CHINESE +22 TEST5158984 -3005d -3005d Alive 1 TEST6390238 TEST2312318 171 00002 +23 TEST7151371 -3022d -3022d Alive 1 2042908 838626 20 00003 CHINESE +24 TEST3137998 -3025d -3025d Alive 1 4778953 690232 20 00004 CHINESE +25 TEST4945025 -3082d -3082d Alive 1 9819418 3066209 20 00005 CHINESE +26 TEST1684145 -3087d -3087d Alive 2 4891303 8982969 20 00001 CHINESE +27 TEST5598475 -3091d -3091d Alive 1 TEST6390238 TEST2312318 171 00002 +28 TEST7407382 -3178d -3178d Alive 1 731451 1712704 20 00003 INDIAN +29 TEST8533200 -3209d -3209d Alive 1 9468964 1636539 20 00004 INDIAN +30 TEST3843301 -3219d -3219d Alive 1 731544 TEST2312318 171 00005 +31 TEST4935165 -3240d -3240d Alive 2 6974794 4343642 20 00001 INDIAN +32 TEST2227135 -3242d -3242d Alive 2 TEST6390238 TEST2312318 171 00002 +33 TEST5292692 -3310d -3310d Alive 1 5542511 4076261 20 00003 INDIAN +34 TEST4013108 -3334d -3334d Alive 1 4891303 7524224 171 00004 +35 TEST3935154 -3338d -3338d Alive 1 7785547 7579363 171 00005 +36 TEST4710248 -3341d -3341d Alive 1 9819418 642333 20 00001 INDIAN +37 TEST2950014 -3358d -3358d -2224d Dead 1 4778953 3386291 20 00002 INDIAN +38 TEST5171727 -3360d -3360d Alive 1 731544 116526 20 00003 INDIAN +39 TEST499022 -3566d -3566d Alive 2 731451 532430 171 00004 +40 TEST2008446 -3618d -3618d Alive 2 4884340 7405528 20 00005 INDIAN +41 TEST3997535 -3637d -3637d Alive 2 4778953 9681212 20 00001 INDIAN +42 TEST6390238 -3923d -3923d Alive 2 3565069 5250080 171 00002 +43 TEST4564246 -4622d -4622d Alive 2 8296075 7877112 171 00003 +44 TEST5904521 -5431d -5431d Alive 1 8377984 20 00004 INDIAN +45 TEST3804589 -5806d -5806d Alive 1 493957 9749422 20 00005 INDIAN +46 TEST4551032 -6362d -6362d Alive 1 5030167 8416939 20 00001 INDIAN +47 TEST2312318 -8069d -8069d Alive 1 5748235 8739374 171 00002 +48 TEST1993532 -11808d -11808d -2259d Dead 2 5409336 3784452 20 00003 INDIAN +49 TESTMICE101 -2200d -2200d Alive 1 200 00001 INDIAN +50 TESTMICE102 -500d -500d Alive 2 TESTMICE101 200 00001 +51 TESTRAT101 -1500d -1500d Alive 1 300 00002 CHINESE +52 TESTRAT102 -700d -700d Alive 2 TESTRAT101 300 00002 +53 TESTGPIG101 -1750d -1750d Alive 1 400 00003 INDIAN +54 TESTGPIG102 -750d -750d Alive 2 TESTGPIG101 400 00003 +55 TESTGRBL101 -2275d -2275d Alive 1 500 00004 INDIAN +56 TESTGRBL102 -1000d -1000d Alive 2 TESTGRBL101 500 00004 +57 TESTRBT101 -3100d -3100d Alive 1 600 00005 CHINESE +58 TESTRBT102 -500d -500d Alive 2 TESTRBT101 600 00005 +59 TESTHMSTR101 -1100d -1100d Alive 1 700 00001 INDIAN +60 TESTHMSTR102 -375d -375d Alive 2 TESTHMSTR101 700 00001 +61 TESTCAT101 -2612d -2612d Alive 1 800 00002 CHINESE +62 TESTCAT102 -1125d -1125d Alive 2 TESTCAT101 800 00002 +63 AnimalTx01 -1125d -1125d Alive 1 44442 44443 171 00001 +64 AnimalTx02 -1125d -1125d Alive 2 44442 44443 171 00001 \ No newline at end of file diff --git a/EHR_App/test/src/org/labkey/test/pages/ReactAnimalHistoryPage.java b/EHR_App/test/src/org/labkey/test/pages/ReactAnimalHistoryPage.java new file mode 100644 index 000000000..360113675 --- /dev/null +++ b/EHR_App/test/src/org/labkey/test/pages/ReactAnimalHistoryPage.java @@ -0,0 +1,596 @@ +package org.labkey.test.pages; + +import org.labkey.test.Locator; +import org.labkey.test.WebDriverWrapper; +import org.labkey.test.WebTestHelper; +import org.labkey.test.util.DataRegionTable; +import org.openqa.selenium.WebDriver; +import org.openqa.selenium.WebElement; + +import java.util.List; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; +import static org.labkey.test.util.DataRegionTable.DataRegion; + +/** + * Page wrapper for the React-based Animal History page (participantViewNew). + * Automates the React Animal History Search By Id functionality. + */ +public class ReactAnimalHistoryPage extends LabKeyPage +{ + public ReactAnimalHistoryPage(WebDriver driver) + { + super(driver); + } + + /** + * Navigate to the React Animal History page for the given container. + */ + public static ReactAnimalHistoryPage beginAt(WebDriverWrapper driver, String containerPath) + { + driver.beginAt(WebTestHelper.buildURL("ehr", containerPath, "participantViewNew")); + return new ReactAnimalHistoryPage(driver.getDriver()); + } + + /** + * Navigate to the React Animal History page with URL hash parameters. + */ + public static ReactAnimalHistoryPage beginAt(WebDriverWrapper driver, String containerPath, String urlHash) + { + String url = WebTestHelper.buildURL("ehr", containerPath, "participantViewNew") + "#" + urlHash; + driver.beginAt(url); + return new ReactAnimalHistoryPage(driver.getDriver()); + } + + @Override + protected void waitForPage() + { + waitForElement(Locators.SEARCH_BY_ID_PANEL, WAIT_FOR_JAVASCRIPT); + } + + // ========================================================================= + // Input Methods + // ========================================================================= + + /** + * Enter animal IDs into the textarea. + */ + public ReactAnimalHistoryPage enterAnimalIds(String... ids) + { + waitForElement(Locators.ANIMAL_ID_TEXTAREA); + setFormElement(Locators.ANIMAL_ID_TEXTAREA, String.join(",", ids)); + return this; + } + + /** + * Clear the animal ID textarea. + */ + public ReactAnimalHistoryPage clearIdInput() + { + waitForElement(Locators.ANIMAL_ID_TEXTAREA); + setFormElement(Locators.ANIMAL_ID_TEXTAREA, ""); + return this; + } + + /** + * Get the current value of the animal ID textarea. + */ + public String getIdInputValue() + { + return getFormElement(Locators.ANIMAL_ID_TEXTAREA); + } + + // ========================================================================= + // Button Click Methods + // ========================================================================= + + /** + * Click the Search By Ids button and wait for results. + */ + public ReactAnimalHistoryPage clickSearchByIds() + { + click(Locators.SEARCH_BY_IDS_BUTTON); + waitForSearchComplete(); + return this; + } + + /** + * Click the All Animals filter button and wait for reports to load. + */ + public ReactAnimalHistoryPage clickAllAnimals() + { + click(Locators.ALL_ANIMALS_BUTTON); + waitForReportPanelToLoad(); + return this; + } + + /** + * Click the All Alive at Center filter button and wait for reports to load. + */ + public ReactAnimalHistoryPage clickAliveAtCenter() + { + click(Locators.ALIVE_AT_CENTER_BUTTON); + waitForReportPanelToLoad(); + return this; + } + + /** + * Click a report tab by name. + */ + public ReactAnimalHistoryPage clickReportTab(String tabName) + { + Locator tab = Locators.REPORT_TAB.containing(tabName); + click(tab); + waitForElement(Locators.REPORT_TARGET); + return this; + } + + /** + * Click a category tab by name (e.g., "General", "Clinical", etc.). + */ + public ReactAnimalHistoryPage clickCategoryTab(String categoryName) + { + Locator tab = Locators.CATEGORY_TAB.containing(categoryName); + click(tab); + sleep(500); // Allow tab switch + return this; + } + + /** + * Click the Demographics tab under General category. + * Navigates to General category first if not already there. + */ + public ReactAnimalHistoryPage clickDemographicsTab() + { + clickCategoryTab("General"); + clickReportTab("Demographics"); + waitForDataRegionToLoad(); + return this; + } + + // ========================================================================= + // Data Region Methods + // ========================================================================= + + /** + * Get the DataRegionTable from the active report panel. + * Use this to interact with standard LabKey data grids in reports. + */ + public DataRegionTable getActiveReportDataRegion() + { + WebElement reportTarget = Locators.REPORT_TARGET.findElement(getDriver()); + DataRegionTable dataRegionTable = DataRegion(getDriver()).timeout(60000).find(reportTarget); + dataRegionTable.setAsync(true); + return dataRegionTable; + } + + /** + * Wait for the DataRegion in the report to fully load. + */ + public ReactAnimalHistoryPage waitForDataRegionToLoad() + { + waitForElement(Locators.REPORT_TARGET); + // Wait for data region to appear and stabilize + longWait().until(d -> { + try + { + WebElement reportTarget = Locators.REPORT_TARGET.findElement(getDriver()); + return DataRegion(getDriver()).timeout(5000).find(reportTarget) != null; + } + catch (Exception e) + { + return false; + } + }); + sleep(500); // Allow grid to stabilize + return this; + } + + // ========================================================================= + // Wait Methods + // ========================================================================= + + /** + * Wait for search to complete (button stops showing "Searching..." and results appear). + */ + public ReactAnimalHistoryPage waitForSearchComplete() + { + longWait().until(d -> { + boolean buttonReady = !getText(Locators.SEARCH_BY_IDS_BUTTON).contains("Searching"); + boolean hasResults = isElementPresent(Locators.REPORT_TARGET) || + isElementPresent(Locators.ID_RESOLUTION_FEEDBACK) || + isElementPresent(Locators.VALIDATION_ERROR); + return buttonReady && hasResults; + }); + return this; + } + + /** + * Wait for report content to load. + */ + public ReactAnimalHistoryPage waitForReportToLoad() + { + longWait().until(d -> isElementPresent(Locators.REPORT_TARGET)); + return this; + } + + /** + * Wait for the report panel to fully load, including category tabs. + * Used after filter mode changes (All Animals, Alive at Center). + */ + public ReactAnimalHistoryPage waitForReportPanelToLoad() + { + // Wait for the report target area to appear + longWait().until(d -> isElementPresent(Locators.REPORT_TARGET)); + // Wait for category tabs to be present (indicates panel is fully rendered) + longWait().until(d -> isElementPresent(Locators.CATEGORY_TAB)); + sleep(500); // Allow panel to stabilize after load + return this; + } + + /** + * Allow URL hash to be processed after navigation. + */ + public ReactAnimalHistoryPage waitForUrlHashProcessing() + { + sleep(1000); + return this; + } + + // ========================================================================= + // State Check Methods + // ========================================================================= + + /** + * Check if the Search By Ids button is active. + */ + public boolean isSearchByIdsActive() + { + return isElementPresent(Locator.css(".search-button.active")); + } + + /** + * Check if the All Animals button is active. + */ + public boolean isAllAnimalsActive() + { + return isElementPresent(Locator.css(".filter-button.all-animals.active")); + } + + /** + * Check if the Alive at Center button is active. + */ + public boolean isAliveAtCenterActive() + { + return isElementPresent(Locator.css(".filter-button.alive-at-center.active")); + } + + /** + * Check if the Alive at Center button is enabled (not disabled). + */ + public boolean isAliveAtCenterEnabled() + { + return isElementPresent(Locator.css(".filter-button.alive-at-center:not(:disabled)")); + } + + /** + * Check if ID resolution feedback is visible. + */ + public boolean isIdResolutionFeedbackVisible() + { + return isElementPresent(Locators.ID_RESOLUTION_FEEDBACK); + } + + /** + * Check if a validation error is visible. + */ + public boolean isValidationErrorVisible() + { + return isElementPresent(Locators.VALIDATION_ERROR); + } + + /** + * Check if the empty state placeholder is visible. + */ + public boolean isEmptyStatePlaceholderVisible() + { + return isElementPresent(Locators.EMPTY_STATE_PLACEHOLDER); + } + + // ========================================================================= + // Assertion Methods + // ========================================================================= + + /** + * Assert that the Search By Id panel is present with all expected elements. + */ + public ReactAnimalHistoryPage assertDefaultStatePresent() + { + assertElementPresent(Locators.SEARCH_BY_ID_PANEL); + assertElementPresent(Locators.ANIMAL_ID_TEXTAREA); + assertElementPresent(Locators.SEARCH_BY_IDS_BUTTON); + assertElementPresent(Locators.ALL_ANIMALS_BUTTON); + assertElementPresent(Locators.ALIVE_AT_CENTER_BUTTON); + return this; + } + + /** + * Assert that the report target area contains an animal ID. + */ + public ReactAnimalHistoryPage assertReportContainsAnimal(String animalId) + { + waitForElement(Locators.REPORT_TARGET); + assertTextPresent(animalId); + return this; + } + + /** + * Assert that the report target area does not contain an animal ID. + */ + public ReactAnimalHistoryPage assertReportDoesNotContainAnimal(String animalId) + { + waitForElement(Locators.REPORT_TARGET); + assertTextNotPresent(animalId); + return this; + } + + /** + * Assert that ID resolution feedback is visible or not. + */ + public ReactAnimalHistoryPage assertIdResolutionVisible(boolean shouldBeVisible) + { + if (shouldBeVisible) + { + assertElementPresent(Locators.ID_RESOLUTION_FEEDBACK); + } + else + { + assertElementNotPresent(Locators.ID_RESOLUTION_FEEDBACK); + } + return this; + } + + /** + * Assert that a resolved alias is shown (inputId -> resolvedId with aliasType). + */ + public ReactAnimalHistoryPage assertResolvedAliasContains(String inputId, String resolvedId, String aliasType) + { + assertElementPresent(Locators.RESOLVED_SECTION_TITLE); + Locator resolvedItem = Locators.RESOLVED_ITEMS.containing(inputId); + assertElementPresent(resolvedItem); + assertTextPresent(resolvedId); + return this; + } + + /** + * Assert that a direct match is shown in the resolved section. + */ + public ReactAnimalHistoryPage assertResolvedContains(String directId) + { + assertElementPresent(Locators.RESOLVED_SECTION_TITLE); + Locator resolvedItem = Locators.RESOLVED_ITEMS.containing(directId); + assertElementPresent(resolvedItem); + return this; + } + + /** + * Assert that a not-found ID is shown. + */ + public ReactAnimalHistoryPage assertNotFoundContains(String id) + { + assertElementPresent(Locators.NOT_FOUND_SECTION_TITLE); + Locator notFoundItem = Locators.NOT_FOUND_ITEMS.containing(id); + assertElementPresent(notFoundItem); + return this; + } + + /** + * Assert that a validation error is shown with the expected message. + */ + public ReactAnimalHistoryPage assertValidationErrorShown(String expectedMessage) + { + waitForElement(Locators.VALIDATION_ERROR); + assertTextPresent(expectedMessage); + return this; + } + + /** + * Assert that no validation error is shown. + */ + public ReactAnimalHistoryPage assertNoValidationError() + { + assertElementNotPresent(Locators.VALIDATION_ERROR); + return this; + } + + /** + * Assert that the empty state placeholder is shown. + */ + public ReactAnimalHistoryPage assertEmptyStatePlaceholderShown() + { + waitForElement(Locators.EMPTY_STATE_PLACEHOLDER); + return this; + } + + /** + * Assert that the Search By Ids button is active. + */ + public ReactAnimalHistoryPage assertSearchByIdsActive() + { + assertElementPresent(Locator.css(".search-button.active")); + return this; + } + + /** + * Assert that the All Animals button is active. + */ + public ReactAnimalHistoryPage assertAllAnimalsActive() + { + assertElementPresent(Locator.css(".filter-button.all-animals.active")); + return this; + } + + /** + * Assert that the Alive at Center button is active. + */ + public ReactAnimalHistoryPage assertAliveAtCenterActive() + { + assertElementPresent(Locator.css(".filter-button.alive-at-center.active")); + return this; + } + + /** + * Assert that the textarea is empty. + */ + public ReactAnimalHistoryPage assertTextareaEmpty() + { + String textareaValue = getFormElement(Locators.ANIMAL_ID_TEXTAREA); + assertTrue("Textarea should be empty", textareaValue.isEmpty()); + return this; + } + + /** + * Assert that the current URL contains a specific string. + */ + public ReactAnimalHistoryPage assertUrlContains(String expectedContent) + { + String currentUrl = getDriver().getCurrentUrl(); + assertTrue("URL should contain '" + expectedContent + "' but was: " + currentUrl, + currentUrl.contains(expectedContent)); + return this; + } + + // ========================================================================= + // Demographics Report Assertions + // ========================================================================= + + /** + * Assert that the Demographics report contains a specific animal ID. + * Filters the grid by ID column and verifies at least one row exists. + */ + public ReactAnimalHistoryPage assertDemographicsContainsId(String animalId) + { + DataRegionTable table = getActiveReportDataRegion(); + table.setFilter("Id", "Equals", animalId); + int rowCount = table.getDataRowCount(); + assertTrue("Demographics should contain animal ID '" + animalId + "' but found " + rowCount + " rows", + rowCount > 0); + table.clearFilter("Id"); + return this; + } + + /** + * Assert that the Demographics report does NOT contain a specific animal ID. + * Filters the grid by ID column and verifies no rows exist. + */ + public ReactAnimalHistoryPage assertDemographicsDoesNotContainId(String animalId) + { + DataRegionTable table = getActiveReportDataRegion(); + table.setFilter("Id", "Equals", animalId); + int rowCount = table.getDataRowCount(); + assertTrue("Demographics should NOT contain animal ID '" + animalId + "' but found " + rowCount + " rows", + rowCount == 0); + table.clearFilter("Id"); + return this; + } + + /** + * Assert that the Demographics report contains more rows than a specified count. + * Useful for verifying All Animals mode shows more than just searched IDs. + */ + public ReactAnimalHistoryPage assertDemographicsRowCountGreaterThan(int minCount) + { + DataRegionTable table = getActiveReportDataRegion(); + int rowCount = table.getDataRowCount(); + assertTrue("Demographics should have more than " + minCount + " rows but found " + rowCount, + rowCount > minCount); + return this; + } + + /** + * Get the current row count in the Demographics report. + */ + public int getDemographicsRowCount() + { + DataRegionTable table = getActiveReportDataRegion(); + return table.getDataRowCount(); + } + + /** + * Assert that all rows in the Demographics report have a specific status value. + * Used to verify Alive at Center mode only shows "Alive" animals. + */ + public ReactAnimalHistoryPage assertDemographicsAllRowsHaveStatus(String expectedStatus) + { + DataRegionTable table = getActiveReportDataRegion(); + List statusValues = table.getColumnDataAsText("calculated_status"); + for (String status : statusValues) + { + assertTrue("All Demographics rows should have status '" + expectedStatus + "' but found '" + status + "'", + status.equals(expectedStatus)); + } + return this; + } + + /** + * Assert that no rows in the Demographics report have a specific status value. + * Used to verify Alive at Center mode does not show "Dead" animals. + */ + public ReactAnimalHistoryPage assertDemographicsNoRowsHaveStatus(String excludedStatus) + { + DataRegionTable table = getActiveReportDataRegion(); + List statusValues = table.getColumnDataAsText("calculated_status"); + for (String status : statusValues) + { + assertFalse("Demographics should not contain status '" + excludedStatus + "' but found it", + status.equals(excludedStatus)); + } + return this; + } + + // ========================================================================= + // Element Cache + // ========================================================================= + + @Override + protected ElementCache newElementCache() + { + return new ElementCache(); + } + + public class ElementCache extends LabKeyPage.ElementCache + { + WebElement searchByIdPanel = Locators.SEARCH_BY_ID_PANEL.findWhenNeeded(this); + WebElement animalIdTextarea = Locators.ANIMAL_ID_TEXTAREA.findWhenNeeded(this); + WebElement searchByIdsButton = Locators.SEARCH_BY_IDS_BUTTON.findWhenNeeded(this); + WebElement allAnimalsButton = Locators.ALL_ANIMALS_BUTTON.findWhenNeeded(this); + WebElement aliveAtCenterButton = Locators.ALIVE_AT_CENTER_BUTTON.findWhenNeeded(this); + WebElement reportTarget = Locators.REPORT_TARGET.findWhenNeeded(this); + WebElement idResolutionFeedback = Locators.ID_RESOLUTION_FEEDBACK.findWhenNeeded(this); + WebElement validationError = Locators.VALIDATION_ERROR.findWhenNeeded(this); + WebElement emptyStatePlaceholder = Locators.EMPTY_STATE_PLACEHOLDER.findWhenNeeded(this); + } + + /** + * CSS selectors for React Animal History components. + */ + public static class Locators + { + public static final Locator SEARCH_BY_ID_PANEL = Locator.css(".search-by-id-panel"); + public static final Locator ANIMAL_ID_TEXTAREA = Locator.css(".animal-id-input"); + public static final Locator SEARCH_BY_IDS_BUTTON = Locator.css(".search-button"); + public static final Locator ALL_ANIMALS_BUTTON = Locator.css(".filter-button.all-animals"); + public static final Locator ALIVE_AT_CENTER_BUTTON = Locator.css(".filter-button.alive-at-center"); + public static final Locator REPORT_TARGET = Locator.css(".tabbed-report-panel .report-target"); + public static final Locator ID_RESOLUTION_FEEDBACK = Locator.css(".id-resolution-feedback"); + public static final Locator RESOLVED_SECTION_TITLE = Locator.css(".id-resolution-feedback .section-title.resolved"); + public static final Locator NOT_FOUND_SECTION_TITLE = Locator.css(".id-resolution-feedback .section-title.not-found"); + public static final Locator RESOLVED_ITEMS = Locator.css(".id-resolution-feedback .section .items .resolved-item"); + public static final Locator NOT_FOUND_ITEMS = Locator.css(".id-resolution-feedback .section .items .not-found-item"); + public static final Locator CATEGORY_TAB = Locator.css(".tabbed-report-panel .category-tabs a"); + public static final Locator REPORT_TAB = Locator.css(".tabbed-report-panel .report-tabs a"); + public static final Locator VALIDATION_ERROR = Locator.css(".search-by-id-panel .validation-error"); + public static final Locator EMPTY_STATE_PLACEHOLDER = Locator.css(".tabbed-report-panel .empty-state-placeholder"); + } +} diff --git a/EHR_App/test/src/org/labkey/test/tests/EHRAppTestSetupHelper.java b/EHR_App/test/src/org/labkey/test/tests/EHRAppTestSetupHelper.java deleted file mode 100644 index fcc7953b7..000000000 --- a/EHR_App/test/src/org/labkey/test/tests/EHRAppTestSetupHelper.java +++ /dev/null @@ -1,97 +0,0 @@ -package org.labkey.test.tests; - -import org.jetbrains.annotations.NotNull; -import org.labkey.remoteapi.CommandException; -import org.labkey.remoteapi.Connection; -import org.labkey.remoteapi.query.InsertRowsCommand; -import org.labkey.remoteapi.query.TruncateTableCommand; -import org.labkey.test.BaseWebDriverTest; -import org.labkey.test.TestFileUtils; -import org.labkey.test.WebTestHelper; -import org.labkey.test.tests.ehr.AbstractEHRTest; - -import java.io.File; -import java.io.IOException; -import java.util.Arrays; -import java.util.List; -import java.util.Map; -import java.util.Objects; - -public class EHRAppTestSetupHelper -{ - private final BaseWebDriverTest _test; - private final String _projectName; - private final String _folderName; - private final String _modulePath; - private final String _containerPath; - - public EHRAppTestSetupHelper(BaseWebDriverTest test, String projectName, String folderName, String modulePath, String containerPath) - { - _test = test; - _projectName = projectName; - _folderName = folderName; - _modulePath = modulePath; - _containerPath = containerPath; - } - - public EHRAppTestSetupHelper(BaseWebDriverTest test, String projectName) - { - this(test, projectName, null, null, null); - } - - public void populateInitialData(AbstractEHRTest test) throws Exception - { - populateInitialDataForSchema("ehr_lookups", Arrays.asList("cage")); - - _test.beginAt(WebTestHelper.buildURL("ehr", _containerPath, "populateInitialData")); - test.repopulate("Reports"); - } - - private void populateInitialDataForSchema(String schemaName, @NotNull List tablesToSkip) throws Exception - { - Connection connection = _test.createDefaultConnection(); - String relativePath = "EHR_App/" + schemaName; - File tsvs = TestFileUtils.getSampleData(relativePath); - File[] files = tsvs.listFiles(); - for (File tsv : Objects.requireNonNull(files)) - { - String queryName = tsv.getName().replace(".tsv", ""); - if (tablesToSkip.contains(queryName)) - continue; - - populateInitialDataForQuery(connection, relativePath, schemaName, queryName); - } - } - - private void populateInitialDataForQuery(Connection connection, String relativePath, String schemaName, String queryName) throws IOException, CommandException - { - truncateTable(connection, schemaName, queryName); - - _test.log("Loading tsv data: " + schemaName + "." + queryName); - File tsvFile = TestFileUtils.getSampleData(relativePath + "/" + queryName + ".tsv"); - insertTsvData(connection, schemaName, queryName, tsvFile); - } - - private void insertTsvData(Connection connection, String schemaName, String queryName, File tsvFile) throws IOException, CommandException - { - InsertRowsCommand command = new InsertRowsCommand(schemaName, queryName); - List> tsv = _test.loadTsv(tsvFile); - command.setRows(tsv); - command.execute(connection, _folderName != null ? _projectName + "/" + _folderName : _projectName); - } - - private void truncateTable(Connection connection, String schemaName, String queryName) throws IOException, CommandException - { - _test.log("Truncating table: " + schemaName + "." + queryName); - TruncateTableCommand command = new TruncateTableCommand(schemaName, queryName); - command.execute(connection, _folderName != null ? _projectName + "/" + _folderName : _projectName); - } - - public void populateRoomRecords() throws Exception - { - // now that QC States have been defined, we can load the cage.tsv file - Connection connection = _test.createDefaultConnection(); - populateInitialDataForQuery(connection, "EHR_App/ehr_lookups", "ehr_lookups", "cage"); - } - -} diff --git a/EHR_App/test/src/org/labkey/test/tests/EHR_AppTest.java b/EHR_App/test/src/org/labkey/test/tests/EHR_AppTest.java index 4ac797f71..dbdef9c46 100644 --- a/EHR_App/test/src/org/labkey/test/tests/EHR_AppTest.java +++ b/EHR_App/test/src/org/labkey/test/tests/EHR_AppTest.java @@ -5,22 +5,28 @@ import org.junit.Test; import org.junit.experimental.categories.Category; import org.labkey.test.Locator; +import org.labkey.test.ModulePropertyValue; import org.labkey.test.TestFileUtils; import org.labkey.test.WebTestHelper; import org.labkey.test.categories.EHR; +import org.labkey.test.pages.ReactAnimalHistoryPage; import org.labkey.test.tests.ehr.AbstractGenericEHRTest; import org.labkey.test.util.PostgresOnlyTest; import java.io.File; import java.util.ArrayList; import java.util.List; +import java.util.Map; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; +import static org.labkey.test.components.html.Input.Input; @Category({EHR.class}) public class EHR_AppTest extends AbstractGenericEHRTest implements PostgresOnlyTest { private static final String PROJECT_NAME = "EHR App"; private static final String FOLDER_NAME = "EHR"; - private final EHRAppTestSetupHelper _setupHelper = new EHRAppTestSetupHelper(this, getProjectName(), FOLDER_NAME, getModulePath(), getContainerPath()); @Override protected String getProjectName() @@ -40,13 +46,26 @@ public void importStudy() @Override protected void populateInitialData() throws Exception { - _setupHelper.populateInitialData(this); - } + List props = new ArrayList<>(); + props.add(new ModulePropertyValue("EHR", "/" + getProjectName(), "EHRCustomModule", "EHR_App")); + goToProjectHome(); + setModuleProperties(props); - @Override - protected void populateRoomRecords() throws Exception - { - _setupHelper.populateRoomRecords(); + beginAt(WebTestHelper.buildURL("ehr", getContainerPath(), "populateLookupData", Map.of("manifest", "lookupsManifestTest"))); + + waitForElement(Locator.linkWithText("Populate Lookups")); + click(Locator.linkWithText("Populate Lookups")); + acceptAlert(); + + waitFor(() -> Input(Locator.textarea("populateLookupResults"), getDriver()).waitFor().getValue().contains("Loading lookups is complete."), + "Lookups didn't finish loading", 60000); + + waitForElement(Locator.linkWithText("Populate Reports")); + click(Locator.linkWithText("Populate Reports")); + acceptAlert(); + + waitFor(() -> Input(Locator.textarea("populateLookupResults"), getDriver()).waitFor().getValue().contains("Loading reports is complete."), + "Reports didn't finish loading", 60000); } public void importFolderByPath(File path, String containerPath, int finishedJobsExpected) @@ -130,6 +149,7 @@ protected List skipLinksForValidation() links.add("ehr-colonyOverview.view"); links.add("ehr-updateTable.view"); links.add("ehr-populateLookupData.view"); + links.add("ehr-participantViewNew.view"); return links; } @@ -150,4 +170,358 @@ public void testCalculatedAgeColumns() { // TODO: fix this test for EHR App } + + // ============================================================================= + // React Animal History Tests + // ============================================================================= + + /** + * Test: Animal History ID Search Modes + * + * Tests the following scenarios based on spec: + * 1. Initial page load - verify default state + * 2. Single direct ID match + * 3. Multi-animal direct search + * 4. Mixed valid/invalid IDs (not found feedback) + * 5. Case-insensitive matching + * + * See spec: LK R&D EHR - React Animal History - Search By Id.md + */ + @Test + public void testAnimalHistoryIdSearchModes() + { + String testAnimalId1 = MORE_ANIMAL_IDS[0]; + String testAnimalId2 = MORE_ANIMAL_IDS[1]; + String testAnimalId3 = MORE_ANIMAL_IDS[2]; + + ReactAnimalHistoryPage animalHistoryPage = ReactAnimalHistoryPage.beginAt(this, getContainerPath()); + + // Scenario 1: Initial page load - verify default state + log("Testing initial page load - default state"); + animalHistoryPage + .assertDefaultStatePresent() + .assertNoValidationError() + .assertEmptyStatePlaceholderShown(); + + // Scenario 2: Single direct ID match + log("Testing single direct ID search"); + animalHistoryPage + .enterAnimalIds(testAnimalId1) + .clickSearchByIds() + .waitForReportToLoad() + .assertReportContainsAnimal(testAnimalId1); + + // Scenario 3: Multi-animal direct search + log("Testing multi-animal search"); + animalHistoryPage + .clearIdInput() + .enterAnimalIds(testAnimalId1, testAnimalId2, testAnimalId3) + .clickSearchByIds() + .waitForReportToLoad() + .assertReportContainsAnimal(testAnimalId1) + .assertReportContainsAnimal(testAnimalId2) + .assertReportContainsAnimal(testAnimalId3); + + // Scenario 4: Mixed valid/invalid IDs (not found feedback) + log("Testing mixed valid/invalid IDs with not-found feedback"); + animalHistoryPage + .clearIdInput() + .enterAnimalIds(testAnimalId1, "INVALID_ID_XYZ_999") + .clickSearchByIds() + .assertIdResolutionVisible(true) + .assertNotFoundContains("INVALID_ID_XYZ_999") + .waitForReportToLoad() + .assertReportContainsAnimal(testAnimalId1); + + // Scenario 5: Case-insensitive matching + log("Testing case-insensitive search"); + String lowercaseId = testAnimalId1.toLowerCase(); + animalHistoryPage + .clearIdInput() + .enterAnimalIds(lowercaseId) + .clickSearchByIds() + .waitForReportToLoad() + .assertReportContainsAnimal(testAnimalId1); + } + + /** + * Test: Animal History ID Search Validation + * + * Tests validation scenarios: + * 1. Empty input validation + * 2. Validation error clears when input is added + * + * Note: 100 ID limit test is deferred as it requires generating many test IDs + * which may exceed test data available. + * + * See spec: LK R&D EHR - React Animal History - Search By Id.md + */ + @Test + public void testAnimalHistoryIdSearchValidation() + { + ReactAnimalHistoryPage animalHistoryPage = ReactAnimalHistoryPage.beginAt(this, getContainerPath()); + + // Scenario 1: Empty input validation + log("Testing empty input validation"); + animalHistoryPage + .clearIdInput() + .clickSearchByIds() + .assertValidationErrorShown("Please enter at least one animal ID"); + + // Scenario 2: Validation error clears when input is added + log("Testing validation error clears when input is added"); + animalHistoryPage + .enterAnimalIds(MORE_ANIMAL_IDS[0]) + .assertNoValidationError(); + + // Scenario 3: Search succeeds after adding input + log("Testing search succeeds after adding input"); + animalHistoryPage + .clickSearchByIds() + .waitForReportToLoad() + .assertReportContainsAnimal(MORE_ANIMAL_IDS[0]); + } + + /** + * Test: Animal History All Animals Mode + * + * Tests All Animals filter mode: + * 1. Search for a single ID and verify Demographics shows only that animal + * 2. Activate All Animals mode + * 3. Verify Demographics now shows more animals (including ones not in original search) + * 4. Verify URL contains filterType + * + * See spec: LK R&D EHR - React Animal History - Search By Id.md + */ + @Test + public void testAnimalHistoryAllAnimalsMode() + { + // Test animal IDs from demographics test data (datasetDemographics.tsv). + // Use exact casing from test data - filters are case-sensitive. + // Do NOT use MORE_ANIMAL_IDS as it gets lowercased by getExpectedAnimalIDCasing(). + String searchedAnimalId = "TEST1020148"; // Dead animal from test data + String otherAnimalId = "44444"; // Alive animal not in search + + ReactAnimalHistoryPage animalHistoryPage = ReactAnimalHistoryPage.beginAt(this, getContainerPath()); + + // Scenario 1: Search for a single ID + log("Setting up initial ID search state with single animal"); + animalHistoryPage + .enterAnimalIds(searchedAnimalId) + .clickSearchByIds() + .waitForReportToLoad(); + + // Navigate to Demographics and verify only the searched animal is shown + log("Verifying Demographics shows only searched animal"); + animalHistoryPage.clickDemographicsTab(); + int initialRowCount = animalHistoryPage.getDemographicsRowCount(); + log("Initial Demographics row count: " + initialRowCount); + animalHistoryPage.assertDemographicsContainsId(searchedAnimalId); + + // Scenario 2: Activate All Animals mode + log("Testing All Animals mode activation"); + animalHistoryPage + .clickAllAnimals() + .assertTextareaEmpty() + .assertAllAnimalsActive(); + + // Scenario 3: Verify URL contains filterType (check BEFORE DataRegion operations which may modify URL) + log("Testing All Animals URL state"); + animalHistoryPage.assertUrlContains("filterType:all"); + + // Scenario 4: Verify Demographics now shows more animals + log("Verifying Demographics shows more animals in All Animals mode"); + animalHistoryPage + .clickDemographicsTab() + .assertDemographicsRowCountGreaterThan(initialRowCount) + .assertDemographicsContainsId(otherAnimalId); // Verify animal NOT in original search now appears + } + + /** + * Test: Animal History Alive At Center Mode + * + * Tests Alive at Center filter mode: + * 1. Search for a Dead animal and verify it appears in Demographics + * 2. Activate Alive at Center mode + * 3. Verify URL contains filterType (before DataRegion operations) + * 4. Verify Demographics shows only Alive animals (Dead animal should disappear) + * 5. Verify all Status values in Demographics are "Alive" + * + * Note: Full testing of disabled state on unsupported reports requires + * specific report configuration in test data. + * + * See spec: LK R&D EHR - React Animal History - Search By Id.md + */ + @Test + public void testAnimalHistoryAliveAtCenterMode() + { + // Test animal IDs from demographics test data (datasetDemographics.tsv). + // Use exact casing from test data - filters are case-sensitive. + // Do NOT use MORE_ANIMAL_IDS as it gets lowercased by getExpectedAnimalIDCasing(). + String deadAnimalId = "TEST1020148"; // Dead animal from test data + String aliveAnimalId = "44444"; // Alive animal from test data + + ReactAnimalHistoryPage animalHistoryPage = ReactAnimalHistoryPage.beginAt(this, getContainerPath()); + + // Scenario 1: Search for a Dead animal first + log("Setting up initial ID search state with Dead animal"); + animalHistoryPage + .enterAnimalIds(deadAnimalId) + .clickSearchByIds() + .waitForReportToLoad(); + + // Navigate to Demographics and verify the Dead animal is shown + log("Verifying Demographics shows the Dead animal"); + animalHistoryPage + .clickDemographicsTab() + .assertDemographicsContainsId(deadAnimalId); + + log("Testing Alive at Center mode activation"); + + if (animalHistoryPage.isAliveAtCenterEnabled()) + { + // Scenario 2: Activate Alive at Center mode + animalHistoryPage + .clickAliveAtCenter() + .assertAliveAtCenterActive() + .assertTextareaEmpty(); + + // Scenario 3: Verify URL contains filterType (check BEFORE DataRegion operations which may modify URL) + log("Testing Alive at Center URL state"); + animalHistoryPage.assertUrlContains("filterType:aliveAtCenter"); + + // Scenario 4: Verify Demographics now shows only Alive animals + log("Verifying Demographics shows only Alive animals"); + animalHistoryPage + .clickDemographicsTab() + .assertDemographicsContainsId(aliveAnimalId) // Alive animal should appear + .assertDemographicsDoesNotContainId(deadAnimalId); // Dead animal should NOT appear + + // Scenario 5: Verify all Status values are "Alive" + log("Verifying all Demographics rows have Status = Alive"); + animalHistoryPage + .assertDemographicsAllRowsHaveStatus("Alive") + .assertDemographicsNoRowsHaveStatus("Dead"); + } + else + { + log("Alive at Center button is disabled - skipping mode activation test"); + // Button may be disabled if current report doesn't support non-ID filters + // This is expected behavior based on spec + } + } + + /** + * Test: Animal History URL Params Mode (Read-Only) + * + * Tests URL Params mode for shared/bookmarked links: + * 1. Navigate via URL with subjects + * 2. Verify search works with URL-provided subjects + * + * Note: Full URL Params read-only mode (readOnly=true) may require + * additional UI implementation to be fully testable. + * + * See spec: LK R&D EHR - React Animal History - Search By Id.md + */ + @Test + public void testAnimalHistoryUrlParamsMode() + { + String testAnimalId1 = MORE_ANIMAL_IDS[0]; + String testAnimalId2 = MORE_ANIMAL_IDS[1]; + + // Scenario 1: Navigate to URL with subjects + log("Testing URL with subjects parameter"); + String urlHash = "subjects:" + testAnimalId1 + ";" + testAnimalId2 + "&filterType:idSearch&showReport:1"; + ReactAnimalHistoryPage animalHistoryPage = ReactAnimalHistoryPage.beginAt(this, getContainerPath(), urlHash); + + // Allow URL hash to be processed + animalHistoryPage.waitForUrlHashProcessing(); + + // Verify subjects are loaded + log("Verifying URL subjects are processed"); + animalHistoryPage + .waitForReportToLoad() + .assertReportContainsAnimal(testAnimalId1); + } + + /** + * Test: Animal History Filter Mode Switching + * + * Tests transitions between filter modes: + * 1. ID Search → All Animals + * 2. All Animals → ID Search + * 3. URL updates correctly through transitions + * + * See spec: LK R&D EHR - React Animal History - Search By Id.md + */ + @Test + public void testAnimalHistoryFilterModeSwitching() + { + String testAnimalId1 = MORE_ANIMAL_IDS[0]; + String testAnimalId2 = MORE_ANIMAL_IDS[1]; + + ReactAnimalHistoryPage animalHistoryPage = ReactAnimalHistoryPage.beginAt(this, getContainerPath()); + + // Scenario 1: Start with ID Search + log("Testing ID Search mode"); + animalHistoryPage + .enterAnimalIds(testAnimalId1, testAnimalId2) + .clickSearchByIds() + .waitForReportToLoad() + .assertReportContainsAnimal(testAnimalId1) + .assertSearchByIdsActive(); + + // Scenario 2: Switch to All Animals + log("Testing ID Search → All Animals transition"); + animalHistoryPage + .clickAllAnimals() + .assertAllAnimalsActive() + .assertTextareaEmpty(); + + // Scenario 3: Switch back to ID Search + log("Testing All Animals → ID Search transition"); + animalHistoryPage + .enterAnimalIds(testAnimalId1) + .clickSearchByIds() + .waitForReportToLoad() + .assertSearchByIdsActive(); + + // Verify URL contains subjects + log("Verifying URL state after transitions"); + String currentUrl = getDriver().getCurrentUrl(); + assertTrue("URL should contain subjects after ID search", + currentUrl.contains("subjects:") || currentUrl.contains("filterType:idSearch")); + } + + /** + * Test: Animal History Keyboard Navigation + * + * Tests basic keyboard accessibility: + * 1. Tab navigation to form elements + * 2. Form submission with Enter key + * + * See spec: LK R&D EHR - React Animal History - Search By Id.md + */ + @Test + public void testAnimalHistoryKeyboardNavigation() + { + String testAnimalId = MORE_ANIMAL_IDS[0]; + + ReactAnimalHistoryPage animalHistoryPage = ReactAnimalHistoryPage.beginAt(this, getContainerPath()); + + // Scenario 1: Enter animal IDs using keyboard + log("Testing keyboard input"); + animalHistoryPage.enterAnimalIds(testAnimalId); + + // Verify input was entered + String enteredValue = animalHistoryPage.getIdInputValue(); + assertEquals("Entered ID should match", testAnimalId, enteredValue); + + // Scenario 2: Verify search button can be activated + log("Testing search activation"); + animalHistoryPage + .clickSearchByIds() + .waitForReportToLoad() + .assertReportContainsAnimal(testAnimalId); + } } diff --git a/ehr/package-lock.json b/ehr/package-lock.json index 91dc4c901..29d3abb31 100644 --- a/ehr/package-lock.json +++ b/ehr/package-lock.json @@ -10,7 +10,7 @@ "dependencies": { "@labkey/api": "1.44.0", "@labkey/components": "6.72.1", - "@labkey/ehr": "0.0.4" + "@labkey/ehr": "0.0.4-fb-ehr-hist-id-search.5" }, "devDependencies": { "@labkey/build": "8.7.0", @@ -3742,9 +3742,9 @@ } }, "node_modules/@labkey/ehr": { - "version": "0.0.4", - "resolved": "https://labkey.jfrog.io/artifactory/api/npm/libs-client/@labkey/ehr/-/@labkey/ehr-0.0.4.tgz", - "integrity": "sha512-Fblu16wYcTIoZ1Hect2k8wjVtXMbxWFe3sPLkQE7gSgiAssgIYVSZXwtbFo0FpqFtW9205GHYbIoOQ4otcDMnQ==", + "version": "0.0.4-fb-ehr-hist-id-search.5", + "resolved": "https://labkey.jfrog.io/artifactory/api/npm/libs-client/@labkey/ehr/-/@labkey/ehr-0.0.4-fb-ehr-hist-id-search.5.tgz", + "integrity": "sha512-5tKMjaFg2Dc+ecka92sjo5nuvoPvylg8LEPHeCI0QJmGNT7T6Kb2fLPG9S4nZ/PmcBC9jM++vJlx6MfrxrM8GQ==", "license": "SEE LICENSE IN LICENSE.txt", "dependencies": { "@labkey/components": "6.72.1" diff --git a/ehr/package.json b/ehr/package.json index 79ef074a3..be792cb00 100644 --- a/ehr/package.json +++ b/ehr/package.json @@ -17,7 +17,7 @@ "dependencies": { "@labkey/api": "1.44.0", "@labkey/components": "6.72.1", - "@labkey/ehr": "0.0.4" + "@labkey/ehr": "0.0.4-fb-ehr-hist-id-search.5" }, "devDependencies": { "@labkey/build": "8.7.0", diff --git a/ehr/resources/queries/study/aliasIdMatches.sql b/ehr/resources/queries/study/aliasIdMatches.sql new file mode 100644 index 000000000..21268a7b4 --- /dev/null +++ b/ehr/resources/queries/study/aliasIdMatches.sql @@ -0,0 +1,9 @@ + +SELECT + a.Id as resolvedId, + a.alias as inputId, + 'alias' as resolvedBy, + a.category as aliasType, + LOWER(a.alias) as lowerAliasForMatching +FROM study.alias a +INNER JOIN study.demographics d ON a.Id = d.Id diff --git a/ehr/resources/queries/study/directIdMatches.sql b/ehr/resources/queries/study/directIdMatches.sql new file mode 100644 index 000000000..7fd4f6297 --- /dev/null +++ b/ehr/resources/queries/study/directIdMatches.sql @@ -0,0 +1,8 @@ + +SELECT + Id as resolvedId, + Id as inputId, + 'direct' as resolvedBy, + NULL as aliasType, + LOWER(Id) as lowerIdForMatching +FROM study.demographics diff --git a/ehr/resources/reports/reports.tsv b/ehr/resources/reports/reports.tsv index 559d365fd..990b9115f 100644 --- a/ehr/resources/reports/reports.tsv +++ b/ehr/resources/reports/reports.tsv @@ -1,52 +1,52 @@ -reportname category reporttype reporttitle visible containerpath schemaname queryname viewname report datefieldname todayonly queryhaslocation sort_order QCStateLabelFieldName description -activeHousing Colony Management query Housing - Active TRUE study housing Active Housing date FALSE TRUE qcstate/publicdata This report shows the active housing record for each animal -birth Colony Management query Birth Records TRUE study birth date FALSE FALSE qcstate/publicdata Birth records -housing Colony Management query Housing History TRUE study housing date FALSE TRUE qcstate/publicdata This report contains the housing history of each animal -roommateHistory Colony Management query Cagemate History TRUE study housingRoommates StartDate FALSE FALSE qcstate/publicdata This report displays all animals that shared a cage, including the start date, stop date and days co-housed -weight Colony Management js Weights TRUE study weightGraph date FALSE FALSE qcstate/publicdata This report contains a summary of the animal\'s weight, including a graph -Flags Colony Management query Flags true study flags StartDate false false qcstate/publicdata Animal attribute flags -demographics General query Demographics TRUE study demographics FALSE FALSE qcstate/publicdata This report displays the demographics data about each animal including species, sex and birth -snapshot General js Snapshot TRUE study snapshot FALSE FALSE qcstate/publicdata This report contains a summary of the animal, including demographics, assignments and weight -death Pathology query Death Records true study deaths date false false qcstate/publicdata Death records -arrival General query Arrivals true study arrival date false false qcstate/publicdata Displays arrival dates -departure General query Departures true study departure date false false qcstate/publicdata Displays departure dates -currentBlood Clinical js Current Blood true study currentBlood date false false qcstate/publicdata This report contains a summary of the current available blood for each animal -bloodDraws Clinical query Blood Draws TRUE study blood date FALSE FALSE qcstate/publicdata This report displays blood draw data for the selected animal -biopsy Clinical query Biopsies TRUE study biopsy date FALSE FALSE qcstate/publicdata This report displays biopsy data for the selected animal -obs Clinical query Observations TRUE study clinical_observations date FALSE FALSE qcstate/publicdata This report displays observations for the selected animal -alopecia Clinical query Alopecia Scores TRUE study alopecia date FALSE FALSE qcstate/publicdata This report contains the alopecia scores for the animal -pairings Colony Management query Pairings TRUE study pairings date FALSE FALSE qcstate/publicdata This report displays pairings for the selected animal -breeder Colony Management query Breeder TRUE study breeder date FALSE FALSE qcstate/publicdata This report displays breeding data for the selected animal -clinremarks Clinical query Clinical Remarks true study Clinical Remarks date false false qcstate/publicdata This report contains the clinical remarks entered about each animal -serology ClinPath query Serology TRUE study serology date FALSE FALSE qcstate/publicdata This report displays serology data for the selected animal -vitals Clinical query Vital Signs TRUE study vitals date FALSE FALSE qcstate/publicdata This report displays vitals data for the selected animal -physicalExam Clinical query Physical Exam TRUE study physicalExam date FALSE FALSE qcstate/publicdata This report displays physical exam data for the selected animal -procedures Clinical query Procedures TRUE study prc date FALSE FALSE qcstate/publicdata This report displays procedures data for the selected animal -exemptions Colony Management query Exemptions TRUE study exemptions date FALSE FALSE qcstate/publicdata This report displays exemptions data for the selected animal -drugAdministration Clinical query Drug Administration TRUE study drug date FALSE FALSE qcstate/publicdata This report displays drug administration data for the selected animal -necropsy Pathology query Necropsy TRUE study necropsy date FALSE FALSE qcstate/publicdata This report displays necropsy data for the selected animal -clinCases Clinical query Cases true study Cases Active Clinical Cases date false false qcstate/publicdata This report contains one record for each case opend for this animal, including surgeries, exams, procedures, etc. -behaviorCases Behavior query Behavior Cases true study cases Open Behavior Cases date false false qcstate/publicdata This displays active behavior cases -behaviorRemarks Behavior query Behavior Remarks true study Clinical Remarks Behavior Remarks date false false qcstate/publicdata This report contains the behavior remarks entered about each animal -clinObsBehavior Behavior query Observations true study Clinical Observations BSU Observations date false false qcstate/publicdata This report contains one record for each encounter with each animal, including surergies, exams, procedures, etc. -drug Behavior query Behavior Treatments true study Drug Administration Behavior Treatments date false false qcstate/publicdata This report contains the behavior treatments entered about each animal -pairingsBehavior Behavior query Pairing Observations true study pairingSummary date false false This report contains records about pairings made using each animal -pairingHousingSummary Behavior query Pairing With Housing true study pairingHousingSummary date false false This report contains records about pairings made using each animal -roommateHistoryBehavior Behavior query Cagemate History true study housingRoommates Caged Housing Only StartDate false false qcstate/publicdata This report displays all animals that shared a cage, including the start date, stop date and days co-housed -pairingHistory Behavior js Pairing History true study pairHistory date false false qcstate/publicdata This report displays all animals that shared a cage, including the start date, stop date and days co-housed -pregnancy Reproductive Management query Pregnancy Outcomes TRUE study pregnancy date FALSE FALSE qcstate/publicdata This report displays pregnancy outcomes -pedigree Genetics js Pedigree true study pedigree false false qcstate/publicdata This report displays pedigree data for animals, including parents, grandparents, siblings and offspring -pedigreePlot Genetics report Pedigree Plot true study pedigree module:EHR/schemas/study/Pedigree/Pedigree.r false false qcstate/publicdata This report will generate a pedigree plot for the selected animal -offspring Reproductive Management query Offspring true study demographicsOffspring false false qcstate/publicdata This report displays pedigree data for animals, including parents, grandparents, siblings and offspring -kinship Genetics js Kinship true ehr kinshipSummary false false qcstate/publicdata This report shows the kinship coefficient between every animal in the colony. The kinship coefficient is a measure of relatedness between two individuals. It represents the probability that two genes, sampled at random from each individual are identical (e.g. the kinship coefficient between a parent and an offspring is 0.25). -inbreeding Genetics query Inbreeding Coefficients true study Inbreeding Coefficients false false qcstate/publicdata This report shows the inbreeding coefficient of each animal, where pedigree data is available. The inbreeding coefficient is the kinship coefficient between the individual's parents. It measures the probability that the two alleles of a gene are identical by descent in the same individual (autozygosity). It is zero if the individual is not inbred. -parentage Genetics query Parentage true study demographicsParents false false qcstate/publicdata This report shows information about the parentage of each animal, drawing from genetic data and observations -clinicalHistory Clinical js Clinical History TRUE study clinicalHistory date FALSE FALSE qcstate/publicdata This report contains an overview of the animal\'s clinical history -clinMedicationSchedule Daily Reports js Clinical Medication Schedule true study clinMedicationSchedule date false false qcstate/publicdata This report contains a list of daily treatments to be administered -dietSchedule Daily Reports js Diets true study dietSchedule date false false qcstate/publicdata This report contains a list of daily treatments to be administered -surgMedicationSchedule Surgery js Surgical Medication Schedule true study surgMedicationSchedule date false false qcstate/publicdata This report contains a list of daily treatments to be administered -surgMedicationScheduleDaily Daily Reports js Surgical Medication Schedule true study surgMedicationSchedule date false false qcstate/publicdata This report contains a list of daily treatments to be administered -incompleteTreatments Daily Reports js Meds/Diet - Incomplete true study incompleteTreatments date false false qcstate/publicdata This report contains a list of daily treatments not yet administered -bloodSchedule Daily Reports js Blood Schedule true study bloodSchedule date false false qcstate/publicdata This report contains an overview of the animal's clinical history -cases Daily Reports query Active Clinical Cases true study Cases Active Clinical Cases date false false qcstate/publicdata This displays active clinical cases -surgicalCases Daily Reports query Active Surgery Cases true study Cases Active Surgery Cases date false false qcstate/publicdata This displays active surgery cases \ No newline at end of file +reportname category reporttype reporttitle visible containerpath schemaname queryname viewname report datefieldname todayonly queryhaslocation sort_order QCStateLabelFieldName description supportsNonIdFilters +activeHousing Colony Management query Housing - Active TRUE study housing Active Housing date FALSE TRUE qcstate/publicdata This report shows the active housing record for each animal true +birth Colony Management query Birth Records TRUE study birth date FALSE FALSE qcstate/publicdata Birth records true +housing Colony Management query Housing History TRUE study housing date FALSE TRUE qcstate/publicdata This report contains the housing history of each animal true +roommateHistory Colony Management query Cagemate History TRUE study housingRoommates StartDate FALSE FALSE qcstate/publicdata This report displays all animals that shared a cage, including the start date, stop date and days co-housed true +weight Colony Management js Weights TRUE study weightGraph date FALSE FALSE qcstate/publicdata This report contains a summary of the animal\'s weight, including a graph false +Flags Colony Management query Flags true study flags StartDate false false qcstate/publicdata Animal attribute flags true +demographics General query Demographics TRUE study demographics FALSE FALSE qcstate/publicdata This report displays the demographics data about each animal including species, sex and birth true +snapshot General js Snapshot TRUE study snapshot FALSE FALSE qcstate/publicdata This report contains a summary of the animal, including demographics, assignments and weight false +death Pathology query Death Records true study deaths date false false qcstate/publicdata Death records true +arrival General query Arrivals true study arrival date false false qcstate/publicdata Displays arrival dates true +departure General query Departures true study departure date false false qcstate/publicdata Displays departure dates true +currentBlood Clinical js Current Blood true study currentBlood date false false qcstate/publicdata This report contains a summary of the current available blood for each animal false +bloodDraws Clinical query Blood Draws TRUE study blood date FALSE FALSE qcstate/publicdata This report displays blood draw data for the selected animal true +biopsy Clinical query Biopsies TRUE study biopsy date FALSE FALSE qcstate/publicdata This report displays biopsy data for the selected animal true +obs Clinical query Observations TRUE study clinical_observations date FALSE FALSE qcstate/publicdata This report displays observations for the selected animal true +alopecia Clinical query Alopecia Scores TRUE study alopecia date FALSE FALSE qcstate/publicdata This report contains the alopecia scores for the animal true +pairings Colony Management query Pairings TRUE study pairings date FALSE FALSE qcstate/publicdata This report displays pairings for the selected animal true +breeder Colony Management query Breeder TRUE study breeder date FALSE FALSE qcstate/publicdata This report displays breeding data for the selected animal true +clinremarks Clinical query Clinical Remarks true study Clinical Remarks date false false qcstate/publicdata This report contains the clinical remarks entered about each animal true +serology ClinPath query Serology TRUE study serology date FALSE FALSE qcstate/publicdata This report displays serology data for the selected animal true +vitals Clinical query Vital Signs TRUE study vitals date FALSE FALSE qcstate/publicdata This report displays vitals data for the selected animal true +physicalExam Clinical query Physical Exam TRUE study physicalExam date FALSE FALSE qcstate/publicdata This report displays physical exam data for the selected animal true +procedures Clinical query Procedures TRUE study prc date FALSE FALSE qcstate/publicdata This report displays procedures data for the selected animal true +exemptions Colony Management query Exemptions TRUE study exemptions date FALSE FALSE qcstate/publicdata This report displays exemptions data for the selected animal true +drugAdministration Clinical query Drug Administration TRUE study drug date FALSE FALSE qcstate/publicdata This report displays drug administration data for the selected animal true +necropsy Pathology query Necropsy TRUE study necropsy date FALSE FALSE qcstate/publicdata This report displays necropsy data for the selected animal true +clinCases Clinical query Cases true study Cases Active Clinical Cases date false false qcstate/publicdata This report contains one record for each case opend for this animal, including surgeries, exams, procedures, etc. true +behaviorCases Behavior query Behavior Cases true study cases Open Behavior Cases date false false qcstate/publicdata This displays active behavior cases true +behaviorRemarks Behavior query Behavior Remarks true study Clinical Remarks Behavior Remarks date false false qcstate/publicdata This report contains the behavior remarks entered about each animal true +clinObsBehavior Behavior query Observations true study Clinical Observations BSU Observations date false false qcstate/publicdata This report contains one record for each encounter with each animal, including surergies, exams, procedures, etc. true +drug Behavior query Behavior Treatments true study Drug Administration Behavior Treatments date false false qcstate/publicdata This report contains the behavior treatments entered about each animal true +pairingsBehavior Behavior query Pairing Observations true study pairingSummary date false false This report contains records about pairings made using each animal true +pairingHousingSummary Behavior query Pairing With Housing true study pairingHousingSummary date false false This report contains records about pairings made using each animal true +roommateHistoryBehavior Behavior query Cagemate History true study housingRoommates Caged Housing Only StartDate false false qcstate/publicdata This report displays all animals that shared a cage, including the start date, stop date and days co-housed true +pairingHistory Behavior js Pairing History true study pairHistory date false false qcstate/publicdata This report displays all animals that shared a cage, including the start date, stop date and days co-housed false +pregnancy Reproductive Management query Pregnancy Outcomes TRUE study pregnancy date FALSE FALSE qcstate/publicdata This report displays pregnancy outcomes true +pedigree Genetics js Pedigree true study pedigree false false qcstate/publicdata This report displays pedigree data for animals, including parents, grandparents, siblings and offspring false +pedigreePlot Genetics report Pedigree Plot true study pedigree module:EHR/schemas/study/Pedigree/Pedigree.r false false qcstate/publicdata This report will generate a pedigree plot for the selected animal false +offspring Reproductive Management query Offspring true study demographicsOffspring false false qcstate/publicdata This report displays pedigree data for animals, including parents, grandparents, siblings and offspring true +kinship Genetics js Kinship true ehr kinshipSummary false false qcstate/publicdata This report shows the kinship coefficient between every animal in the colony. The kinship coefficient is a measure of relatedness between two individuals. It represents the probability that two genes, sampled at random from each individual are identical (e.g. the kinship coefficient between a parent and an offspring is 0.25). false +inbreeding Genetics query Inbreeding Coefficients true study Inbreeding Coefficients false false qcstate/publicdata This report shows the inbreeding coefficient of each animal, where pedigree data is available. The inbreeding coefficient is the kinship coefficient between the individual's parents. It measures the probability that the two alleles of a gene are identical by descent in the same individual (autozygosity). It is zero if the individual is not inbred. true +parentage Genetics query Parentage true study demographicsParents false false qcstate/publicdata This report shows information about the parentage of each animal, drawing from genetic data and observations true +clinicalHistory Clinical js Clinical History TRUE study clinicalHistory date FALSE FALSE qcstate/publicdata This report contains an overview of the animal\'s clinical history false +clinMedicationSchedule Daily Reports js Clinical Medication Schedule true study clinMedicationSchedule date false false qcstate/publicdata This report contains a list of daily treatments to be administered false +dietSchedule Daily Reports js Diets true study dietSchedule date false false qcstate/publicdata This report contains a list of daily treatments to be administered false +surgMedicationSchedule Surgery js Surgical Medication Schedule true study surgMedicationSchedule date false false qcstate/publicdata This report contains a list of daily treatments to be administered false +surgMedicationScheduleDaily Daily Reports js Surgical Medication Schedule true study surgMedicationSchedule date false false qcstate/publicdata This report contains a list of daily treatments to be administered false +incompleteTreatments Daily Reports js Meds/Diet - Incomplete true study incompleteTreatments date false false qcstate/publicdata This report contains a list of daily treatments not yet administered false +bloodSchedule Daily Reports js Blood Schedule true study bloodSchedule date false false qcstate/publicdata This report contains an overview of the animal's clinical history false +cases Daily Reports query Active Clinical Cases true study Cases Active Clinical Cases date false false qcstate/publicdata This displays active clinical cases true +surgicalCases Daily Reports query Active Surgery Cases true study Cases Active Surgery Cases date false false qcstate/publicdata This displays active surgery cases true \ No newline at end of file diff --git a/ehr/resources/views/ehrAdmin.html b/ehr/resources/views/ehrAdmin.html index d88ff04e1..c2d3f22f6 100644 --- a/ehr/resources/views/ehrAdmin.html +++ b/ehr/resources/views/ehrAdmin.html @@ -11,6 +11,7 @@

Admin:

+
diff --git a/ehr/resources/views/participantView.html b/ehr/resources/views/participantView.html index c08e7ab80..e2cd18893 100644 --- a/ehr/resources/views/participantView.html +++ b/ehr/resources/views/participantView.html @@ -20,12 +20,24 @@ // Add "New Participant View" link in upper left corner if experimental feature is enabled var ehrContext = LABKEY.getModuleContext('ehr'); if (ehrContext && ehrContext.isReactAnimalHistoryEnabled) { + // Build base URL with query parameters var newViewUrl = LABKEY.ActionURL.buildURL( LABKEY.ActionURL.getController(), 'participantViewNew', LABKEY.ActionURL.getContainer(), - LABKEY.ActionURL.getParameters() + { participantId: participantId } ); + + // Add hash parameters for React Animal History page + // readOnly:true makes the SearchByIdPanel show read-only summary instead of editable inputs + var hashParams = [ + 'subjects:' + encodeURIComponent(participantId), + 'readOnly:true', + 'showReport:1', + 'activeReport:snapshot' + ]; + newViewUrl += '#' + hashParams.join('&'); + var linkDiv = document.createElement('div'); linkDiv.style.cssText = 'text-align: left;'; linkDiv.innerHTML = 'New Participant View'; diff --git a/labkey-ui-ehr/.gitignore b/labkey-ui-ehr/.gitignore index b2d59d1f7..a7e59f080 100644 --- a/labkey-ui-ehr/.gitignore +++ b/labkey-ui-ehr/.gitignore @@ -1,2 +1,3 @@ /node_modules -/dist \ No newline at end of file +/dist +/coverage \ No newline at end of file diff --git a/labkey-ui-ehr/README.md b/labkey-ui-ehr/README.md index 6238e323f..7d125efb0 100644 --- a/labkey-ui-ehr/README.md +++ b/labkey-ui-ehr/README.md @@ -24,11 +24,31 @@ To install using npm ``` npm install @labkey/ehr ``` -You can then import `@labkey/ehr` in your application as follows: + +## Usage + +### ParticipantHistory Module + +The `participanthistory` export provides the `ParticipantReports` component for displaying animal history data with search, filtering, and reporting capabilities. + ```js -import { TestComponent } from '@labkey/ehr'; +import { ParticipantReports } from '@labkey/ehr/participanthistory'; + +export const AnimalHistoryPage = () => { + return ( +
+ +
+ ); +}; ``` +**Features:** +- Multi-mode filtering (ID Search, All Animals, Alive at Center, URL Params) +- ID and alias resolution +- Tabbed report interface with category grouping +- URL-based state persistence for shareable links + ## Development ### Getting Started diff --git a/labkey-ui-ehr/jest.config.js b/labkey-ui-ehr/jest.config.js index 77b7de0de..9b500a89c 100644 --- a/labkey-ui-ehr/jest.config.js +++ b/labkey-ui-ehr/jest.config.js @@ -51,4 +51,7 @@ module.exports = { transformIgnorePatterns: [ 'node_modules/(?!(lib0|y-protocols))' ], + moduleNameMapper: { + '\\.(css|scss|sass)$': '/src/test/styleMock.js' + }, }; diff --git a/labkey-ui-ehr/package-lock.json b/labkey-ui-ehr/package-lock.json index 0f14e4920..56d0c668b 100644 --- a/labkey-ui-ehr/package-lock.json +++ b/labkey-ui-ehr/package-lock.json @@ -1,12 +1,12 @@ { "name": "@labkey/ehr", - "version": "0.0.4", + "version": "0.0.4-fb-ehr-hist-id-search.5", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "@labkey/ehr", - "version": "0.0.4", + "version": "0.0.4-fb-ehr-hist-id-search.5", "license": "SEE LICENSE IN LICENSE.txt", "dependencies": { "@labkey/components": "6.72.1" diff --git a/labkey-ui-ehr/package.json b/labkey-ui-ehr/package.json index 3dc44a379..1eff96190 100644 --- a/labkey-ui-ehr/package.json +++ b/labkey-ui-ehr/package.json @@ -1,6 +1,6 @@ { "name": "@labkey/ehr", - "version": "0.0.4", + "version": "0.0.4-fb-ehr-hist-id-search.5", "description": "Components, models, actions, and utility functions for LabKey EHR applications and pages", "sideEffects": false, "files": [ @@ -31,6 +31,7 @@ "prepublishOnly": "npm install --legacy-peer-deps && cross-env WEBPACK_STATS=errors-only npm run build", "test": "cross-env NODE_ENV=test jest --maxWorkers=6 --silent", "test-ci": "cross-env NODE_ENV=test jest --ci --silent", + "test-coverage": "cross-env NODE_ENV=test jest --maxWorkers=6 --coverage", "lint": "npx eslint", "lint-fix": "npx eslint --fix", "lint-precommit": "node lint.diff.mjs", diff --git a/labkey-ui-ehr/src/ParticipantHistory/ParticipantReports.test.tsx b/labkey-ui-ehr/src/ParticipantHistory/ParticipantReports.test.tsx index 383999f8a..34c2bfbeb 100644 --- a/labkey-ui-ehr/src/ParticipantHistory/ParticipantReports.test.tsx +++ b/labkey-ui-ehr/src/ParticipantHistory/ParticipantReports.test.tsx @@ -1,6 +1,8 @@ import React from 'react'; import { screen } from '@testing-library/react'; +import { Query } from '@labkey/api'; + import { ParticipantReports } from './ParticipantReports'; import { defaultServerContext, renderWithServerContext } from '../test/utils'; @@ -36,12 +38,19 @@ const mockExt4Container = { }, }; -// Mock LABKEY.WebPart for OtherReportWrapper +// Mock LABKEY API for OtherReportWrapper and ParticipantReports +// Note: Query.selectRows is mocked via @labkey/api mock above (global as any).LABKEY = { ...(global as any).LABKEY, WebPart: jest.fn().mockImplementation(() => ({ render: jest.fn(), })), + Filter: { + create: jest.fn((field, value, type) => ({ field, value, type })), + Types: { + EQUAL: 'EQUAL', + }, + }, }; describe('ParticipantReports', () => { @@ -52,6 +61,25 @@ describe('ParticipantReports', () => { jest.clearAllMocks(); mockExt4Container.isDestroyed = false; + // Mock Query.selectRows with default behavior + // Returns a proper reports array for the consolidated query + (Query.selectRows as jest.Mock).mockImplementation((config: any) => { + if (config.success) { + config.success({ + rows: [ + { + reportname: 'test-report', + reporttitle: 'Test Report', + reporttype: 'query', + supportsnonidfilters: true, + visible: true, + category: 'General', + }, + ], + }); + } + }); + // Save and reset document.location.hash and search before each test originalHash = window.location.hash; originalSearch = window.location.search; @@ -74,15 +102,15 @@ describe('ParticipantReports', () => { test('renders TabbedReportPanel component', () => { renderWithServerContext(, defaultServerContext()); - // Component should render and show loading state (since no reports are loaded yet) - expect(screen.getByText('Loading reports...')).toBeVisible(); + // Component should render and show the report category tabs + expect(screen.getByText('General')).toBeVisible(); }); test('renders with default subjects filter when no URL hash present', () => { renderWithServerContext(, defaultServerContext()); // Component renders without errors when no hash is present - expect(screen.getByText('Loading reports...')).toBeVisible(); + expect(screen.getByText('General')).toBeVisible(); }); }); @@ -93,7 +121,7 @@ describe('ParticipantReports', () => { renderWithServerContext(, defaultServerContext()); // Component should render without errors when activeReport is in hash - expect(screen.getByText('Loading reports...')).toBeVisible(); + expect(screen.getByText('General')).toBeVisible(); }); test('parses inputType from URL hash', () => { @@ -101,7 +129,7 @@ describe('ParticipantReports', () => { renderWithServerContext(, defaultServerContext()); - expect(screen.getByText('Loading reports...')).toBeVisible(); + expect(screen.getByText('General')).toBeVisible(); }); test('parses showReport as true from URL hash', () => { @@ -109,7 +137,7 @@ describe('ParticipantReports', () => { renderWithServerContext(, defaultServerContext()); - expect(screen.getByText('Loading reports...')).toBeVisible(); + expect(screen.getByText('General')).toBeVisible(); }); test('parses showReport as false from URL hash', () => { @@ -117,7 +145,7 @@ describe('ParticipantReports', () => { renderWithServerContext(, defaultServerContext()); - expect(screen.getByText('Loading reports...')).toBeVisible(); + expect(screen.getByText('General')).toBeVisible(); }); test('parses subjects from URL hash', () => { @@ -125,7 +153,7 @@ describe('ParticipantReports', () => { renderWithServerContext(, defaultServerContext()); - expect(screen.getByText('Loading reports...')).toBeVisible(); + expect(screen.getByText('General')).toBeVisible(); }); test('parses multiple parameters from URL hash', () => { @@ -133,7 +161,7 @@ describe('ParticipantReports', () => { renderWithServerContext(, defaultServerContext()); - expect(screen.getByText('Loading reports...')).toBeVisible(); + expect(screen.getByText('General')).toBeVisible(); }); test('parses custom/unknown parameters from URL hash', () => { @@ -141,7 +169,7 @@ describe('ParticipantReports', () => { renderWithServerContext(, defaultServerContext()); - expect(screen.getByText('Loading reports...')).toBeVisible(); + expect(screen.getByText('General')).toBeVisible(); }); test('handles URL-encoded values in hash parameters', () => { @@ -149,7 +177,7 @@ describe('ParticipantReports', () => { renderWithServerContext(, defaultServerContext()); - expect(screen.getByText('Loading reports...')).toBeVisible(); + expect(screen.getByText('General')).toBeVisible(); }); test('handles empty subjects value in URL hash', () => { @@ -157,7 +185,7 @@ describe('ParticipantReports', () => { renderWithServerContext(, defaultServerContext()); - expect(screen.getByText('Loading reports...')).toBeVisible(); + expect(screen.getByText('General')).toBeVisible(); }); test('ignores parameters without values', () => { @@ -165,7 +193,7 @@ describe('ParticipantReports', () => { renderWithServerContext(, defaultServerContext()); - expect(screen.getByText('Loading reports...')).toBeVisible(); + expect(screen.getByText('General')).toBeVisible(); }); }); @@ -175,14 +203,14 @@ describe('ParticipantReports', () => { // The component should render the TabbedReportPanel with EHR.reports namespace // This is verified indirectly by successful render - expect(screen.getByText('Loading reports...')).toBeVisible(); + expect(screen.getByText('General')).toBeVisible(); }); test('passes correct reportsQuery and reportsSchema props', () => { renderWithServerContext(, defaultServerContext()); // The component should render with ehr schema and reports query - expect(screen.getByText('Loading reports...')).toBeVisible(); + expect(screen.getByText('General')).toBeVisible(); }); }); @@ -194,7 +222,7 @@ describe('ParticipantReports', () => { renderWithServerContext(, defaultServerContext()); // Component should render without errors when participantId is in query params - expect(screen.getByText('Loading reports...')).toBeVisible(); + expect(screen.getByText('General')).toBeVisible(); // Verify the participantId was parsed correctly by checking document.location.search const urlParams = new URLSearchParams(document.location.search); @@ -212,7 +240,7 @@ describe('ParticipantReports', () => { expect(urlParams.get('participantId')).toBe('12345'); // Component renders successfully - expect(screen.getByText('Loading reports...')).toBeVisible(); + expect(screen.getByText('General')).toBeVisible(); }); test('participantId is merged with hash subjects when both are present', () => { @@ -228,7 +256,7 @@ describe('ParticipantReports', () => { expect(window.location.hash).toContain('subjects:subject1'); // Component renders successfully - expect(screen.getByText('Loading reports...')).toBeVisible(); + expect(screen.getByText('General')).toBeVisible(); }); test('participantId from query params takes priority when not in hash subjects', () => { @@ -243,7 +271,7 @@ describe('ParticipantReports', () => { expect(urlParams.get('participantId')).toBe('55555'); // Component renders successfully - participantId should be merged with hash subjects - expect(screen.getByText('Loading reports...')).toBeVisible(); + expect(screen.getByText('General')).toBeVisible(); }); test('handles participantId with other URL query parameters', () => { @@ -258,7 +286,7 @@ describe('ParticipantReports', () => { expect(urlParams.get('otherParam')).toBe('value'); // Component renders successfully - expect(screen.getByText('Loading reports...')).toBeVisible(); + expect(screen.getByText('General')).toBeVisible(); }); test('renders correctly when participantId is not present in query params', () => { @@ -272,7 +300,357 @@ describe('ParticipantReports', () => { expect(urlParams.get('participantId')).toBeNull(); // Component renders successfully - expect(screen.getByText('Loading reports...')).toBeVisible(); + expect(screen.getByText('General')).toBeVisible(); + }); + }); + + describe('Search By Id integration', () => { + describe('initial filter type from URL', () => { + test('initializes with ID Search mode when filterType:idSearch in hash', () => { + window.location.hash = '#filterType:idSearch&subjects:ID123%3BID456'; + + renderWithServerContext(, defaultServerContext()); + + // Component should render with subjects from hash + expect(screen.getByText('General')).toBeVisible(); + }); + + test('initializes with All Records mode when filterType:all in hash', () => { + window.location.hash = '#filterType:all'; + + renderWithServerContext(, defaultServerContext()); + + expect(screen.getByText('General')).toBeVisible(); + }); + + test('initializes with Alive at Center mode when filterType:aliveAtCenter in hash', () => { + window.location.hash = '#filterType:aliveAtCenter'; + + renderWithServerContext(, defaultServerContext()); + + expect(screen.getByText('General')).toBeVisible(); + }); + + test('defaults to ID Search mode when no filterType in hash', () => { + window.location.hash = ''; + + renderWithServerContext(, defaultServerContext()); + + expect(screen.getByText('General')).toBeVisible(); + }); + }); + + describe('URL Params mode (readOnly)', () => { + test('activates URL Params mode when readOnly:true in URL with subjects', () => { + window.location.hash = '#subjects:ID123%3BID456&readOnly:true'; + + renderWithServerContext(, defaultServerContext()); + + // Component should render in URL Params mode + expect(screen.getByText('General')).toBeVisible(); + }); + + test('hides SearchByIdPanel when in readOnly mode with subjects', () => { + window.location.hash = '#subjects:ID123%3BID456&readOnly:true'; + + renderWithServerContext(, defaultServerContext()); + + // SearchByIdPanel should NOT be rendered + expect(screen.queryByLabelText(/enter animal ids/i)).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: /search by ids/i })).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: /all animals/i })).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: /all alive at center/i })).not.toBeInTheDocument(); + }); + + test('shows SearchByIdPanel in normal mode (not readOnly)', () => { + window.location.hash = '#filterType:idSearch'; + + renderWithServerContext(, defaultServerContext()); + + // SearchByIdPanel should be rendered with its elements + expect(screen.getByLabelText(/enter animal ids/i)).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /search by ids/i })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /all animals/i })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /all alive at center/i })).toBeInTheDocument(); + }); + + test('ignores readOnly:true when no subjects in URL and shows SearchByIdPanel', () => { + window.location.hash = '#readOnly:true'; + + renderWithServerContext(, defaultServerContext()); + + // Without subjects, readOnly is ignored and SearchByIdPanel should be visible + expect(screen.getByLabelText(/enter animal ids/i)).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /search by ids/i })).toBeInTheDocument(); + }); + + test('readOnly parameter takes priority over filterType parameter', () => { + window.location.hash = '#filterType:all&subjects:ID123&readOnly:true'; + + renderWithServerContext(, defaultServerContext()); + + // Should use URL Params mode (readOnly), not All Records mode + // SearchByIdPanel should be hidden + expect(screen.queryByLabelText(/enter animal ids/i)).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: /all animals/i })).not.toBeInTheDocument(); + }); + + test('shows reports immediately in readOnly mode (showReport defaults to true)', () => { + window.location.hash = '#subjects:ID123&readOnly:true'; + + renderWithServerContext(, defaultServerContext()); + + // In readOnly mode, reports should be loading immediately + // The TabbedReportPanel should be attempting to load reports + expect(screen.getByText('General')).toBeVisible(); + }); + }); + + describe('filter state management', () => { + test('manages subjects state from URL hash', () => { + window.location.hash = '#subjects:ID123%3BID456%3BID789'; + + renderWithServerContext(, defaultServerContext()); + + // Verify component renders with subjects + expect(screen.getByText('General')).toBeVisible(); + }); + + test('manages filterType state from URL hash', () => { + window.location.hash = '#filterType:aliveAtCenter'; + + renderWithServerContext(, defaultServerContext()); + + expect(screen.getByText('General')).toBeVisible(); + }); + }); + + describe('URL hash updates', () => { + test('updates URL hash when filter mode changes', () => { + window.location.hash = ''; + + renderWithServerContext(, defaultServerContext()); + + // After component mounts, simulate filter change + // Note: This would require exposing handleFilterChange or testing through UI interaction + expect(screen.getByText('General')).toBeVisible(); + }); + + test('includes subjects in URL hash for ID Search mode', () => { + window.location.hash = '#filterType:idSearch&subjects:ID123%3BID456'; + + renderWithServerContext(, defaultServerContext()); + + expect(window.location.hash).toContain('subjects:'); + }); + + test('removes subjects from URL hash for All Records mode', () => { + window.location.hash = '#filterType:all'; + + renderWithServerContext(, defaultServerContext()); + + expect(window.location.hash).not.toContain('subjects:'); + }); + + test('removes readOnly parameter when switching from URL Params to ID Search', () => { + window.location.hash = '#subjects:ID123&readOnly:true'; + + renderWithServerContext(, defaultServerContext()); + + // Component should be in URL Params mode initially + // After switching to ID Search (would require UI interaction), readOnly should be removed + expect(screen.getByText('General')).toBeVisible(); + }); + + test('preserves activeReport parameter from URL hash', () => { + window.location.hash = '#filterType:idSearch&activeReport:test-report&subjects:ID123'; + + renderWithServerContext(, defaultServerContext()); + + // Component should preserve activeReport in state + expect(window.location.hash).toContain('activeReport:test-report'); + }); + + test('preserves activeReport when changing filter modes', () => { + // Use test-report which matches the mock data + window.location.hash = '#filterType:all&activeReport:test-report&showReport:1'; + + renderWithServerContext(, defaultServerContext()); + + // activeReport should remain in the hash + expect(window.location.hash).toContain('activeReport:test-report'); + }); + }); + + describe('activeReportSupportsNonIdFilters query', () => { + test('queries report metadata for supportsNonIdFilters field', () => { + window.location.hash = '#activeReport:test-report'; + + renderWithServerContext(, defaultServerContext()); + + // Component should query ehr.reports for the active report's metadata + expect(screen.getByText('General')).toBeVisible(); + }); + + test('updates activeReportSupportsNonIdFilters when switching report tabs', () => { + window.location.hash = '#activeReport:report1'; + + renderWithServerContext(, defaultServerContext()); + + // After switching to different report tab, should re-query metadata + expect(screen.getByText('General')).toBeVisible(); + }); + + test('defaults to false when no active report selected', () => { + window.location.hash = ''; + + renderWithServerContext(, defaultServerContext()); + + // Should handle no active report gracefully + expect(screen.getByText('General')).toBeVisible(); + }); + }); + + describe('race conditions', () => { + test('handles rapid filter mode changes before state updates', () => { + window.location.hash = ''; + + renderWithServerContext(, defaultServerContext()); + + // Simulate rapid filter changes + // This would require UI interaction or exposing handleFilterChange + expect(screen.getByText('General')).toBeVisible(); + }); + }); + + describe('malformed URL hash', () => { + test('handles malformed URL hash gracefully', () => { + window.location.hash = '#malformed&invalid::data'; + + renderWithServerContext(, defaultServerContext()); + + // Should fall back to default state without crashing + expect(screen.getByText('General')).toBeVisible(); + }); + + test('handles URL hash with missing values', () => { + window.location.hash = '#filterType:&subjects:'; + + renderWithServerContext(, defaultServerContext()); + + // Should handle empty values gracefully + expect(screen.getByText('General')).toBeVisible(); + }); + }); + + describe('filter integration with TabbedReportPanel', () => { + test('passes filters prop to TabbedReportPanel', () => { + window.location.hash = '#filterType:idSearch&subjects:ID123'; + + renderWithServerContext(, defaultServerContext()); + + // TabbedReportPanel should receive filters prop with filterType and subjects + expect(screen.getByText('General')).toBeVisible(); + }); + + test('passes undefined subjects for All Records mode', () => { + window.location.hash = '#filterType:all'; + + renderWithServerContext(, defaultServerContext()); + + // filters.subjects should be undefined for All Records + expect(screen.getByText('General')).toBeVisible(); + }); + + test('passes subjects for URL Params mode', () => { + window.location.hash = '#subjects:ID123&readOnly:true'; + + renderWithServerContext(, defaultServerContext()); + + // filters.subjects should be populated for URL Params mode + expect(screen.getByText('General')).toBeVisible(); + }); + }); + + describe('LABKEY query error handling', () => { + test('handles LABKEY query failure gracefully', () => { + // Mock the selectRows to call the failure callback + (Query.selectRows as jest.Mock).mockImplementationOnce((config: any) => { + if (config.failure) { + config.failure({ message: 'Query failed' }); + } + }); + + window.location.hash = '#activeReport:demographics'; + + renderWithServerContext(, defaultServerContext()); + + // Component should render without crashing despite the query failure + // When failure happens, no reports are loaded so TabbedReportPanel shows empty state + expect(screen.queryByText('No reports configuration provided.')).toBeInTheDocument(); + }); + + test('defaults to supporting all filters when report metadata not found', () => { + // Mock the selectRows to return empty rows + (Query.selectRows as jest.Mock).mockImplementationOnce((config: any) => { + if (config.success) { + config.success({ rows: [] }); + } + }); + + window.location.hash = '#activeReport:nonexistent'; + + renderWithServerContext(, defaultServerContext()); + + // Component should render with default behavior (all filters supported) + // With no reports, TabbedReportPanel shows empty state + expect(screen.queryByText('No reports configuration provided.')).toBeInTheDocument(); + }); + }); + + describe('filter unsupported error message', () => { + test('shows error message when Alive at Center filter is not supported by report', async () => { + // Mock the selectRows to return a report with supportsnonidfilters: false + (Query.selectRows as jest.Mock).mockImplementationOnce((config: any) => { + if (config.success) { + config.success({ + rows: [ + { + reportname: 'test-report', + reporttitle: 'Test Report', + reporttype: 'query', + supportsnonidfilters: false, + visible: true, + category: 'General', + }, + ], + }); + } + }); + + // Start with Alive at Center filter active + window.location.hash = '#filterType:aliveAtCenter&activeReport:test-report&showReport:1'; + + renderWithServerContext(, defaultServerContext()); + + // Wait for the error message to appear + const errorMessage = await screen.findByRole('alert'); + expect(errorMessage).toHaveTextContent( + 'Filter type unsupported for this report. Switched to All Animals.' + ); + }); + + test('does not show error message when Alive at Center filter is supported', () => { + // Default mock already returns supportsnonidfilters: true + window.location.hash = '#filterType:aliveAtCenter&activeReport:test-report&showReport:1'; + + renderWithServerContext(, defaultServerContext()); + + // Should not show error message + expect( + screen.queryByText('Filter type unsupported for this report. Switched to All Animals.') + ).not.toBeInTheDocument(); + }); }); }); }); diff --git a/labkey-ui-ehr/src/ParticipantHistory/ParticipantReports.tsx b/labkey-ui-ehr/src/ParticipantHistory/ParticipantReports.tsx index a5e2b916e..4a4ea9cf6 100644 --- a/labkey-ui-ehr/src/ParticipantHistory/ParticipantReports.tsx +++ b/labkey-ui-ehr/src/ParticipantHistory/ParticipantReports.tsx @@ -1,111 +1,210 @@ -import React, { FC, memo, useCallback, useMemo } from 'react'; -import { useServerContext } from '@labkey/components'; +import React, { FC, memo, useCallback, useEffect, useMemo, useState } from 'react'; + +import { Query } from '@labkey/api'; +import { SearchByIdPanel } from './SearchByIdPanel/SearchByIdPanel'; import { TabbedReportPanel } from './TabbedReportPanel/TabbedReportPanel'; +import { getFiltersFromUrl, updateUrlHash } from './utils/urlHashUtils'; +import { + FILTER_TYPE_ALIVE_AT_CENTER, + FILTER_TYPE_ALL, + FILTER_TYPE_ID_SEARCH, + FILTER_TYPE_URL_PARAMS, + FilterType, + ReportConfig, + ReportFilters, +} from './models'; -interface UrlFilters { - [key: string]: any; - activeReport?: string; - inputType?: string; - participantId?: string; - showReport?: boolean; - subjects?: string[]; -} - -const getFiltersFromUrl = (): UrlFilters => { - const context: UrlFilters = {}; - - // Parse participantId from URL query parameters (e.g., ?participantId=44444) - const urlParams = new URLSearchParams(document.location.search); - const participantId = urlParams.get('participantId'); - if (participantId) { - context.participantId = participantId; - context.subjects = [participantId]; - } - - if (document.location.hash) { - const token = document.location.hash.split('#'); - const params = token[1]?.split('&') || []; - - for (let i = 0; i < params.length; i++) { - const t = params[i].split(':'); - const key = decodeURIComponent(t[0]); - const value = t.length > 1 ? decodeURIComponent(t[1]) : undefined; - - switch (key) { - case 'activeReport': - context.activeReport = value; - break; - case 'inputType': - context.inputType = value; - break; - case 'showReport': - context.showReport = value === '1'; - break; - case 'subjects': - // If subjects are in hash, merge with participantId if present - const hashSubjects = value ? value.split(';') : []; - if (context.participantId && !hashSubjects.includes(context.participantId)) { - context.subjects = [context.participantId, ...hashSubjects]; - } else { - context.subjects = hashSubjects; - } - break; - default: - if (value !== undefined) { - context[key] = value; +// Declare global LABKEY API +declare const LABKEY: any; + +const ParticipantReportsComponent: FC = () => { + const urlFilters = useMemo(() => getFiltersFromUrl(), []); + const [subjects, setSubjects] = useState(urlFilters.subjects || []); + + // Determine if we're in read-only mode from URL (for shared/bookmarked links) + const isReadOnly = useMemo(() => { + return urlFilters.readOnly && (urlFilters.subjects?.length ?? 0) > 0; + }, [urlFilters]); + + // Determine initial filter type based on URL parameters + const initialFilterType = useMemo(() => { + if (isReadOnly) { + return FILTER_TYPE_URL_PARAMS; // Read-only mode for shared links + } + return urlFilters.filterType || FILTER_TYPE_ID_SEARCH; + }, [urlFilters, isReadOnly]); + + const [filterType, setFilterType] = useState(initialFilterType); + const [activeReport, setActiveReport] = useState(urlFilters.activeReport); + const [filterNotSupportedError, setFilterNotSupportedError] = useState(null); + // In readOnly mode, always show reports immediately + const [showReport, setShowReport] = useState(isReadOnly || (urlFilters.showReport ?? false)); + const [reports, setReports] = useState([]); + const [reportsLoading, setReportsLoading] = useState(true); + + // Fetch all visible reports once on mount + // This consolidates the query that was previously in TabbedReportPanel + useEffect(() => { + if (typeof LABKEY === 'undefined') { + setReportsLoading(false); + return; + } + + Query.selectRows({ + schemaName: 'ehr', + queryName: 'reports', + filterArray: [LABKEY.Filter.create('visible', true, LABKEY.Filter.Types.EQUAL)], + sort: 'category,sort_order,reporttitle,reportstatus', + success: (data: any) => { + // Match TabbedReportPanel's mapping format + const loadedReports: ReportConfig[] = data.rows.map((row: any) => { + const report: ReportConfig = { + id: row.reportname, + title: row.reporttitle, + reportType: row.reporttype, + schemaName: row.schemaname, + queryName: row.queryname, + viewName: row.viewname, + reportId: row.report, + ...row, // Spreads all fields including supportsnonidfilters + }; + + if (row.jsonConfig) { + try { + const json = JSON.parse(row.jsonConfig); + Object.assign(report, json); + } catch (e) { + console.warn('Failed to parse jsonConfig for report: ' + row.reportname, e); + } } + return report; + }); + setReports(loadedReports); + setReportsLoading(false); + }, + failure: (error: any) => { + console.error('Failed to load reports', error); + setReportsLoading(false); + }, + }); + }, []); + + // Look up supportsnonidfilters from cached reports instead of making a separate query + // Note: Use lowercase 'supportsnonidfilters' to match the database column name + const activeReportSupportsNonIdFilters = useMemo(() => { + if (!activeReport || reports.length === 0) return true; + const report = reports.find(r => r.id === activeReport); + return report?.supportsnonidfilters ?? true; + }, [activeReport, reports]); + + const handleFilterChange = useCallback( + (newFilterType: FilterType, newSubjects?: string[], clearError = true) => { + setFilterType(newFilterType); + setSubjects(newSubjects || []); + if (clearError) { + setFilterNotSupportedError(null); // Clear any previous error } - } - } - return context; -}; + // Determine if report should be shown + // Show report for 'all' and 'aliveAtCenter' modes always + // Show report for 'idSearch' and 'urlParams' only when subjects exist + const shouldShowReport = + newFilterType === FILTER_TYPE_ALL || + newFilterType === FILTER_TYPE_ALIVE_AT_CENTER || + ((newFilterType === FILTER_TYPE_ID_SEARCH || newFilterType === FILTER_TYPE_URL_PARAMS) && + (newSubjects?.length ?? 0) > 0); + setShowReport(shouldShowReport); -export const ParticipantReports: FC = memo(() => { - const urlFilters = useMemo(() => getFiltersFromUrl(), []); + // When switching from urlParams to idSearch (via "Modify Search"), remove readOnly parameter + const isLeavingReadOnly = filterType === FILTER_TYPE_URL_PARAMS && newFilterType !== FILTER_TYPE_URL_PARAMS; + const readOnly = newFilterType === FILTER_TYPE_URL_PARAMS && !isLeavingReadOnly; - const filters = useMemo( - () => ({ - subjects: urlFilters.subjects || [], - ...urlFilters, - }), - [urlFilters] + updateUrlHash(newFilterType, newSubjects, readOnly, shouldShowReport, activeReport); + }, + [filterType, activeReport] ); - const onTabChange = useCallback((reportId: string) => { - const hash = document.location.hash; - const params = hash ? hash.substring(1).split('&') : []; - const newParams: string[] = []; - let found = false; - - for (const param of params) { - const [key] = param.split(':'); - if (decodeURIComponent(key) === 'activeReport') { - newParams.push(`activeReport:${encodeURIComponent(reportId)}`); - found = true; - } else { - newParams.push(param); + const handleTabChange = useCallback( + (reportId: string) => { + setActiveReport(reportId); + // Update URL hash with new activeReport + updateUrlHash(filterType, subjects, filterType === FILTER_TYPE_URL_PARAMS, showReport, reportId); + }, + [filterType, subjects, showReport] + ); + + // Auto-switch from aliveAtCenter to all when report doesn't support it + // Also set the error message when switching + useEffect(() => { + if (filterType === FILTER_TYPE_ALIVE_AT_CENTER && !activeReportSupportsNonIdFilters) { + // Set error message before switching to All Animals mode + setFilterNotSupportedError('Filter type unsupported for this report. Switched to All Animals.'); + // Automatically switch to All Animals mode + handleFilterChange(FILTER_TYPE_ALL, undefined, false); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [activeReportSupportsNonIdFilters, filterType]); + + // Clear error message when user manually changes filter or when report supports the filter + useEffect(() => { + // Only clear error if user has changed to a filter mode that works + // Don't clear if we just auto-switched to all (that's handled above with clearError=false) + if (filterType !== FILTER_TYPE_ALL || activeReportSupportsNonIdFilters) { + // When filter type is not 'all', or when the report supports non-id filters, + // the error should be cleared (unless it was just set by the auto-switch) + // We use a check for activeReportSupportsNonIdFilters here + if (activeReportSupportsNonIdFilters) { + setFilterNotSupportedError(null); } } + }, [filterType, activeReportSupportsNonIdFilters]); - if (!found) { - newParams.push(`activeReport:${encodeURIComponent(reportId)}`); + // Compute effective filter - override to 'all' if aliveAtCenter is not supported + const effectiveFilterType = useMemo(() => { + if (filterType === FILTER_TYPE_ALIVE_AT_CENTER && !activeReportSupportsNonIdFilters) { + return FILTER_TYPE_ALL; // Override to show all animals } + return filterType; + }, [filterType, activeReportSupportsNonIdFilters]); - document.location.hash = newParams.join('&'); - }, []); + const filters: ReportFilters = useMemo( + () => ({ + filterType: effectiveFilterType, + subjects: + effectiveFilterType === FILTER_TYPE_ID_SEARCH || effectiveFilterType === FILTER_TYPE_URL_PARAMS + ? subjects + : undefined, + }), + [effectiveFilterType, subjects] + ); return ( -
+
+ {!isReadOnly && ( + + )} + {filterNotSupportedError && ( +
+ {filterNotSupportedError} +
+ )}
); -}); +}; + +ParticipantReportsComponent.displayName = 'ParticipantReports'; + +export const ParticipantReports = memo(ParticipantReportsComponent); diff --git a/labkey-ui-ehr/src/ParticipantHistory/SearchByIdPanel/IdResolutionFeedback.test.tsx b/labkey-ui-ehr/src/ParticipantHistory/SearchByIdPanel/IdResolutionFeedback.test.tsx new file mode 100644 index 000000000..c0f98581b --- /dev/null +++ b/labkey-ui-ehr/src/ParticipantHistory/SearchByIdPanel/IdResolutionFeedback.test.tsx @@ -0,0 +1,284 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; + +import { IdResolutionFeedback } from './IdResolutionFeedback'; +import { IdResolutionResult } from '../models'; + +describe('IdResolutionFeedback', () => { + describe('visibility logic', () => { + test('component hidden when all IDs are direct matches (no aliases, no not-found)', () => { + const resolutionResult: IdResolutionResult = { + resolved: [ + { inputId: 'ID123', resolvedId: 'ID123', resolvedBy: 'direct', aliasType: null }, + { inputId: 'ID456', resolvedId: 'ID456', resolvedBy: 'direct', aliasType: null }, + ], + notFound: [], + }; + + const { container } = render( + + ); + + // Component should not render anything + expect(container.firstChild).toBeNull(); + }); + + test('component visible when aliases present', () => { + const resolutionResult: IdResolutionResult = { + resolved: [ + { inputId: 'ID123', resolvedId: 'ID123', resolvedBy: 'direct', aliasType: null }, + { inputId: 'TATTOO_001', resolvedId: 'ID456', resolvedBy: 'alias', aliasType: 'tattoo' }, + ], + notFound: [], + }; + + render(); + + expect(screen.getByText(/resolved/i)).toBeVisible(); + }); + + test('component visible when not-found IDs present', () => { + const resolutionResult: IdResolutionResult = { + resolved: [{ inputId: 'ID123', resolvedId: 'ID123', resolvedBy: 'direct', aliasType: null }], + notFound: ['INVALID_ID'], + }; + + render(); + + expect(screen.getByText(/not found/i)).toBeVisible(); + }); + + test('component visible when both aliases and not-found IDs present', () => { + const resolutionResult: IdResolutionResult = { + resolved: [{ inputId: 'TATTOO_001', resolvedId: 'ID123', resolvedBy: 'alias', aliasType: 'tattoo' }], + notFound: ['INVALID_ID'], + }; + + render(); + + expect(screen.getByText(/resolved/i)).toBeVisible(); + expect(screen.getByText(/not found/i)).toBeVisible(); + }); + }); + + describe('resolved section display', () => { + test('displays direct matches without arrow', () => { + const resolutionResult: IdResolutionResult = { + resolved: [ + { inputId: 'ID123', resolvedId: 'ID123', resolvedBy: 'direct', aliasType: null }, + { inputId: 'ID456', resolvedId: 'ID456', resolvedBy: 'direct', aliasType: null }, + ], + notFound: [], + }; + + render(); + + expect(screen.getByText('ID123')).toBeVisible(); + expect(screen.getByText('ID456')).toBeVisible(); + // Should not contain arrow symbols for direct matches + expect(screen.queryByText(/→/)).not.toBeInTheDocument(); + }); + + test('displays alias matches with arrow and type', () => { + const resolutionResult: IdResolutionResult = { + resolved: [{ inputId: 'TATTOO_001', resolvedId: 'ID123', resolvedBy: 'alias', aliasType: 'tattoo' }], + notFound: [], + }; + + render(); + + // Should show: "TATTOO_001 → ID123 (tattoo)" + expect(screen.getByText(/TATTOO_001/)).toBeVisible(); + expect(screen.getByText(/→/)).toBeVisible(); + expect(screen.getByText(/ID123/)).toBeVisible(); + expect(screen.getByText('(tattoo)')).toBeVisible(); + }); + + test('displays multiple alias matches with different types', () => { + const resolutionResult: IdResolutionResult = { + resolved: [ + { inputId: 'TATTOO_001', resolvedId: 'ID123', resolvedBy: 'alias', aliasType: 'tattoo' }, + { inputId: 'CHIP_12345', resolvedId: 'ID456', resolvedBy: 'alias', aliasType: 'chip' }, + ], + notFound: [], + }; + + render(); + + expect(screen.getByText(/TATTOO_001/)).toBeVisible(); + expect(screen.getByText('(tattoo)')).toBeVisible(); + expect(screen.getByText(/CHIP_12345/)).toBeVisible(); + expect(screen.getByText('(chip)')).toBeVisible(); + }); + + test('displays mixed direct and alias matches correctly', () => { + const resolutionResult: IdResolutionResult = { + resolved: [ + { inputId: 'ID123', resolvedId: 'ID123', resolvedBy: 'direct', aliasType: null }, + { inputId: 'TATTOO_001', resolvedId: 'ID456', resolvedBy: 'alias', aliasType: 'tattoo' }, + { inputId: 'ID789', resolvedId: 'ID789', resolvedBy: 'direct', aliasType: null }, + ], + notFound: [], + }; + + render(); + + // Direct matches should not have arrow + expect(screen.getByText('ID123')).toBeVisible(); + expect(screen.getByText('ID789')).toBeVisible(); + + // Alias match should have arrow and type + expect(screen.getByText(/TATTOO_001/)).toBeVisible(); + expect(screen.getByText('(tattoo)')).toBeVisible(); + }); + }); + + describe('not found section display', () => { + test('displays unresolved IDs in not found section', () => { + const resolutionResult: IdResolutionResult = { + resolved: [], + notFound: ['INVALID_ID_1', 'INVALID_ID_2'], + }; + + render(); + + expect(screen.getByText(/not found/i)).toBeVisible(); + expect(screen.getByText('INVALID_ID_1')).toBeVisible(); + expect(screen.getByText('INVALID_ID_2')).toBeVisible(); + }); + + test('displays single not found ID', () => { + const resolutionResult: IdResolutionResult = { + resolved: [{ inputId: 'ID123', resolvedId: 'ID123', resolvedBy: 'direct', aliasType: null }], + notFound: ['INVALID_ID'], + }; + + render(); + + expect(screen.getByText(/not found/i)).toBeVisible(); + expect(screen.getByText('INVALID_ID')).toBeVisible(); + }); + }); + + describe('multiple inputs resolving to same ID', () => { + test('displays all inputs that resolved to same ID', () => { + const resolutionResult: IdResolutionResult = { + resolved: [ + { inputId: 'TATTOO_001', resolvedId: 'ID123', resolvedBy: 'alias', aliasType: 'tattoo' }, + { inputId: 'CHIP_12345', resolvedId: 'ID123', resolvedBy: 'alias', aliasType: 'chip' }, + ], + notFound: [], + }; + + render(); + + // Both inputs should be displayed even though they resolve to the same ID + expect(screen.getByText(/TATTOO_001/)).toBeVisible(); + expect(screen.getByText(/CHIP_12345/)).toBeVisible(); + // ID123 should appear twice (once for each resolution) + const id123Elements = screen.getAllByText(/ID123/); + expect(id123Elements.length).toBeGreaterThanOrEqual(2); + }); + }); + + describe('empty results', () => { + test('does not render when no resolved and no not found IDs', () => { + const resolutionResult: IdResolutionResult = { + resolved: [], + notFound: [], + }; + + const { container } = render( + + ); + + expect(container.firstChild).toBeNull(); + }); + }); + + describe('section headings', () => { + test('resolved section has proper heading', () => { + const resolutionResult: IdResolutionResult = { + resolved: [{ inputId: 'TATTOO_001', resolvedId: 'ID123', resolvedBy: 'alias', aliasType: 'tattoo' }], + notFound: [], + }; + + render(); + + const heading = screen.getByRole('heading', { name: /resolved/i }); + expect(heading).toBeInTheDocument(); + }); + + test('not found section has proper heading', () => { + const resolutionResult: IdResolutionResult = { + resolved: [], + notFound: ['INVALID_ID'], + }; + + render(); + + const heading = screen.getByRole('heading', { name: /not found/i }); + expect(heading).toBeInTheDocument(); + }); + }); + + describe('accessibility', () => { + test('displays resolved IDs with proper structure', () => { + const resolutionResult: IdResolutionResult = { + resolved: [ + { inputId: 'ID123', resolvedId: 'ID123', resolvedBy: 'direct', aliasType: null }, + { inputId: 'TATTOO_001', resolvedId: 'ID456', resolvedBy: 'alias', aliasType: 'tattoo' }, + ], + notFound: [], + }; + + const { container } = render(); + + const resolvedItems = container.querySelectorAll('.resolved-item'); + expect(resolvedItems).toHaveLength(2); + }); + + test('displays not found IDs with proper structure', () => { + const resolutionResult: IdResolutionResult = { + resolved: [], + notFound: ['INVALID_ID_1', 'INVALID_ID_2'], + }; + + const { container } = render(); + + const notFoundItems = container.querySelectorAll('.not-found-item'); + expect(notFoundItems).toHaveLength(2); + }); + }); + + describe('special characters in IDs', () => { + test('handles IDs with spaces', () => { + const resolutionResult: IdResolutionResult = { + resolved: [{ inputId: 'ID 123', resolvedId: 'ID 123', resolvedBy: 'direct', aliasType: null }], + notFound: ['INVALID ID'], + }; + + render(); + + expect(screen.getByText('ID 123')).toBeVisible(); + expect(screen.getByText('INVALID ID')).toBeVisible(); + }); + + test('handles IDs with special characters', () => { + const resolutionResult: IdResolutionResult = { + resolved: [ + { inputId: 'ID-123', resolvedId: 'ID-123', resolvedBy: 'direct', aliasType: null }, + { inputId: 'TAG_456', resolvedId: 'ID.789', resolvedBy: 'alias', aliasType: 'tag' }, + ], + notFound: ['INVALID@ID'], + }; + + render(); + + expect(screen.getByText('ID-123')).toBeVisible(); + expect(screen.getByText(/TAG_456/)).toBeVisible(); + expect(screen.getByText(/ID\.789/)).toBeVisible(); + expect(screen.getByText('INVALID@ID')).toBeVisible(); + }); + }); +}); diff --git a/labkey-ui-ehr/src/ParticipantHistory/SearchByIdPanel/IdResolutionFeedback.tsx b/labkey-ui-ehr/src/ParticipantHistory/SearchByIdPanel/IdResolutionFeedback.tsx new file mode 100644 index 000000000..f74224ef0 --- /dev/null +++ b/labkey-ui-ehr/src/ParticipantHistory/SearchByIdPanel/IdResolutionFeedback.tsx @@ -0,0 +1,73 @@ +import React, { FC } from 'react'; + +import { IdResolutionResult } from '../models'; + +/** + * Component to display ID resolution feedback + * + * Shows two sections: + * - "Resolved" section: IDs that were found (directly or via alias) + * - Direct matches: "ID123" + * - Alias matches: "TATTOO_001 → ID123 (tattoo)" + * - "Not Found" section: IDs that could not be resolved + * + * Only visible when there are aliases or not-found IDs (hidden for all direct matches) + */ + +export interface IdResolutionFeedbackProps { + isVisible: boolean; + resolutionResult: IdResolutionResult; +} + +export const IdResolutionFeedback: FC = ({ resolutionResult, isVisible }) => { + // Don't render if not visible + if (!isVisible) { + return null; + } + + const { resolved, notFound } = resolutionResult; + + // Separate direct matches from alias matches + const directMatches = resolved.filter(r => r.resolvedBy === 'direct'); + const aliasMatches = resolved.filter(r => r.resolvedBy === 'alias'); + + return ( +
+

ID Resolution

+ + {resolved.length > 0 && ( +
+

Resolved ({resolved.length})

+
+ {directMatches.map((match, index) => ( +
+ {match.resolvedId} +
+ ))} + {aliasMatches.map((match, index) => ( +
+ {match.inputId} + + {match.resolvedId} + {match.aliasType && ({match.aliasType})} +
+ ))} +
+
+ )} + + {notFound.length > 0 && ( +
+

Not Found ({notFound.length})

+
+ {notFound.map((id, index) => ( +
+ {id} +
+ ))} +
+
+ )} +
+ ); +}; diff --git a/labkey-ui-ehr/src/ParticipantHistory/SearchByIdPanel/SearchByIdPanel.test.tsx b/labkey-ui-ehr/src/ParticipantHistory/SearchByIdPanel/SearchByIdPanel.test.tsx new file mode 100644 index 000000000..c1cd9d53a --- /dev/null +++ b/labkey-ui-ehr/src/ParticipantHistory/SearchByIdPanel/SearchByIdPanel.test.tsx @@ -0,0 +1,1065 @@ +import React from 'react'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +import { parseIds, SearchByIdPanel, validateInput } from './SearchByIdPanel'; +import * as idResolutionService from '../services/idResolutionService'; +import { FILTER_TYPE_ALIVE_AT_CENTER, FILTER_TYPE_ALL, FILTER_TYPE_ID_SEARCH, FILTER_TYPE_URL_PARAMS } from '../models'; + +// Mock the idResolutionService +jest.mock('../services/idResolutionService'); + +const mockResolveAnimalIds = idResolutionService.resolveAnimalIds as jest.MockedFunction< + typeof idResolutionService.resolveAnimalIds +>; + +describe('parseIds utility function', () => { + test('parses IDs with newline separators', () => { + const result = parseIds('ID1\nID2\nID3'); + expect(result).toEqual(['ID1', 'ID2', 'ID3']); + }); + + test('parses IDs with comma separators', () => { + const result = parseIds('ID1,ID2,ID3'); + expect(result).toEqual(['ID1', 'ID2', 'ID3']); + }); + + test('parses IDs with tab separators', () => { + const result = parseIds('ID1\tID2\tID3'); + expect(result).toEqual(['ID1', 'ID2', 'ID3']); + }); + + test('parses IDs with semicolon separators', () => { + const result = parseIds('ID1;ID2;ID3'); + expect(result).toEqual(['ID1', 'ID2', 'ID3']); + }); + + test('parses IDs with mixed separators', () => { + const result = parseIds('ID1,ID2\nID3;ID4\tID5'); + expect(result).toEqual(['ID1', 'ID2', 'ID3', 'ID4', 'ID5']); + }); + + test('trims whitespace from IDs', () => { + const result = parseIds(' ID1 , ID2 \n ID3 '); + expect(result).toEqual(['ID1', 'ID2', 'ID3']); + }); + + test('filters out empty strings', () => { + const result = parseIds('ID1,,ID2\n\nID3'); + expect(result).toEqual(['ID1', 'ID2', 'ID3']); + }); + + test('de-duplicates IDs (case-insensitive)', () => { + const result = parseIds('ID1,id1,ID2,Id2'); + expect(result).toEqual(['ID1', 'ID2']); + }); + + test('preserves original casing of first occurrence', () => { + const result = parseIds('id1,ID1,Id2,ID2'); + expect(result).toEqual(['id1', 'Id2']); + }); + + test('handles empty input', () => { + const result = parseIds(''); + expect(result).toEqual([]); + }); + + test('handles whitespace-only input', () => { + const result = parseIds(' \n\t '); + expect(result).toEqual([]); + }); + + test('handles special characters in IDs', () => { + const result = parseIds('ID-123,ID_456,ID@789'); + expect(result).toEqual(['ID-123', 'ID_456', 'ID@789']); + }); + + test('handles IDs with spaces', () => { + const result = parseIds('ID 123,ID 456'); + expect(result).toEqual(['ID 123', 'ID 456']); + }); +}); + +describe('validateInput utility function', () => { + test('returns null for valid input with 1 ID', () => { + const result = validateInput(['ID1']); + expect(result).toBeNull(); + }); + + test('returns null for valid input with 100 IDs', () => { + const ids = Array.from({ length: 100 }, (_, i) => `ID${i}`); + const result = validateInput(ids); + expect(result).toBeNull(); + }); + + test('returns error for empty array', () => { + const result = validateInput([]); + expect(result).toBe('Please enter at least one animal ID.'); + }); + + test('returns error for 101 IDs', () => { + const ids = Array.from({ length: 101 }, (_, i) => `ID${i}`); + const result = validateInput(ids); + expect(result).toBe('Maximum of 100 animal IDs allowed. You entered 101 IDs.'); + }); + + test('returns error for 150 IDs', () => { + const ids = Array.from({ length: 150 }, (_, i) => `ID${i}`); + const result = validateInput(ids); + expect(result).toBe('Maximum of 100 animal IDs allowed. You entered 150 IDs.'); + }); +}); + +describe('SearchByIdPanel', () => { + const mockOnFilterChange = jest.fn(); + + beforeEach(() => { + jest.clearAllMocks(); + mockResolveAnimalIds.mockResolvedValue({ + resolved: [], + notFound: [], + }); + }); + + describe('ID parsing', () => { + test('parses IDs with newline separators', async () => { + render(); + + const textarea = screen.getByRole('textbox'); + const updateButton = screen.getByRole('button', { name: /search by ids/i }); + + await userEvent.type(textarea, 'ID123\nID456\nID789'); + + mockResolveAnimalIds.mockResolvedValue({ + resolved: [ + { inputId: 'ID123', resolvedId: 'ID123', resolvedBy: 'direct', aliasType: null }, + { inputId: 'ID456', resolvedId: 'ID456', resolvedBy: 'direct', aliasType: null }, + { inputId: 'ID789', resolvedId: 'ID789', resolvedBy: 'direct', aliasType: null }, + ], + notFound: [], + }); + + fireEvent.click(updateButton); + + await waitFor(() => { + expect(mockResolveAnimalIds).toHaveBeenCalledWith({ + inputIds: ['ID123', 'ID456', 'ID789'], + }); + }); + }); + + test('parses IDs with comma separators', async () => { + render(); + + const textarea = screen.getByRole('textbox'); + const updateButton = screen.getByRole('button', { name: /search by ids/i }); + + await userEvent.type(textarea, 'ID123,ID456,ID789'); + + mockResolveAnimalIds.mockResolvedValue({ + resolved: [ + { inputId: 'ID123', resolvedId: 'ID123', resolvedBy: 'direct', aliasType: null }, + { inputId: 'ID456', resolvedId: 'ID456', resolvedBy: 'direct', aliasType: null }, + { inputId: 'ID789', resolvedId: 'ID789', resolvedBy: 'direct', aliasType: null }, + ], + notFound: [], + }); + + fireEvent.click(updateButton); + + await waitFor(() => { + expect(mockResolveAnimalIds).toHaveBeenCalledWith({ + inputIds: ['ID123', 'ID456', 'ID789'], + }); + }); + }); + + test('parses IDs with tab separators', async () => { + render(); + + const textarea = screen.getByRole('textbox'); + const updateButton = screen.getByRole('button', { name: /search by ids/i }); + + fireEvent.change(textarea, { target: { value: 'ID123\tID456\tID789' } }); + + mockResolveAnimalIds.mockResolvedValue({ + resolved: [ + { inputId: 'ID123', resolvedId: 'ID123', resolvedBy: 'direct', aliasType: null }, + { inputId: 'ID456', resolvedId: 'ID456', resolvedBy: 'direct', aliasType: null }, + { inputId: 'ID789', resolvedId: 'ID789', resolvedBy: 'direct', aliasType: null }, + ], + notFound: [], + }); + + fireEvent.click(updateButton); + + await waitFor(() => { + expect(mockResolveAnimalIds).toHaveBeenCalledWith({ + inputIds: ['ID123', 'ID456', 'ID789'], + }); + }); + }); + + test('parses IDs with semicolon separators', async () => { + render(); + + const textarea = screen.getByRole('textbox'); + const updateButton = screen.getByRole('button', { name: /search by ids/i }); + + await userEvent.type(textarea, 'ID123;ID456;ID789'); + + mockResolveAnimalIds.mockResolvedValue({ + resolved: [ + { inputId: 'ID123', resolvedId: 'ID123', resolvedBy: 'direct', aliasType: null }, + { inputId: 'ID456', resolvedId: 'ID456', resolvedBy: 'direct', aliasType: null }, + { inputId: 'ID789', resolvedId: 'ID789', resolvedBy: 'direct', aliasType: null }, + ], + notFound: [], + }); + + fireEvent.click(updateButton); + + await waitFor(() => { + expect(mockResolveAnimalIds).toHaveBeenCalledWith({ + inputIds: ['ID123', 'ID456', 'ID789'], + }); + }); + }); + + test('parses IDs with mixed separators', async () => { + render(); + + const textarea = screen.getByRole('textbox'); + const updateButton = screen.getByRole('button', { name: /search by ids/i }); + + fireEvent.change(textarea, { target: { value: 'ID1,ID2\nID3;ID4\tID5' } }); + + mockResolveAnimalIds.mockResolvedValue({ + resolved: [ + { inputId: 'ID1', resolvedId: 'ID1', resolvedBy: 'direct', aliasType: null }, + { inputId: 'ID2', resolvedId: 'ID2', resolvedBy: 'direct', aliasType: null }, + { inputId: 'ID3', resolvedId: 'ID3', resolvedBy: 'direct', aliasType: null }, + { inputId: 'ID4', resolvedId: 'ID4', resolvedBy: 'direct', aliasType: null }, + { inputId: 'ID5', resolvedId: 'ID5', resolvedBy: 'direct', aliasType: null }, + ], + notFound: [], + }); + + fireEvent.click(updateButton); + + await waitFor(() => { + expect(mockResolveAnimalIds).toHaveBeenCalledWith({ + inputIds: ['ID1', 'ID2', 'ID3', 'ID4', 'ID5'], + }); + }); + }); + + test('trims whitespace from IDs', async () => { + render(); + + const textarea = screen.getByRole('textbox'); + const updateButton = screen.getByRole('button', { name: /search by ids/i }); + + fireEvent.change(textarea, { target: { value: ' ID123 , ID456 ' } }); + + mockResolveAnimalIds.mockResolvedValue({ + resolved: [ + { inputId: 'ID123', resolvedId: 'ID123', resolvedBy: 'direct', aliasType: null }, + { inputId: 'ID456', resolvedId: 'ID456', resolvedBy: 'direct', aliasType: null }, + ], + notFound: [], + }); + + fireEvent.click(updateButton); + + await waitFor(() => { + expect(mockResolveAnimalIds).toHaveBeenCalledWith({ + inputIds: ['ID123', 'ID456'], + }); + }); + }); + + test('de-duplicates IDs across different separators', async () => { + render(); + + const textarea = screen.getByRole('textbox'); + const updateButton = screen.getByRole('button', { name: /search by ids/i }); + + fireEvent.change(textarea, { target: { value: 'ID123,ID456\nID123;ID456' } }); + + mockResolveAnimalIds.mockResolvedValue({ + resolved: [ + { inputId: 'ID123', resolvedId: 'ID123', resolvedBy: 'direct', aliasType: null }, + { inputId: 'ID456', resolvedId: 'ID456', resolvedBy: 'direct', aliasType: null }, + ], + notFound: [], + }); + + fireEvent.click(updateButton); + + await waitFor(() => { + expect(mockResolveAnimalIds).toHaveBeenCalledWith({ + inputIds: ['ID123', 'ID456'], + }); + }); + }); + + test('filters out empty strings from parsed IDs', async () => { + render(); + + const textarea = screen.getByRole('textbox'); + const updateButton = screen.getByRole('button', { name: /search by ids/i }); + + fireEvent.change(textarea, { target: { value: 'ID123,,ID456' } }); + + mockResolveAnimalIds.mockResolvedValue({ + resolved: [ + { inputId: 'ID123', resolvedId: 'ID123', resolvedBy: 'direct', aliasType: null }, + { inputId: 'ID456', resolvedId: 'ID456', resolvedBy: 'direct', aliasType: null }, + ], + notFound: [], + }); + + fireEvent.click(updateButton); + + await waitFor(() => { + expect(mockResolveAnimalIds).toHaveBeenCalledWith({ + inputIds: ['ID123', 'ID456'], + }); + }); + }); + }); + + describe('validation', () => { + test('shows validation error when input is empty', async () => { + render(); + + const updateButton = screen.getByRole('button', { name: /search by ids/i }); + fireEvent.click(updateButton); + + await waitFor(() => { + expect(screen.getByText(/please enter at least one animal id/i)).toBeVisible(); + }); + + expect(mockResolveAnimalIds).not.toHaveBeenCalled(); + expect(mockOnFilterChange).toHaveBeenCalledWith(FILTER_TYPE_ID_SEARCH, []); + }); + + test('treats whitespace-only input as empty', async () => { + render(); + + const textarea = screen.getByRole('textbox'); + const updateButton = screen.getByRole('button', { name: /search by ids/i }); + + fireEvent.change(textarea, { target: { value: ' \n\t ' } }); + fireEvent.click(updateButton); + + await waitFor(() => { + expect(screen.getByText(/please enter at least one animal id/i)).toBeVisible(); + }); + + expect(mockResolveAnimalIds).not.toHaveBeenCalled(); + expect(mockOnFilterChange).toHaveBeenCalledWith(FILTER_TYPE_ID_SEARCH, []); + }); + + test('allows exactly 100 IDs without validation error', async () => { + render(); + + const textarea = screen.getByRole('textbox'); + const updateButton = screen.getByRole('button', { name: /search by ids/i }); + + const ids = Array.from({ length: 100 }, (_, i) => `ID${i}`).join(','); + fireEvent.change(textarea, { target: { value: ids } }); + + // Should not show validation error + expect(screen.queryByText(/maximum of 100 animal ids/i)).not.toBeInTheDocument(); + + mockResolveAnimalIds.mockResolvedValue({ + resolved: Array.from({ length: 100 }, (_, i) => ({ + inputId: `ID${i}`, + resolvedId: `ID${i}`, + resolvedBy: 'direct' as const, + aliasType: null, + })), + notFound: [], + }); + + fireEvent.click(updateButton); + + await waitFor(() => { + expect(mockResolveAnimalIds).toHaveBeenCalled(); + }); + }); + + test('shows validation error when more than 100 IDs entered', async () => { + render(); + + const textarea = screen.getByRole('textbox'); + const ids = Array.from({ length: 101 }, (_, i) => `ID${i}`).join(','); + + fireEvent.change(textarea, { target: { value: ids } }); + + await waitFor(() => { + expect(screen.getByText(/maximum of 100 animal ids allowed\. you entered 101 ids/i)).toBeVisible(); + }); + }); + + test('button remains enabled when validation fails', async () => { + render(); + + const textarea = screen.getByRole('textbox'); + const updateButton = screen.getByRole('button', { name: /search by ids/i }); + + const ids = Array.from({ length: 101 }, (_, i) => `ID${i}`).join(','); + fireEvent.change(textarea, { target: { value: ids } }); + + await waitFor(() => { + expect(screen.getByText(/maximum of 100 animal ids/i)).toBeVisible(); + }); + + // Button should still be enabled even with validation error + expect(updateButton).not.toBeDisabled(); + + // Clicking button should call onFilterChange with empty array to show no records + fireEvent.click(updateButton); + expect(mockOnFilterChange).toHaveBeenCalledWith(FILTER_TYPE_ID_SEARCH, []); + expect(mockResolveAnimalIds).not.toHaveBeenCalled(); + }); + + test('clears validation error when IDs reduced below limit', async () => { + render(); + + const textarea = screen.getByRole('textbox'); + + // First enter 101 IDs + const ids101 = Array.from({ length: 101 }, (_, i) => `ID${i}`).join(','); + fireEvent.change(textarea, { target: { value: ids101 } }); + + await waitFor(() => { + expect(screen.getByText(/maximum of 100 animal ids/i)).toBeVisible(); + }); + + // Then reduce to 100 IDs + const ids100 = Array.from({ length: 100 }, (_, i) => `ID${i}`).join(','); + fireEvent.change(textarea, { target: { value: ids100 } }); + + await waitFor(() => { + expect(screen.queryByText(/maximum of 100 animal ids/i)).not.toBeInTheDocument(); + }); + }); + }); + + describe('filter mode toggles', () => { + test('renders filter mode toggle buttons', () => { + render(); + + expect(screen.getByRole('button', { name: /search by ids/i })).toBeVisible(); + expect(screen.getByRole('button', { name: /all animals/i })).toBeVisible(); + expect(screen.getByRole('button', { name: /all alive at center/i })).toBeVisible(); + }); + + test('filter buttons visible in all modes except URL Params', () => { + const { rerender } = render( + + ); + + // ID Search mode - buttons visible + expect(screen.getByRole('button', { name: /search by ids/i })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /all animals/i })).toBeInTheDocument(); + + // all animals mode - buttons visible + rerender( + + ); + expect(screen.getByRole('button', { name: /search by ids/i })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /all animals/i })).toBeInTheDocument(); + + // all alive at center mode - buttons visible + rerender( + + ); + expect(screen.getByRole('button', { name: /search by ids/i })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /all alive at center/i })).toBeInTheDocument(); + + // URL Params mode - buttons NOT visible + rerender( + + ); + expect(screen.queryByRole('button', { name: /search by ids/i })).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: /all animals/i })).not.toBeInTheDocument(); + }); + + test('switches between filter modes', () => { + render(); + + const allRecordsButton = screen.getByRole('button', { name: /all animals/i }); + fireEvent.click(allRecordsButton); + + expect(mockOnFilterChange).toHaveBeenCalledWith(FILTER_TYPE_ALL, undefined); + }); + + test('search by ids button sets filter mode even with validation error', () => { + render(); + + // First switch to All Animals mode + const allAnimalsButton = screen.getByRole('button', { name: /all animals/i }); + fireEvent.click(allAnimalsButton); + + // Verify All Animals is active + expect(allAnimalsButton).toHaveClass('active'); + + // Now click Search By Ids with no input (will trigger validation error) + const searchByIdsButton = screen.getByRole('button', { name: /search by ids/i }); + fireEvent.click(searchByIdsButton); + + // Verify validation error appears + expect(screen.getByRole('alert')).toHaveTextContent('Please enter at least one animal ID'); + + // Verify Search By Ids button is now active + expect(searchByIdsButton).toHaveClass('active'); + + // Verify All Animals button is now inactive + expect(allAnimalsButton).toHaveClass('inactive'); + }); + + test('ID textarea is always visible', () => { + render( + + ); + + expect(screen.getByRole('textbox')).toBeVisible(); + + const allRecordsButton = screen.getByRole('button', { name: /all animals/i }); + fireEvent.click(allRecordsButton); + + expect(screen.getByRole('textbox')).toBeVisible(); + }); + + test('search by ids button is always visible', () => { + render( + + ); + + expect(screen.getByRole('button', { name: /search by ids/i })).toBeVisible(); + + const allRecordsButton = screen.getByRole('button', { name: /all animals/i }); + fireEvent.click(allRecordsButton); + + expect(screen.getByRole('button', { name: /search by ids/i })).toBeVisible(); + }); + + test('clears input when switching to all animals mode', () => { + render( + + ); + + const textarea = screen.getByRole('textbox'); + fireEvent.change(textarea, { target: { value: 'ID123,ID456' } }); + + const allRecordsButton = screen.getByRole('button', { name: /all animals/i }); + fireEvent.click(allRecordsButton); + + // Verify input was cleared + expect(textarea).toHaveValue(''); + }); + + test('clears input when switching to all alive at center mode', () => { + render( + + ); + + const textarea = screen.getByRole('textbox'); + fireEvent.change(textarea, { target: { value: 'ID123,ID456' } }); + + const aliveAtCenterButton = screen.getByRole('button', { name: /all alive at center/i }); + fireEvent.click(aliveAtCenterButton); + + expect(mockOnFilterChange).toHaveBeenCalledWith(FILTER_TYPE_ALIVE_AT_CENTER, undefined); + }); + + test('clears validation error when switching to all animals mode', () => { + render( + + ); + + const textarea = screen.getByRole('textbox'); + + // Enter more than 100 IDs to trigger validation error + const manyIds = Array.from({ length: 101 }, (_, i) => `ID${i + 1}`).join(','); + fireEvent.change(textarea, { target: { value: manyIds } }); + + // Verify validation error appears + expect(screen.getByRole('alert')).toHaveTextContent('Maximum of 100 animal IDs allowed'); + + // Switch to All Animals mode + const allAnimalsButton = screen.getByRole('button', { name: /all animals/i }); + fireEvent.click(allAnimalsButton); + + // Verify validation error is cleared + expect(screen.queryByRole('alert')).not.toBeInTheDocument(); + expect(textarea).toHaveValue(''); + }); + + test('clears validation error when switching to all alive at center mode', () => { + render( + + ); + + const textarea = screen.getByRole('textbox'); + + // Enter more than 100 IDs to trigger validation error + const manyIds = Array.from({ length: 101 }, (_, i) => `ID${i + 1}`).join(','); + fireEvent.change(textarea, { target: { value: manyIds } }); + + // Verify validation error appears + expect(screen.getByRole('alert')).toHaveTextContent('Maximum of 100 animal IDs allowed'); + + // Switch to All Alive at Center mode + const aliveAtCenterButton = screen.getByRole('button', { name: /all alive at center/i }); + fireEvent.click(aliveAtCenterButton); + + // Verify validation error is cleared + expect(screen.queryByRole('alert')).not.toBeInTheDocument(); + expect(textarea).toHaveValue(''); + }); + }); + + describe('textarea and button visibility', () => { + test('textarea and search by ids button always visible in all modes', () => { + const { rerender } = render( + + ); + + // ID Search mode - always visible + expect(screen.getByRole('textbox')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /search by ids/i })).toBeInTheDocument(); + + // all animals mode - still visible + rerender( + + ); + expect(screen.getByRole('textbox')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /search by ids/i })).toBeInTheDocument(); + + // all alive at center mode - still visible + rerender( + + ); + expect(screen.getByRole('textbox')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /search by ids/i })).toBeInTheDocument(); + }); + + test('shows loading state while resolving IDs', async () => { + // Mock a slow resolution + let resolvePromise: (value: IdResolutionResult) => void; + const slowPromise = new Promise(resolve => { + resolvePromise = resolve; + }); + mockResolveAnimalIds.mockReturnValue(slowPromise); + + render(); + + const textarea = screen.getByRole('textbox'); + const updateButton = screen.getByRole('button', { name: /search by ids/i }); + + fireEvent.change(textarea, { target: { value: 'ID123' } }); + fireEvent.click(updateButton); + + // Should show "Searching..." while loading + await waitFor(() => { + expect(screen.getByRole('button', { name: /searching/i })).toBeInTheDocument(); + }); + + // Button should be disabled while loading + expect(screen.getByRole('button', { name: /searching/i })).toBeDisabled(); + + // Resolve the promise + resolvePromise!({ + resolved: [{ inputId: 'ID123', resolvedId: 'ID123', resolvedBy: 'direct', aliasType: null }], + notFound: [], + }); + + // Should return to "search by ids" after loading + await waitFor(() => { + expect(screen.getByRole('button', { name: /search by ids/i })).toBeInTheDocument(); + }); + }); + }); + + describe('resolution feedback visibility', () => { + test('resolution feedback always visible when there are aliases or not-found IDs', async () => { + mockResolveAnimalIds.mockResolvedValue({ + resolved: [{ inputId: 'alias1', resolvedId: 'ID123', resolvedBy: 'alias', aliasType: 'tattoo' }], + notFound: ['notfound1'], + }); + + const { rerender } = render( + + ); + + const textarea = screen.getByRole('textbox'); + const updateButton = screen.getByRole('button', { name: /search by ids/i }); + + // Trigger resolution + fireEvent.change(textarea, { target: { value: 'alias1,notfound1' } }); + fireEvent.click(updateButton); + + // Wait for resolution to complete + await waitFor(() => { + expect(mockResolveAnimalIds).toHaveBeenCalled(); + }); + + // Resolution feedback should be visible + await waitFor(() => { + expect(screen.getByText(/id resolution/i)).toBeInTheDocument(); + }); + + // Switch to all animals mode + rerender( + + ); + + // Resolution feedback should still be visible (textarea is always visible) + expect(screen.getByText(/id resolution/i)).toBeInTheDocument(); + + // Switch to all alive at center mode + rerender( + + ); + + // Resolution feedback should still be visible + expect(screen.getByText(/id resolution/i)).toBeInTheDocument(); + }); + + test('shows resolution feedback when aliases are resolved', async () => { + mockResolveAnimalIds.mockResolvedValue({ + resolved: [ + { inputId: 'alias1', resolvedId: 'ID123', resolvedBy: 'alias', aliasType: 'tattoo' }, + { inputId: 'ID456', resolvedId: 'ID456', resolvedBy: 'direct', aliasType: null }, + ], + notFound: [], + }); + + render(); + + const textarea = screen.getByRole('textbox'); + const updateButton = screen.getByRole('button', { name: /search by ids/i }); + + fireEvent.change(textarea, { target: { value: 'alias1,ID456' } }); + fireEvent.click(updateButton); + + await waitFor(() => { + expect(screen.getByText(/id resolution/i)).toBeInTheDocument(); + }); + }); + + test('shows resolution feedback when IDs are not found', async () => { + mockResolveAnimalIds.mockResolvedValue({ + resolved: [{ inputId: 'ID123', resolvedId: 'ID123', resolvedBy: 'direct', aliasType: null }], + notFound: ['notfound1', 'notfound2'], + }); + + render(); + + const textarea = screen.getByRole('textbox'); + const updateButton = screen.getByRole('button', { name: /search by ids/i }); + + fireEvent.change(textarea, { target: { value: 'ID123,notfound1,notfound2' } }); + fireEvent.click(updateButton); + + await waitFor(() => { + expect(screen.getByText(/id resolution/i)).toBeInTheDocument(); + }); + }); + + test('hides resolution feedback when all IDs resolve directly', async () => { + mockResolveAnimalIds.mockResolvedValue({ + resolved: [ + { inputId: 'ID123', resolvedId: 'ID123', resolvedBy: 'direct', aliasType: null }, + { inputId: 'ID456', resolvedId: 'ID456', resolvedBy: 'direct', aliasType: null }, + ], + notFound: [], + }); + + render(); + + const textarea = screen.getByRole('textbox'); + const updateButton = screen.getByRole('button', { name: /search by ids/i }); + + fireEvent.change(textarea, { target: { value: 'ID123,ID456' } }); + fireEvent.click(updateButton); + + await waitFor(() => { + expect(mockResolveAnimalIds).toHaveBeenCalled(); + }); + + // Should not show resolution feedback when all resolve directly + expect(screen.queryByText(/id resolution/i)).not.toBeInTheDocument(); + }); + }); + + describe('all alive at center button state', () => { + test('all alive at center button enabled when activeReportSupportsNonIdFilters is true', () => { + render(); + + const aliveAtCenterButton = screen.getByRole('button', { name: /all alive at center/i }); + expect(aliveAtCenterButton).not.toBeDisabled(); + }); + + test('all alive at center button disabled when activeReportSupportsNonIdFilters is false', () => { + render(); + + const aliveAtCenterButton = screen.getByRole('button', { name: /all alive at center/i }); + expect(aliveAtCenterButton).toBeDisabled(); + }); + }); + + describe('URL Params mode (read-only)', () => { + test('hides filter toggle buttons in URL Params mode', () => { + render( + + ); + + expect(screen.queryByRole('button', { name: /search by ids/i })).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: /all animals/i })).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: /all alive at center/i })).not.toBeInTheDocument(); + }); + + test('hides ID textarea and search by ids button in URL Params mode', () => { + render( + + ); + + expect(screen.queryByRole('textbox')).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: /search by ids/i })).not.toBeInTheDocument(); + }); + + test('shows read-only summary in URL Params mode', () => { + render( + + ); + + expect(screen.getByText(/viewing 3 animal\(s\)/i)).toBeVisible(); + expect(screen.getByText(/ID123/)).toBeVisible(); + expect(screen.getByText(/ID456/)).toBeVisible(); + expect(screen.getByText(/ID789/)).toBeVisible(); + }); + + test('shows Modify Search button in URL Params mode', () => { + render( + + ); + + expect(screen.getByRole('button', { name: /modify search/i })).toBeVisible(); + }); + + test('Modify Search button switches to ID Search mode with subjects pre-populated', () => { + render( + + ); + + const modifyButton = screen.getByRole('button', { name: /modify search/i }); + fireEvent.click(modifyButton); + + expect(mockOnFilterChange).toHaveBeenCalledWith(FILTER_TYPE_ID_SEARCH, ['ID123', 'ID456']); + }); + }); + + describe('component behavior with initialSubjects prop', () => { + test('pre-populates textarea when transitioning from URL Params to ID Search', () => { + const { rerender } = render( + + ); + + // Simulate switching to ID Search mode + rerender( + + ); + + const textarea = screen.getByRole('textbox'); + expect(textarea).toHaveValue('ID123,ID456'); + }); + }); + + describe('accessibility', () => { + test('textarea has accessible label', () => { + render(); + + const textarea = screen.getByRole('textbox'); + expect(textarea).toHaveAccessibleName(); + }); + + test('buttons have accessible names', () => { + render(); + + expect(screen.getByRole('button', { name: /search by ids/i })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /all animals/i })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /all alive at center/i })).toBeInTheDocument(); + }); + + test('validation errors have role="alert" for screen readers', async () => { + render(); + + const textarea = screen.getByRole('textbox'); + const ids = Array.from({ length: 101 }, (_, i) => `ID${i}`).join(','); + fireEvent.change(textarea, { target: { value: ids } }); + + await waitFor(() => { + const alert = screen.getByRole('alert'); + expect(alert).toBeInTheDocument(); + expect(alert).toHaveTextContent(/maximum of 100 animal ids/i); + }); + }); + + test('keyboard navigation works correctly', async () => { + render(); + + const textarea = screen.getByRole('textbox'); + const updateButton = screen.getByRole('button', { name: /search by ids/i }); + + // Tab to textarea first (it's rendered first) + await userEvent.tab(); + expect(textarea).toHaveFocus(); + + // Type IDs + await userEvent.keyboard('ID123'); + + // Tab to search by ids button + await userEvent.tab(); + expect(updateButton).toHaveFocus(); + + // Tab through remaining filter buttons (all animals, all alive at center) + await userEvent.tab(); // all animals button + await userEvent.tab(); // all alive at center button + // Note: Tab order is textarea -> search by ids -> all animals -> all alive at center + // This test verifies tab order is logical + }); + }); + + describe('security - SQL injection protection', () => { + test('treats IDs with SQL injection patterns as literal strings', async () => { + render(); + + const textarea = screen.getByRole('textbox'); + const updateButton = screen.getByRole('button', { name: /search by ids/i }); + + // Note: Semicolons are treated as separators, so this input will be split + const maliciousInput = "'; DROP TABLE--;,ID123' OR '1'='1"; + fireEvent.change(textarea, { target: { value: maliciousInput } }); + + mockResolveAnimalIds.mockResolvedValue({ + resolved: [], + notFound: ["'", 'DROP TABLE--', "ID123' OR '1'='1"], + }); + + fireEvent.click(updateButton); + + await waitFor(() => { + // Semicolon acts as separator, so "'; DROP TABLE--;" splits into "'" and "DROP TABLE--" + expect(mockResolveAnimalIds).toHaveBeenCalledWith({ + inputIds: ["'", 'DROP TABLE--', "ID123' OR '1'='1"], + }); + }); + }); + }); +}); diff --git a/labkey-ui-ehr/src/ParticipantHistory/SearchByIdPanel/SearchByIdPanel.tsx b/labkey-ui-ehr/src/ParticipantHistory/SearchByIdPanel/SearchByIdPanel.tsx new file mode 100644 index 000000000..2512dc874 --- /dev/null +++ b/labkey-ui-ehr/src/ParticipantHistory/SearchByIdPanel/SearchByIdPanel.tsx @@ -0,0 +1,257 @@ +import React, { FC, useCallback, useEffect, useState } from 'react'; +import { incrementClientSideMetricCount } from '@labkey/components'; + +import { IdResolutionFeedback } from './IdResolutionFeedback'; +import { resolveAnimalIds } from '../services/idResolutionService'; +import { + FILTER_TYPE_ALIVE_AT_CENTER, + FILTER_TYPE_ALL, + FILTER_TYPE_ID_SEARCH, + FILTER_TYPE_URL_PARAMS, + FilterType, + IdResolutionResult, +} from '../models'; + +/** + * Parse IDs from input string (split by newline, tab, comma, semicolon) + * Returns de-duplicated array of trimmed IDs (case-insensitive matching) + * @internal - Exported for testing + */ +export const parseIds = (input: string): string[] => { + // Split by newline, tab, comma, or semicolon + const rawIds = input.split(/[\n\t,;]+/); + + // Trim whitespace and filter out empty strings + const trimmedIds = rawIds.map(id => id.trim()).filter(id => id.length > 0); + + // De-duplicate (case-insensitive) while preserving original casing + const seenLower = new Set(); + const uniqueIds: string[] = []; + + trimmedIds.forEach(id => { + const lowerCase = id.toLowerCase(); + if (!seenLower.has(lowerCase)) { + seenLower.add(lowerCase); + uniqueIds.push(id); + } + }); + + return uniqueIds; +}; + +/** + * Validate input IDs (check for empty, check 100 ID limit) + * Returns null if valid, error message string if invalid + * @internal - Exported for testing + */ +export const validateInput = (ids: string[]): null | string => { + if (ids.length === 0) { + return 'Please enter at least one animal ID.'; + } + + if (ids.length > 100) { + return `Maximum of 100 animal IDs allowed. You entered ${ids.length} IDs.`; + } + + return null; +}; + +/** + * Search By Id Panel Component + * + * Provides UI for searching animals by ID with three filter modes: + * - ID Search: Enter single or multiple animal IDs (max 100) + * - All Records: View all animals (no filters) + * - Alive at Center: View only animals with calculated_status = 'Alive' + * - URL Params: Read-only view for shared/bookmarked links + * + * Features: + * - Multi-separator parsing (newlines, tabs, commas, semicolons) + * - Alias resolution (tattoos, chip numbers, etc.) + * - Case-insensitive matching + * - 100 ID limit validation + * - ID Resolution feedback for aliases and not-found IDs + */ + +export interface SearchByIdPanelProps { + activeReportSupportsNonIdFilters: boolean; + initialFilterType?: FilterType; + initialSubjects?: string[]; + onFilterChange: (filterType: FilterType, subjects?: string[]) => void; +} + +export const SearchByIdPanel: FC = ({ + onFilterChange, + initialSubjects = [], + initialFilterType = FILTER_TYPE_ID_SEARCH, + activeReportSupportsNonIdFilters, +}) => { + const [inputValue, setInputValue] = useState(initialSubjects.join(',')); + const [filterType, setFilterType] = useState(initialFilterType); + const [isResolving, setIsResolving] = useState(false); + const [resolutionResult, setResolutionResult] = useState({ + resolved: [], + notFound: [], + }); + const [validationError, setValidationError] = useState(null); + const [hasUserTyped, setHasUserTyped] = useState(false); + + // Sync filterType with initialFilterType prop changes + useEffect(() => { + setFilterType(initialFilterType); + }, [initialFilterType]); + + // Validate input whenever it changes (but only after user has typed) + useEffect(() => { + if (hasUserTyped) { + const parsedIds = parseIds(inputValue); + const error = validateInput(parsedIds); + setValidationError(error); + } + }, [inputValue, hasUserTyped]); + + // Handle Update Report button click + const handleUpdateReport = useCallback(async () => { + // Set filter mode to ID Search + setFilterType(FILTER_TYPE_ID_SEARCH); + + // Parse IDs from input + const parsedIds = parseIds(inputValue); + + // Validate input (in case user clicked without typing) + const error = validateInput(parsedIds); + if (error) { + setValidationError(error); + setHasUserTyped(true); // Show validation errors now + // Call onFilterChange with empty array to show no records in reports + onFilterChange(FILTER_TYPE_ID_SEARCH, []); + return; // Stop if validation fails + } + + // Call resolveAnimalIds service + setIsResolving(true); + try { + const result = await resolveAnimalIds({ inputIds: parsedIds }); + + // Update resolutionResult state + setResolutionResult(result); + + // Extract resolved subject IDs + const resolvedSubjects = result.resolved.map(r => r.resolvedId); + + // Track ID search metric + incrementClientSideMetricCount('ehrParticipantHistoryFilter', FILTER_TYPE_ID_SEARCH); + + // Call onFilterChange with resolved subject IDs + onFilterChange(FILTER_TYPE_ID_SEARCH, resolvedSubjects); + } catch (error) { + // Handle error + console.error('Failed to resolve animal IDs:', error); + setValidationError('Failed to resolve animal IDs. Please try again.'); + // Call onFilterChange with empty array to show no records in reports when error occurs + onFilterChange(FILTER_TYPE_ID_SEARCH, []); + } finally { + setIsResolving(false); + } + }, [inputValue, onFilterChange]); + + // Handle filter mode button clicks + const handleFilterModeChange = useCallback( + (newFilterType: FilterType) => { + setFilterType(newFilterType); + + // Track filter metric + incrementClientSideMetricCount('ehrParticipantHistoryFilter', newFilterType); + + // Clear input when switching to non-ID modes + setInputValue(''); + setResolutionResult({ resolved: [], notFound: [] }); + setValidationError(null); + setHasUserTyped(false); + + onFilterChange(newFilterType, undefined); + }, + [onFilterChange] + ); + + // Handle Modify Search button (URL Params mode) + const handleModifySearch = useCallback(() => { + setFilterType(FILTER_TYPE_ID_SEARCH); + setInputValue(initialSubjects.join(',')); + onFilterChange(FILTER_TYPE_ID_SEARCH, initialSubjects); + }, [initialSubjects, onFilterChange]); + + // Determine if resolution feedback should be visible + const isResolutionFeedbackVisible = + resolutionResult.resolved.some(r => r.resolvedBy === 'alias') || resolutionResult.notFound.length > 0; + + if (filterType === FILTER_TYPE_URL_PARAMS) { + return ( +
+
+ Viewing {initialSubjects.length} animal(s): {initialSubjects.join(', ')} +
+ +
+ ); + } + + return ( +
+
+ +