Skip to content

Commit

Permalink
(feat) Add an empty state view to BaseVisitType
Browse files Browse the repository at this point in the history
This PR tweaks the `BaseVisitType` component as follows:

- Adds an empty state to the search input to show when there are no matching results.
- Conditionally renders the Pagination container based on whether there are any matching results.
- Refactors the debounce logic in the search input to use the `useDebounce` hook.
- Tweaks the `StructuredListSkeleton` component to remove a double bottom border.
- Adds some missing test coverage to the `BaseVisitType` component.
- Moves styling to a colocated SCSS file.
  • Loading branch information
denniskigen committed Sep 30, 2024
1 parent 8c8e20d commit d8e2c81
Show file tree
Hide file tree
Showing 3 changed files with 164 additions and 51 deletions.
Original file line number Diff line number Diff line change
@@ -1,36 +1,38 @@
import React, { useState, useMemo } from 'react';
import React, { useState, useMemo, useCallback } from 'react';
import classNames from 'classnames';
import { useTranslation } from 'react-i18next';
import { useFormContext, Controller } from 'react-hook-form';
import classNames from 'classnames';
import debounce from 'lodash-es/debounce';
import isEmpty from 'lodash-es/isEmpty';
import { Layer, RadioButtonGroup, RadioButton, Search, StructuredListSkeleton } from '@carbon/react';
import { Layer, RadioButton, RadioButtonGroup, Search, StructuredListSkeleton, Tile } from '@carbon/react';
import { PatientChartPagination } from '@openmrs/esm-patient-common-lib';
import { useLayoutType, usePagination, type VisitType } from '@openmrs/esm-framework';
import { useDebounce, useLayoutType, usePagination, type VisitType } from '@openmrs/esm-framework';
import { type VisitFormData } from './visit-form.resource';
import styles from './visit-type-overview.scss';
import styles from './base-visit-type.scss';

interface BaseVisitTypeProps {
visitTypes: Array<VisitType>;
}

const BaseVisitType: React.FC<BaseVisitTypeProps> = ({ visitTypes }) => {
const { t } = useTranslation();
const { control } = useFormContext<VisitFormData>();
const isTablet = useLayoutType() === 'tablet';
const [searchTerm, setSearchTerm] = useState<string>('');
const { control } = useFormContext<VisitFormData>();
const debouncedSearchTerm = useDebounce(searchTerm, 300);

const searchResults = useMemo(() => {
if (!isEmpty(searchTerm)) {
return visitTypes.filter((visitType) => visitType.display.toLowerCase().search(searchTerm.toLowerCase()) !== -1);
} else {
if (!debouncedSearchTerm.trim()) {
return visitTypes;
}
}, [searchTerm, visitTypes]);

const handleSearch = useMemo(() => debounce((searchTerm) => setSearchTerm(searchTerm), 300), []);
const lowercasedTerm = debouncedSearchTerm.toLowerCase();
return visitTypes.filter((visitType) => visitType.display.toLowerCase().includes(lowercasedTerm));
}, [debouncedSearchTerm, visitTypes]);

const { results, currentPage, goTo } = usePagination(searchResults, 5);
const hasNoMatchingSearchResults = debouncedSearchTerm.trim() !== '' && searchResults.length === 0;

const handleSearchTermChange = useCallback((event: React.ChangeEvent<HTMLInputElement>) => {
setSearchTerm(event.target.value);
}, []);

return (
<div className={classNames(styles.visitTypeOverviewWrapper, isTablet ? styles.tablet : styles.desktop)}>
Expand All @@ -39,49 +41,63 @@ const BaseVisitType: React.FC<BaseVisitTypeProps> = ({ visitTypes }) => {
{isTablet ? (
<Layer>
<Search
onChange={(event) => handleSearch(event.target.value)}
placeholder={t('searchForAVisitType', 'Search for a visit type')}
labelText=""
onChange={handleSearchTermChange}
placeholder={t('searchForAVisitType', 'Search for a visit type')}
/>
</Layer>
) : (
<Search
onChange={(event) => handleSearch(event.target.value)}
placeholder={t('searchForAVisitType', 'Search for a visit type')}
labelText=""
onChange={handleSearchTermChange}
placeholder={t('searchForAVisitType', 'Search for a visit type')}
/>
)}

<Controller
name="visitType"
control={control}
defaultValue={results?.length === 1 ? results[0].uuid : ''}
render={({ field: { onChange, value } }) => (
<RadioButtonGroup
className={styles.radioButtonGroup}
orientation="vertical"
onChange={onChange}
name="radio-button-group"
valueSelected={value}
>
{results.map(({ uuid, display, name }) => (
<RadioButton key={uuid} className={styles.radioButton} id={name} labelText={display} value={uuid} />
))}
</RadioButtonGroup>
)}
/>
<div className={styles.paginationContainer}>
<PatientChartPagination
pageNumber={currentPage}
totalItems={visitTypes?.length}
currentItems={results.length}
pageSize={5}
onPageNumberChange={({ page }) => goTo(page)}
{hasNoMatchingSearchResults ? (
<div className={styles.tileContainer}>
<Tile className={styles.tile}>
<div className={styles.tileContent}>
<p className={styles.content}>{t('noVisitTypesToDisplay', 'No visit types to display')}</p>
<p className={styles.helper}>{t('checkFilters', 'Check the filters above')}</p>
</div>
</Tile>
</div>
) : (
<Controller
name="visitType"
control={control}
defaultValue={results?.length === 1 ? results[0].uuid : ''}
render={({ field: { onChange, value } }) => (
<RadioButtonGroup
className={styles.radioButtonGroup}
name="visit-types"
onChange={onChange}
orientation="vertical"
valueSelected={value}
>
{results.map(({ uuid, display, name }) => (
<RadioButton key={uuid} className={styles.radioButton} id={name} labelText={display} value={uuid} />
))}
</RadioButtonGroup>
)}
/>
</div>
)}

{!hasNoMatchingSearchResults && (
<div className={styles.paginationContainer}>
<PatientChartPagination
currentItems={results.length}
onPageNumberChange={({ page }) => goTo(page)}
pageNumber={currentPage}
pageSize={5}
totalItems={visitTypes?.length}
/>
</div>
)}
</>
) : (
<StructuredListSkeleton />
<StructuredListSkeleton className={styles.skeleton} />
)}
</div>
);
Expand Down
Original file line number Diff line number Diff line change
@@ -1,12 +1,8 @@
@use '@carbon/colors';
@use '@carbon/layout';
@use '@carbon/type';
@use '@openmrs/esm-styleguide/src/vars' as *;

.visitTypeOverviewWrapper {
margin: layout.$spacing-05 0;
border: 0.0625rem solid $grey-2;
}

.tablet {
background-color: $ui-02;
}
Expand All @@ -19,6 +15,15 @@
}
}

.visitTypeOverviewWrapper {
margin: layout.$spacing-05 0;
border: 0.0625rem solid $grey-2;

&:has(.skeleton) {
border-bottom: none !important;
}
}

.visitTypeOverviewWrapper div:nth-child(3) > div:nth-child(2) {
position: relative;
}
Expand All @@ -41,3 +46,30 @@
padding: layout.$spacing-02 layout.$spacing-05;
margin: layout.$spacing-03 0;
}

.tileContainer {
background-color: $ui-02;
padding: layout.$spacing-09 0;
}

.tile {
margin: auto;
width: fit-content;
}

.tileContent {
display: flex;
flex-direction: column;
align-items: center;
}

.content {
@include type.type-style('heading-compact-02');
color: $text-02;
margin-bottom: layout.$spacing-03;
}

.helper {
@include type.type-style('body-compact-01');
color: $text-02;
}
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,49 @@ describe('VisitTypeOverview', () => {
render(<BaseVisitType visitTypes={mockVisitTypes} />);
};

it('should be able to search for a visit type', async () => {
it('renders a list of the available visit types', () => {
renderVisitTypeOverview();

mockVisitTypes.forEach((visitType) => {
const radio = screen.getByRole('radio', { name: new RegExp(visitType.display, 'i') });
expect(radio).toBeInTheDocument();
expect(radio).not.toBeChecked();
});
});

it('allows keyboard navigation through visit types', async () => {
const user = userEvent.setup();

renderVisitTypeOverview();

const firstVisitType = screen.getByRole('radio', { name: new RegExp(mockVisitTypes[0].display, 'i') });
firstVisitType.focus();

await user.keyboard('{ArrowDown}');
expect(screen.getByRole('radio', { name: new RegExp(mockVisitTypes[1].display, 'i') })).toHaveFocus();

await user.keyboard('{ArrowUp}');
expect(firstVisitType).toHaveFocus();
});

it('clears the search input when the clear button is clicked', async () => {
const user = userEvent.setup();

renderVisitTypeOverview();

const searchInput = screen.getByRole('searchbox');
await user.type(searchInput, 'HIV');

const clearButton = screen.getByRole('button', { name: /clear/i });
await user.click(clearButton);

expect(searchInput).toHaveValue('');
mockVisitTypes.forEach((visitType) => {
expect(screen.getByRole('radio', { name: new RegExp(visitType.display, 'i') })).toBeInTheDocument();
});
});

it('searches for a matching visit type when the user types in the search input', async () => {
const user = userEvent.setup();

renderVisitTypeOverview();
Expand All @@ -84,4 +126,27 @@ describe('VisitTypeOverview', () => {
expect(outpatientVisit).toBeEmptyDOMElement();
expect(hivVisit).toBeInTheDocument();
});

it('renders an empty state when a search yields no matching results', async () => {
const user = userEvent.setup();

renderVisitTypeOverview();

const searchInput = screen.getByRole('searchbox');
await user.type(searchInput, 'NonexistentVisitType');

expect(screen.getByText(/no visit types to display/i)).toBeInTheDocument();
expect(screen.getByText(/check the filters above/i)).toBeInTheDocument();
});

it('selects a visit type when clicked', async () => {
const user = userEvent.setup();

renderVisitTypeOverview();

const hivVisit = screen.getByRole('radio', { name: /HIV Return Visit/i });
await user.click(hivVisit);

expect(hivVisit).toBeChecked();
});
});

0 comments on commit d8e2c81

Please sign in to comment.