From d8e2c8149ccfd07a3b1f9142b2ed910375f3a79a Mon Sep 17 00:00:00 2001 From: Dennis Kigen Date: Mon, 30 Sep 2024 14:20:41 +0300 Subject: [PATCH] (feat) Add an empty state view to BaseVisitType 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. --- .../visit-form/base-visit-type.component.tsx | 106 ++++++++++-------- ...ype-overview.scss => base-visit-type.scss} | 42 ++++++- .../visit/visit-form/base-visit-type.test.tsx | 67 ++++++++++- 3 files changed, 164 insertions(+), 51 deletions(-) rename packages/esm-patient-chart-app/src/visit/visit-form/{visit-type-overview.scss => base-visit-type.scss} (62%) diff --git a/packages/esm-patient-chart-app/src/visit/visit-form/base-visit-type.component.tsx b/packages/esm-patient-chart-app/src/visit/visit-form/base-visit-type.component.tsx index 94ea90588c..d7e183c291 100644 --- a/packages/esm-patient-chart-app/src/visit/visit-form/base-visit-type.component.tsx +++ b/packages/esm-patient-chart-app/src/visit/visit-form/base-visit-type.component.tsx @@ -1,14 +1,12 @@ -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; @@ -16,21 +14,25 @@ interface BaseVisitTypeProps { const BaseVisitType: React.FC = ({ visitTypes }) => { const { t } = useTranslation(); + const { control } = useFormContext(); const isTablet = useLayoutType() === 'tablet'; const [searchTerm, setSearchTerm] = useState(''); - const { control } = useFormContext(); + 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) => { + setSearchTerm(event.target.value); + }, []); return (
@@ -39,49 +41,63 @@ const BaseVisitType: React.FC = ({ visitTypes }) => { {isTablet ? ( handleSearch(event.target.value)} - placeholder={t('searchForAVisitType', 'Search for a visit type')} labelText="" + onChange={handleSearchTermChange} + placeholder={t('searchForAVisitType', 'Search for a visit type')} /> ) : ( handleSearch(event.target.value)} - placeholder={t('searchForAVisitType', 'Search for a visit type')} labelText="" + onChange={handleSearchTermChange} + placeholder={t('searchForAVisitType', 'Search for a visit type')} /> )} - ( - - {results.map(({ uuid, display, name }) => ( - - ))} - - )} - /> -
- goTo(page)} + {hasNoMatchingSearchResults ? ( +
+ +
+

{t('noVisitTypesToDisplay', 'No visit types to display')}

+

{t('checkFilters', 'Check the filters above')}

+
+
+
+ ) : ( + ( + + {results.map(({ uuid, display, name }) => ( + + ))} + + )} /> -
+ )} + + {!hasNoMatchingSearchResults && ( +
+ goTo(page)} + pageNumber={currentPage} + pageSize={5} + totalItems={visitTypes?.length} + /> +
+ )} ) : ( - + )}
); diff --git a/packages/esm-patient-chart-app/src/visit/visit-form/visit-type-overview.scss b/packages/esm-patient-chart-app/src/visit/visit-form/base-visit-type.scss similarity index 62% rename from packages/esm-patient-chart-app/src/visit/visit-form/visit-type-overview.scss rename to packages/esm-patient-chart-app/src/visit/visit-form/base-visit-type.scss index 3504a58016..b9ce6ad351 100644 --- a/packages/esm-patient-chart-app/src/visit/visit-form/visit-type-overview.scss +++ b/packages/esm-patient-chart-app/src/visit/visit-form/base-visit-type.scss @@ -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; } @@ -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; } @@ -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; +} diff --git a/packages/esm-patient-chart-app/src/visit/visit-form/base-visit-type.test.tsx b/packages/esm-patient-chart-app/src/visit/visit-form/base-visit-type.test.tsx index a02b502530..e7a6973f1c 100644 --- a/packages/esm-patient-chart-app/src/visit/visit-form/base-visit-type.test.tsx +++ b/packages/esm-patient-chart-app/src/visit/visit-form/base-visit-type.test.tsx @@ -67,7 +67,49 @@ describe('VisitTypeOverview', () => { render(); }; - 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(); @@ -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(); + }); });