Chapter 3: Search & Query

By following this guide we will
  • Build a search form with selectable results.
  • Query the FHIR server to retrieve patients.
  • Handle the FHIR response depending on the FhirDataQuery state.

Adding a framing structure

Creating a two-panel layout

We now wish to add some structure to the page in order to allow for a two-panel layout. In this chapter, we will populate the left panel (sidebar) with a search form and search results of patients. In a future chapter, we will populate the right panel (main section) with the health information of a selected patient.

To create a two-panel layout, we will tweak the Dashboard component to use the LeftPanelLayout component provided by the Foundation library, @commure/components-foundation. Let’s replace the app-container div in src/components/Dashboard/Dashboard.tsx with a LeftPanelLayout component:

src/components/Dashboard/Dashboard.tsx
1import React from "react";
2
3import { AppHeader } from "@commure/components-core";
4import { LeftPanelLayout } from "@commure/components-foundation";
5import { PatientList } from "../PatientList/PatientList";
6import PanelBody from "../PanelBody/PanelBody";
7
8const Dashboard: React.FC = (): React.ReactElement => {
9 return (
10 <>
11 <AppHeader appName="Patient Chart" fixedToTop />
12 <LeftPanelLayout collapsible panelBody={<PanelBody />}>
13 <PatientList />
14 </LeftPanelLayout>
15 </>
16 );
17};
18
19export default Dashboard;

Let's also initialize the PanelBody that goes in the left panel in a new file:

src/components/PanelBody/PanelBody.tsx
1import React, { useState } from "react";
2import { InputGroup } from "@commure/components-foundation";
3
4const PanelBody: React.FC = () => {
5 const [searchTerm, setSearchTerm] = useState("");
6 return (
7 <>
8 <InputGroup
9 className="cm-search-input"
10 large
11 placeholder="Search patient ..."
12 leftIcon="search"
13 onChange={(event: React.ChangeEvent<HTMLInputElement>) =>
14 setSearchTerm(event.target.value)
15 }
16 />
17 </>
18 );
19};
20
21export default PanelBody;

Setup to allow for selecting a patient

Later on, we will want to have a notion of a “selected patient,” so let’s define that here. We will also need to set the selected patient from somewhere within the LeftPanelLayout’s children. Since the place of usage will be several layers deep, instead of prop drilling, we will use a context provider as such in Dashboard.tsx:

src/components/Dashboard/Dashboard.tsx
1import React, { createContext, useState } from "react";
2
3import { AppHeader } from "@commure/components-core";
4import { LeftPanelLayout } from "@commure/components-foundation";
5import { PatientList } from "../PatientList/PatientList";
6import PanelBody from "../PanelBody/PanelBody";
7import { DashboardContextType } from "../../types";
8
9export const DashboardContext = createContext<DashboardContextType>(undefined);
10
11const Dashboard: React.FC = (): React.ReactElement => {
12 const [patientId, setPatientId] = useState<string | null>(null);
13
14 const selectMenuItem = (id: string) => {
15 setPatientId(id);
16 };
17
18 return (
19 <>
20 <AppHeader appName="Patient Chart" fixedToTop />
21 <DashboardContext.Provider value={{ selectMenuItem }}>
22 <LeftPanelLayout collapsible panelBody={<PanelBody />}>
23 <PatientList />
24 </LeftPanelLayout>
25 </DashboardContext.Provider>
26 </>
27 );
28};
29
30export default Dashboard;

We can define the DashboardContextType in src/types/index.ts:

src/types/index.ts
1import React from "react";
2
3export type HOFSmartApp = <P>(
4 WrappedComponent: React.FC<P>
5) => (props: P) => React.ReactElement;
6
7export type DashboardContextType =
8 | {
9 selectMenuItem: (id: string) => void;
10 }
11 | undefined;

Enabling search

Now that we have the left panel added to the page, let’s add searching functionality. We will go to PanelBody and add some extra logic to fetch based on the query that the user has entered.

Regulating requests and re-rendering rate

Because we do not wish to send a request on every keystroke, we will need to employ a debounce hook. We can place this in src/utils/hooks/useDebounce.ts:

src/utils/hooks/useDebounce.ts
1import { useState, useEffect } from "react";
2
3export default function useDebounce<T>(value: T, delay: number): T {
4 const [debouncedValue, setDebouncedValue] = useState(value);
5
6 useEffect(() => {
7 const handler = setTimeout(() => {
8 setDebouncedValue(value);
9 }, delay);
10 return () => {
11 clearTimeout(handler);
12 };
13 });
14
15 return debouncedValue;
16}

Now, we use this to apply a signal filter to the searchTerm state in order to only trigger a re-render of the soon-to-be-defined PatientListLoader when a half a second has elapsed.

src/components/PanelBody/PanelBody.tsx
1import React, { useState } from "react";
2import { InputGroup } from "@commure/components-foundation";
3import useDebounce from "../../utils/hooks/useDebounce";
4import PatientListLoader from "../PatientListLoader/PatientListLoader";
5
6const PanelBody: React.FC = () => {
7 const [searchTerm, setSearchTerm] = useState("");
8 const debouncedSearchTerm = useDebounce(searchTerm, 500);
9 return (
10 <>
11 <InputGroup
12 className="cm-search-input"
13 large
14 placeholder="Search patient ..."
15 leftIcon="search"
16 onChange={(event: React.ChangeEvent<HTMLInputElement>) =>
17 setSearchTerm(event.target.value)
18 }
19 />
20 <PatientListLoader searchPatientName={debouncedSearchTerm} />
21 </>
22 );
23};
24
25export default PanelBody;

Building the query

We will now be able to define PatientListLoader which does the heavy lifting. The following goes in src/components/PatientListLoader/PatientListLoader.tsx:

src/components/PatientListLoader/PatientListLoader.tsx
1import React from "react";
2import { FhirDataQuery } from "@commure/components-data";
3import { Bundle, Patient } from "@commure/fhir-types/r4/types";
4import { buildNameQuery } from "../../utils/helpers/queryBuilders";
5
6export interface Props {
7 searchPatientName: string;
8}
9
10const PatientListLoader: React.FC<Props> = ({ searchPatientName = "" }) => {
11 return (
12 <FhirDataQuery queryString={`Patient?${buildNameQuery(searchPatientName)}`}>
13 {({ loading, error, data }) => {
14 const patientData = data as Bundle;
15 let patients: Patient[] | undefined;
16 if (patientData && patientData.entry) {
17 patients = patientData.entry.map(value => value.resource as Patient);
18 }
19 return (
20 <pre>{JSON.stringify({ loading, error, patients }, null, 3)}</pre>
21 );
22 }}
23 </FhirDataQuery>
24 );
25};
26
27export default PatientListLoader;

We have defined a simple readout which suits us as developers before we go to build out the view entirely. Note that the fetching is performed using the FHIR GET /Patient endpoint, along with some query parameters which we have yet to define. FhirDataQuery conveniently provides some information about the state of the request, including whether it is currently loading and if there were any errors.

To define the query parameters, let’s create the file src/utils/helpers/queryBuilders.ts:

src/utils/helpers/queryBuilders.ts
1export const buildNameQuery = (searchName: string): string => {
2 const trimmedName = searchName.trim();
3 if (trimmedName === "") return "";
4 const query = trimmedName
5 .split(" ")
6 .filter(namePart => namePart !== "")
7 .join(",");
8 return `name=${query}`;
9};

This just does some cleanup on the raw user input before it is passed to the server.

Handling query states and search results

We will want to display the search results in a human-readable format. To do so, let's define a PatientMenuItem component that shows basic information for a given patient.

src/components/PatientMenuItem/PatientMenuItem.tsx
1import React, { MouseEventHandler } from "react";
2
3import { FhirDateTime, FhirHumanName } from "@commure/components-core";
4import { Patient } from "@commure/fhir-types/r4/types";
5
6export interface Props {
7 isSelected?: boolean;
8 patient: Patient;
9 onClick: MouseEventHandler<HTMLLIElement>;
10}
11
12const PatientMenuItem: React.FC<Props> = ({ isSelected, patient, onClick }) => {
13 let patientClassName = "patient-menu-item";
14 if (isSelected) {
15 patientClassName = `${patientClassName} patient-menu-item--selected`;
16 }
17 return (
18 <li className={patientClassName} onClick={onClick}>
19 {patient.name && !!patient.name.length ? (
20 <FhirHumanName
21 className="patient-menu-item__name"
22 nameAssemblyOrder="G"
23 hidePrefixes
24 value={patient.name[0]}
25 />
26 ) : (
27 "Unknown"
28 )}
29
30 <p className="patient-menu-item__dob">
31 DOB: <FhirDateTime value={patient.birthDate} inline />
32 </p>
33 </li>
34 );
35};
36
37export default PatientMenuItem;

Note that this component is largely similar to the old PatientList.

Lastly, let’s fill out the final parts of this component to show a proper loading state and error state, to display the search results using the PatientMenuItem we just built, and to allow for selecting a patient.

src/components/PatientListLoader/PatientListLoader.tsx
1import React, { useContext, useState } from "react";
2import { FhirDataQuery } from "@commure/components-data";
3import { NonIdealState, Spinner } from "@commure/components-foundation";
4import { Bundle, Patient } from "@commure/fhir-types/r4/types";
5import { buildNameQuery } from "../../utils/helpers/queryBuilders";
6import { DashboardContext } from "../Dashboard/Dashboard";
7import PatientMenuItem from "../PatientMenuItem/PatientMenuItem";
8
9export interface Props {
10 searchPatientName: string;
11}
12
13const PatientListLoader: React.FC<Props> = ({ searchPatientName = "" }) => {
14 const [patientIdSelected, setPatientIdSelected] = useState<string>("");
15 const dashboardContext = useContext(DashboardContext);
16
17 const selectPatient = (id: string) => {
18 dashboardContext!.selectMenuItem(id);
19 setPatientIdSelected(id);
20 };
21
22 return (
23 <FhirDataQuery queryString={`Patient?${buildNameQuery(searchPatientName)}`}>
24 {({ loading, error, data }) => {
25 const patientData = data as Bundle;
26 let patients: Patient[] | undefined;
27 if (patientData && patientData.entry) {
28 patients = patientData.entry.map(value => value.resource as Patient);
29 }
30 return (
31 <>
32 {patients && !!patients.length && (
33 <ul className="cm-panel-menu">
34 {patients.map(patient => (
35 <PatientMenuItem
36 key={patient.id}
37 patient={patient}
38 isSelected={patientIdSelected === patient.id}
39 onClick={_ => selectPatient(patient.id || "")}
40 />
41 ))}
42 </ul>
43 )}
44 {patients && !patients.length && (
45 <NonIdealState
46 icon="search"
47 title="Patient not found."
48 description={`
49 We couldn't find a patient with that identifier.
50 Make sure it's spelled correctly, or try using a different one.
51 `}
52 />
53 )}
54 {loading && <Spinner />}
55 {error && (
56 <p className="patient-fetch-error">
57 An error has occurred fetching the patients
58 </p>
59 )}
60 </>
61 );
62 }}
63 </FhirDataQuery>
64 );
65};
66
67export default PatientListLoader;

Applying styling

Now we have a nice search functionality built. The app currently looks like this:

left panel without styling

There are clearly some issues with the formatting and improvements that can be made for the overall look. The last step for the left panel is to apply some styling to the page to make it more aesthetically pleasing.

To fix the occlusion happening with the AppHeader bar, we add the following margins to the PanelBody:

src/components/PanelBody/panelBody.scss
1.cm-search-input {
2 margin: $spacing-medium-large;
3 margin-bottom: $spacing-large;
4 margin-top: 60px + $spacing-large;
5}

We would like to make the left panel collapsible. To do this, we apply the following styling to the Dashboard:

src/components/Dashboard/dashboard.scss
1$border-color: #d5d5db;
2$grey-text-color: #44444f;
3
4%toggle-button {
5 background-color: $white;
6 border-radius: $spacing-large;
7 width: $spacing-larger;
8 height: $spacing-larger;
9 border: 1px solid $border-color;
10 box-shadow: 0px 3px 16px rgba(0, 0, 0, 0.1);
11 svg {
12 fill: $grey-text-color;
13 }
14 &:hover {
15 background-color: $white;
16 box-shadow: 0px 3px 16px rgba(0, 0, 0, 0.1);
17 }
18}
19
20.empty-no-patient {
21 margin: 16px;
22}
23
24.cm-panel {
25 &-layout {
26 min-height: 100vh;
27 }
28 border-right: 1px solid $border-color;
29 position: relative;
30 min-width: 242px;
31 width: 242px;
32
33 &-toggle-button {
34 margin-left: -1 * $spacing-large;
35 margin-top: 115px;
36 z-index: 5;
37 align-self: flex-start;
38 & > button.bp3-button {
39 @extend %toggle-button;
40 }
41 }
42
43 &--closed {
44 min-width: $spacing-larger;
45 max-width: $spacing-larger;
46
47 .cm-panel-body {
48 overflow: hidden;
49 visibility: hidden;
50 }
51 }
52}
53
54.cm-panel-layout-main {
55 position: relative;
56 overflow-x: auto;
57 min-height: calc(100vh - 40px);
58}

Let's also make the components in the left panel look nicer. This SCSS can go in src/components/PatientMenuItem/patientMenuItem.scss:

src/components/PatientMenuItem/patientMenuItem.scss
1.patient-menu-item {
2 padding: $spacing-large $spacing-larger;
3 background-color: $white;
4 &--selected {
5 box-shadow: inset 3px 0 0 $blue2;
6 }
7 &:hover {
8 background-color: rgba($gray5, 0.15);
9 cursor: pointer;
10 }
11 &__name,
12 &__dob {
13 white-space: nowrap;
14 overflow: hidden;
15 text-overflow: ellipsis;
16 }
17 &__name {
18 text-transform: uppercase;
19 font-weight: $label-font-weight;
20 font-size: $spacing-large;
21 color: $dark-gray;
22 margin-bottom: $spacing-small;
23 }
24 &__dob {
25 color: $gray3;
26 margin: 0;
27 }
28}

And the following will go in src/components/PatientListLoader/patientListLoader.scss:

src/components/PatientListLoader/patientListLoader.scss
1$panel-max-height: calc(100vh - 63px);
2
3.cm-panel-menu {
4 list-style-type: none;
5 max-height: $panel-max-height;
6 overflow-y: auto;
7 padding: 0;
8}
9
10.bp3-non-ideal-state {
11 max-height: $panel-max-height;
12 padding: 0 $spacing-small;
13}
14
15.patient-fetch-error {
16 color: $vermilion;
17 margin: $spacing-large;
18 padding: $spacing-medium-large;
19 border: 1px solid $vermilion;
20}

Note that in order for these files to be used, we must add them to the all.scss file:

src/styles/all.scss
1@import "./_styles.scss"; // Global styles
2@import "./constants.scss"; // Presentation constants
3
4// Component styles:
5@import "../components/PatientList/patientList.scss";
6@import "../components/Dashboard/dashboard.scss";
7@import "../components/PanelBody/panelBody.scss";
8@import "../components/PatientListLoader/patientListLoader.scss";
9@import "../components/PatientMenuItem/patientMenuItem.scss";

After adding all the styling, our app should look like this in the browser:

left panel with styling

box icon

Conclusion

Now that we have our beautiful list and selectable functionality, let’s switch our attention to the main panel on the right. In the next chapters, we will replace the old PatientList with a view of the selected patient's health information.