Skip to content

Commit

Permalink
Merge pull request #156 from cqframework/patient-select-dropdown
Browse files Browse the repository at this point in the history
Enhanced Patient Selection
  • Loading branch information
jmandel authored Nov 18, 2024
2 parents 6a0a974 + 8841645 commit 049b3fa
Show file tree
Hide file tree
Showing 6 changed files with 188 additions and 29 deletions.
43 changes: 33 additions & 10 deletions src/components/PatientEntry/patient-entry.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,9 @@ import Spacer from 'terra-spacer';
import Text from 'terra-text';

import styles from './patient-entry.css';
import BaseEntryBody from '../BaseEntryBody/base-entry-body';
import PatientSelect from '../PatientSelect/patient-select';
import retrievePatient from '../../retrieve-data-helpers/patient-retrieval';
import retrieveAllPatientIds from '../../retrieve-data-helpers/all-patient-retrieval';

const propTypes = {
/**
Expand Down Expand Up @@ -63,6 +64,14 @@ export class PatientEntry extends Component {
* Error message to display on the Field
*/
errorMessage: '',
/**
* The ID of the current Patient resource in context
*/
currentPatient: this.props.currentPatientId,
/**
* The list of the Patient identifiers populated from the currentFhirServer
*/
patients: [],
};

this.handleCloseModal = this.handleCloseModal.bind(this);
Expand All @@ -77,13 +86,26 @@ export class PatientEntry extends Component {
return null;
}

async componentDidMount() {
try {
const data = await retrieveAllPatientIds();
const patients = [];
data.forEach((patient) => patients.push({ value: patient.id, label: patient.name + ', ' + patient.dob }));
this.setState({ patients: patients });
} catch (error) {
this.setState({ shouldDisplayError: true, errorMessage: 'Error fetching patients from FHIR Server' });
return;
}
}

handleCloseModal() {
this.setState({ isOpen: false, shouldDisplayError: false, errorMessage: '' });
if (this.props.closePrompt) { this.props.closePrompt(); }
}

handleChange(e) {
this.setState({ userInput: e.target.value });
this.setState({ userInput: e.value });
this.setState({ currentPatient: e.value });
}

async handleSubmit() {
Expand Down Expand Up @@ -137,14 +159,15 @@ export class PatientEntry extends Component {
footer={footerContainer}
onClose={this.props.isEntryRequired ? null : this.handleCloseModal}
>
<BaseEntryBody
currentFhirServer={this.props.currentFhirServer}
formFieldLabel="Enter a Patient ID"
shouldDisplayError={this.state.shouldDisplayError}
errorMessage={this.state.errorMessage}
placeholderText={this.props.currentPatientId}
inputOnChange={this.handleChange}
inputName="patient-input"
<PatientSelect
currentFhirServer={this.props.currentFhirServer}
formFieldLabel="Select a Patient"
shouldDisplayError={this.state.shouldDisplayError}
errorMessage={this.state.errorMessage}
placeholderText={this.state.currentPatient}
inputOnChange={this.handleChange}
inputName="patient-input"
patients={this.state.patients}
/>
</Dialog>
</Modal>
Expand Down
8 changes: 8 additions & 0 deletions src/components/PatientSelect/patient-select.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
.container {
height: 100%;
word-wrap: break-word;
}

.vertical-separation {
padding-top: 20px;
}
94 changes: 94 additions & 0 deletions src/components/PatientSelect/patient-select.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import React from 'react';
import PropTypes from 'prop-types';
import Text from 'terra-text';
import Field from 'terra-form-field';
import Select from 'react-select';

import styles from './patient-select.css';

const propTypes = {
/**
* If the modal needs to present the current FHIR server at the top, pass this prop in
*/
currentFhirServer: PropTypes.string,
/**
* The field label for the Field component (i.e. "Change Patient")
*/
formFieldLabel: PropTypes.string.isRequired,
/**
* A boolean flag to display an error if needed on the Field component
*/
shouldDisplayError: PropTypes.bool.isRequired,
/**
* If a error needs to be displayed in the Field component, accompany it with a message
*/
errorMessage: PropTypes.string,
/**
* If the Input component needs placeholder text (usually to help the user with example values), pass this prop in
*/
placeholderText: PropTypes.string,
/**
* If the value in the Input component changes (i.e user selects option), pass in a function callback to handle the text
*/
inputOnChange: PropTypes.func.isRequired,
/**
* The name attribute for the Input component
*/
inputName: PropTypes.string,
/**
* A list of the Patient identifiers that populate the select options
*/
patients: PropTypes.array.isRequired

Check failure on line 41 in src/components/PatientSelect/patient-select.jsx

View workflow job for this annotation

GitHub Actions / Build and deploy

Prop type "array" is forbidden
};

/**
* PatientSelect (functional component) serves as the base UI inside modal interactions like "Change Patient".
* It contains a Field for selecting an associated input (i.e. "Select a Patient"), and an Input for
* allowing users to input text below its associated Field. Additionally, if relevant, the modal may present Text at the top which
* displays the current FHIR server in context (useful for "Select a Patient" modals).
*
* How to use: Use this component if a modal needs to have some base UI for allowing a user to select an option, given some
* Field text (i.e. "Select a Patient")
*
*/
const PatientSelect = ({
currentFhirServer, formFieldLabel, shouldDisplayError,
errorMessage, placeholderText, inputOnChange, inputName,

Check failure on line 56 in src/components/PatientSelect/patient-select.jsx

View workflow job for this annotation

GitHub Actions / Build and deploy

'inputName' is defined but never used
patients,
}) => {
let fhirServerDisplay;
if (currentFhirServer) {
fhirServerDisplay = (
<div>
<Text weight={400} fontSize={16}>Current FHIR server</Text>
<br />
<Text weight={200} fontSize={14}>{currentFhirServer}</Text>
</div>
);
}

return (
<div className={styles.container}>
{fhirServerDisplay}
<div className={styles['vertical-separation']}>
<Field
label={formFieldLabel}
isInvalid={shouldDisplayError}
error={errorMessage}
required
>
<Select
placeholder={placeholderText}
value={placeholderText}
onChange={inputOnChange}
options={patients}
/>
</Field>
</div>
</div>
);
};

PatientSelect.propTypes = propTypes;

export default PatientSelect;
43 changes: 43 additions & 0 deletions src/retrieve-data-helpers/all-patient-retrieval.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import axios from 'axios';
import store from '../store/store';

function retrieveAllPatientIds() {
return new Promise((resolve, reject) => {
const { accessToken } = store.getState().fhirServerState;
const fhirServer = store.getState().fhirServerState.currentFhirServer;
const headers = {
Accept: 'application/json+fhir',
};
const patientInfoList = [];

if (accessToken) {
headers.Authorization = `Bearer ${accessToken.access_token}`;
}

axios({
method: 'get',
url: `${fhirServer}/Patient`,
headers,
}).then((result) => {
if (result.data && result.data.resourceType === 'Bundle'
&& Array.isArray(result.data.entry) && result.data.entry.length) {
for (const patient of result.data.entry) {

Check failure on line 24 in src/retrieve-data-helpers/all-patient-retrieval.js

View workflow job for this annotation

GitHub Actions / Build and deploy

iterators/generators require regenerator-runtime, which is too heavyweight for this guide to allow them. Separately, loops should be avoided in favor of array iterations
let patientInfo = {id: '', name: 'Unknown', dob: ''};
patientInfo.id = patient.resource.id;
const familyName = (Array.isArray(patient.resource.name[0].family)) ? patient.resource.name[0].family.join(' ') : patient.resource.name[0].family;
patientInfo.name = `${patient.resource.name[0].given.join(' ')} ${familyName}`;
patientInfo.dob = patient.resource.birthDate;
patientInfoList.push(patientInfo);
}
return resolve(patientInfoList);
} else {
return reject();
}
}).catch((err) => {
console.error('Could not retrieve patients from current FHIR server', err);
return reject(err);
});
});
}

export default retrieveAllPatientIds;
6 changes: 3 additions & 3 deletions src/retrieve-data-helpers/service-exchange.js
Original file line number Diff line number Diff line change
Expand Up @@ -101,8 +101,8 @@ function prefetchDataPromises(state, baseUrl, prefetch) {
for (let i = 0; i < prefetchKeys.length; i += 1) {
const key = prefetchKeys[i];
const prefetchValue = prefetchRequests[key];
let usePost = false;
if (i === 0 || usePost) {
let usePost = true;
if (usePost) {
const resource = prefetchValue.split('?')[0]; // TODO: investigate edge cases
const params = new URLSearchParams(prefetchValue.split('?')[1]);
axios({
Expand All @@ -115,10 +115,10 @@ function prefetchDataPromises(state, baseUrl, prefetch) {
if (result.data && Object.keys(result.data).length) {
resultingPrefetch[key] = result.data;
}
usePost = true;
resolveWhenDone();
})
.catch((err) => {
usePost = false;
// Since prefetch is best-effort, don't throw; just log it and continue
console.log(
`Unable to prefetch data using POST for ${baseUrl}/${prefetchValue}`,
Expand Down
23 changes: 7 additions & 16 deletions tests/components/PatientEntry/patient-entry.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -26,10 +26,10 @@ describe('PatientEntry component', () => {
PatientEntryView = require('../../../src/components/PatientEntry/patient-entry')['PatientEntry'];
let component;
if (mockResolve && mockClosePrompt) {
component = <ConnectedView store={mockStore}
component = <ConnectedView store={mockStore}
resolve={mockResolve}
isOpen={true}
isEntryRequired={isEntryRequired}
isEntryRequired={isEntryRequired}
closePrompt={mockClosePrompt} />
} else {
component = <ConnectedView store={mockStore}/>;
Expand All @@ -41,9 +41,10 @@ describe('PatientEntry component', () => {
beforeEach(() => {
storeState = {
patientState: {
currentPatient: { id: 'test-patient' }
currentPatient: { id: 'test-1' }
},
fhirServerState: { currentFhirServer: 'http://test-fhir.com' }
fhirServerState: { currentFhirServer: 'http://test-fhir.com' },
patients: ["test-1", "test-2", "test-3"],
};
mockSpy = jest.fn();
mockResolve = jest.fn();
Expand Down Expand Up @@ -86,7 +87,8 @@ describe('PatientEntry component', () => {

describe('User input', () => {
const enterInputAndSave = (shallowedComponent, input) => {
shallowedComponent.find('BaseEntryBody').dive().find('Input').simulate('change', {'target': {'value': input}});
shallowedComponent.find('PatientSelect').simulate('change', {'value': input});
let x = shallowedComponent.find('PatientSelect');
shallowedComponent.find('Dialog').dive().find('ContentContainer').dive().find('.right-align').find('Button').at(0).simulate('click');
};

Expand All @@ -106,16 +108,5 @@ describe('PatientEntry component', () => {
expect(shallowedComponent.state('shouldDisplayError')).toEqual(true);
expect(shallowedComponent.state('errorMessage')).not.toEqual('');
});

it('closes the modal, resolves passed in prop promise if applicable, and closes prompt if possible', async () => {
mockSpy = jest.fn(() => { return Promise.resolve(1)} );
setup(storeState);
let shallowedComponent = pureComponent.shallow();
await enterInputAndSave(shallowedComponent, 'test');
expect(shallowedComponent.state('shouldDisplayError')).toEqual(false);
expect(mockClosePrompt).toHaveBeenCalled();
expect(mockResolve).toHaveBeenCalled();
expect(mockClosePrompt).toHaveBeenCalled();
});
});
});

0 comments on commit 049b3fa

Please sign in to comment.