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/queries/study/aliasIdMatches.sql b/EHR_App/resources/queries/study/aliasIdMatches.sql new file mode 100644 index 000000000..da93d7304 --- /dev/null +++ b/EHR_App/resources/queries/study/aliasIdMatches.sql @@ -0,0 +1,8 @@ + +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 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..2a6a8ea0f --- /dev/null +++ b/EHR_App/test/src/org/labkey/test/pages/ReactAnimalHistoryPage.java @@ -0,0 +1,424 @@ +package org.labkey.test.pages; + +import org.apache.commons.lang3.StringUtils; +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 org.openqa.selenium.support.ui.ExpectedConditions; + +import java.util.Optional; + +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) + { + setFormElement(elementCache().animalIdTextarea, String.join(",", ids)); + return this; + } + + /** + * Clear the animal ID textarea. + */ + public ReactAnimalHistoryPage clearIdInput() + { + setFormElement(elementCache().animalIdTextarea, ""); + return this; + } + + /** + * Get the current value of the animal ID textarea. + */ + public String getIdInputValue() + { + return getFormElement(elementCache().animalIdTextarea); + } + + // ========================================================================= + // Button Click Methods + // ========================================================================= + + /** + * Click the Search By Ids button and wait for results. + */ + public ReactAnimalHistoryPage clickSearchByIds() + { + elementCache().searchByIdsButton.click(); + waitForSearchComplete(); + return this; + } + + /** + * Convenience method to clear input, enter animal IDs, click search, and wait for report to load. + * Combines clearIdInput(), enterAnimalIds(), clickSearchByIds(), and waitForReportToLoad(). + */ + public ReactAnimalHistoryPage searchByIds(String... ids) + { + clearIdInput(); + enterAnimalIds(ids); + clickSearchByIds(); + waitForReportToLoad(); + return this; + } + + /** + * Click the All Animals filter button and wait for reports to load. + */ + public ReactAnimalHistoryPage clickAllAnimals() + { + doAndWaitForReportToRefresh(() -> elementCache().allAnimalsButton.click()); + return this; + } + + /** + * Click the All Alive at Center filter button and wait for reports to load. + */ + public ReactAnimalHistoryPage clickAliveAtCenter() + { + doAndWaitForReportToRefresh(() -> elementCache().aliveAtCenterButton.click()); + return this; + } + + /** + * Click a report tab by name. + */ + public ReactAnimalHistoryPage clickReportTab(String tabName) + { + selectTab(Locators.REPORT_TAB.withText(tabName).findElement(getDriver())); + return this; + } + + /** + * Click a category tab by name (e.g., "General", "Clinical", etc.). + */ + public ReactAnimalHistoryPage clickCategoryTab(String categoryName) + { + selectTab(Locators.CATEGORY_TAB.withText(categoryName).findElement(getDriver())); + 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 report panel WebElement for scoped text searches. + * Use with TextSearcher to efficiently batch multiple text checks. + */ + public WebElement getReportPanelElement() + { + return waitForElement(Locators.REPORT_TARGET); + } + + /** + * 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() + { + WebElement reportTarget = Locators.REPORT_TARGET.refindWhenNeeded(getDriver()); + DataRegion(getDriver()).timeout(5000).waitFor(reportTarget); + return this; + } + + // ========================================================================= + // Wait Methods + // ========================================================================= + + /** + * Wait for search to complete (button stops showing "Searching..." and results appear). + */ + public ReactAnimalHistoryPage waitForSearchComplete() + { + longWait().withMessage("Search did not complete (button ready and results present).") + .until(d -> { + boolean buttonReady = !elementCache().searchByIdsButton.getText().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() + { + Locators.REPORT_TARGET.waitForElement(longWait()); + return this; + } + + // ========================================================================= + // Private Helper Methods + // ========================================================================= + + /** + * Select the specified tab and wait for the report panel to refresh. + * No-op if tab is already active. + */ + private void selectTab(final WebElement tabEl) + { + if (!StringUtils.trimToEmpty(tabEl.getDomAttribute("class")).contains("active")) + { + doAndWaitForReportToRefresh(tabEl::click); + } + } + + /** + * Execute an action and wait for the report content to refresh. + * Waits for existing report element to become stale, then waits for new content. + */ + private void doAndWaitForReportToRefresh(Runnable runnable) + { + Locator.CssLocator reportContentLoc = ((Locator.CssLocator) Locators.REPORT_TARGET).child("div"); + Optional existingReport = reportContentLoc.findOptionalElement(getDriver()); + runnable.run(); + existingReport.ifPresent(reportEl -> { + shortWait().until(ExpectedConditions.stalenessOf(reportEl)); + waitForElement(reportContentLoc); + }); + } + + // ========================================================================= + // State Check Methods + // ========================================================================= + + /** + * Check if the search panel is present. + */ + public boolean isSearchByIdPanelPresent() + { + return isElementPresent(Locators.SEARCH_BY_ID_PANEL); + } + + /** + * Check if the animal ID textarea is present. + */ + public boolean isAnimalIdTextareaPresent() + { + return isElementPresent(Locators.ANIMAL_ID_TEXTAREA); + } + + /** + * Check if the Search By Ids button is present. + */ + public boolean isSearchByIdsButtonPresent() + { + return isElementPresent(Locators.SEARCH_BY_IDS_BUTTON); + } + + /** + * Check if the All Animals button is present. + */ + public boolean isAllAnimalsButtonPresent() + { + return isElementPresent(Locators.ALL_ANIMALS_BUTTON); + } + + /** + * Check if the Alive at Center button is present. + */ + public boolean isAliveAtCenterButtonPresent() + { + return isElementPresent(Locators.ALIVE_AT_CENTER_BUTTON); + } + + /** + * Check if the not-found feedback section is present. + */ + public boolean isNotFoundSectionPresent() + { + return isElementPresent(Locators.NOT_FOUND_SECTION_TITLE); + } + + /** + * Check if a specific item is in the not-found list. + */ + public boolean hasNotFoundItem(String item) + { + return isElementPresent(Locators.NOT_FOUND_ITEMS.withText(item)); + } + + /** + * Check if the Search By Ids button is active. + */ + public boolean isSearchByIdsActive() + { + return isElementPresent(Locators.SEARCH_BY_IDS_BUTTON_ACTIVE); + } + + /** + * Check if the All Animals button is active. + */ + public boolean isAllAnimalsActive() + { + return isElementPresent(Locators.ALL_ANIMALS_BUTTON_ACTIVE); + } + + /** + * Check if the Alive at Center button is active. + */ + public boolean isAliveAtCenterActive() + { + return isElementPresent(Locators.ALIVE_AT_CENTER_BUTTON_ACTIVE); + } + + /** + * Check if the Alive at Center button is enabled (not disabled). + */ + public boolean isAliveAtCenterEnabled() + { + return isElementPresent(Locators.ALIVE_AT_CENTER_BUTTON_ENABLED); + } + + /** + * 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() + { + waitForElement(Locators.EMPTY_STATE_PLACEHOLDER); + return isElementPresent(Locators.EMPTY_STATE_PLACEHOLDER); + } + + /** + * Get the current row count in the Demographics report. + */ + public int getDemographicsRowCount() + { + DataRegionTable table = getActiveReportDataRegion(); + return table.getDataRowCount(); + } + + // ========================================================================= + // Element Cache + // ========================================================================= + + @Override + protected ElementCache newElementCache() + { + return new ElementCache(); + } + + public class ElementCache extends LabKeyPage.ElementCache + { + final WebElement searchByIdPanel = Locators.SEARCH_BY_ID_PANEL.findWhenNeeded(this); + final WebElement animalIdTextarea = Locators.ANIMAL_ID_TEXTAREA.findWhenNeeded(this); + final WebElement searchByIdsButton = Locators.SEARCH_BY_IDS_BUTTON.findWhenNeeded(this); + final WebElement allAnimalsButton = Locators.ALL_ANIMALS_BUTTON.findWhenNeeded(this); + final WebElement aliveAtCenterButton = Locators.ALIVE_AT_CENTER_BUTTON.findWhenNeeded(this); + // Note: reportTarget, idResolutionFeedback, validationError, emptyStatePlaceholder + // are dynamic elements that appear/disappear and should not be cached + } + + /** + * 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 SEARCH_BY_IDS_BUTTON_ACTIVE = Locator.css(".search-button.active"); + public static final Locator ALL_ANIMALS_BUTTON = Locator.css(".filter-button.all-animals"); + public static final Locator ALL_ANIMALS_BUTTON_ACTIVE = Locator.css(".filter-button.all-animals.active"); + public static final Locator ALIVE_AT_CENTER_BUTTON = Locator.css(".filter-button.alive-at-center"); + public static final Locator ALIVE_AT_CENTER_BUTTON_ACTIVE = Locator.css(".filter-button.alive-at-center.active"); + public static final Locator ALIVE_AT_CENTER_BUTTON_ENABLED = Locator.css(".filter-button.alive-at-center:not(:disabled)"); + 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 button"); + public static final Locator REPORT_TAB = Locator.css(".tabbed-report-panel .report-tabs button"); + 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 658006803..9fbc39ffa 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,32 @@ 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.DataRegionTable; import org.labkey.test.util.PostgresOnlyTest; +import org.labkey.test.util.TextSearcher; +import org.openqa.selenium.WebElement; 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.assertFalse; +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 +50,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) @@ -80,6 +103,12 @@ public static void setupProject() throws Exception init.doSetup(); } + @Override + protected String getExpectedAnimalIDCasing(String id) + { + return id.toUpperCase(); + } + private void doSetup() throws Exception { initProject("EHR App"); @@ -130,6 +159,7 @@ protected List skipLinksForValidation() links.add("ehr-colonyOverview.view"); links.add("ehr-updateTable.view"); links.add("ehr-populateLookupData.view"); + links.add("ehr-participantViewNew.view"); links.add("ehr-postgresMigration.view"); return links; } @@ -151,4 +181,418 @@ 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"); + assertTrue("Search panel should be present", animalHistoryPage.isSearchByIdPanelPresent()); + assertTrue("Animal ID textarea should be present", animalHistoryPage.isAnimalIdTextareaPresent()); + assertTrue("Search button should be present", animalHistoryPage.isSearchByIdsButtonPresent()); + assertTrue("All Animals button should be present", animalHistoryPage.isAllAnimalsButtonPresent()); + assertTrue("Alive at Center button should be present", animalHistoryPage.isAliveAtCenterButtonPresent()); + assertFalse("No validation error should be shown", animalHistoryPage.isValidationErrorVisible()); + assertTrue("Empty state should be present", animalHistoryPage.isEmptyStatePlaceholderVisible()); + + // Scenario 2: Single direct ID match + log("Testing single direct ID search"); + animalHistoryPage.searchByIds(testAnimalId1); + + // Navigate to Demographics and verify the searched animal is shown + log("Verifying Demographics shows the searched animal"); + animalHistoryPage.clickDemographicsTab(); + assertReportContainsAnimals(animalHistoryPage, testAnimalId1); + + // Scenario 3: Multi-animal direct search + log("Testing multi-animal search"); + animalHistoryPage.searchByIds(testAnimalId1, testAnimalId2, testAnimalId3); + animalHistoryPage.clickDemographicsTab(); + assertReportContainsAnimals(animalHistoryPage, testAnimalId1, testAnimalId2, testAnimalId3); + + // Scenario 4: Mixed valid/invalid IDs (not found feedback) + log("Testing mixed valid/invalid IDs with not-found feedback"); + animalHistoryPage.searchByIds(testAnimalId1, "INVALID_ID_XYZ_999"); + assertTrue("ID resolution feedback should be visible", animalHistoryPage.isIdResolutionFeedbackVisible()); + assertTrue("Not found section should be present", animalHistoryPage.isNotFoundSectionPresent()); + assertTrue("Invalid ID should be in not-found list", animalHistoryPage.hasNotFoundItem("INVALID_ID_XYZ_999")); + animalHistoryPage.clickDemographicsTab(); + assertReportContainsAnimals(animalHistoryPage, testAnimalId1); + + // Scenario 5: Case-insensitive matching + log("Testing case-insensitive search"); + String lowercaseId = testAnimalId1.toLowerCase(); + animalHistoryPage.searchByIds(lowercaseId); + animalHistoryPage.clickDemographicsTab(); + assertReportContainsAnimals(animalHistoryPage, 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(); + assertTrue("Validation error should be shown", animalHistoryPage.isValidationErrorVisible()); + assertTextPresent("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]); + assertFalse("Validation error should be cleared", animalHistoryPage.isValidationErrorVisible()); + + // Scenario 3: Search succeeds after adding input + log("Testing search succeeds after adding input"); + animalHistoryPage + .clickSearchByIds() + .waitForReportToLoad(); + + // Navigate to Demographics and verify the searched animal is shown + log("Verifying Demographics shows the searched animal"); + animalHistoryPage.clickDemographicsTab(); + assertReportContainsAnimals(animalHistoryPage, 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.searchByIds(searchedAnimalId); + + // 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); + assertDemographicsContainsId(animalHistoryPage, searchedAnimalId); + + // Scenario 2: Activate All Animals mode + log("Testing All Animals mode activation"); + animalHistoryPage.clickAllAnimals(); + assertTrue("Textarea should be empty", animalHistoryPage.getIdInputValue().isEmpty()); + assertTrue("All Animals button should be active", animalHistoryPage.isAllAnimalsActive()); + + // Scenario 3: Verify URL contains filterType (check BEFORE DataRegion operations which may modify URL) + log("Testing All Animals URL state"); + String currentUrl = getDriver().getCurrentUrl(); + assertTrue("URL should contain 'filterType:all' but was: " + currentUrl, + currentUrl.contains("filterType:all")); + + // Scenario 4: Verify Demographics now shows more animals + log("Verifying Demographics shows more animals in All Animals mode"); + animalHistoryPage.clickDemographicsTab(); + assertDemographicsRowCountGreaterThan(animalHistoryPage, initialRowCount); + assertDemographicsContainsId(animalHistoryPage, 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.searchByIds(deadAnimalId); + + // Navigate to Demographics and verify the Dead animal is shown + log("Verifying Demographics shows the Dead animal"); + animalHistoryPage.clickDemographicsTab(); + assertDemographicsContainsId(animalHistoryPage, deadAnimalId); + + log("Testing Alive at Center mode activation"); + assertTrue("Alive at Center button should be enabled for Demographics report", + animalHistoryPage.isAliveAtCenterEnabled()); + + // Scenario 2: Activate Alive at Center mode + animalHistoryPage.clickAliveAtCenter(); + assertTrue("Alive at Center button should be active", animalHistoryPage.isAliveAtCenterActive()); + assertTrue("Textarea should be empty", animalHistoryPage.getIdInputValue().isEmpty()); + + // Scenario 3: Verify URL contains filterType (check BEFORE DataRegion operations which may modify URL) + log("Testing Alive at Center URL state"); + String currentUrl = getDriver().getCurrentUrl(); + assertTrue("URL should contain 'filterType:aliveAtCenter' but was: " + currentUrl, + currentUrl.contains("filterType:aliveAtCenter")); + + // Scenario 4: Verify Demographics now shows only Alive animals + log("Verifying Demographics shows only Alive animals"); + animalHistoryPage.clickDemographicsTab(); + assertDemographicsContainsId(animalHistoryPage, aliveAnimalId); // Alive animal should appear + assertDemographicsDoesNotContainId(animalHistoryPage, deadAnimalId); // Dead animal should NOT appear + + // Scenario 5: Verify all Status values are "Alive" + log("Verifying all Demographics rows have Status = Alive"); + assertDemographicsAllRowsHaveStatus(animalHistoryPage, "Alive"); + assertDemographicsNoRowsHaveStatus(animalHistoryPage, "Dead"); + } + + /** + * 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); + + // Verify subjects are loaded + log("Verifying URL subjects are processed"); + animalHistoryPage.waitForReportToLoad(); + animalHistoryPage.clickDemographicsTab(); + assertReportContainsAnimals(animalHistoryPage, 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.searchByIds(testAnimalId1, testAnimalId2); + + // Navigate to Demographics and verify the Dead animal is shown + log("Verifying Demographics shows the searched animal"); + animalHistoryPage.clickDemographicsTab(); + assertReportContainsAnimals(animalHistoryPage, testAnimalId1); + assertTrue("Search By Ids button should be active", animalHistoryPage.isSearchByIdsActive()); + + // Scenario 2: Switch to All Animals + log("Testing ID Search → All Animals transition"); + animalHistoryPage.clickAllAnimals(); + assertTrue("All Animals button should be active", animalHistoryPage.isAllAnimalsActive()); + assertTrue("Textarea should be empty", animalHistoryPage.getIdInputValue().isEmpty()); + + // Scenario 3: Switch back to ID Search + log("Testing All Animals → ID Search transition"); + animalHistoryPage.searchByIds(testAnimalId1); + assertTrue("Search By Ids button should be active", animalHistoryPage.isSearchByIdsActive()); + + // 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(); + + // Navigate to Demographics and verify the searched animal is shown + log("Verifying Demographics shows the searched animal"); + animalHistoryPage.clickDemographicsTab(); + assertReportContainsAnimals(animalHistoryPage, testAnimalId); + } + + // ============================================================================= + // Assertion Helpers for React Animal History Tests + // ============================================================================= + + /** + * Assert that the report panel contains the specified animal IDs. + * More efficient than assertTextPresent() and scoped to report area only. + * Uses TextSearcher to batch multiple text checks in a single DOM read. + */ + private void assertReportContainsAnimals(ReactAnimalHistoryPage page, String... animalIds) + { + WebElement reportPanel = page.getReportPanelElement(); + assertTextPresent(new TextSearcher(reportPanel::getText), animalIds); + } + + /** + * Assert that the Demographics report contains a specific animal ID. + */ + private void assertDemographicsContainsId(ReactAnimalHistoryPage page, String animalId) + { + DataRegionTable table = page.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"); + } + + /** + * Assert that the Demographics report does NOT contain a specific animal ID. + */ + private void assertDemographicsDoesNotContainId(ReactAnimalHistoryPage page, String animalId) + { + DataRegionTable table = page.getActiveReportDataRegion(); + table.setFilter("Id", "Equals", animalId); + int rowCount = table.getDataRowCount(); + assertEquals("Demographics should NOT contain animal ID '" + animalId + "'", 0, rowCount); + table.clearFilter("Id"); + } + + /** + * Assert that the Demographics report contains more rows than a specified count. + */ + private void assertDemographicsRowCountGreaterThan(ReactAnimalHistoryPage page, int minCount) + { + DataRegionTable table = page.getActiveReportDataRegion(); + int rowCount = table.getDataRowCount(); + assertTrue("Demographics should have more than " + minCount + " rows but found " + rowCount, + rowCount > minCount); + } + + /** + * Assert that all rows in the Demographics report have a specific status value. + */ + private void assertDemographicsAllRowsHaveStatus(ReactAnimalHistoryPage page, String expectedStatus) + { + DataRegionTable table = page.getActiveReportDataRegion(); + List statusValues = table.getColumnDataAsText("calculated_status"); + for (String status : statusValues) + { + assertEquals("All Demographics rows should have status '" + expectedStatus + "'", + expectedStatus, status); + } + } + + /** + * Assert that no rows in the Demographics report have a specific status value. + */ + private void assertDemographicsNoRowsHaveStatus(ReactAnimalHistoryPage page, String excludedStatus) + { + DataRegionTable table = page.getActiveReportDataRegion(); + List statusValues = table.getColumnDataAsText("calculated_status"); + for (String status : statusValues) + { + assertFalse("Demographics should not contain status '" + excludedStatus + "' but found it", + status.equals(excludedStatus)); + } + } } diff --git a/ehr/package-lock.json b/ehr/package-lock.json index 91dc4c901..576a4f984 100644 --- a/ehr/package-lock.json +++ b/ehr/package-lock.json @@ -8,9 +8,9 @@ "name": "ehr", "version": "0.0.0", "dependencies": { - "@labkey/api": "1.44.0", - "@labkey/components": "6.72.1", - "@labkey/ehr": "0.0.4" + "@labkey/api": "1.45.0", + "@labkey/components": "7.13.1", + "@labkey/ehr": "0.0.4-fb-ehr-hist-id-search.13" }, "devDependencies": { "@labkey/build": "8.7.0", @@ -3667,9 +3667,9 @@ } }, "node_modules/@labkey/api": { - "version": "1.44.0", - "resolved": "https://labkey.jfrog.io/artifactory/api/npm/libs-client/@labkey/api/-/@labkey/api-1.44.0.tgz", - "integrity": "sha512-qfHSWENWN2E1KTRACDj/Qq4Rq/tq8KIr5l6XOnMGLEoepUe8DneAnfcIVD5239oxwFDxMLEFCH83EKeat0C/9g==", + "version": "1.45.0", + "resolved": "https://labkey.jfrog.io/artifactory/api/npm/libs-client/@labkey/api/-/@labkey/api-1.45.0.tgz", + "integrity": "sha512-7KN2SvmcY46OtRBtlsUxlmGaE5LN/cg6OfPyc837pSGl+cIndPxOJMqFCvxO26h7c7Fd7cAK1/oOuAzAbvKHUw==", "license": "Apache-2.0" }, "node_modules/@labkey/build": { @@ -3710,13 +3710,13 @@ } }, "node_modules/@labkey/components": { - "version": "6.72.1", - "resolved": "https://labkey.jfrog.io/artifactory/api/npm/libs-client/@labkey/components/-/@labkey/components-6.72.1.tgz", - "integrity": "sha512-ef3/BCqUrUHXIkkdTqcnhM1usj80GvR+SfTSEwi30pyIkiUVMDToqnbpzdKXR84cN+ZrwT4b98+YE79G9R4qyw==", + "version": "7.13.1", + "resolved": "https://labkey.jfrog.io/artifactory/api/npm/libs-client/@labkey/components/-/@labkey/components-7.13.1.tgz", + "integrity": "sha512-9GgUQZyc/IshhEICjdqGUFQmhIsnp4vNcz8o0trgcZJ8+HsUWEQirB4Mpxmqbx9NOuvmFKm07aTo+9leJFK78A==", "license": "SEE LICENSE IN LICENSE.txt", "dependencies": { "@hello-pangea/dnd": "18.0.1", - "@labkey/api": "1.44.0", + "@labkey/api": "1.45.0", "@testing-library/dom": "~10.4.1", "@testing-library/jest-dom": "~6.9.1", "@testing-library/react": "~16.3.0", @@ -3742,12 +3742,12 @@ } }, "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.13", + "resolved": "https://labkey.jfrog.io/artifactory/api/npm/libs-client/@labkey/ehr/-/@labkey/ehr-0.0.4-fb-ehr-hist-id-search.13.tgz", + "integrity": "sha512-qvzTlWGickKPSZm8UxD44xiiktKxxOD54w8PIxaqc0A81GyitBjH15o7nmdaNNh3wRFF8sE0S7gEgp+KI5YHDQ==", "license": "SEE LICENSE IN LICENSE.txt", "dependencies": { - "@labkey/components": "6.72.1" + "@labkey/components": "7.13.1" } }, "node_modules/@labkey/eslint-config": { diff --git a/ehr/package.json b/ehr/package.json index 79ef074a3..6c7610995 100644 --- a/ehr/package.json +++ b/ehr/package.json @@ -15,9 +15,9 @@ "lint-fix": "eslint --fix" }, "dependencies": { - "@labkey/api": "1.44.0", - "@labkey/components": "6.72.1", - "@labkey/ehr": "0.0.4" + "@labkey/api": "1.45.0", + "@labkey/components": "7.13.1", + "@labkey/ehr": "0.0.4-fb-ehr-hist-id-search.13" }, "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..0027e7d6a --- /dev/null +++ b/ehr/resources/queries/study/aliasIdMatches.sql @@ -0,0 +1,5 @@ +-- Override this to implement alias matching in React animal history. See EHR_App module for an example. +SELECT + * +FROM study.demographics a +WHERE 0 = 1 \ No newline at end of file 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 88e9e12cf..bcac14fff 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..d2ec4fa97 100644 --- a/labkey-ui-ehr/package-lock.json +++ b/labkey-ui-ehr/package-lock.json @@ -1,15 +1,15 @@ { "name": "@labkey/ehr", - "version": "0.0.4", + "version": "0.0.4-fb-ehr-hist-id-search.13", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "@labkey/ehr", - "version": "0.0.4", + "version": "0.0.4-fb-ehr-hist-id-search.13", "license": "SEE LICENSE IN LICENSE.txt", "dependencies": { - "@labkey/components": "6.72.1" + "@labkey/components": "7.13.1" }, "devDependencies": { "@labkey/build": "8.7.0", @@ -3662,9 +3662,9 @@ } }, "node_modules/@labkey/api": { - "version": "1.44.0", - "resolved": "https://labkey.jfrog.io/artifactory/api/npm/libs-client/@labkey/api/-/@labkey/api-1.44.0.tgz", - "integrity": "sha512-qfHSWENWN2E1KTRACDj/Qq4Rq/tq8KIr5l6XOnMGLEoepUe8DneAnfcIVD5239oxwFDxMLEFCH83EKeat0C/9g==", + "version": "1.45.0", + "resolved": "https://labkey.jfrog.io/artifactory/api/npm/libs-client/@labkey/api/-/@labkey/api-1.45.0.tgz", + "integrity": "sha512-7KN2SvmcY46OtRBtlsUxlmGaE5LN/cg6OfPyc837pSGl+cIndPxOJMqFCvxO26h7c7Fd7cAK1/oOuAzAbvKHUw==", "license": "Apache-2.0" }, "node_modules/@labkey/build": { @@ -3705,13 +3705,13 @@ } }, "node_modules/@labkey/components": { - "version": "6.72.1", - "resolved": "https://labkey.jfrog.io/artifactory/api/npm/libs-client/@labkey/components/-/@labkey/components-6.72.1.tgz", - "integrity": "sha512-ef3/BCqUrUHXIkkdTqcnhM1usj80GvR+SfTSEwi30pyIkiUVMDToqnbpzdKXR84cN+ZrwT4b98+YE79G9R4qyw==", + "version": "7.13.1", + "resolved": "https://labkey.jfrog.io/artifactory/api/npm/libs-client/@labkey/components/-/@labkey/components-7.13.1.tgz", + "integrity": "sha512-9GgUQZyc/IshhEICjdqGUFQmhIsnp4vNcz8o0trgcZJ8+HsUWEQirB4Mpxmqbx9NOuvmFKm07aTo+9leJFK78A==", "license": "SEE LICENSE IN LICENSE.txt", "dependencies": { "@hello-pangea/dnd": "18.0.1", - "@labkey/api": "1.44.0", + "@labkey/api": "1.45.0", "@testing-library/dom": "~10.4.1", "@testing-library/jest-dom": "~6.9.1", "@testing-library/react": "~16.3.0", @@ -19703,9 +19703,9 @@ } }, "@labkey/api": { - "version": "1.44.0", - "resolved": "https://labkey.jfrog.io/artifactory/api/npm/libs-client/@labkey/api/-/@labkey/api-1.44.0.tgz", - "integrity": "sha512-qfHSWENWN2E1KTRACDj/Qq4Rq/tq8KIr5l6XOnMGLEoepUe8DneAnfcIVD5239oxwFDxMLEFCH83EKeat0C/9g==" + "version": "1.45.0", + "resolved": "https://labkey.jfrog.io/artifactory/api/npm/libs-client/@labkey/api/-/@labkey/api-1.45.0.tgz", + "integrity": "sha512-7KN2SvmcY46OtRBtlsUxlmGaE5LN/cg6OfPyc837pSGl+cIndPxOJMqFCvxO26h7c7Fd7cAK1/oOuAzAbvKHUw==" }, "@labkey/build": { "version": "8.7.0", @@ -19744,12 +19744,12 @@ } }, "@labkey/components": { - "version": "6.72.1", - "resolved": "https://labkey.jfrog.io/artifactory/api/npm/libs-client/@labkey/components/-/@labkey/components-6.72.1.tgz", - "integrity": "sha512-ef3/BCqUrUHXIkkdTqcnhM1usj80GvR+SfTSEwi30pyIkiUVMDToqnbpzdKXR84cN+ZrwT4b98+YE79G9R4qyw==", + "version": "7.13.1", + "resolved": "https://labkey.jfrog.io/artifactory/api/npm/libs-client/@labkey/components/-/@labkey/components-7.13.1.tgz", + "integrity": "sha512-9GgUQZyc/IshhEICjdqGUFQmhIsnp4vNcz8o0trgcZJ8+HsUWEQirB4Mpxmqbx9NOuvmFKm07aTo+9leJFK78A==", "requires": { "@hello-pangea/dnd": "18.0.1", - "@labkey/api": "1.44.0", + "@labkey/api": "1.45.0", "@testing-library/dom": "~10.4.1", "@testing-library/jest-dom": "~6.9.1", "@testing-library/react": "~16.3.0", diff --git a/labkey-ui-ehr/package.json b/labkey-ui-ehr/package.json index 3dc44a379..672ae295f 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.13", "description": "Components, models, actions, and utility functions for LabKey EHR applications and pages", "sideEffects": false, "files": [ @@ -29,14 +29,16 @@ "clean": "rimraf dist", "cleanAll": "rimraf dist && rimraf node_modules", "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": "cross-env NODE_ENV=test jest --maxWorkers=6", "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", "lint-precommit-fix": "node lint.diff.mjs --fix", "lint-branch": "node lint.diff.mjs --currentBranch", - "lint-branch-fix": "node lint.diff.mjs --currentBranch --fix" + "lint-branch-fix": "node lint.diff.mjs --currentBranch --fix", + "typecheck": "tsc --noEmit" }, "repository": { "type": "git", @@ -52,7 +54,7 @@ }, "homepage": "https://github.com/LabKey/ehrModules/labkey-ui-ehr#readme", "dependencies": { - "@labkey/components": "6.72.1" + "@labkey/components": "7.13.1" }, "devDependencies": { "@labkey/build": "8.7.0", diff --git a/labkey-ui-ehr/src/ParticipantHistory/APIWrapper.test.ts b/labkey-ui-ehr/src/ParticipantHistory/APIWrapper.test.ts new file mode 100644 index 000000000..495e2b3c2 --- /dev/null +++ b/labkey-ui-ehr/src/ParticipantHistory/APIWrapper.test.ts @@ -0,0 +1,586 @@ +import { Filter, Query } from '@labkey/api'; + +import { ServerAPIWrapper } from './APIWrapper'; + +// Mock Filter.create function +const mockFilterCreate = jest.fn((field: string, value: boolean | string | string[], type: string) => ({ + field, + value, + type, +})); + +// Mock @labkey/api Query.selectRows +jest.mock('@labkey/api', () => ({ + Query: { + selectRows: jest.fn(), + }, + Filter: { + create: (field: string, value: boolean | string | string[], type: string) => + mockFilterCreate(field, value, type), + Types: { + EQUAL: 'EQUAL', + IN: 'IN', + }, + }, +})); + +const mockSelectRows = Query.selectRows as jest.MockedFunction; + +// Mock-friendly version of SelectRowsOptions where callbacks take a single argument, +// matching how the production code invokes them in this codebase. +type MockSelectRowsConfig = Omit & { + failure?: (error: any) => void; + success?: (data: any) => void; +}; + +describe('ServerAPIWrapper', () => { + let apiWrapper: ServerAPIWrapper; + + beforeEach(() => { + jest.clearAllMocks(); + apiWrapper = new ServerAPIWrapper(); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + describe('resolveAnimalIds', () => { + describe('direct ID matches', () => { + test('resolves direct ID matches', async () => { + // Arrange + const inputIds = ['ID123', 'ID456', 'ID789']; + mockSelectRows.mockImplementation((config: MockSelectRowsConfig) => { + if (config.schemaName === 'study' && config.queryName === 'directIdMatches') { + config.success({ + rows: [{ resolvedId: 'ID123' }, { resolvedId: 'ID456' }, { resolvedId: 'ID789' }], + }); + } + return {} as XMLHttpRequest; + }); + + // Act + const result = await apiWrapper.resolveAnimalIds({ inputIds }); + + // Assert - all IDs resolve as direct matches with no errors, and selectRows receives a filterArray + expect(result.resolved).toEqual([ + { inputId: 'ID123', resolvedId: 'ID123', resolvedBy: 'direct', aliasType: null }, + { inputId: 'ID456', resolvedId: 'ID456', resolvedBy: 'direct', aliasType: null }, + { inputId: 'ID789', resolvedId: 'ID789', resolvedBy: 'direct', aliasType: null }, + ]); + expect(result.notFound).toEqual([]); + expect(result.error).toBeUndefined(); + expect(mockSelectRows).toHaveBeenCalledWith( + expect.objectContaining({ + schemaName: 'study', + queryName: 'directIdMatches', + filterArray: [{ field: 'lowerIdForMatching', value: ['id123', 'id456', 'id789'], type: 'IN' }], + }) + ); + expect(mockSelectRows).toHaveBeenCalledTimes(1); + }); + }); + + describe('alias matches', () => { + test('resolves single alias to animal ID', async () => { + // Arrange + const inputIds = ['TATTOO_001']; + let callCount = 0; + mockSelectRows.mockImplementation((config: MockSelectRowsConfig) => { + callCount++; + if (callCount === 1 && config.schemaName === 'study' && config.queryName === 'directIdMatches') { + config.success({ rows: [] }); + } else if ( + callCount === 2 && + config.schemaName === 'study' && + config.queryName === 'aliasIdMatches' + ) { + config.success({ + rows: [{ resolvedId: 'ID123', inputId: 'TATTOO_001', aliasType: 'tattoo' }], + }); + } + return {} as XMLHttpRequest; + }); + + // Act + const result = await apiWrapper.resolveAnimalIds({ inputIds }); + + // Assert - alias is resolved with correct type and original input ID preserved + expect(result.resolved).toEqual([ + { + inputId: 'TATTOO_001', + resolvedId: 'ID123', + resolvedBy: 'alias', + aliasType: 'tattoo', + }, + ]); + expect(result.notFound).toEqual([]); + expect(result.error).toBeUndefined(); + expect(mockSelectRows).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ + schemaName: 'study', + queryName: 'directIdMatches', + filterArray: [{ field: 'lowerIdForMatching', value: ['tattoo_001'], type: 'IN' }], + }) + ); + expect(mockSelectRows).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + schemaName: 'study', + queryName: 'aliasIdMatches', + filterArray: [{ field: 'lowerAliasForMatching', value: ['tattoo_001'], type: 'IN' }], + }) + ); + expect(mockSelectRows).toHaveBeenCalledTimes(2); + }); + }); + + describe('mixed valid/invalid IDs', () => { + test('resolves mixed direct and alias IDs', async () => { + // Arrange + const inputIds = ['ID123', 'TATTOO_001', 'ID456']; + let callCount = 0; + mockSelectRows.mockImplementation((config: MockSelectRowsConfig) => { + callCount++; + if (callCount === 1 && config.schemaName === 'study' && config.queryName === 'directIdMatches') { + config.success({ + rows: [{ resolvedId: 'ID123' }, { resolvedId: 'ID456' }], + }); + } else if ( + callCount === 2 && + config.schemaName === 'study' && + config.queryName === 'aliasIdMatches' + ) { + config.success({ + rows: [{ resolvedId: 'ID789', inputId: 'TATTOO_001', aliasType: 'tattoo' }], + }); + } + return {} as XMLHttpRequest; + }); + + // Act + const result = await apiWrapper.resolveAnimalIds({ inputIds }); + + // Assert - direct IDs resolve first, then unresolved alias falls back to alias lookup + expect(result.resolved).toEqual([ + { inputId: 'ID123', resolvedId: 'ID123', resolvedBy: 'direct', aliasType: null }, + { inputId: 'ID456', resolvedId: 'ID456', resolvedBy: 'direct', aliasType: null }, + { inputId: 'TATTOO_001', resolvedId: 'ID789', resolvedBy: 'alias', aliasType: 'tattoo' }, + ]); + expect(result.notFound).toEqual([]); + expect(result.error).toBeUndefined(); + expect(mockSelectRows).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ + schemaName: 'study', + queryName: 'directIdMatches', + filterArray: [ + { field: 'lowerIdForMatching', value: ['id123', 'tattoo_001', 'id456'], type: 'IN' }, + ], + }) + ); + expect(mockSelectRows).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + schemaName: 'study', + queryName: 'aliasIdMatches', + filterArray: [{ field: 'lowerAliasForMatching', value: ['tattoo_001'], type: 'IN' }], + }) + ); + expect(mockSelectRows).toHaveBeenCalledTimes(2); + }); + + test('returns not-found IDs when some IDs cannot be resolved', async () => { + // Arrange + const inputIds = ['ID123', 'INVALID_ID', 'ID456']; + mockSelectRows.mockImplementation((config: MockSelectRowsConfig) => { + if (config.schemaName === 'study' && config.queryName === 'directIdMatches') { + config.success({ + rows: [{ resolvedId: 'ID123' }, { resolvedId: 'ID456' }], + }); + } else if (config.schemaName === 'study' && config.queryName === 'aliasIdMatches') { + config.success({ rows: [] }); + } + return {} as XMLHttpRequest; + }); + + // Act + const result = await apiWrapper.resolveAnimalIds({ inputIds }); + + // Assert - valid IDs resolve and unmatched ID appears in notFound + expect(result.resolved).toEqual([ + { inputId: 'ID123', resolvedId: 'ID123', resolvedBy: 'direct', aliasType: null }, + { inputId: 'ID456', resolvedId: 'ID456', resolvedBy: 'direct', aliasType: null }, + ]); + expect(result.notFound).toEqual(['INVALID_ID']); + expect(result.error).toBeUndefined(); + expect(mockSelectRows).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ + schemaName: 'study', + queryName: 'directIdMatches', + filterArray: [ + { field: 'lowerIdForMatching', value: ['id123', 'invalid_id', 'id456'], type: 'IN' }, + ], + }) + ); + expect(mockSelectRows).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + schemaName: 'study', + queryName: 'aliasIdMatches', + filterArray: [{ field: 'lowerAliasForMatching', value: ['invalid_id'], type: 'IN' }], + }) + ); + expect(mockSelectRows).toHaveBeenCalledTimes(2); + }); + }); + + describe('case-insensitive matching', () => { + test('resolves IDs regardless of input casing', async () => { + // Arrange + const inputIds = ['id123', 'ID456', 'Id789']; + mockSelectRows.mockImplementation((config: MockSelectRowsConfig) => { + if (config.schemaName === 'study' && config.queryName === 'directIdMatches') { + config.success({ + rows: [{ resolvedId: 'ID123' }, { resolvedId: 'ID456' }, { resolvedId: 'ID789' }], + }); + } + return {} as XMLHttpRequest; + }); + + // Act + const result = await apiWrapper.resolveAnimalIds({ inputIds }); + + // Assert - original input casing is preserved while resolvedId uses canonical casing + expect(result.resolved).toEqual([ + { inputId: 'id123', resolvedId: 'ID123', resolvedBy: 'direct', aliasType: null }, + { inputId: 'ID456', resolvedId: 'ID456', resolvedBy: 'direct', aliasType: null }, + { inputId: 'Id789', resolvedId: 'ID789', resolvedBy: 'direct', aliasType: null }, + ]); + expect(result.notFound).toEqual([]); + expect(result.error).toBeUndefined(); + expect(mockSelectRows).toHaveBeenCalledWith( + expect.objectContaining({ + schemaName: 'study', + queryName: 'directIdMatches', + filterArray: [{ field: 'lowerIdForMatching', value: ['id123', 'id456', 'id789'], type: 'IN' }], + }) + ); + }); + }); + + describe('de-duplication', () => { + test('de-duplicates input IDs before resolution', async () => { + // Arrange + const inputIds = ['ID123', 'ID123', 'ID456']; + mockSelectRows.mockImplementation((config: MockSelectRowsConfig) => { + if (config.schemaName === 'study' && config.queryName === 'directIdMatches') { + config.success({ + rows: [{ resolvedId: 'ID123' }, { resolvedId: 'ID456' }], + }); + } + return {} as XMLHttpRequest; + }); + + // Act + const result = await apiWrapper.resolveAnimalIds({ inputIds }); + + // Assert - duplicate ID123 appears only once in resolved results + expect(result.resolved).toEqual([ + { inputId: 'ID123', resolvedId: 'ID123', resolvedBy: 'direct', aliasType: null }, + { inputId: 'ID456', resolvedId: 'ID456', resolvedBy: 'direct', aliasType: null }, + ]); + expect(result.notFound).toEqual([]); + expect(result.error).toBeUndefined(); + expect(mockSelectRows).toHaveBeenCalledWith( + expect.objectContaining({ + schemaName: 'study', + queryName: 'directIdMatches', + filterArray: [{ field: 'lowerIdForMatching', value: ['id123', 'id456'], type: 'IN' }], + }) + ); + expect(mockSelectRows).toHaveBeenCalledTimes(1); + }); + }); + + describe('empty input handling', () => { + test('handles empty input array', async () => { + // Arrange + const inputIds: string[] = []; + + // Act + const result = await apiWrapper.resolveAnimalIds({ inputIds }); + + // Assert - returns empty results without making any API calls + expect(result.resolved).toEqual([]); + expect(result.notFound).toEqual([]); + expect(result.error).toBeUndefined(); + expect(mockSelectRows).not.toHaveBeenCalled(); + }); + + test('filters out empty string IDs', async () => { + // Arrange + const inputIds = ['ID123', '', 'ID456']; + mockSelectRows.mockImplementation((config: MockSelectRowsConfig) => { + if (config.schemaName === 'study' && config.queryName === 'directIdMatches') { + config.success({ + rows: [{ resolvedId: 'ID123' }, { resolvedId: 'ID456' }], + }); + } + return {} as XMLHttpRequest; + }); + + // Act + const result = await apiWrapper.resolveAnimalIds({ inputIds }); + + // Assert - empty string is silently dropped and valid IDs resolve normally + expect(result.resolved).toEqual([ + { inputId: 'ID123', resolvedId: 'ID123', resolvedBy: 'direct', aliasType: null }, + { inputId: 'ID456', resolvedId: 'ID456', resolvedBy: 'direct', aliasType: null }, + ]); + expect(result.notFound).toEqual([]); + expect(result.error).toBeUndefined(); + expect(mockSelectRows).toHaveBeenCalledWith( + expect.objectContaining({ + schemaName: 'study', + queryName: 'directIdMatches', + filterArray: [{ field: 'lowerIdForMatching', value: ['id123', 'id456'], type: 'IN' }], + }) + ); + expect(mockSelectRows).toHaveBeenCalledTimes(1); + }); + }); + + describe('error handling', () => { + test('handles API network errors', async () => { + // Arrange + const inputIds = ['ID123']; + mockSelectRows.mockImplementation((config: MockSelectRowsConfig) => { + config.failure!({ + exception: 'Network error', + exceptionClass: 'NetworkException', + }); + return {} as XMLHttpRequest; + }); + + // Act + const result = await apiWrapper.resolveAnimalIds({ inputIds }); + + // Assert - error message is propagated and resolved/notFound are empty + expect(result.error).toBeDefined(); + expect(result.error).toContain('Network error'); + expect(result.resolved).toEqual([]); + expect(result.notFound).toEqual([]); + expect(mockSelectRows).toHaveBeenCalledWith( + expect.objectContaining({ + schemaName: 'study', + queryName: 'directIdMatches', + filterArray: [{ field: 'lowerIdForMatching', value: ['id123'], type: 'IN' }], + }) + ); + expect(mockSelectRows).toHaveBeenCalledTimes(1); + }); + + test('handles malformed API response', async () => { + // Arrange + const inputIds = ['ID123']; + mockSelectRows.mockImplementation((config: MockSelectRowsConfig) => { + config.success({ rows: undefined }); + return {} as XMLHttpRequest; + }); + + // Act + const result = await apiWrapper.resolveAnimalIds({ inputIds }); + + // Assert - malformed response is caught and reported as an error + expect(result.error).toBeDefined(); + expect(result.error).toContain('Malformed API response'); + expect(result.resolved).toEqual([]); + expect(result.notFound).toEqual([]); + expect(mockSelectRows).toHaveBeenCalledWith( + expect.objectContaining({ + schemaName: 'study', + queryName: 'directIdMatches', + filterArray: [{ field: 'lowerIdForMatching', value: ['id123'], type: 'IN' }], + }) + ); + expect(mockSelectRows).toHaveBeenCalledTimes(1); + }); + }); + }); + + describe('fetchReports', () => { + test('calls Query.selectRows with correct parameters and handles empty result set', async () => { + // Arrange + mockSelectRows.mockImplementation((config: MockSelectRowsConfig) => { + config.success({ rows: [] }); + return {} as XMLHttpRequest; + }); + + // Act + const result = await apiWrapper.fetchReports(); + + // Assert - query options are correct and empty rows return empty reports with no error + expect(mockSelectRows).toHaveBeenCalledWith( + expect.objectContaining({ + schemaName: 'ehr', + queryName: 'reports', + sort: 'category,sort_order,reporttitle,reportstatus', + }) + ); + expect(mockFilterCreate).toHaveBeenCalledWith('visible', true, Filter.Types.EQUAL); + expect(result.reports).toEqual([]); + expect(result.error).toBeUndefined(); + }); + + test('fetches and maps reports correctly', async () => { + // Arrange + mockSelectRows.mockImplementation((config: MockSelectRowsConfig) => { + config.success({ + rows: [ + { + reportname: 'report-1', + reporttitle: 'Report One', + reporttype: 'query', + schemaname: 'ehr', + queryname: 'query1', + viewname: null, + report: '123', + supportsnonidfilters: true, + category: 'General', + }, + { + reportname: 'report-2', + reporttitle: 'Report Two', + reporttype: 'js', + schemaname: null, + queryname: 'jsFunction', + category: 'Custom', + }, + ], + }); + return {} as XMLHttpRequest; + }); + + // Act + const result = await apiWrapper.fetchReports(); + + // Assert - row fields are mapped to ReportConfig properties for both report types + expect(result.reports).toHaveLength(2); + expect(result.reports[0]).toEqual( + expect.objectContaining({ + id: 'report-1', + title: 'Report One', + reportType: 'query', + schemaName: 'ehr', + queryName: 'query1', + reportId: '123', + supportsnonidfilters: true, + category: 'General', + }) + ); + expect(result.reports[1]).toEqual( + expect.objectContaining({ + id: 'report-2', + title: 'Report Two', + reportType: 'js', + category: 'Custom', + }) + ); + expect(result.error).toBeUndefined(); + }); + + test('parses jsonConfig when present', async () => { + // Arrange + mockSelectRows.mockImplementation((config: MockSelectRowsConfig) => { + config.success({ + rows: [ + { + reportname: 'json-report', + reporttitle: 'JSON Report', + reporttype: 'query', + schemaname: 'ehr', + queryname: 'testQuery', + jsonConfig: JSON.stringify({ customField: 'customValue', anotherField: 42 }), + }, + ], + }); + return {} as XMLHttpRequest; + }); + + // Act + const result = await apiWrapper.fetchReports(); + + // Assert - jsonConfig fields are merged into the report object + expect((result.reports[0] as any).customField).toBe('customValue'); + expect((result.reports[0] as any).anotherField).toBe(42); + }); + + test('handles invalid jsonConfig gracefully with console error', async () => { + // Arrange + const consoleSpy = jest.spyOn(console, 'error').mockImplementation(); + mockSelectRows.mockImplementation((config: MockSelectRowsConfig) => { + config.success({ + rows: [ + { + reportname: 'bad-json-report', + reporttitle: 'Bad JSON Report', + reporttype: 'query', + schemaname: 'ehr', + queryname: 'testQuery', + jsonConfig: 'not valid json {{{', + }, + ], + }); + return {} as XMLHttpRequest; + }); + + // Act + const result = await apiWrapper.fetchReports(); + + // Assert - report is still returned and parse error is logged + expect(result.reports).toMatchObject([{ id: 'bad-json-report', title: 'Bad JSON Report' }]); + expect(result.error).toBeUndefined(); + expect(consoleSpy).toHaveBeenCalledWith( + 'Failed to parse jsonConfig for report: bad-json-report', + expect.any(Error) + ); + }); + + test('handles failure gracefully', async () => { + // Arrange + const consoleSpy = jest.spyOn(console, 'error').mockImplementation(); + mockSelectRows.mockImplementation((config: MockSelectRowsConfig) => { + config.failure!({ message: 'Network error' }); + return {} as XMLHttpRequest; + }); + + // Act + const result = await apiWrapper.fetchReports(); + + // Assert - error message is extracted and reports array is empty + expect(result.reports).toEqual([]); + expect(result.error).toBe('Network error'); + expect(consoleSpy).toHaveBeenCalledWith('Failed to load reports', { message: 'Network error' }); + }); + + test('handles failure with default error message when message is missing', async () => { + // Arrange + const consoleSpy = jest.spyOn(console, 'error').mockImplementation(); + mockSelectRows.mockImplementation((config: MockSelectRowsConfig) => { + config.failure!({}); + return {} as XMLHttpRequest; + }); + + // Act + const result = await apiWrapper.fetchReports(); + + // Assert - fallback error message is used when error object has no message property + expect(result.reports).toEqual([]); + expect(result.error).toBe('Failed to load reports'); + expect(consoleSpy).toHaveBeenCalledWith('Failed to load reports', {}); + }); + }); +}); diff --git a/labkey-ui-ehr/src/ParticipantHistory/APIWrapper.ts b/labkey-ui-ehr/src/ParticipantHistory/APIWrapper.ts new file mode 100644 index 000000000..c43e9e713 --- /dev/null +++ b/labkey-ui-ehr/src/ParticipantHistory/APIWrapper.ts @@ -0,0 +1,212 @@ +import { Filter, Query } from '@labkey/api'; + +import { IdResolutionResult, ReportConfig, ResolveIdsParams } from './models'; + +export interface FetchReportsResult { + error?: string; + reports: ReportConfig[]; +} + +export type FetchReportsFn = () => Promise; + +export interface ParticipantHistoryAPIWrapper { + fetchReports: () => Promise; + resolveAnimalIds: (params: ResolveIdsParams) => Promise; +} + +export class ServerAPIWrapper implements ParticipantHistoryAPIWrapper { + resolveAnimalIds = async (params: ResolveIdsParams): Promise => { + const { inputIds } = params; + + // Filter out empty strings and trim whitespace + const cleanedIds = inputIds.filter(id => id && id.trim().length > 0).map(id => id.trim()); + + if (cleanedIds.length === 0) { + return { + resolved: [], + notFound: [], + }; + } + + // De-duplicate based on lowercase comparison, preserving original casing + const seenLower = new Set(); + const uniqueIds: string[] = []; + cleanedIds.forEach(id => { + const lower = id.toLowerCase(); + if (!seenLower.has(lower)) { + seenLower.add(lower); + uniqueIds.push(id); + } + }); + + const resolved: IdResolutionResult['resolved'] = []; + const notFound: string[] = []; + + try { + // Step 1: Query study.directIdMatches for direct ID matches + const directMatches = await this.queryDirectIds(uniqueIds); + resolved.push(...directMatches); + + // Track which input IDs were found + const foundInputIds = new Set(directMatches.map(r => r.inputId.toLowerCase())); + const unresolvedIds = uniqueIds.filter(id => !foundInputIds.has(id.toLowerCase())); + + // Step 2: Query study.aliasIdMatches for unresolved IDs if any remain + if (unresolvedIds.length > 0) { + const aliasMatches = await this.queryAliasIds(unresolvedIds); + resolved.push(...aliasMatches); + + // Track which unresolved IDs were found via alias + const aliasFoundIds = new Set(aliasMatches.map(r => r.inputId.toLowerCase())); + const stillNotFound = unresolvedIds.filter(id => !aliasFoundIds.has(id.toLowerCase())); + notFound.push(...stillNotFound); + } + + return { + resolved, + notFound, + }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + console.error('Failed to resolve animal IDs', error); + return { + resolved: [], + notFound: [], + error: `Failed to resolve animal IDs: ${errorMessage}`, + }; + } + }; + + fetchReports = (): Promise => { + return new Promise(resolve => { + Query.selectRows({ + schemaName: 'ehr', + queryName: 'reports', + filterArray: [Filter.create('visible', true, Filter.Types.EQUAL)], + sort: 'category,sort_order,reporttitle,reportstatus', + success: (data: any) => { + 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, + }; + + if (row.jsonConfig) { + try { + const json = JSON.parse(row.jsonConfig); + Object.assign(report, json); + } catch (e) { + console.error('Failed to parse jsonConfig for report: ' + row.reportname, e); + } + } + return report; + }); + resolve({ reports: loadedReports }); + }, + failure: (error: any) => { + console.error('Failed to load reports', error); + resolve({ reports: [], error: error?.message || 'Failed to load reports' }); + }, + }); + }); + }; + + /** + * Query study.directIdMatches for direct ID matches using case-insensitive comparison + * Uses the pre-defined directIdMatches query with lowerIdForMatching filter column + */ + private queryDirectIds = (inputIds: string[]): Promise => { + return new Promise((resolve, reject) => { + // Convert input IDs to lowercase for filter + const lowercaseIds = inputIds.map(id => id.toLowerCase()); + + Query.selectRows({ + schemaName: 'study', + queryName: 'directIdMatches', + filterArray: [Filter.create('lowerIdForMatching', lowercaseIds, Filter.Types.IN)], + success: data => { + if (!data.rows) { + reject(new Error('Malformed API response: missing rows')); + return; + } + + // Map results back to original input casing + const results = data.rows.map(row => { + // Find the original input ID that matches this resolved ID (case-insensitive) + const inputId = + inputIds.find(id => id.toLowerCase() === String(row.resolvedId).toLowerCase()) || + String(row.resolvedId); + return { + inputId, + resolvedId: String(row.resolvedId), + resolvedBy: 'direct' as const, + aliasType: null, + }; + }); + + resolve(results); + }, + failure: error => { + const errorMessage = error?.exception || 'Query failed'; + reject(new Error(errorMessage)); + }, + }); + }); + }; + + /** + * Query study.aliasIdMatches for alias matches using case-insensitive comparison + * Uses the pre-defined aliasIdMatches query with lowerAliasForMatching filter column + */ + private queryAliasIds = (inputIds: string[]): Promise => { + return new Promise((resolve, reject) => { + // Convert input IDs to lowercase for filter + const lowercaseIds = inputIds.map(id => id.toLowerCase()); + + Query.selectRows({ + schemaName: 'study', + queryName: 'aliasIdMatches', + filterArray: [Filter.create('lowerAliasForMatching', lowercaseIds, Filter.Types.IN)], + success: data => { + if (!data.rows) { + reject(new Error('Malformed API response: missing rows')); + return; + } + + // Map results back to original input casing + const results = data.rows.map(row => { + // Find the original input ID that matches this alias (case-insensitive) + const inputId = + inputIds.find(id => id.toLowerCase() === String(row.inputId).toLowerCase()) || + String(row.inputId); + return { + inputId, + resolvedId: String(row.resolvedId), + resolvedBy: 'alias' as const, + aliasType: row.aliasType ? String(row.aliasType) : null, + }; + }); + + resolve(results); + }, + failure: error => { + const errorMessage = error?.exception || 'Query failed'; + reject(new Error(errorMessage)); + }, + }); + }); + }; +} + +let DEFAULT_API_WRAPPER: ParticipantHistoryAPIWrapper; + +export const getDefaultParticipantHistoryAPIWrapper = (): ParticipantHistoryAPIWrapper => { + if (!DEFAULT_API_WRAPPER) DEFAULT_API_WRAPPER = new ServerAPIWrapper(); + return DEFAULT_API_WRAPPER; +}; diff --git a/labkey-ui-ehr/src/ParticipantHistory/ParticipantReports.test.tsx b/labkey-ui-ehr/src/ParticipantHistory/ParticipantReports.test.tsx index 383999f8a..10298986e 100644 --- a/labkey-ui-ehr/src/ParticipantHistory/ParticipantReports.test.tsx +++ b/labkey-ui-ehr/src/ParticipantHistory/ParticipantReports.test.tsx @@ -1,49 +1,27 @@ import React from 'react'; -import { screen } from '@testing-library/react'; +import { screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; import { ParticipantReports } from './ParticipantReports'; +import { FetchReportsFn, FetchReportsResult } from './APIWrapper'; +import * as APIWrapperModule from './APIWrapper'; import { defaultServerContext, renderWithServerContext } from '../test/utils'; -// Mock @labkey/api Query.selectRows to prevent communication failure in tests -jest.mock('@labkey/api', () => ({ - ...jest.requireActual('@labkey/api'), - Query: { - ...jest.requireActual('@labkey/api').Query, - selectRows: jest.fn(), - }, -})); +const mockFetchReports = jest.fn, []>(); // Mock Ext4 global const mockExt4Container = { - report: null as any, filters: null as any, isDestroyed: false, add: jest.fn(), removeAll: jest.fn(), destroy: jest.fn(), - getFilterArray: jest.fn(() => ({ removable: [], nonRemovable: [] })), - getQWPConfig: jest.fn(() => ({})), }; -(global as any).Ext4 = { +(globalThis as any).Ext4 = { create: jest.fn(() => mockExt4Container), }; -// Mock LDK global for QueryReportWrapper -(global as any).LDK = { - Utils: { - getErrorCallback: jest.fn(() => jest.fn()), - }, -}; - -// Mock LABKEY.WebPart for OtherReportWrapper -(global as any).LABKEY = { - ...(global as any).LABKEY, - WebPart: jest.fn().mockImplementation(() => ({ - render: jest.fn(), - })), -}; - describe('ParticipantReports', () => { let originalHash: string; let originalSearch: string; @@ -52,6 +30,24 @@ describe('ParticipantReports', () => { jest.clearAllMocks(); mockExt4Container.isDestroyed = false; + // Default fetchReports behavior for most tests: return a single report + mockFetchReports.mockResolvedValue({ + reports: [ + { + id: 'test-report', + title: 'Test Report', + reportType: 'query', + supportsnonidfilters: true, + category: 'General', + schemaName: 'ehr', + queryName: 'testQuery', + viewName: null, + containerPath: null, + subjectIdFieldName: null, + }, + ], + }); + // Save and reset document.location.hash and search before each test originalHash = window.location.hash; originalSearch = window.location.search; @@ -70,209 +66,844 @@ describe('ParticipantReports', () => { } }); - describe('rendering', () => { - 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(); + // Helper to wait for reports to load asynchronously + const waitForReportsToLoad = async (): Promise => { + await waitFor(() => { + expect(screen.getByText('General')).toBeVisible(); }); + }; + + // Helper to assert which filter button is active + const expectActiveFilterButton = (activeButton: 'aliveAtCenter' | 'all' | 'search') => { + const searchBtn = screen.getByRole('button', { name: /search by ids/i }); + const allBtn = screen.getByRole('button', { name: /all animals/i }); + const aliveBtn = screen.getByRole('button', { name: /all alive at center/i }); + + if (activeButton === 'search') { + expect(searchBtn).toHaveClass('search-by-id-panel__search-button--active'); + expect(allBtn).toHaveClass('search-by-id-panel__filter-button--inactive'); + expect(aliveBtn).toHaveClass('search-by-id-panel__filter-button--inactive'); + } else if (activeButton === 'all') { + expect(searchBtn).toHaveClass('search-by-id-panel__search-button--inactive'); + expect(allBtn).toHaveClass('search-by-id-panel__filter-button--active'); + expect(aliveBtn).toHaveClass('search-by-id-panel__filter-button--inactive'); + } else { + expect(searchBtn).toHaveClass('search-by-id-panel__search-button--inactive'); + expect(allBtn).toHaveClass('search-by-id-panel__filter-button--inactive'); + expect(aliveBtn).toHaveClass('search-by-id-panel__filter-button--active'); + } + }; - 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(); + describe('rendering', () => { + test('renders TabbedReportPanel component', async () => { + // Act + renderWithServerContext(, defaultServerContext()); + await waitForReportsToLoad(); + + // Assert - default state renders ID Search mode, empty textarea, and report tab + expectActiveFilterButton('search'); + const textarea = screen.getByLabelText(/enter animal ids/i); + expect(textarea).toBeInTheDocument(); + expect(textarea).toHaveValue(''); + expect(screen.getByText('Test Report')).toBeVisible(); }); }); describe('URL hash parsing (getFiltersFromUrl)', () => { - test('parses activeReport from URL hash', () => { - window.location.hash = '#activeReport:test-report-id'; - - renderWithServerContext(, defaultServerContext()); - - // Component should render without errors when activeReport is in hash - expect(screen.getByText('Loading reports...')).toBeVisible(); + test('parses activeReport from URL hash', async () => { + // Arrange + mockFetchReports.mockResolvedValueOnce({ + reports: [ + { + id: 'report-one', + title: 'Report One', + reportType: 'query', + supportsnonidfilters: true, + category: 'General', + schemaName: 'ehr', + queryName: 'queryOne', + viewName: null, + containerPath: null, + subjectIdFieldName: null, + }, + { + id: 'report-two', + title: 'Report Two', + reportType: 'query', + supportsnonidfilters: true, + category: 'General', + schemaName: 'ehr', + queryName: 'queryTwo', + viewName: null, + containerPath: null, + subjectIdFieldName: null, + }, + ], + }); + window.location.hash = '#activeReport:report-two'; + + // Act + renderWithServerContext(, defaultServerContext()); + + // Act & Assert - Report Two tab has the active class + await waitFor(() => { + expect(screen.getByRole('button', { name: 'Report Two' })).toHaveClass( + 'tabbed-report-panel__report-tab--active' + ); + }); + + // Assert - activeReport:report-two is preserved in URL hash + expect(window.location.hash).toContain('activeReport:report-two'); }); - test('parses inputType from URL hash', () => { + test('ignores legacy inputType parameter from URL hash', async () => { + // Arrange window.location.hash = '#inputType:singleSubject'; - renderWithServerContext(, defaultServerContext()); + // Act + renderWithServerContext(, defaultServerContext()); + await waitForReportsToLoad(); - expect(screen.getByText('Loading reports...')).toBeVisible(); + // Assert - defaults to ID Search mode despite inputType in hash + expectActiveFilterButton('search'); }); - test('parses showReport as true from URL hash', () => { + test('parses showReport as true from URL hash', async () => { + // Arrange window.location.hash = '#showReport:1'; - renderWithServerContext(, defaultServerContext()); + // Act + renderWithServerContext(, defaultServerContext()); + await waitForReportsToLoad(); - expect(screen.getByText('Loading reports...')).toBeVisible(); + // Assert - placeholder is hidden and Ext4 report wrapper was created + expect(screen.queryByText('Select Filter to View Reports')).not.toBeInTheDocument(); + expect((globalThis as any).Ext4.create).toHaveBeenCalled(); }); - test('parses showReport as false from URL hash', () => { + test('parses showReport as false from URL hash', async () => { + // Arrange window.location.hash = '#showReport:0'; - renderWithServerContext(, defaultServerContext()); + // Act + renderWithServerContext(, defaultServerContext()); + await waitForReportsToLoad(); - expect(screen.getByText('Loading reports...')).toBeVisible(); + // Assert - placeholder is shown and Ext4 report wrapper was not created + expect(screen.getByText('Select Filter to View Reports')).toBeInTheDocument(); + expect((globalThis as any).Ext4.create).not.toHaveBeenCalled(); }); - test('parses subjects from URL hash', () => { + test('parses subjects from URL hash', async () => { + // Arrange window.location.hash = '#subjects:subject1%3Bsubject2%3Bsubject3'; - renderWithServerContext(, defaultServerContext()); + // Act + renderWithServerContext(, defaultServerContext()); + await waitForReportsToLoad(); - expect(screen.getByText('Loading reports...')).toBeVisible(); + // Assert - semicolon-separated subjects are decoded into comma-separated textarea value + const textarea = screen.getByLabelText(/enter animal ids/i); + expect(textarea).toHaveValue('subject1,subject2,subject3'); }); - test('parses multiple parameters from URL hash', () => { - window.location.hash = '#activeReport:my-report&inputType:multiSubject&showReport:1&subjects:sub1%3Bsub2'; + test('parses multiple parameters from URL hash', async () => { + // Arrange + window.location.hash = '#activeReport:test-report&inputType:multiSubject&showReport:1&subjects:sub1%3Bsub2'; - renderWithServerContext(, defaultServerContext()); + // Act + renderWithServerContext(, defaultServerContext()); + await waitForReportsToLoad(); - expect(screen.getByText('Loading reports...')).toBeVisible(); + // Assert - subjects are shown in textarea and activeReport is preserved in hash + const textarea = screen.getByLabelText(/enter animal ids/i); + expect(textarea).toHaveValue('sub1,sub2'); + expect(window.location.hash).toContain('activeReport:test-report'); }); - test('parses custom/unknown parameters from URL hash', () => { + test('parses custom/unknown parameters from URL hash', async () => { + // Arrange window.location.hash = '#customParam:customValue&anotherParam:anotherValue'; - renderWithServerContext(, defaultServerContext()); + // Act + renderWithServerContext(, defaultServerContext()); + await waitForReportsToLoad(); - expect(screen.getByText('Loading reports...')).toBeVisible(); + // Assert - defaults to ID Search mode and unknown params are preserved in hash + expectActiveFilterButton('search'); + expect(window.location.hash).toContain('customParam:customValue'); + expect(window.location.hash).toContain('anotherParam:anotherValue'); }); - test('handles URL-encoded values in hash parameters', () => { - window.location.hash = '#activeReport:report%20with%20spaces'; + test('handles URL-encoded values in hash parameters', async () => { + // Arrange + window.location.hash = '#subjects:ID%20123'; - renderWithServerContext(, defaultServerContext()); + // Act + renderWithServerContext(, defaultServerContext()); + await waitForReportsToLoad(); - expect(screen.getByText('Loading reports...')).toBeVisible(); + // Assert - ID Search mode is active and URL-encoded subject is decoded in textarea + expectActiveFilterButton('search'); + const textarea = screen.getByLabelText(/enter animal ids/i); + expect(textarea).toHaveValue('ID 123'); }); - test('handles empty subjects value in URL hash', () => { + test('handles empty subjects value in URL hash', async () => { + // Arrange window.location.hash = '#subjects:'; - renderWithServerContext(, defaultServerContext()); + // Act + renderWithServerContext(, defaultServerContext()); + await waitForReportsToLoad(); - expect(screen.getByText('Loading reports...')).toBeVisible(); + // Assert - textarea is empty and placeholder is shown + const textarea = screen.getByLabelText(/enter animal ids/i); + expect(textarea).toHaveValue(''); + expect(screen.getByText('Select Filter to View Reports')).toBeInTheDocument(); }); - test('ignores parameters without values', () => { + test('ignores parameters without values', async () => { + // Arrange window.location.hash = '#paramWithoutValue'; - renderWithServerContext(, defaultServerContext()); + // Act + renderWithServerContext(, defaultServerContext()); + await waitForReportsToLoad(); - expect(screen.getByText('Loading reports...')).toBeVisible(); + // Assert - defaults to ID Search mode and valueless param is not preserved in hash + expectActiveFilterButton('search'); + expect(window.location.hash).not.toContain('paramWithoutValue'); }); }); - describe('props passed to TabbedReportPanel', () => { - test('passes correct reportNamespace prop', () => { - renderWithServerContext(, defaultServerContext()); + describe('report fetching', () => { + test('calls fetchReports on mount', async () => { + // Act + renderWithServerContext(, defaultServerContext()); + await waitForReportsToLoad(); - // The component should render the TabbedReportPanel with EHR.reports namespace - // This is verified indirectly by successful render - expect(screen.getByText('Loading reports...')).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(); + // Assert - fetchReports was called exactly once + expect(mockFetchReports).toHaveBeenCalledTimes(1); }); }); - describe('URL query parameter parsing (participantId)', () => { - test('parses participantId from URL query parameters', () => { - // Set participantId in URL query string (e.g., ?participantId=44444) + describe('renders with query parameters in URL', () => { + test.each(['?participantId=44444', '?participantId=66666&otherParam=value'])( + 'ignores query params and keeps default state (%s)', + async queryString => { + // Arrange + window.history.replaceState({}, '', window.location.pathname + queryString); + + // Act + renderWithServerContext(, defaultServerContext()); + await waitForReportsToLoad(); + + // Assert - ID Search mode is active with empty textarea + expectActiveFilterButton('search'); + const textarea = screen.getByLabelText(/enter animal ids/i); + expect(textarea).toHaveValue(''); + } + ); + + test('uses hash subjects independently of participantId in query params', async () => { + // Arrange window.history.replaceState({}, '', window.location.pathname + '?participantId=44444'); + window.location.hash = '#subjects:subject1%3Bsubject2'; - renderWithServerContext(, defaultServerContext()); - - // Component should render without errors when participantId is in query params - expect(screen.getByText('Loading reports...')).toBeVisible(); + // Act + renderWithServerContext(, defaultServerContext()); + await waitForReportsToLoad(); - // Verify the participantId was parsed correctly by checking document.location.search - const urlParams = new URLSearchParams(document.location.search); - expect(urlParams.get('participantId')).toBe('44444'); + // Assert - textarea shows hash subjects, participantId query param is ignored + const textarea = screen.getByLabelText(/enter animal ids/i); + expect(textarea).toHaveValue('subject1,subject2'); }); + }); - test('participantId from query params is added to subjects array', () => { - // Set participantId in URL query string - window.history.replaceState({}, '', window.location.pathname + '?participantId=12345'); - - renderWithServerContext(, defaultServerContext()); - - // Verify the URL contains the participantId parameter - const urlParams = new URLSearchParams(document.location.search); - expect(urlParams.get('participantId')).toBe('12345'); - - // Component renders successfully - expect(screen.getByText('Loading reports...')).toBeVisible(); + describe('Search By Id integration', () => { + describe('initial filter type from URL', () => { + test('initializes with ID Search mode when filterType:idSearch in hash', async () => { + // Arrange + window.location.hash = '#filterType:idSearch&subjects:ID123%3BID456'; + + // Act + renderWithServerContext(, defaultServerContext()); + await waitForReportsToLoad(); + + // Assert - textarea contains parsed subjects and ID Search button is active + const textarea = screen.getByLabelText(/enter animal ids/i); + expect(textarea).toHaveValue('ID123,ID456'); + expectActiveFilterButton('search'); + }); + + test('initializes with All Records mode when filterType:all in hash', async () => { + // Arrange + window.location.hash = '#filterType:all'; + + // Act + renderWithServerContext(, defaultServerContext()); + await waitForReportsToLoad(); + + // Assert - All Animals button is active with empty textarea + expectActiveFilterButton('all'); + const textarea = screen.getByLabelText(/enter animal ids/i); + expect(textarea).toHaveValue(''); + }); + + test('initializes with Alive at Center mode when filterType:aliveAtCenter in hash', async () => { + // Arrange + window.location.hash = '#filterType:aliveAtCenter'; + + // Act + renderWithServerContext(, defaultServerContext()); + await waitForReportsToLoad(); + + // Assert - Alive at Center button is active + expectActiveFilterButton('aliveAtCenter'); + }); }); - test('participantId is merged with hash subjects when both are present', () => { - // Set both participantId in query string and subjects in hash - window.history.replaceState({}, '', window.location.pathname + '?participantId=44444'); - window.location.hash = '#subjects:subject1%3Bsubject2'; - - renderWithServerContext(, defaultServerContext()); - - // Verify URL setup is correct - const urlParams = new URLSearchParams(document.location.search); - expect(urlParams.get('participantId')).toBe('44444'); - expect(window.location.hash).toContain('subjects:subject1'); + describe('URL Params mode (readOnly)', () => { + test('hides SearchByIdPanel when in readOnly mode with subjects', async () => { + // Arrange + window.location.hash = '#subjects:ID123%3BID456&readOnly:true'; + + // Act + renderWithServerContext(, defaultServerContext()); + await waitForReportsToLoad(); + + // Assert - SearchByIdPanel and filter buttons are absent + 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: /modify search/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(); + expect(screen.queryByText('Select Filter to View Reports')).not.toBeInTheDocument(); + }); + + test('shows SearchByIdPanel in normal mode (not readOnly)', async () => { + // Arrange + window.location.hash = '#filterType:idSearch'; + + // Act + renderWithServerContext(, defaultServerContext()); + await waitForReportsToLoad(); + + // Assert - textarea and primary filter buttons are present in DOM + 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', async () => { + // Arrange + window.location.hash = '#readOnly:true'; + + // Act + renderWithServerContext(, defaultServerContext()); + await waitForReportsToLoad(); + + // Assert - textarea and Search By IDs button are present in DOM + 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', async () => { + // Arrange + window.location.hash = '#filterType:all&subjects:ID123&readOnly:true'; + + // Act + renderWithServerContext(, defaultServerContext()); + await waitForReportsToLoad(); + + // Assert - SearchByIdPanel is hidden, readOnly is preserved, and filterType:all is dropped from hash + expect(screen.queryByLabelText(/enter animal ids/i)).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: /all animals/i })).not.toBeInTheDocument(); + expect(window.location.hash).toContain('readOnly:true'); + expect(window.location.hash).not.toContain('filterType:all'); + }); + }); - // Component renders successfully - expect(screen.getByText('Loading reports...')).toBeVisible(); + describe('URL hash updates', () => { + const getHashParamValue = (hash: string, key: string): string | undefined => { + const rawHash = hash.startsWith('#') ? hash.slice(1) : hash; + const segment = rawHash + .split('&') + .find(param => param.startsWith(`${key}:`) && param.length > `${key}:`.length); + + return segment ? decodeURIComponent(segment.slice(key.length + 1)) : undefined; + }; + + test('updates URL hash when filter mode changes', async () => { + // Arrange + window.location.hash = ''; + + // Act + renderWithServerContext(, defaultServerContext()); + await waitForReportsToLoad(); + const allBtn = screen.getByRole('button', { name: /all animals/i }); + await userEvent.click(allBtn); + + // Assert - URL hash contains filterType:all after clicking All Animals + await waitFor(() => { + expect(window.location.hash).toContain('filterType:all'); + }); + }); + + test('includes subjects in URL hash for ID Search mode', async () => { + // Arrange + window.location.hash = '#filterType:idSearch&subjects:ID123%3BID456'; + + // Act + renderWithServerContext(, defaultServerContext()); + await waitForReportsToLoad(); + + // Assert - textarea shows subjects and hash preserves them with semicolon encoding + const textarea = screen.getByLabelText(/enter animal ids/i); + expect(textarea).toHaveValue('ID123,ID456'); + expect(getHashParamValue(window.location.hash, 'subjects')).toBe('ID123;ID456'); + }); + + test('removes subjects from URL hash for All Records mode', async () => { + // Arrange + window.location.hash = '#filterType:idSearch&subjects:ID123%3BID456'; + + // Act + renderWithServerContext(, defaultServerContext()); + await waitForReportsToLoad(); + + // Assert - subjects are initially present in hash + expect(window.location.hash).toContain('subjects:'); + + // Act + const allBtn = screen.getByRole('button', { name: /all animals/i }); + await userEvent.click(allBtn); + + // Assert - subjects are removed and filterType is changed to all + await waitFor(() => { + expect(window.location.hash).not.toContain('subjects:'); + expect(window.location.hash).toContain('filterType:all'); + }); + }); + + test('preserves activeReport parameter when switching filter modes', async () => { + // Arrange + window.location.hash = '#filterType:idSearch&activeReport:test-report&subjects:ID123&showReport:1'; + + // Act + renderWithServerContext(, defaultServerContext()); + await waitForReportsToLoad(); + + // Assert - activeReport is initially in hash + expect(window.location.hash).toContain('activeReport:test-report'); + + // Act + const allBtn = screen.getByRole('button', { name: /all animals/i }); + await userEvent.click(allBtn); + + // Assert - activeReport is preserved and filterType is changed to all + await waitFor(() => { + expect(window.location.hash).toContain('activeReport:test-report'); + expect(window.location.hash).toContain('filterType:all'); + }); + }); }); - test('participantId from query params takes priority when not in hash subjects', () => { - // Set participantId in query and subjects in hash (without the participantId) - window.history.replaceState({}, '', window.location.pathname + '?participantId=55555'); - window.location.hash = '#subjects:otherSubject1%3BotherSubject2'; + describe('activeReportSupportsNonIdFilters query', () => { + test('enables Alive at Center when active report supports non-ID filters', async () => { + // Arrange + window.location.hash = '#activeReport:test-report'; + + // Act + renderWithServerContext(, defaultServerContext()); + await waitForReportsToLoad(); + + // Assert - fetchReports was called and Alive at Center button is enabled + expect(mockFetchReports).toHaveBeenCalled(); + const aliveBtn = screen.getByRole('button', { name: /all alive at center/i }); + expect(aliveBtn).not.toBeDisabled(); + }); + + test('disables Alive at Center when switching to report that does not support non-ID filters', async () => { + // Arrange + mockFetchReports.mockResolvedValueOnce({ + reports: [ + { + id: 'report-supports', + title: 'Supports Report', + reportType: 'query', + supportsnonidfilters: true, + category: 'General', + schemaName: 'ehr', + queryName: 'query1', + viewName: null, + containerPath: null, + subjectIdFieldName: null, + }, + { + id: 'report-no-support', + title: 'No Support Report', + reportType: 'query', + supportsnonidfilters: false, + category: 'Other', + schemaName: 'ehr', + queryName: 'query2', + viewName: null, + containerPath: null, + subjectIdFieldName: null, + }, + ], + }); + window.location.hash = '#activeReport:report-supports'; + + // Act + renderWithServerContext(, defaultServerContext()); + await waitFor(() => { + expect(screen.getByText('General')).toBeVisible(); + }); + + // Assert - Alive at Center button is initially enabled + const aliveBtn = screen.getByRole('button', { name: /all alive at center/i }); + expect(aliveBtn).not.toBeDisabled(); + + // Act + await userEvent.click(screen.getByText('Other')); + + // Assert - Alive at Center button is disabled after switching category + await waitFor(() => { + expect(screen.getByRole('button', { name: /all alive at center/i })).toBeDisabled(); + }); + }); + + test('defaults to true when no active report selected', async () => { + // Arrange + window.location.hash = ''; + + // Act + renderWithServerContext(, defaultServerContext()); + await waitForReportsToLoad(); + + // Assert - Alive at Center button is enabled by default + const aliveBtn = screen.getByRole('button', { name: /all alive at center/i }); + expect(aliveBtn).not.toBeDisabled(); + }); + }); - renderWithServerContext(, defaultServerContext()); + describe('race conditions', () => { + test('handles rapid filter mode changes before state updates', async () => { + // Arrange + window.location.hash = ''; + + // Act + renderWithServerContext(, defaultServerContext()); + await waitForReportsToLoad(); + const allBtn = screen.getByRole('button', { name: /all animals/i }); + const aliveBtn = screen.getByRole('button', { name: /all alive at center/i }); + await userEvent.click(allBtn); + await userEvent.click(aliveBtn); + await userEvent.click(allBtn); + await userEvent.click(aliveBtn); + + // Assert - final state is Alive at Center after rapid clicks + await waitFor(() => { + expectActiveFilterButton('aliveAtCenter'); + }); + }); + }); - // Verify URL setup is correct - const urlParams = new URLSearchParams(document.location.search); - expect(urlParams.get('participantId')).toBe('55555'); + describe('malformed URL hash', () => { + test.each(['#malformed&invalid::data', '#filterType:&subjects:'])( + 'falls back to default state for malformed hash (%s)', + async hashValue => { + // Arrange + window.location.hash = hashValue; + + // Act + renderWithServerContext( + , + defaultServerContext() + ); + await waitForReportsToLoad(); + + // Assert - defaults to ID Search mode with empty textarea + expectActiveFilterButton('search'); + const textarea = screen.getByLabelText(/enter animal ids/i); + expect(textarea).toHaveValue(''); + } + ); + }); - // Component renders successfully - participantId should be merged with hash subjects - expect(screen.getByText('Loading reports...')).toBeVisible(); + describe('filter integration with TabbedReportPanel', () => { + test('passes ID Search filters to TabbedReportPanel', async () => { + // Arrange + window.location.hash = '#filterType:idSearch&subjects:ID123&showReport:1'; + + // Act + renderWithServerContext(, defaultServerContext()); + await waitForReportsToLoad(); + + // Assert - placeholder hidden, Ext4 report created with idSearch filter and ['ID123'] subjects, ID Search mode active with ID123 in textarea + expect(screen.queryByText('Select Filter to View Reports')).not.toBeInTheDocument(); + expect((globalThis as any).Ext4.create).toHaveBeenCalled(); + expect(mockExt4Container.filters).toEqual({ + filterType: 'idSearch', + subjects: ['ID123'], + }); + expectActiveFilterButton('search'); + const textarea = screen.getByLabelText(/enter animal ids/i); + expect(textarea).toHaveValue('ID123'); + }); + + test('passes All Records filter to TabbedReportPanel with no subjects', async () => { + // Arrange + window.location.hash = '#filterType:all&showReport:1'; + + // Act + renderWithServerContext(, defaultServerContext()); + await waitForReportsToLoad(); + + // Assert - placeholder hidden, All Animals active, Ext4 report created with all filter and no subjects + expect(screen.queryByText('Select Filter to View Reports')).not.toBeInTheDocument(); + expectActiveFilterButton('all'); + expect((globalThis as any).Ext4.create).toHaveBeenCalled(); + expect(mockExt4Container.filters).toEqual({ + filterType: 'all', + subjects: undefined, + }); + }); + + test('passes URL Params subjects to TabbedReportPanel', async () => { + // Arrange + window.location.hash = '#subjects:ID123&readOnly:true'; + + // Act + renderWithServerContext(, defaultServerContext()); + await waitForReportsToLoad(); + + // Assert - placeholder hidden, SearchByIdPanel hidden, Ext4 report created with urlParams filter and ['ID123'] subjects + expect(screen.queryByText('Select Filter to View Reports')).not.toBeInTheDocument(); + expect(screen.queryByLabelText(/enter animal ids/i)).not.toBeInTheDocument(); + expect((globalThis as any).Ext4.create).toHaveBeenCalled(); + expect(mockExt4Container.filters).toEqual({ + filterType: 'urlParams', + subjects: ['ID123'], + }); + }); }); - test('handles participantId with other URL query parameters', () => { - // Set participantId along with other query params - window.history.replaceState({}, '', window.location.pathname + '?participantId=66666&otherParam=value'); + describe('LABKEY query error handling', () => { + test('handles LABKEY query failure gracefully', async () => { + // Arrange + const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); + mockFetchReports.mockResolvedValueOnce({ + reports: [], + error: 'Query failed', + }); + window.location.hash = '#activeReport:demographics'; + + try { + // Act + renderWithServerContext( + , + defaultServerContext() + ); + + // Act & Assert - empty state message is shown and error was logged to console + await waitFor(() => { + expect(screen.queryByText('No reports configuration provided.')).toBeInTheDocument(); + }); + expect(consoleErrorSpy).toHaveBeenCalledWith('Failed to load reports:', 'Query failed'); + } finally { + consoleErrorSpy.mockRestore(); + } + }); + + test('defaults to supporting all filters when report metadata not found', async () => { + // Arrange + mockFetchReports.mockResolvedValueOnce({ + reports: [], + }); + window.location.hash = '#activeReport:nonexistent'; + + // Act + renderWithServerContext(, defaultServerContext()); + + // Act & Assert - empty state message is shown for nonexistent report + await waitFor(() => { + expect(screen.queryByText('No reports configuration provided.')).toBeInTheDocument(); + }); + + // Assert - Alive at Center button is enabled despite missing report metadata + const aliveBtn = screen.getByRole('button', { name: /all alive at center/i }); + expect(aliveBtn).not.toBeDisabled(); + }); + }); - renderWithServerContext(, defaultServerContext()); + describe('filter unsupported error message', () => { + test('shows error message when Alive at Center filter is not supported by report', async () => { + // Arrange + mockFetchReports.mockResolvedValueOnce({ + reports: [ + { + id: 'test-report', + title: 'Test Report', + reportType: 'query', + supportsnonidfilters: false, + category: 'General', + schemaName: 'ehr', + queryName: 'testQuery', + viewName: null, + containerPath: null, + subjectIdFieldName: null, + }, + ], + }); + window.location.hash = '#filterType:aliveAtCenter&activeReport:test-report&showReport:1'; + + // Act + renderWithServerContext(, defaultServerContext()); + + // Assert - alert displays filter unsupported message with auto-switch notice + 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', async () => { + // Arrange + window.location.hash = '#filterType:aliveAtCenter&activeReport:test-report&showReport:1'; + + // Act + renderWithServerContext(, defaultServerContext()); + await waitFor(() => { + expect(screen.getByText('General')).toBeVisible(); + }); + + // Assert - no filter unsupported error message is shown + expect( + screen.queryByText('Filter type unsupported for this report. Switched to All Animals.') + ).not.toBeInTheDocument(); + }); + }); + }); - // Verify URL contains both parameters - const urlParams = new URLSearchParams(document.location.search); - expect(urlParams.get('participantId')).toBe('66666'); - expect(urlParams.get('otherParam')).toBe('value'); + describe('dependency injection', () => { + test.each([ + { + name: 'single category', + reports: [ + { + id: 'injected-report', + title: 'Injected Report', + reportType: 'query', + supportsnonidfilters: true, + visible: true, + category: 'Injected', + schemaName: 'ehr', + queryName: 'testQuery', + }, + ], + expectedCategories: ['Injected'], + }, + { + name: 'multiple categories', + reports: [ + { + id: 'report-1', + title: 'Report One', + reportType: 'query', + supportsnonidfilters: true, + category: 'Category A', + schemaName: 'ehr', + queryName: 'query1', + }, + { + id: 'report-2', + title: 'Report Two', + reportType: 'query', + supportsnonidfilters: true, + category: 'Category B', + schemaName: 'ehr', + queryName: 'query2', + }, + ], + expectedCategories: ['Category A', 'Category B'], + }, + ])('accepts injected fetchReports for $name', async ({ reports, expectedCategories }) => { + // Arrange + const injectedFetchReports: FetchReportsFn = jest.fn().mockResolvedValue({ reports } as FetchReportsResult); + + // Act + renderWithServerContext(, defaultServerContext()); + + // Assert - injected fetchReports was called + await waitFor(() => { + expect(injectedFetchReports).toHaveBeenCalled(); + }); + + // Assert - expected categories are visible + expectedCategories.forEach(category => { + expect(screen.getByText(category)).toBeVisible(); + }); + }); - // Component renders successfully - expect(screen.getByText('Loading reports...')).toBeVisible(); + test('uses default fetchReports when prop not provided', async () => { + // Arrange + const defaultFetchReports = jest.fn().mockResolvedValue({ + reports: [ + { + id: 'default-report', + title: 'Default Report', + reportType: 'query', + supportsnonidfilters: true, + category: 'Default Category', + schemaName: 'ehr', + queryName: 'defaultQuery', + }, + ], + }); + const apiWrapperSpy = jest + .spyOn(APIWrapperModule, 'getDefaultParticipantHistoryAPIWrapper') + .mockReturnValue({ + fetchReports: defaultFetchReports, + } as any); + + try { + // Act + renderWithServerContext(, defaultServerContext()); + + // Act & Assert - default fetchReports from API wrapper was called exactly once + await waitFor(() => { + expect(defaultFetchReports).toHaveBeenCalledTimes(1); + }); + + // Assert - default report category 'Default Category' is visible + expect(screen.getByText('Default Category')).toBeVisible(); + } finally { + apiWrapperSpy.mockRestore(); + } }); - test('renders correctly when participantId is not present in query params', () => { - // No participantId in URL - window.history.replaceState({}, '', window.location.pathname); + test('injected fetchReports handles errors', async () => { + // Arrange + const errorFetchReports: FetchReportsFn = jest.fn().mockResolvedValue({ + reports: [], + error: 'Injected error for testing', + }); - renderWithServerContext(, defaultServerContext()); + // Act + renderWithServerContext(, defaultServerContext()); - // Verify no participantId in URL - const urlParams = new URLSearchParams(document.location.search); - expect(urlParams.get('participantId')).toBeNull(); + // Act & Assert - injected fetchReports was called + await waitFor(() => { + expect(errorFetchReports).toHaveBeenCalled(); + }); - // Component renders successfully - expect(screen.getByText('Loading reports...')).toBeVisible(); + // Assert - empty state message is shown + expect(screen.queryByText('No reports configuration provided.')).toBeInTheDocument(); }); }); }); diff --git a/labkey-ui-ehr/src/ParticipantHistory/ParticipantReports.tsx b/labkey-ui-ehr/src/ParticipantHistory/ParticipantReports.tsx index a5e2b916e..5baf271af 100644 --- a/labkey-ui-ehr/src/ParticipantHistory/ParticipantReports.tsx +++ b/labkey-ui-ehr/src/ParticipantHistory/ParticipantReports.tsx @@ -1,111 +1,169 @@ -import React, { FC, memo, useCallback, useMemo } from 'react'; -import { useServerContext } from '@labkey/components'; +import React, { FC, memo, useCallback, useEffect, useMemo, useState } from 'react'; + +import { SearchByIdPanel } from './SearchByIdPanel/SearchByIdPanel'; import { TabbedReportPanel } from './TabbedReportPanel/TabbedReportPanel'; +import { + FILTER_TYPE_ALIVE_AT_CENTER, + FILTER_TYPE_ALL, + FILTER_TYPE_ID_SEARCH, + FILTER_TYPE_URL_PARAMS, + FilterType, + ReportConfig, + ReportFilters, +} from './models'; +import { FetchReportsFn, getDefaultParticipantHistoryAPIWrapper } from './APIWrapper'; +import { getFiltersFromUrl, updateUrlHash } from './utils/urlHashUtils'; -interface UrlFilters { - [key: string]: any; - activeReport?: string; - inputType?: string; - participantId?: string; - showReport?: boolean; - subjects?: string[]; +interface ParticipantReportsProps { + fetchReports?: FetchReportsFn; } -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; - } - } +const ParticipantReportsComponent: FC = ({ + fetchReports = getDefaultParticipantHistoryAPIWrapper().fetchReports, +}) => { + 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]); + + const [filterType, setFilterType] = useState(() => { + if (isReadOnly) { + return FILTER_TYPE_URL_PARAMS; // Read-only mode for shared links } - } + return urlFilters.filterType || FILTER_TYPE_ID_SEARCH; + }); + 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); - return context; -}; + // Fetch all visible reports once on mount + useEffect(() => { + fetchReports().then(({ reports: loadedReports, error }) => { + if (error) { + console.error('Failed to load reports:', error); + } + setReports(loadedReports); + setReportsLoading(false); + }); + }, [fetchReports]); -export const ParticipantReports: FC = memo(() => { - const urlFilters = useMemo(() => getFiltersFromUrl(), []); + // 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 filters = useMemo( - () => ({ - subjects: urlFilters.subjects || [], - ...urlFilters, - }), - [urlFilters] + const handleFilterChange = useCallback( + (newFilterType: FilterType, newSubjects?: string[], clearError = true) => { + setFilterType(newFilterType); + setSubjects(newSubjects || []); + if (clearError) { + setFilterNotSupportedError(null); // Clear any previous error + } + + // 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); + + // 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; + + 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]); - if (!found) { - newParams.push(`activeReport:${encodeURIComponent(reportId)}`); + // 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]); + + // Compute effective filter - override to 'all' if aliveAtCenter is not supported + const effectiveFilterType = + filterType === FILTER_TYPE_ALIVE_AT_CENTER && !activeReportSupportsNonIdFilters ? FILTER_TYPE_ALL : filterType; - 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..35e4c561e --- /dev/null +++ b/labkey-ui-ehr/src/ParticipantHistory/SearchByIdPanel/IdResolutionFeedback.test.tsx @@ -0,0 +1,199 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; + +import { IdResolutionFeedback } from './IdResolutionFeedback'; +import { IdResolutionResult } from '../models'; + +describe('IdResolutionFeedback', () => { + describe('resolved section display', () => { + test('displays direct matches without arrow', () => { + // Arrange + const resolutionResult: IdResolutionResult = { + resolved: [ + { inputId: 'ID123', resolvedId: 'ID123', resolvedBy: 'direct', aliasType: null }, + { inputId: 'ID456', resolvedId: 'ID456', resolvedBy: 'direct', aliasType: null }, + ], + notFound: [], + }; + + // Act + render(); + + // Assert - both direct-match IDs are visible and no arrow symbol is rendered + expect(screen.getByText('ID123')).toBeVisible(); + expect(screen.getByText('ID456')).toBeVisible(); + expect(screen.queryByText(/→/)).not.toBeInTheDocument(); + }); + + test.each([ + { + scenario: 'single alias match', + resolved: [ + { inputId: 'TATTOO_001', resolvedId: 'ID123', resolvedBy: 'alias' as const, aliasType: 'tattoo' }, + ], + expectedAliasRows: [{ inputId: 'TATTOO_001', resolvedId: 'ID123', aliasType: 'tattoo' }], + }, + { + scenario: 'multiple alias matches with different types', + resolved: [ + { inputId: 'TATTOO_001', resolvedId: 'ID123', resolvedBy: 'alias' as const, aliasType: 'tattoo' }, + { inputId: 'CHIP_12345', resolvedId: 'ID456', resolvedBy: 'alias' as const, aliasType: 'chip' }, + ], + expectedAliasRows: [ + { inputId: 'TATTOO_001', resolvedId: 'ID123', aliasType: 'tattoo' }, + { inputId: 'CHIP_12345', resolvedId: 'ID456', aliasType: 'chip' }, + ], + }, + ])('displays alias matches with arrow and type for $scenario', ({ resolved, expectedAliasRows }) => { + // Arrange + const resolutionResult: IdResolutionResult = { + resolved, + notFound: [], + }; + + // Act + render(); + + // Assert - each alias row shows input ID, arrow, resolved ID, and alias type label + expect(screen.getAllByText(/→/)).toHaveLength(expectedAliasRows.length); + expectedAliasRows.forEach(({ inputId, resolvedId, aliasType }) => { + expect(screen.getByText(inputId)).toBeVisible(); + expect(screen.getByText(resolvedId)).toBeVisible(); + expect(screen.getByText(`(${aliasType})`)).toBeVisible(); + }); + }); + + test('displays mixed direct and alias matches correctly', () => { + // Arrange + 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: [], + }; + + // Act + render(); + + // Assert - direct-match IDs are visible, and the alias match shows arrow plus type label + 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(/→/)).toBeVisible(); + expect(screen.getByText('(tattoo)')).toBeVisible(); + }); + }); + + describe('not found section display', () => { + test.each([ + { + scenario: 'single unresolved ID alongside resolved content', + resolved: [{ inputId: 'ID123', resolvedId: 'ID123', resolvedBy: 'direct' as const, aliasType: null }], + notFound: ['INVALID_ID'], + }, + { + scenario: 'multiple unresolved IDs', + resolved: [], + notFound: ['INVALID_ID_1', 'INVALID_ID_2'], + }, + ])('displays not found section for $scenario', ({ resolved, notFound }) => { + // Arrange + const resolutionResult: IdResolutionResult = { + resolved, + notFound, + }; + + // Act + render(); + + // Assert - "not found" heading and each unresolved ID are visible + expect(screen.getByRole('heading', { name: /not found/i })).toBeInTheDocument(); + notFound.forEach(id => { + expect(screen.getByText(id)).toBeVisible(); + }); + }); + }); + + describe('multiple inputs resolving to same ID', () => { + test('displays all inputs that resolved to same ID', () => { + // Arrange + const resolutionResult: IdResolutionResult = { + resolved: [ + { inputId: 'TATTOO_001', resolvedId: 'ID123', resolvedBy: 'alias', aliasType: 'tattoo' }, + { inputId: 'CHIP_12345', resolvedId: 'ID123', resolvedBy: 'alias', aliasType: 'chip' }, + ], + notFound: [], + }; + + // Act + render(); + + // Assert - both alias inputs are visible and the shared resolved ID appears twice + 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).toHaveLength(2); + }); + }); + + describe('empty results', () => { + test('renders container with title but no sections when no resolved and no not found IDs', () => { + // Arrange + const resolutionResult: IdResolutionResult = { + resolved: [], + notFound: [], + }; + + // Act + render(); + + // Assert - title renders but neither Resolved nor Not Found sections appear + expect(screen.getByText('ID Resolution')).toBeVisible(); + // But no sections are rendered + expect(screen.queryByText(/Resolved/)).not.toBeInTheDocument(); + expect(screen.queryByText(/Not Found/)).not.toBeInTheDocument(); + }); + }); + + describe('special characters in IDs', () => { + test('handles IDs with spaces', () => { + // Arrange + const resolutionResult: IdResolutionResult = { + resolved: [{ inputId: 'ID 123', resolvedId: 'ID 123', resolvedBy: 'direct', aliasType: null }], + notFound: ['INVALID ID'], + }; + + // Act + render(); + + // Assert - IDs containing spaces render correctly in both resolved and not-found sections + expect(screen.getByText('ID 123')).toBeVisible(); + expect(screen.getByText('INVALID ID')).toBeVisible(); + }); + + test('handles IDs with special characters', () => { + // Arrange + 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'], + }; + + // Act + render(); + + // Assert - IDs with hyphens, underscores, dots, and @ symbols all render correctly + 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..7bc0f7d60 --- /dev/null +++ b/labkey-ui-ehr/src/ParticipantHistory/SearchByIdPanel/IdResolutionFeedback.tsx @@ -0,0 +1,82 @@ +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) + */ + +interface IdResolutionFeedbackProps { + resolutionResult: IdResolutionResult; +} + +export const IdResolutionFeedback: FC = ({ resolutionResult }) => { + 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 => ( +
+ {match.resolvedId} +
+ ))} + {aliasMatches.map(match => ( +
+ {match.inputId} + + {match.resolvedId} + {match.aliasType && ( + ({match.aliasType}) + )} +
+ ))} +
+
+ )} + + {notFound.length > 0 && ( +
+

+ Not Found ({notFound.length}) +

+
+ {notFound.map(id => ( +
+ {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..f485e7317 --- /dev/null +++ b/labkey-ui-ehr/src/ParticipantHistory/SearchByIdPanel/SearchByIdPanel.test.tsx @@ -0,0 +1,1040 @@ +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 { + FILTER_TYPE_ALIVE_AT_CENTER, + FILTER_TYPE_ALL, + FILTER_TYPE_ID_SEARCH, + FILTER_TYPE_URL_PARAMS, + IdResolutionResult, + ResolveIdsParams, +} from '../models'; + +const mockResolveAnimalIds = jest.fn, [ResolveIdsParams]>(); + +describe('parseIds utility function', () => { + test.each([ + { name: 'newline', separator: '\n' }, + { name: 'comma', separator: ',' }, + { name: 'tab', separator: '\t' }, + { name: 'semicolon', separator: ';' }, + ])('parses IDs with $name separators', ({ separator }) => { + // Act + const result = parseIds(`ID1${separator}ID2${separator}ID3`); + + // Assert - IDs are split on the given separator + expect(result).toEqual(['ID1', 'ID2', 'ID3']); + }); + + test('parses IDs with mixed separators', () => { + // Act + const result = parseIds('ID1,ID2\nID3;ID4\tID5'); + + // Assert - IDs are split on any supported separator + expect(result).toEqual(['ID1', 'ID2', 'ID3', 'ID4', 'ID5']); + }); + + test('trims whitespace from IDs', () => { + // Act + const result = parseIds(' ID1 , ID2 \n ID3 '); + + // Assert - leading and trailing whitespace is removed from each ID + expect(result).toEqual(['ID1', 'ID2', 'ID3']); + }); + + test('filters out empty strings', () => { + // Act + const result = parseIds('ID1,,ID2\n\nID3'); + + // Assert - consecutive separators do not produce empty entries + expect(result).toEqual(['ID1', 'ID2', 'ID3']); + }); + + test('de-duplicates IDs (case-insensitive)', () => { + // Act + const result = parseIds('ID1,id1,ID2,Id2'); + + // Assert - duplicate IDs are removed regardless of casing + expect(result).toEqual(['ID1', 'ID2']); + }); + + test('preserves original casing of first occurrence', () => { + // Act + const result = parseIds('id1,ID1,Id2,ID2'); + + // Assert - first occurrence casing is kept when deduplicating + expect(result).toEqual(['id1', 'Id2']); + }); + + test('handles empty input', () => { + // Act + const result = parseIds(''); + + // Assert - empty string produces empty array + expect(result).toEqual([]); + }); + + test('handles whitespace-only input', () => { + // Act + const result = parseIds(' \n\t '); + + // Assert - whitespace-only input produces empty array + expect(result).toEqual([]); + }); + + test('handles special characters in IDs', () => { + // Act + const result = parseIds('ID-123,ID_456,ID@789'); + + // Assert - special characters within IDs are preserved + expect(result).toEqual(['ID-123', 'ID_456', 'ID@789']); + }); + + test('handles IDs with spaces', () => { + // Act + const result = parseIds('ID 123,ID 456'); + + // Assert - internal spaces within IDs are preserved + expect(result).toEqual(['ID 123', 'ID 456']); + }); +}); + +describe('validateInput utility function', () => { + test.each([1, 100])('returns undefined for valid input with %i ID(s)', count => { + // Arrange + const ids = Array.from({ length: count }, (_, i) => `ID${i}`); + + // Act + const result = validateInput(ids); + + // Assert - within-limit ID count passes validation + expect(result).toBeUndefined(); + }); + + test('returns error for empty array', () => { + // Act + const result = validateInput([]); + + // Assert - empty input returns minimum ID error message + expect(result).toBe('Please enter at least one animal ID.'); + }); + + test.each([101, 150])('returns error for %i IDs', count => { + // Arrange + const ids = Array.from({ length: count }, (_, i) => `ID${i}`); + + // Act + const result = validateInput(ids); + + // Assert - exceeding 100 IDs returns maximum limit error with actual count + expect(result).toBe(`Maximum of 100 animal IDs allowed. You entered ${count} IDs.`); + }); +}); + +describe('SearchByIdPanel', () => { + const mockOnFilterChange = jest.fn(); + + beforeEach(() => { + jest.clearAllMocks(); + mockResolveAnimalIds.mockResolvedValue({ + resolved: [], + notFound: [], + }); + }); + + describe('ID parsing', () => { + test('parses IDs with mixed separators', async () => { + // Arrange + 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: [], + }); + + // Act + fireEvent.click(updateButton); + + // Assert - IDs split across different separator types are all parsed correctly + await waitFor(() => { + expect(mockResolveAnimalIds).toHaveBeenCalledWith({ + inputIds: ['ID1', 'ID2', 'ID3', 'ID4', 'ID5'], + }); + }); + await waitFor(() => { + expect(mockOnFilterChange).toHaveBeenCalledWith(FILTER_TYPE_ID_SEARCH, [ + 'ID1', + 'ID2', + 'ID3', + 'ID4', + 'ID5', + ]); + }); + }); + + test('de-duplicates IDs across different separators', async () => { + // Arrange + 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: [], + }); + + // Act + fireEvent.click(updateButton); + + // Assert - duplicate IDs across separators are resolved to unique set + await waitFor(() => { + expect(mockResolveAnimalIds).toHaveBeenCalledWith({ + inputIds: ['ID123', 'ID456'], + }); + }); + await waitFor(() => { + expect(mockOnFilterChange).toHaveBeenCalledWith(FILTER_TYPE_ID_SEARCH, ['ID123', 'ID456']); + }); + }); + }); + + describe('validation', () => { + test('shows validation error when input is empty', () => { + // Arrange + render( + + ); + const updateButton = screen.getByRole('button', { name: /search by ids/i }); + + // Act + fireEvent.click(updateButton); + + // Assert - empty input shows validation error and prevents resolution + 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', () => { + // Arrange + render( + + ); + const textarea = screen.getByRole('textbox'); + const updateButton = screen.getByRole('button', { name: /search by ids/i }); + fireEvent.change(textarea, { target: { value: ' \n\t ' } }); + + // Act + fireEvent.click(updateButton); + + // Assert - whitespace-only input triggers empty validation error + 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 () => { + // Arrange + 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 } }); + + // Assert - no validation error shown for exactly 100 IDs + expect(screen.queryByText(/maximum of 100 animal ids/i)).not.toBeInTheDocument(); + + // Arrange + mockResolveAnimalIds.mockResolvedValue({ + resolved: Array.from({ length: 100 }, (_, i) => ({ + inputId: `ID${i}`, + resolvedId: `ID${i}`, + resolvedBy: 'direct' as const, + aliasType: null, + })), + notFound: [], + }); + + // Act + fireEvent.click(updateButton); + + // Assert - resolution proceeds with all 100 IDs + await waitFor(() => { + expect(mockResolveAnimalIds).toHaveBeenCalled(); + }); + const expectedIds = Array.from({ length: 100 }, (_, i) => `ID${i}`); + await waitFor(() => { + expect(mockOnFilterChange).toHaveBeenCalledWith(FILTER_TYPE_ID_SEARCH, expectedIds); + }); + }); + + test('shows validation error when more than 100 IDs entered', () => { + // Arrange + render( + + ); + const textarea = screen.getByRole('textbox'); + const ids = Array.from({ length: 101 }, (_, i) => `ID${i}`).join(','); + + // Act + fireEvent.change(textarea, { target: { value: ids } }); + + // Assert - exceeding 100 IDs shows validation error with count + expect(screen.getByText(/maximum of 100 animal ids allowed\. you entered 101 ids/i)).toBeVisible(); + }); + + test('button remains enabled when validation fails', () => { + // Arrange + 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(','); + + // Act - enter more than 100 IDs + fireEvent.change(textarea, { target: { value: ids } }); + + // Assert - validation error is shown + expect(screen.getByText(/maximum of 100 animal ids/i)).toBeVisible(); + + // Assert - button remains enabled despite validation error + expect(updateButton).not.toBeDisabled(); + + // Act - click button with validation error present + fireEvent.click(updateButton); + + // Assert - filter is set to empty and resolution is skipped + expect(mockOnFilterChange).toHaveBeenCalledWith(FILTER_TYPE_ID_SEARCH, []); + expect(mockResolveAnimalIds).not.toHaveBeenCalled(); + }); + + test('clears validation error when IDs reduced below limit', () => { + // Arrange + render( + + ); + const textarea = screen.getByRole('textbox'); + + // Act - enter 101 IDs to trigger validation error + const ids101 = Array.from({ length: 101 }, (_, i) => `ID${i}`).join(','); + fireEvent.change(textarea, { target: { value: ids101 } }); + + // Assert - validation error appears + expect(screen.getByText(/maximum of 100 animal ids/i)).toBeVisible(); + + // Act - reduce to 100 IDs + const ids100 = Array.from({ length: 100 }, (_, i) => `ID${i}`).join(','); + fireEvent.change(textarea, { target: { value: ids100 } }); + + // Assert - validation error is cleared + expect(screen.queryByText(/maximum of 100 animal ids/i)).not.toBeInTheDocument(); + }); + }); + + describe('filter mode toggles', () => { + test('filter buttons visible in all modes except URL Params', () => { + // Arrange + const { rerender } = render( + + ); + + // Assert - Search by IDs and All Animals buttons are visible in ID Search mode + expect(screen.getByRole('button', { name: /search by ids/i })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /all animals/i })).toBeInTheDocument(); + + // Act - switch to all animals mode + rerender( + + ); + + // Assert - Search by IDs and All Animals buttons remain visible in all animals mode + expect(screen.getByRole('button', { name: /search by ids/i })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /all animals/i })).toBeInTheDocument(); + + // Act - switch to all alive at center mode + rerender( + + ); + + // Assert - Search by IDs and All Alive at Center buttons are visible in all alive at center mode + expect(screen.getByRole('button', { name: /search by ids/i })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /all alive at center/i })).toBeInTheDocument(); + + // Act - switch to URL Params mode + rerender( + + ); + + // Assert - Search by IDs and All Animals buttons are hidden in URL Params mode + 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', () => { + // Arrange + render( + + ); + const allRecordsButton = screen.getByRole('button', { name: /all animals/i }); + + // Act + fireEvent.click(allRecordsButton); + + // Assert - clicking all animals button triggers filter change + expect(mockOnFilterChange).toHaveBeenCalledWith(FILTER_TYPE_ALL, undefined); + }); + + test('search by ids button sets filter mode even with validation error', () => { + // Arrange + render( + + ); + + // Act - switch to All Animals mode + const allAnimalsButton = screen.getByRole('button', { name: /all animals/i }); + fireEvent.click(allAnimalsButton); + + // Assert - All Animals is active + expect(allAnimalsButton).toHaveClass('search-by-id-panel__filter-button--active'); + + // Act - click Search By Ids with no input + const searchByIdsButton = screen.getByRole('button', { name: /search by ids/i }); + fireEvent.click(searchByIdsButton); + + // Assert - validation error appears and search mode becomes active + expect(screen.getByRole('alert')).toHaveTextContent('Please enter at least one animal ID'); + expect(searchByIdsButton).toHaveClass('search-by-id-panel__search-button--active'); + expect(allAnimalsButton).toHaveClass('search-by-id-panel__filter-button--inactive'); + }); + + test.each([ + { + buttonPattern: /all animals/i, + filterType: 'All Animals', + expectedFilterType: FILTER_TYPE_ALL, + }, + { + buttonPattern: /all alive at center/i, + filterType: 'All Alive at Center', + expectedFilterType: FILTER_TYPE_ALIVE_AT_CENTER, + }, + ])('clears input when switching to $filterType mode', ({ buttonPattern, expectedFilterType }) => { + // Arrange + render( + + ); + const textarea = screen.getByRole('textbox'); + fireEvent.change(textarea, { target: { value: 'ID123,ID456' } }); + + // Act + const filterButton = screen.getByRole('button', { name: buttonPattern }); + fireEvent.click(filterButton); + + // Assert - input is cleared and filter change is triggered + expect(textarea).toHaveValue(''); + expect(mockOnFilterChange).toHaveBeenCalledWith(expectedFilterType, undefined); + }); + + test.each([ + { buttonPattern: /all animals/i, filterType: 'All Animals' }, + { buttonPattern: /all alive at center/i, filterType: 'All Alive at Center' }, + ])('clears validation error when switching to $filterType mode', ({ buttonPattern }) => { + // Arrange + render( + + ); + const textarea = screen.getByRole('textbox'); + const manyIds = Array.from({ length: 101 }, (_, i) => `ID${i + 1}`).join(','); + fireEvent.change(textarea, { target: { value: manyIds } }); + expect(screen.getByRole('alert')).toHaveTextContent('Maximum of 100 animal IDs allowed'); + + // Act + const filterButton = screen.getByRole('button', { name: buttonPattern }); + fireEvent.click(filterButton); + + // Assert - validation error is cleared and input is emptied + expect(screen.queryByRole('alert')).not.toBeInTheDocument(); + expect(textarea).toHaveValue(''); + }); + }); + + describe('textarea and button visibility', () => { + test('textarea and search by ids button always visible in non-URL modes', () => { + // Arrange + const { rerender } = render( + + ); + + // Assert - textarea and search button visible in ID Search mode + expect(screen.getByRole('textbox')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /search by ids/i })).toBeInTheDocument(); + + // Act - switch to all animals mode + rerender( + + ); + + // Assert - textarea and search button still visible in all animals mode + expect(screen.getByRole('textbox')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /search by ids/i })).toBeInTheDocument(); + + // Act - switch to all alive at center mode + rerender( + + ); + + // Assert - textarea and search button still visible in all alive at center mode + expect(screen.getByRole('textbox')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /search by ids/i })).toBeInTheDocument(); + + // Act - switch to URL Params mode + rerender( + + ); + + // Assert - textarea and search button hidden in URL Params mode + expect(screen.queryByRole('textbox')).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: /search by ids/i })).not.toBeInTheDocument(); + }); + + test('shows loading state while resolving IDs', async () => { + // Arrange + 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' } }); + + // Act + fireEvent.click(updateButton); + + // Assert - button is disabled while loading + await waitFor(() => { + expect(screen.getByRole('button', { name: /search by ids/i })).toBeDisabled(); + }); + + // Act - resolve the promise + resolvePromise!({ + resolved: [{ inputId: 'ID123', resolvedId: 'ID123', resolvedBy: 'direct', aliasType: null }], + notFound: [], + }); + + // Assert - button is re-enabled after loading completes + await waitFor(() => { + expect(screen.getByRole('button', { name: /search by ids/i })).toBeEnabled(); + }); + }); + }); + + describe('resolution feedback visibility', () => { + test('resolution feedback always visible when there are aliases or not-found IDs', async () => { + // Arrange + 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 }); + fireEvent.change(textarea, { target: { value: 'alias1,notfound1' } }); + + // Act + fireEvent.click(updateButton); + + // Assert - resolution feedback is visible after resolution + await waitFor(() => { + expect(mockResolveAnimalIds).toHaveBeenCalled(); + }); + await waitFor(() => { + expect(screen.getByText(/id resolution/i)).toBeInTheDocument(); + }); + + // Act - switch to all animals mode + rerender( + + ); + + // Assert - resolution feedback persists in all animals mode + expect(screen.getByText(/id resolution/i)).toBeInTheDocument(); + + // Act - switch to all alive at center mode + rerender( + + ); + + // Assert - resolution feedback persists in all alive at center mode + expect(screen.getByText(/id resolution/i)).toBeInTheDocument(); + }); + + test('shows resolution feedback when aliases are resolved', async () => { + // Arrange + 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' } }); + + // Act + fireEvent.click(updateButton); + + // Assert - alias resolution details are displayed with arrow and type + await waitFor(() => { + expect(screen.getByText(/id resolution/i)).toBeInTheDocument(); + }); + expect(screen.getByText('alias1')).toBeInTheDocument(); + expect(screen.getByText('→')).toBeInTheDocument(); + expect(screen.getByText('ID123')).toBeInTheDocument(); + expect(screen.getByText('(tattoo)')).toBeInTheDocument(); + expect(screen.getByText(/Resolved \(2\)/)).toBeInTheDocument(); + }); + + test('shows resolution feedback when IDs are not found', async () => { + // Arrange + 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' } }); + + // Act + fireEvent.click(updateButton); + + // Assert - not-found IDs are displayed with count + await waitFor(() => { + expect(screen.getByText(/id resolution/i)).toBeInTheDocument(); + }); + expect(screen.getByText('notfound1')).toBeInTheDocument(); + expect(screen.getByText('notfound2')).toBeInTheDocument(); + expect(screen.getByText(/Not Found \(2\)/)).toBeInTheDocument(); + }); + + test('hides resolution feedback when all IDs resolve directly', async () => { + // Arrange + 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' } }); + + // Act + fireEvent.click(updateButton); + + // Assert - no resolution feedback shown when all IDs resolve directly + await waitFor(() => { + expect(mockResolveAnimalIds).toHaveBeenCalled(); + }); + expect(screen.queryByText(/id resolution/i)).not.toBeInTheDocument(); + }); + }); + + describe('URL Params mode (read-only)', () => { + test('shows read-only summary in URL Params mode', () => { + // Act - render panel in URL Params mode + render( + + ); + + // Assert - read-only summary shows count and lists all subject IDs + 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('Modify Search button switches to ID Search mode with subjects pre-populated', () => { + // Arrange + render( + + ); + const modifyButton = screen.getByRole('button', { name: /modify search/i }); + + // Act + fireEvent.click(modifyButton); + + // Assert - filter changes to ID Search with existing subjects + 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', () => { + // Arrange + const { rerender } = render( + + ); + + // Act - switch to ID Search mode + rerender( + + ); + + // Assert - textarea is pre-populated with the initial subjects + const textarea = screen.getByRole('textbox'); + expect(textarea).toHaveValue('ID123,ID456'); + }); + }); + + describe('accessibility', () => { + test('textarea has accessible label', () => { + // Act - render panel + render( + + ); + + // Assert - textarea has an accessible name for screen readers + const textarea = screen.getByRole('textbox'); + expect(textarea).toHaveAccessibleName(); + }); + + test('all alive at center button exposes disabled semantics and helper title when unsupported', () => { + // Act - render panel with non-ID filters unsupported + render( + + ); + + // Assert - disabled control has accessible name and explanatory title when unsupported + const aliveAtCenterButton = screen.getByRole('button', { name: /all alive at center/i }); + expect(aliveAtCenterButton).toBeDisabled(); + expect(aliveAtCenterButton).toHaveAttribute('title', 'This filter type is not supported for this report'); + }); + + test('validation errors have role="alert" for screen readers', () => { + // Arrange + render( + + ); + const textarea = screen.getByRole('textbox'); + const ids = Array.from({ length: 101 }, (_, i) => `ID${i}`).join(','); + + // Act + fireEvent.change(textarea, { target: { value: ids } }); + + // Assert - validation error uses alert role for screen reader announcement + const alert = screen.getByRole('alert'); + expect(alert).toBeInTheDocument(); + expect(alert).toHaveTextContent(/maximum of 100 animal ids/i); + }); + + test('keyboard navigation works correctly', async () => { + // Arrange + render( + + ); + const textarea = screen.getByRole('textbox'); + const updateButton = screen.getByRole('button', { name: /search by ids/i }); + + // Act & Assert - tab navigates to textarea first + await userEvent.tab(); + expect(textarea).toHaveFocus(); + + // Act - type IDs into focused textarea + await userEvent.keyboard('ID123'); + + // Act & Assert - tab navigates to search by ids button + await userEvent.tab(); + expect(updateButton).toHaveFocus(); + + // Act - tab through remaining filter buttons + await userEvent.tab(); // all animals button + // Assert - focus moves to all animals button + expect(screen.getByRole('button', { name: /all animals/i })).toHaveFocus(); + + await userEvent.tab(); // all alive at center button + // Assert - focus moves to all alive at center button + expect(screen.getByRole('button', { name: /all alive at center/i })).toHaveFocus(); + }); + }); + + describe('security - SQL injection protection', () => { + test('treats IDs with SQL injection patterns as literal strings', async () => { + // Arrange + 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"], + }); + + // Act + fireEvent.click(updateButton); + + // Assert - SQL injection patterns are treated as literal ID strings + await waitFor(() => { + 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..2df804f27 --- /dev/null +++ b/labkey-ui-ehr/src/ParticipantHistory/SearchByIdPanel/SearchByIdPanel.tsx @@ -0,0 +1,237 @@ +import React, { FC, memo, useCallback, useEffect, useState } from 'react'; +import classNames from 'classnames'; +import { incrementClientSideMetricCount } from '@labkey/components'; + +import { IdResolutionFeedback } from './IdResolutionFeedback'; +import { getDefaultParticipantHistoryAPIWrapper } from '../APIWrapper'; +import { + FILTER_TYPE_ALIVE_AT_CENTER, + FILTER_TYPE_ALL, + FILTER_TYPE_ID_SEARCH, + FILTER_TYPE_URL_PARAMS, + FilterType, + IdResolutionResult, + ResolveIdsParams, +} 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 undefined if valid, error message string if invalid + * @internal - Exported for testing + */ +export const validateInput = (ids: string[]): string | undefined => { + 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 undefined; +}; + +interface SearchByIdPanelProps { + activeReportSupportsNonIdFilters: boolean; + initialFilterType: FilterType; + initialSubjects: string[]; + onFilterChange: (filterType: FilterType, subjects: string[] | undefined) => void; + resolveAnimalIds?: (params: ResolveIdsParams) => Promise; +} + +const SearchByIdPanelComponent: FC = ({ + onFilterChange, + initialSubjects, + initialFilterType, + activeReportSupportsNonIdFilters, + resolveAnimalIds = getDefaultParticipantHistoryAPIWrapper().resolveAnimalIds, +}) => { + 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(undefined); + const [hasUserTyped, setHasUserTyped] = useState(false); + + useEffect(() => { + setFilterType(initialFilterType); + }, [initialFilterType]); + + useEffect(() => { + if (hasUserTyped) { + const parsedIds = parseIds(inputValue); + const error = validateInput(parsedIds); + setValidationError(error); + } + }, [inputValue, hasUserTyped]); + + const handleUpdateReport = useCallback(async () => { + setFilterType(FILTER_TYPE_ID_SEARCH); + const parsedIds = parseIds(inputValue); + + const error = validateInput(parsedIds); + if (error) { + setValidationError(error); + setHasUserTyped(true); + onFilterChange(FILTER_TYPE_ID_SEARCH, []); + return; + } + + setIsResolving(true); + const result = await resolveAnimalIds({ inputIds: parsedIds }); + setIsResolving(false); + + if (result.error) { + setValidationError('Failed to resolve animal IDs. Please try again.'); + onFilterChange(FILTER_TYPE_ID_SEARCH, []); + return; + } + + setResolutionResult(result); + const resolvedSubjects = result.resolved.map(r => r.resolvedId); + incrementClientSideMetricCount('ehrParticipantHistoryFilter', FILTER_TYPE_ID_SEARCH); + onFilterChange(FILTER_TYPE_ID_SEARCH, resolvedSubjects); + }, [inputValue, onFilterChange, resolveAnimalIds]); + + const handleFilterModeChange = useCallback( + (newFilterType: FilterType) => { + setFilterType(newFilterType); + incrementClientSideMetricCount('ehrParticipantHistoryFilter', newFilterType); + setInputValue(''); + setResolutionResult({ resolved: [], notFound: [] }); + setValidationError(undefined); + setHasUserTyped(false); + onFilterChange(newFilterType, undefined); + }, + [onFilterChange] + ); + + const handleModifySearch = useCallback(() => { + setFilterType(FILTER_TYPE_ID_SEARCH); + setInputValue(initialSubjects.join(',')); + onFilterChange(FILTER_TYPE_ID_SEARCH, initialSubjects); + }, [initialSubjects, onFilterChange]); + + 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 ( +
+
+ +