diff --git a/ehr/package-lock.json b/ehr/package-lock.json index 91dc4c901..600ceacde 100644 --- a/ehr/package-lock.json +++ b/ehr/package-lock.json @@ -10,7 +10,7 @@ "dependencies": { "@labkey/api": "1.44.0", "@labkey/components": "6.72.1", - "@labkey/ehr": "0.0.4" + "@labkey/ehr": "0.0.4-fb-ehr-hist-id-search.1" }, "devDependencies": { "@labkey/build": "8.7.0", @@ -3742,9 +3742,9 @@ } }, "node_modules/@labkey/ehr": { - "version": "0.0.4", - "resolved": "https://labkey.jfrog.io/artifactory/api/npm/libs-client/@labkey/ehr/-/@labkey/ehr-0.0.4.tgz", - "integrity": "sha512-Fblu16wYcTIoZ1Hect2k8wjVtXMbxWFe3sPLkQE7gSgiAssgIYVSZXwtbFo0FpqFtW9205GHYbIoOQ4otcDMnQ==", + "version": "0.0.4-fb-ehr-hist-id-search.1", + "resolved": "https://labkey.jfrog.io/artifactory/api/npm/libs-client/@labkey/ehr/-/@labkey/ehr-0.0.4-fb-ehr-hist-id-search.1.tgz", + "integrity": "sha512-JxM4qEr1z8xvgTGPun0SZSEBbSJ/BaiKrEa9cU3y4tWu+8dJCRREWR7Izvm+D9BXNUfKN+3rJb98GhtfYOFHAQ==", "license": "SEE LICENSE IN LICENSE.txt", "dependencies": { "@labkey/components": "6.72.1" diff --git a/ehr/package.json b/ehr/package.json index 79ef074a3..7ebfa31e0 100644 --- a/ehr/package.json +++ b/ehr/package.json @@ -17,7 +17,7 @@ "dependencies": { "@labkey/api": "1.44.0", "@labkey/components": "6.72.1", - "@labkey/ehr": "0.0.4" + "@labkey/ehr": "0.0.4-fb-ehr-hist-id-search.1" }, "devDependencies": { "@labkey/build": "8.7.0", diff --git a/ehr/resources/queries/study/aliasIdMatches.sql b/ehr/resources/queries/study/aliasIdMatches.sql new file mode 100644 index 000000000..21268a7b4 --- /dev/null +++ b/ehr/resources/queries/study/aliasIdMatches.sql @@ -0,0 +1,9 @@ + +SELECT + a.Id as resolvedId, + a.alias as inputId, + 'alias' as resolvedBy, + a.category as aliasType, + LOWER(a.alias) as lowerAliasForMatching +FROM study.alias a +INNER JOIN study.demographics d ON a.Id = d.Id diff --git a/ehr/resources/queries/study/directIdMatches.sql b/ehr/resources/queries/study/directIdMatches.sql new file mode 100644 index 000000000..7fd4f6297 --- /dev/null +++ b/ehr/resources/queries/study/directIdMatches.sql @@ -0,0 +1,8 @@ + +SELECT + Id as resolvedId, + Id as inputId, + 'direct' as resolvedBy, + NULL as aliasType, + LOWER(Id) as lowerIdForMatching +FROM study.demographics diff --git a/ehr/resources/reports/reports.tsv b/ehr/resources/reports/reports.tsv index 559d365fd..990b9115f 100644 --- a/ehr/resources/reports/reports.tsv +++ b/ehr/resources/reports/reports.tsv @@ -1,52 +1,52 @@ -reportname category reporttype reporttitle visible containerpath schemaname queryname viewname report datefieldname todayonly queryhaslocation sort_order QCStateLabelFieldName description -activeHousing Colony Management query Housing - Active TRUE study housing Active Housing date FALSE TRUE qcstate/publicdata This report shows the active housing record for each animal -birth Colony Management query Birth Records TRUE study birth date FALSE FALSE qcstate/publicdata Birth records -housing Colony Management query Housing History TRUE study housing date FALSE TRUE qcstate/publicdata This report contains the housing history of each animal -roommateHistory Colony Management query Cagemate History TRUE study housingRoommates StartDate FALSE FALSE qcstate/publicdata This report displays all animals that shared a cage, including the start date, stop date and days co-housed -weight Colony Management js Weights TRUE study weightGraph date FALSE FALSE qcstate/publicdata This report contains a summary of the animal\'s weight, including a graph -Flags Colony Management query Flags true study flags StartDate false false qcstate/publicdata Animal attribute flags -demographics General query Demographics TRUE study demographics FALSE FALSE qcstate/publicdata This report displays the demographics data about each animal including species, sex and birth -snapshot General js Snapshot TRUE study snapshot FALSE FALSE qcstate/publicdata This report contains a summary of the animal, including demographics, assignments and weight -death Pathology query Death Records true study deaths date false false qcstate/publicdata Death records -arrival General query Arrivals true study arrival date false false qcstate/publicdata Displays arrival dates -departure General query Departures true study departure date false false qcstate/publicdata Displays departure dates -currentBlood Clinical js Current Blood true study currentBlood date false false qcstate/publicdata This report contains a summary of the current available blood for each animal -bloodDraws Clinical query Blood Draws TRUE study blood date FALSE FALSE qcstate/publicdata This report displays blood draw data for the selected animal -biopsy Clinical query Biopsies TRUE study biopsy date FALSE FALSE qcstate/publicdata This report displays biopsy data for the selected animal -obs Clinical query Observations TRUE study clinical_observations date FALSE FALSE qcstate/publicdata This report displays observations for the selected animal -alopecia Clinical query Alopecia Scores TRUE study alopecia date FALSE FALSE qcstate/publicdata This report contains the alopecia scores for the animal -pairings Colony Management query Pairings TRUE study pairings date FALSE FALSE qcstate/publicdata This report displays pairings for the selected animal -breeder Colony Management query Breeder TRUE study breeder date FALSE FALSE qcstate/publicdata This report displays breeding data for the selected animal -clinremarks Clinical query Clinical Remarks true study Clinical Remarks date false false qcstate/publicdata This report contains the clinical remarks entered about each animal -serology ClinPath query Serology TRUE study serology date FALSE FALSE qcstate/publicdata This report displays serology data for the selected animal -vitals Clinical query Vital Signs TRUE study vitals date FALSE FALSE qcstate/publicdata This report displays vitals data for the selected animal -physicalExam Clinical query Physical Exam TRUE study physicalExam date FALSE FALSE qcstate/publicdata This report displays physical exam data for the selected animal -procedures Clinical query Procedures TRUE study prc date FALSE FALSE qcstate/publicdata This report displays procedures data for the selected animal -exemptions Colony Management query Exemptions TRUE study exemptions date FALSE FALSE qcstate/publicdata This report displays exemptions data for the selected animal -drugAdministration Clinical query Drug Administration TRUE study drug date FALSE FALSE qcstate/publicdata This report displays drug administration data for the selected animal -necropsy Pathology query Necropsy TRUE study necropsy date FALSE FALSE qcstate/publicdata This report displays necropsy data for the selected animal -clinCases Clinical query Cases true study Cases Active Clinical Cases date false false qcstate/publicdata This report contains one record for each case opend for this animal, including surgeries, exams, procedures, etc. -behaviorCases Behavior query Behavior Cases true study cases Open Behavior Cases date false false qcstate/publicdata This displays active behavior cases -behaviorRemarks Behavior query Behavior Remarks true study Clinical Remarks Behavior Remarks date false false qcstate/publicdata This report contains the behavior remarks entered about each animal -clinObsBehavior Behavior query Observations true study Clinical Observations BSU Observations date false false qcstate/publicdata This report contains one record for each encounter with each animal, including surergies, exams, procedures, etc. -drug Behavior query Behavior Treatments true study Drug Administration Behavior Treatments date false false qcstate/publicdata This report contains the behavior treatments entered about each animal -pairingsBehavior Behavior query Pairing Observations true study pairingSummary date false false This report contains records about pairings made using each animal -pairingHousingSummary Behavior query Pairing With Housing true study pairingHousingSummary date false false This report contains records about pairings made using each animal -roommateHistoryBehavior Behavior query Cagemate History true study housingRoommates Caged Housing Only StartDate false false qcstate/publicdata This report displays all animals that shared a cage, including the start date, stop date and days co-housed -pairingHistory Behavior js Pairing History true study pairHistory date false false qcstate/publicdata This report displays all animals that shared a cage, including the start date, stop date and days co-housed -pregnancy Reproductive Management query Pregnancy Outcomes TRUE study pregnancy date FALSE FALSE qcstate/publicdata This report displays pregnancy outcomes -pedigree Genetics js Pedigree true study pedigree false false qcstate/publicdata This report displays pedigree data for animals, including parents, grandparents, siblings and offspring -pedigreePlot Genetics report Pedigree Plot true study pedigree module:EHR/schemas/study/Pedigree/Pedigree.r false false qcstate/publicdata This report will generate a pedigree plot for the selected animal -offspring Reproductive Management query Offspring true study demographicsOffspring false false qcstate/publicdata This report displays pedigree data for animals, including parents, grandparents, siblings and offspring -kinship Genetics js Kinship true ehr kinshipSummary false false qcstate/publicdata This report shows the kinship coefficient between every animal in the colony. The kinship coefficient is a measure of relatedness between two individuals. It represents the probability that two genes, sampled at random from each individual are identical (e.g. the kinship coefficient between a parent and an offspring is 0.25). -inbreeding Genetics query Inbreeding Coefficients true study Inbreeding Coefficients false false qcstate/publicdata This report shows the inbreeding coefficient of each animal, where pedigree data is available. The inbreeding coefficient is the kinship coefficient between the individual's parents. It measures the probability that the two alleles of a gene are identical by descent in the same individual (autozygosity). It is zero if the individual is not inbred. -parentage Genetics query Parentage true study demographicsParents false false qcstate/publicdata This report shows information about the parentage of each animal, drawing from genetic data and observations -clinicalHistory Clinical js Clinical History TRUE study clinicalHistory date FALSE FALSE qcstate/publicdata This report contains an overview of the animal\'s clinical history -clinMedicationSchedule Daily Reports js Clinical Medication Schedule true study clinMedicationSchedule date false false qcstate/publicdata This report contains a list of daily treatments to be administered -dietSchedule Daily Reports js Diets true study dietSchedule date false false qcstate/publicdata This report contains a list of daily treatments to be administered -surgMedicationSchedule Surgery js Surgical Medication Schedule true study surgMedicationSchedule date false false qcstate/publicdata This report contains a list of daily treatments to be administered -surgMedicationScheduleDaily Daily Reports js Surgical Medication Schedule true study surgMedicationSchedule date false false qcstate/publicdata This report contains a list of daily treatments to be administered -incompleteTreatments Daily Reports js Meds/Diet - Incomplete true study incompleteTreatments date false false qcstate/publicdata This report contains a list of daily treatments not yet administered -bloodSchedule Daily Reports js Blood Schedule true study bloodSchedule date false false qcstate/publicdata This report contains an overview of the animal's clinical history -cases Daily Reports query Active Clinical Cases true study Cases Active Clinical Cases date false false qcstate/publicdata This displays active clinical cases -surgicalCases Daily Reports query Active Surgery Cases true study Cases Active Surgery Cases date false false qcstate/publicdata This displays active surgery cases \ No newline at end of file +reportname category reporttype reporttitle visible containerpath schemaname queryname viewname report datefieldname todayonly queryhaslocation sort_order QCStateLabelFieldName description supportsNonIdFilters +activeHousing Colony Management query Housing - Active TRUE study housing Active Housing date FALSE TRUE qcstate/publicdata This report shows the active housing record for each animal true +birth Colony Management query Birth Records TRUE study birth date FALSE FALSE qcstate/publicdata Birth records true +housing Colony Management query Housing History TRUE study housing date FALSE TRUE qcstate/publicdata This report contains the housing history of each animal true +roommateHistory Colony Management query Cagemate History TRUE study housingRoommates StartDate FALSE FALSE qcstate/publicdata This report displays all animals that shared a cage, including the start date, stop date and days co-housed true +weight Colony Management js Weights TRUE study weightGraph date FALSE FALSE qcstate/publicdata This report contains a summary of the animal\'s weight, including a graph false +Flags Colony Management query Flags true study flags StartDate false false qcstate/publicdata Animal attribute flags true +demographics General query Demographics TRUE study demographics FALSE FALSE qcstate/publicdata This report displays the demographics data about each animal including species, sex and birth true +snapshot General js Snapshot TRUE study snapshot FALSE FALSE qcstate/publicdata This report contains a summary of the animal, including demographics, assignments and weight false +death Pathology query Death Records true study deaths date false false qcstate/publicdata Death records true +arrival General query Arrivals true study arrival date false false qcstate/publicdata Displays arrival dates true +departure General query Departures true study departure date false false qcstate/publicdata Displays departure dates true +currentBlood Clinical js Current Blood true study currentBlood date false false qcstate/publicdata This report contains a summary of the current available blood for each animal false +bloodDraws Clinical query Blood Draws TRUE study blood date FALSE FALSE qcstate/publicdata This report displays blood draw data for the selected animal true +biopsy Clinical query Biopsies TRUE study biopsy date FALSE FALSE qcstate/publicdata This report displays biopsy data for the selected animal true +obs Clinical query Observations TRUE study clinical_observations date FALSE FALSE qcstate/publicdata This report displays observations for the selected animal true +alopecia Clinical query Alopecia Scores TRUE study alopecia date FALSE FALSE qcstate/publicdata This report contains the alopecia scores for the animal true +pairings Colony Management query Pairings TRUE study pairings date FALSE FALSE qcstate/publicdata This report displays pairings for the selected animal true +breeder Colony Management query Breeder TRUE study breeder date FALSE FALSE qcstate/publicdata This report displays breeding data for the selected animal true +clinremarks Clinical query Clinical Remarks true study Clinical Remarks date false false qcstate/publicdata This report contains the clinical remarks entered about each animal true +serology ClinPath query Serology TRUE study serology date FALSE FALSE qcstate/publicdata This report displays serology data for the selected animal true +vitals Clinical query Vital Signs TRUE study vitals date FALSE FALSE qcstate/publicdata This report displays vitals data for the selected animal true +physicalExam Clinical query Physical Exam TRUE study physicalExam date FALSE FALSE qcstate/publicdata This report displays physical exam data for the selected animal true +procedures Clinical query Procedures TRUE study prc date FALSE FALSE qcstate/publicdata This report displays procedures data for the selected animal true +exemptions Colony Management query Exemptions TRUE study exemptions date FALSE FALSE qcstate/publicdata This report displays exemptions data for the selected animal true +drugAdministration Clinical query Drug Administration TRUE study drug date FALSE FALSE qcstate/publicdata This report displays drug administration data for the selected animal true +necropsy Pathology query Necropsy TRUE study necropsy date FALSE FALSE qcstate/publicdata This report displays necropsy data for the selected animal true +clinCases Clinical query Cases true study Cases Active Clinical Cases date false false qcstate/publicdata This report contains one record for each case opend for this animal, including surgeries, exams, procedures, etc. true +behaviorCases Behavior query Behavior Cases true study cases Open Behavior Cases date false false qcstate/publicdata This displays active behavior cases true +behaviorRemarks Behavior query Behavior Remarks true study Clinical Remarks Behavior Remarks date false false qcstate/publicdata This report contains the behavior remarks entered about each animal true +clinObsBehavior Behavior query Observations true study Clinical Observations BSU Observations date false false qcstate/publicdata This report contains one record for each encounter with each animal, including surergies, exams, procedures, etc. true +drug Behavior query Behavior Treatments true study Drug Administration Behavior Treatments date false false qcstate/publicdata This report contains the behavior treatments entered about each animal true +pairingsBehavior Behavior query Pairing Observations true study pairingSummary date false false This report contains records about pairings made using each animal true +pairingHousingSummary Behavior query Pairing With Housing true study pairingHousingSummary date false false This report contains records about pairings made using each animal true +roommateHistoryBehavior Behavior query Cagemate History true study housingRoommates Caged Housing Only StartDate false false qcstate/publicdata This report displays all animals that shared a cage, including the start date, stop date and days co-housed true +pairingHistory Behavior js Pairing History true study pairHistory date false false qcstate/publicdata This report displays all animals that shared a cage, including the start date, stop date and days co-housed false +pregnancy Reproductive Management query Pregnancy Outcomes TRUE study pregnancy date FALSE FALSE qcstate/publicdata This report displays pregnancy outcomes true +pedigree Genetics js Pedigree true study pedigree false false qcstate/publicdata This report displays pedigree data for animals, including parents, grandparents, siblings and offspring false +pedigreePlot Genetics report Pedigree Plot true study pedigree module:EHR/schemas/study/Pedigree/Pedigree.r false false qcstate/publicdata This report will generate a pedigree plot for the selected animal false +offspring Reproductive Management query Offspring true study demographicsOffspring false false qcstate/publicdata This report displays pedigree data for animals, including parents, grandparents, siblings and offspring true +kinship Genetics js Kinship true ehr kinshipSummary false false qcstate/publicdata This report shows the kinship coefficient between every animal in the colony. The kinship coefficient is a measure of relatedness between two individuals. It represents the probability that two genes, sampled at random from each individual are identical (e.g. the kinship coefficient between a parent and an offspring is 0.25). false +inbreeding Genetics query Inbreeding Coefficients true study Inbreeding Coefficients false false qcstate/publicdata This report shows the inbreeding coefficient of each animal, where pedigree data is available. The inbreeding coefficient is the kinship coefficient between the individual's parents. It measures the probability that the two alleles of a gene are identical by descent in the same individual (autozygosity). It is zero if the individual is not inbred. true +parentage Genetics query Parentage true study demographicsParents false false qcstate/publicdata This report shows information about the parentage of each animal, drawing from genetic data and observations true +clinicalHistory Clinical js Clinical History TRUE study clinicalHistory date FALSE FALSE qcstate/publicdata This report contains an overview of the animal\'s clinical history false +clinMedicationSchedule Daily Reports js Clinical Medication Schedule true study clinMedicationSchedule date false false qcstate/publicdata This report contains a list of daily treatments to be administered false +dietSchedule Daily Reports js Diets true study dietSchedule date false false qcstate/publicdata This report contains a list of daily treatments to be administered false +surgMedicationSchedule Surgery js Surgical Medication Schedule true study surgMedicationSchedule date false false qcstate/publicdata This report contains a list of daily treatments to be administered false +surgMedicationScheduleDaily Daily Reports js Surgical Medication Schedule true study surgMedicationSchedule date false false qcstate/publicdata This report contains a list of daily treatments to be administered false +incompleteTreatments Daily Reports js Meds/Diet - Incomplete true study incompleteTreatments date false false qcstate/publicdata This report contains a list of daily treatments not yet administered false +bloodSchedule Daily Reports js Blood Schedule true study bloodSchedule date false false qcstate/publicdata This report contains an overview of the animal's clinical history false +cases Daily Reports query Active Clinical Cases true study Cases Active Clinical Cases date false false qcstate/publicdata This displays active clinical cases true +surgicalCases Daily Reports query Active Surgery Cases true study Cases Active Surgery Cases date false false qcstate/publicdata This displays active surgery cases true \ No newline at end of file diff --git a/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..b3cdb1e1f 100644 --- a/labkey-ui-ehr/package-lock.json +++ b/labkey-ui-ehr/package-lock.json @@ -1,12 +1,12 @@ { "name": "@labkey/ehr", - "version": "0.0.4", + "version": "0.0.4-fb-ehr-hist-id-search.1", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "@labkey/ehr", - "version": "0.0.4", + "version": "0.0.4-fb-ehr-hist-id-search.1", "license": "SEE LICENSE IN LICENSE.txt", "dependencies": { "@labkey/components": "6.72.1" diff --git a/labkey-ui-ehr/package.json b/labkey-ui-ehr/package.json index 3dc44a379..acaa96a68 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.1", "description": "Components, models, actions, and utility functions for LabKey EHR applications and pages", "sideEffects": false, "files": [ @@ -31,6 +31,7 @@ "prepublishOnly": "npm install --legacy-peer-deps && cross-env WEBPACK_STATS=errors-only npm run build", "test": "cross-env NODE_ENV=test jest --maxWorkers=6 --silent", "test-ci": "cross-env NODE_ENV=test jest --ci --silent", + "test-coverage": "cross-env NODE_ENV=test jest --maxWorkers=6 --coverage", "lint": "npx eslint", "lint-fix": "npx eslint --fix", "lint-precommit": "node lint.diff.mjs", diff --git a/labkey-ui-ehr/src/ParticipantHistory/ParticipantReports.test.tsx b/labkey-ui-ehr/src/ParticipantHistory/ParticipantReports.test.tsx index 383999f8a..d63548a6c 100644 --- a/labkey-ui-ehr/src/ParticipantHistory/ParticipantReports.test.tsx +++ b/labkey-ui-ehr/src/ParticipantHistory/ParticipantReports.test.tsx @@ -36,12 +36,22 @@ const mockExt4Container = { }, }; -// Mock LABKEY.WebPart for OtherReportWrapper +// Mock LABKEY API for OtherReportWrapper and ParticipantReports +const mockSelectRows = jest.fn(); (global as any).LABKEY = { ...(global as any).LABKEY, WebPart: jest.fn().mockImplementation(() => ({ render: jest.fn(), })), + Query: { + selectRows: mockSelectRows, + }, + Filter: { + create: jest.fn((field, value, type) => ({ field, value, type })), + Types: { + EQUAL: 'EQUAL', + }, + }, }; describe('ParticipantReports', () => { @@ -52,6 +62,15 @@ describe('ParticipantReports', () => { jest.clearAllMocks(); mockExt4Container.isDestroyed = false; + // Mock LABKEY.Query.selectRows with default behavior (returns supportsnonidfilters: true) + mockSelectRows.mockImplementation((config: any) => { + if (config.success) { + config.success({ + rows: [{ supportsnonidfilters: true }], + }); + } + }); + // Save and reset document.location.hash and search before each test originalHash = window.location.hash; originalSearch = window.location.search; @@ -275,4 +294,252 @@ describe('ParticipantReports', () => { 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', () => { + window.location.hash = '#filterType:idSearch&subjects:ID123%3BID456'; + + renderWithServerContext(, defaultServerContext()); + + // Component should render with subjects from hash + expect(screen.getByText('Loading reports...')).toBeVisible(); + }); + + test('initializes with All Records mode when filterType:all in hash', () => { + window.location.hash = '#filterType:all'; + + renderWithServerContext(, defaultServerContext()); + + expect(screen.getByText('Loading reports...')).toBeVisible(); + }); + + test('initializes with Alive at Center mode when filterType:aliveAtCenter in hash', () => { + window.location.hash = '#filterType:aliveAtCenter'; + + renderWithServerContext(, defaultServerContext()); + + expect(screen.getByText('Loading reports...')).toBeVisible(); + }); + + test('defaults to ID Search mode when no filterType in hash', () => { + window.location.hash = ''; + + renderWithServerContext(, defaultServerContext()); + + expect(screen.getByText('Loading reports...')).toBeVisible(); + }); + }); + + describe('URL Params mode (readOnly)', () => { + test('activates URL Params mode when readOnly:true in URL with subjects', () => { + window.location.hash = '#subjects:ID123%3BID456&readOnly:true'; + + renderWithServerContext(, defaultServerContext()); + + // Component should render in URL Params mode + expect(screen.getByText('Loading reports...')).toBeVisible(); + }); + + test('ignores readOnly:true when no subjects in URL', () => { + window.location.hash = '#readOnly:true'; + + renderWithServerContext(, defaultServerContext()); + + // Should default to a safe mode (likely All Records or ID Search) + expect(screen.getByText('Loading reports...')).toBeVisible(); + }); + + test('readOnly parameter takes priority over filterType parameter', () => { + window.location.hash = '#filterType:all&subjects:ID123&readOnly:true'; + + renderWithServerContext(, defaultServerContext()); + + // Should use URL Params mode, not All Records mode + expect(screen.getByText('Loading reports...')).toBeVisible(); + }); + }); + + describe('filter state management', () => { + test('manages subjects state from URL hash', () => { + window.location.hash = '#subjects:ID123%3BID456%3BID789'; + + renderWithServerContext(, defaultServerContext()); + + // Verify component renders with subjects + expect(screen.getByText('Loading reports...')).toBeVisible(); + }); + + test('manages filterType state from URL hash', () => { + window.location.hash = '#filterType:aliveAtCenter'; + + renderWithServerContext(, defaultServerContext()); + + expect(screen.getByText('Loading reports...')).toBeVisible(); + }); + }); + + describe('URL hash updates', () => { + test('updates URL hash when filter mode changes', () => { + window.location.hash = ''; + + renderWithServerContext(, defaultServerContext()); + + // After component mounts, simulate filter change + // Note: This would require exposing handleFilterChange or testing through UI interaction + expect(screen.getByText('Loading reports...')).toBeVisible(); + }); + + test('includes subjects in URL hash for ID Search mode', () => { + window.location.hash = '#filterType:idSearch&subjects:ID123%3BID456'; + + renderWithServerContext(, defaultServerContext()); + + expect(window.location.hash).toContain('subjects:'); + }); + + test('removes subjects from URL hash for All Records mode', () => { + window.location.hash = '#filterType:all'; + + renderWithServerContext(, defaultServerContext()); + + expect(window.location.hash).not.toContain('subjects:'); + }); + + test('removes readOnly parameter when switching from URL Params to ID Search', () => { + window.location.hash = '#subjects:ID123&readOnly:true'; + + renderWithServerContext(, defaultServerContext()); + + // Component should be in URL Params mode initially + // After switching to ID Search (would require UI interaction), readOnly should be removed + expect(screen.getByText('Loading reports...')).toBeVisible(); + }); + }); + + describe('activeReportSupportsNonIdFilters query', () => { + test('queries report metadata for supportsNonIdFilters field', () => { + window.location.hash = '#activeReport:test-report'; + + renderWithServerContext(, defaultServerContext()); + + // Component should query ehr.reports for the active report's metadata + expect(screen.getByText('Loading reports...')).toBeVisible(); + }); + + test('updates activeReportSupportsNonIdFilters when switching report tabs', () => { + window.location.hash = '#activeReport:report1'; + + renderWithServerContext(, defaultServerContext()); + + // After switching to different report tab, should re-query metadata + expect(screen.getByText('Loading reports...')).toBeVisible(); + }); + + test('defaults to false when no active report selected', () => { + window.location.hash = ''; + + renderWithServerContext(, defaultServerContext()); + + // Should handle no active report gracefully + expect(screen.getByText('Loading reports...')).toBeVisible(); + }); + }); + + describe('race conditions', () => { + test('handles rapid filter mode changes before state updates', () => { + window.location.hash = ''; + + renderWithServerContext(, defaultServerContext()); + + // Simulate rapid filter changes + // This would require UI interaction or exposing handleFilterChange + expect(screen.getByText('Loading reports...')).toBeVisible(); + }); + }); + + describe('malformed URL hash', () => { + test('handles malformed URL hash gracefully', () => { + window.location.hash = '#malformed&invalid::data'; + + renderWithServerContext(, defaultServerContext()); + + // Should fall back to default state without crashing + expect(screen.getByText('Loading reports...')).toBeVisible(); + }); + + test('handles URL hash with missing values', () => { + window.location.hash = '#filterType:&subjects:'; + + renderWithServerContext(, defaultServerContext()); + + // Should handle empty values gracefully + expect(screen.getByText('Loading reports...')).toBeVisible(); + }); + }); + + describe('filter integration with TabbedReportPanel', () => { + test('passes filters prop to TabbedReportPanel', () => { + window.location.hash = '#filterType:idSearch&subjects:ID123'; + + renderWithServerContext(, defaultServerContext()); + + // TabbedReportPanel should receive filters prop with filterType and subjects + expect(screen.getByText('Loading reports...')).toBeVisible(); + }); + + test('passes undefined subjects for All Records mode', () => { + window.location.hash = '#filterType:all'; + + renderWithServerContext(, defaultServerContext()); + + // filters.subjects should be undefined for All Records + expect(screen.getByText('Loading reports...')).toBeVisible(); + }); + + test('passes subjects for URL Params mode', () => { + window.location.hash = '#subjects:ID123&readOnly:true'; + + renderWithServerContext(, defaultServerContext()); + + // filters.subjects should be populated for URL Params mode + expect(screen.getByText('Loading reports...')).toBeVisible(); + }); + }); + + describe('LABKEY query error handling', () => { + test('handles LABKEY query failure gracefully', () => { + // Mock the selectRows to call the failure callback + mockSelectRows.mockImplementationOnce((config: any) => { + if (config.failure) { + config.failure({ message: 'Query failed' }); + } + }); + + window.location.hash = '#activeReport:demographics'; + + renderWithServerContext(, defaultServerContext()); + + // Component should render without crashing despite the query failure + // The error will be logged to console but shouldn't break the UI + expect(screen.queryByText('Loading reports...')).toBeInTheDocument(); + }); + + test('defaults to supporting all filters when report metadata not found', () => { + // Mock the selectRows to return empty rows + mockSelectRows.mockImplementationOnce((config: any) => { + if (config.success) { + config.success({ rows: [] }); + } + }); + + window.location.hash = '#activeReport:nonexistent'; + + renderWithServerContext(, defaultServerContext()); + + // Component should render with default behavior (all filters supported) + expect(screen.queryByText('Loading reports...')).toBeInTheDocument(); + }); + }); + }); }); diff --git a/labkey-ui-ehr/src/ParticipantHistory/ParticipantReports.tsx b/labkey-ui-ehr/src/ParticipantHistory/ParticipantReports.tsx index a5e2b916e..d92b4138b 100644 --- a/labkey-ui-ehr/src/ParticipantHistory/ParticipantReports.tsx +++ b/labkey-ui-ehr/src/ParticipantHistory/ParticipantReports.tsx @@ -1,111 +1,167 @@ -import React, { FC, memo, useCallback, useMemo } from 'react'; -import { useServerContext } from '@labkey/components'; +import React, { FC, memo, useCallback, useEffect, useMemo, useState } from 'react'; +import { incrementClientSideMetricCount } from '@labkey/components'; + +import { SearchByIdPanel } from './SearchByIdPanel/SearchByIdPanel'; import { TabbedReportPanel } from './TabbedReportPanel/TabbedReportPanel'; +import { getFiltersFromUrl, updateUrlHash } from './utils/urlHashUtils'; +import { + FILTER_TYPE_ALIVE_AT_CENTER, + FILTER_TYPE_ALL, + FILTER_TYPE_ID_SEARCH, + FILTER_TYPE_URL_PARAMS, + FilterType, +} from './models'; -interface UrlFilters { - [key: string]: any; - activeReport?: string; - inputType?: string; - participantId?: string; - showReport?: boolean; - subjects?: string[]; -} - -const getFiltersFromUrl = (): UrlFilters => { - const context: UrlFilters = {}; - - // Parse participantId from URL query parameters (e.g., ?participantId=44444) - const urlParams = new URLSearchParams(document.location.search); - const participantId = urlParams.get('participantId'); - if (participantId) { - context.participantId = participantId; - context.subjects = [participantId]; - } - - if (document.location.hash) { - const token = document.location.hash.split('#'); - const params = token[1]?.split('&') || []; - - for (let i = 0; i < params.length; i++) { - const t = params[i].split(':'); - const key = decodeURIComponent(t[0]); - const value = t.length > 1 ? decodeURIComponent(t[1]) : undefined; - - switch (key) { - case 'activeReport': - context.activeReport = value; - break; - case 'inputType': - context.inputType = value; - break; - case 'showReport': - context.showReport = value === '1'; - break; - case 'subjects': - // If subjects are in hash, merge with participantId if present - const hashSubjects = value ? value.split(';') : []; - if (context.participantId && !hashSubjects.includes(context.participantId)) { - context.subjects = [context.participantId, ...hashSubjects]; - } else { - context.subjects = hashSubjects; - } - break; - default: - if (value !== undefined) { - context[key] = value; - } - } +// Declare global LABKEY API +declare const LABKEY: any; + +const ParticipantReportsComponent: FC = () => { + const urlFilters = useMemo(() => getFiltersFromUrl(), []); + const [subjects, setSubjects] = useState(urlFilters.subjects || []); + + // Determine initial filter type based on URL parameters + const initialFilterType = useMemo(() => { + if (urlFilters.readOnly && urlFilters.subjects?.length > 0) { + return FILTER_TYPE_URL_PARAMS; // Read-only mode for shared links } - } + return urlFilters.filterType || FILTER_TYPE_ID_SEARCH; + }, [urlFilters]); - return context; -}; + const [filterType, setFilterType] = useState(initialFilterType); + const [activeReport, setActiveReport] = useState(urlFilters.activeReport); + const [activeReportSupportsNonIdFilters, setActiveReportSupportsNonIdFilters] = useState(true); + const [filterNotSupportedError, setFilterNotSupportedError] = useState(null); + const [showReport, setShowReport] = useState(urlFilters.showReport ?? false); -export const ParticipantReports: FC = memo(() => { - const urlFilters = useMemo(() => getFiltersFromUrl(), []); + // Query active report metadata to get supportsNonIdFilters field from ehr.reports + useEffect(() => { + if (!activeReport || typeof LABKEY === 'undefined') { + setActiveReportSupportsNonIdFilters(true); // Default to true if no report or LABKEY not available + return; + } - const filters = useMemo( - () => ({ - subjects: urlFilters.subjects || [], - ...urlFilters, - }), - [urlFilters] - ); + LABKEY.Query.selectRows({ + schemaName: 'ehr', + queryName: 'reports', + filterArray: [LABKEY.Filter.create('reportname', activeReport, LABKEY.Filter.Types.EQUAL)], + columns: 'supportsnonidfilters', + success: (data: any) => { + if (data.rows && data.rows.length > 0) { + const supportsNonIdFilters = data.rows[0].supportsnonidfilters; + setActiveReportSupportsNonIdFilters(supportsNonIdFilters === true); + } else { + // Report not found in metadata, default to true (allow all filters) + setActiveReportSupportsNonIdFilters(true); + } + }, + failure: (error: any) => { + console.error('Failed to query report metadata:', error); + // On error, default to true (allow all filters) + setActiveReportSupportsNonIdFilters(true); + }, + }); + }, [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 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); + }, + [filterType] + ); + + const handleTabChange = useCallback( + (reportId: string) => { + setActiveReport(reportId); + // Update URL hash with new activeReport + updateUrlHash(filterType, subjects, filterType === FILTER_TYPE_URL_PARAMS, showReport); + }, + [filterType, subjects, showReport] + ); + + // Determine if current filter is not supported and set error message + useEffect(() => { + if (filterType === FILTER_TYPE_ALIVE_AT_CENTER && !activeReportSupportsNonIdFilters) { + setFilterNotSupportedError('This report does not support Alive at Center filtering.'); + } else { + setFilterNotSupportedError(null); } + }, [filterType, activeReportSupportsNonIdFilters]); - if (!found) { - newParams.push(`activeReport:${encodeURIComponent(reportId)}`); + // Auto-switch from aliveAtCenter to all when report doesn't support it + useEffect(() => { + if (filterType === FILTER_TYPE_ALIVE_AT_CENTER && !activeReportSupportsNonIdFilters) { + // Automatically switch to All Animals mode, but keep error message visible + + handleFilterChange(FILTER_TYPE_ALL, undefined, false); } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [activeReportSupportsNonIdFilters, filterType]); - document.location.hash = newParams.join('&'); - }, []); + // Compute effective filter - override to 'all' if aliveAtCenter is not supported + const effectiveFilterType = useMemo(() => { + if (filterType === FILTER_TYPE_ALIVE_AT_CENTER && !activeReportSupportsNonIdFilters) { + return FILTER_TYPE_ALL; // Override to show all animals + } + return filterType; + }, [filterType, activeReportSupportsNonIdFilters]); + + const filters = useMemo( + () => ({ + filterType: effectiveFilterType, + subjects: + effectiveFilterType === FILTER_TYPE_ID_SEARCH || effectiveFilterType === FILTER_TYPE_URL_PARAMS + ? subjects + : undefined, + }), + [effectiveFilterType, subjects] + ); return ( -
+
+ + {filterNotSupportedError && ( +
+ {filterNotSupportedError} +
+ )}
); -}); +}; + +ParticipantReportsComponent.displayName = 'ParticipantReports'; + +export const ParticipantReports = memo(ParticipantReportsComponent); diff --git a/labkey-ui-ehr/src/ParticipantHistory/SearchByIdPanel/IdResolutionFeedback.test.tsx b/labkey-ui-ehr/src/ParticipantHistory/SearchByIdPanel/IdResolutionFeedback.test.tsx new file mode 100644 index 000000000..c0f98581b --- /dev/null +++ b/labkey-ui-ehr/src/ParticipantHistory/SearchByIdPanel/IdResolutionFeedback.test.tsx @@ -0,0 +1,284 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; + +import { IdResolutionFeedback } from './IdResolutionFeedback'; +import { IdResolutionResult } from '../models'; + +describe('IdResolutionFeedback', () => { + describe('visibility logic', () => { + test('component hidden when all IDs are direct matches (no aliases, no not-found)', () => { + const resolutionResult: IdResolutionResult = { + resolved: [ + { inputId: 'ID123', resolvedId: 'ID123', resolvedBy: 'direct', aliasType: null }, + { inputId: 'ID456', resolvedId: 'ID456', resolvedBy: 'direct', aliasType: null }, + ], + notFound: [], + }; + + const { container } = render( + + ); + + // Component should not render anything + expect(container.firstChild).toBeNull(); + }); + + test('component visible when aliases present', () => { + const resolutionResult: IdResolutionResult = { + resolved: [ + { inputId: 'ID123', resolvedId: 'ID123', resolvedBy: 'direct', aliasType: null }, + { inputId: 'TATTOO_001', resolvedId: 'ID456', resolvedBy: 'alias', aliasType: 'tattoo' }, + ], + notFound: [], + }; + + render(); + + expect(screen.getByText(/resolved/i)).toBeVisible(); + }); + + test('component visible when not-found IDs present', () => { + const resolutionResult: IdResolutionResult = { + resolved: [{ inputId: 'ID123', resolvedId: 'ID123', resolvedBy: 'direct', aliasType: null }], + notFound: ['INVALID_ID'], + }; + + render(); + + expect(screen.getByText(/not found/i)).toBeVisible(); + }); + + test('component visible when both aliases and not-found IDs present', () => { + const resolutionResult: IdResolutionResult = { + resolved: [{ inputId: 'TATTOO_001', resolvedId: 'ID123', resolvedBy: 'alias', aliasType: 'tattoo' }], + notFound: ['INVALID_ID'], + }; + + render(); + + expect(screen.getByText(/resolved/i)).toBeVisible(); + expect(screen.getByText(/not found/i)).toBeVisible(); + }); + }); + + describe('resolved section display', () => { + test('displays direct matches without arrow', () => { + const resolutionResult: IdResolutionResult = { + resolved: [ + { inputId: 'ID123', resolvedId: 'ID123', resolvedBy: 'direct', aliasType: null }, + { inputId: 'ID456', resolvedId: 'ID456', resolvedBy: 'direct', aliasType: null }, + ], + notFound: [], + }; + + render(); + + expect(screen.getByText('ID123')).toBeVisible(); + expect(screen.getByText('ID456')).toBeVisible(); + // Should not contain arrow symbols for direct matches + expect(screen.queryByText(/→/)).not.toBeInTheDocument(); + }); + + test('displays alias matches with arrow and type', () => { + const resolutionResult: IdResolutionResult = { + resolved: [{ inputId: 'TATTOO_001', resolvedId: 'ID123', resolvedBy: 'alias', aliasType: 'tattoo' }], + notFound: [], + }; + + render(); + + // Should show: "TATTOO_001 → ID123 (tattoo)" + expect(screen.getByText(/TATTOO_001/)).toBeVisible(); + expect(screen.getByText(/→/)).toBeVisible(); + expect(screen.getByText(/ID123/)).toBeVisible(); + expect(screen.getByText('(tattoo)')).toBeVisible(); + }); + + test('displays multiple alias matches with different types', () => { + const resolutionResult: IdResolutionResult = { + resolved: [ + { inputId: 'TATTOO_001', resolvedId: 'ID123', resolvedBy: 'alias', aliasType: 'tattoo' }, + { inputId: 'CHIP_12345', resolvedId: 'ID456', resolvedBy: 'alias', aliasType: 'chip' }, + ], + notFound: [], + }; + + render(); + + expect(screen.getByText(/TATTOO_001/)).toBeVisible(); + expect(screen.getByText('(tattoo)')).toBeVisible(); + expect(screen.getByText(/CHIP_12345/)).toBeVisible(); + expect(screen.getByText('(chip)')).toBeVisible(); + }); + + test('displays mixed direct and alias matches correctly', () => { + const resolutionResult: IdResolutionResult = { + resolved: [ + { inputId: 'ID123', resolvedId: 'ID123', resolvedBy: 'direct', aliasType: null }, + { inputId: 'TATTOO_001', resolvedId: 'ID456', resolvedBy: 'alias', aliasType: 'tattoo' }, + { inputId: 'ID789', resolvedId: 'ID789', resolvedBy: 'direct', aliasType: null }, + ], + notFound: [], + }; + + render(); + + // Direct matches should not have arrow + expect(screen.getByText('ID123')).toBeVisible(); + expect(screen.getByText('ID789')).toBeVisible(); + + // Alias match should have arrow and type + expect(screen.getByText(/TATTOO_001/)).toBeVisible(); + expect(screen.getByText('(tattoo)')).toBeVisible(); + }); + }); + + describe('not found section display', () => { + test('displays unresolved IDs in not found section', () => { + const resolutionResult: IdResolutionResult = { + resolved: [], + notFound: ['INVALID_ID_1', 'INVALID_ID_2'], + }; + + render(); + + expect(screen.getByText(/not found/i)).toBeVisible(); + expect(screen.getByText('INVALID_ID_1')).toBeVisible(); + expect(screen.getByText('INVALID_ID_2')).toBeVisible(); + }); + + test('displays single not found ID', () => { + const resolutionResult: IdResolutionResult = { + resolved: [{ inputId: 'ID123', resolvedId: 'ID123', resolvedBy: 'direct', aliasType: null }], + notFound: ['INVALID_ID'], + }; + + render(); + + expect(screen.getByText(/not found/i)).toBeVisible(); + expect(screen.getByText('INVALID_ID')).toBeVisible(); + }); + }); + + describe('multiple inputs resolving to same ID', () => { + test('displays all inputs that resolved to same ID', () => { + const resolutionResult: IdResolutionResult = { + resolved: [ + { inputId: 'TATTOO_001', resolvedId: 'ID123', resolvedBy: 'alias', aliasType: 'tattoo' }, + { inputId: 'CHIP_12345', resolvedId: 'ID123', resolvedBy: 'alias', aliasType: 'chip' }, + ], + notFound: [], + }; + + render(); + + // Both inputs should be displayed even though they resolve to the same ID + expect(screen.getByText(/TATTOO_001/)).toBeVisible(); + expect(screen.getByText(/CHIP_12345/)).toBeVisible(); + // ID123 should appear twice (once for each resolution) + const id123Elements = screen.getAllByText(/ID123/); + expect(id123Elements.length).toBeGreaterThanOrEqual(2); + }); + }); + + describe('empty results', () => { + test('does not render when no resolved and no not found IDs', () => { + const resolutionResult: IdResolutionResult = { + resolved: [], + notFound: [], + }; + + const { container } = render( + + ); + + expect(container.firstChild).toBeNull(); + }); + }); + + describe('section headings', () => { + test('resolved section has proper heading', () => { + const resolutionResult: IdResolutionResult = { + resolved: [{ inputId: 'TATTOO_001', resolvedId: 'ID123', resolvedBy: 'alias', aliasType: 'tattoo' }], + notFound: [], + }; + + render(); + + const heading = screen.getByRole('heading', { name: /resolved/i }); + expect(heading).toBeInTheDocument(); + }); + + test('not found section has proper heading', () => { + const resolutionResult: IdResolutionResult = { + resolved: [], + notFound: ['INVALID_ID'], + }; + + render(); + + const heading = screen.getByRole('heading', { name: /not found/i }); + expect(heading).toBeInTheDocument(); + }); + }); + + describe('accessibility', () => { + test('displays resolved IDs with proper structure', () => { + const resolutionResult: IdResolutionResult = { + resolved: [ + { inputId: 'ID123', resolvedId: 'ID123', resolvedBy: 'direct', aliasType: null }, + { inputId: 'TATTOO_001', resolvedId: 'ID456', resolvedBy: 'alias', aliasType: 'tattoo' }, + ], + notFound: [], + }; + + const { container } = render(); + + const resolvedItems = container.querySelectorAll('.resolved-item'); + expect(resolvedItems).toHaveLength(2); + }); + + test('displays not found IDs with proper structure', () => { + const resolutionResult: IdResolutionResult = { + resolved: [], + notFound: ['INVALID_ID_1', 'INVALID_ID_2'], + }; + + const { container } = render(); + + const notFoundItems = container.querySelectorAll('.not-found-item'); + expect(notFoundItems).toHaveLength(2); + }); + }); + + describe('special characters in IDs', () => { + test('handles IDs with spaces', () => { + const resolutionResult: IdResolutionResult = { + resolved: [{ inputId: 'ID 123', resolvedId: 'ID 123', resolvedBy: 'direct', aliasType: null }], + notFound: ['INVALID ID'], + }; + + render(); + + expect(screen.getByText('ID 123')).toBeVisible(); + expect(screen.getByText('INVALID ID')).toBeVisible(); + }); + + test('handles IDs with special characters', () => { + const resolutionResult: IdResolutionResult = { + resolved: [ + { inputId: 'ID-123', resolvedId: 'ID-123', resolvedBy: 'direct', aliasType: null }, + { inputId: 'TAG_456', resolvedId: 'ID.789', resolvedBy: 'alias', aliasType: 'tag' }, + ], + notFound: ['INVALID@ID'], + }; + + render(); + + expect(screen.getByText('ID-123')).toBeVisible(); + expect(screen.getByText(/TAG_456/)).toBeVisible(); + expect(screen.getByText(/ID\.789/)).toBeVisible(); + expect(screen.getByText('INVALID@ID')).toBeVisible(); + }); + }); +}); diff --git a/labkey-ui-ehr/src/ParticipantHistory/SearchByIdPanel/IdResolutionFeedback.tsx b/labkey-ui-ehr/src/ParticipantHistory/SearchByIdPanel/IdResolutionFeedback.tsx new file mode 100644 index 000000000..f74224ef0 --- /dev/null +++ b/labkey-ui-ehr/src/ParticipantHistory/SearchByIdPanel/IdResolutionFeedback.tsx @@ -0,0 +1,73 @@ +import React, { FC } from 'react'; + +import { IdResolutionResult } from '../models'; + +/** + * Component to display ID resolution feedback + * + * Shows two sections: + * - "Resolved" section: IDs that were found (directly or via alias) + * - Direct matches: "ID123" + * - Alias matches: "TATTOO_001 → ID123 (tattoo)" + * - "Not Found" section: IDs that could not be resolved + * + * Only visible when there are aliases or not-found IDs (hidden for all direct matches) + */ + +export interface IdResolutionFeedbackProps { + isVisible: boolean; + resolutionResult: IdResolutionResult; +} + +export const IdResolutionFeedback: FC = ({ resolutionResult, isVisible }) => { + // Don't render if not visible + if (!isVisible) { + return null; + } + + const { resolved, notFound } = resolutionResult; + + // Separate direct matches from alias matches + const directMatches = resolved.filter(r => r.resolvedBy === 'direct'); + const aliasMatches = resolved.filter(r => r.resolvedBy === 'alias'); + + return ( +
+

ID Resolution

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

Resolved ({resolved.length})

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

Not Found ({notFound.length})

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