From c6e1c4c7ae4404c03e773abfff38444f03ee2b4f Mon Sep 17 00:00:00 2001 From: Matthew Warman Date: Mon, 23 Sep 2024 10:37:48 -0400 Subject: [PATCH 1/5] initial user search toolbar --- src/common/components/Header/Header.tsx | 20 +++++++++++- .../Users/components/UserList/UserGrid.tsx | 18 ++++++++--- .../Users/components/UserList/UserList.tsx | 16 +++++++--- .../components/UserList/UserListPage.scss | 11 ++++++- .../components/UserList/UserListPage.tsx | 31 ++++++++++++++++--- src/pages/Users/utils/users.ts | 24 ++++++++++++++ 6 files changed, 105 insertions(+), 15 deletions(-) create mode 100644 src/pages/Users/utils/users.ts diff --git a/src/common/components/Header/Header.tsx b/src/common/components/Header/Header.tsx index 88eb934..e8e26f2 100644 --- a/src/common/components/Header/Header.tsx +++ b/src/common/components/Header/Header.tsx @@ -25,6 +25,11 @@ import { useAuth } from 'common/hooks/useAuth'; * @param {string} [defaultHref] - Optional. The default back navigation * href if there is no history in the route stack. * @param {string} [title] - Optional. The header title. + * @param [toolbars] - Optional. Array of IonToolbar properties objects + * each describing an additional toolbar to appear in the header. + * @see {@link PropsWithTestId} + * @see {@link IonBackButton} + * @see {@link IonToolbar} */ interface HeaderProps extends PropsWithTestId, @@ -32,6 +37,7 @@ interface HeaderProps backButton?: boolean; buttons?: ReactNode; title?: string; + toolbars?: ComponentPropsWithoutRef[]; } const Header = ({ @@ -40,6 +46,7 @@ const Header = ({ defaultHref, testid = 'header-app', title, + toolbars, }: HeaderProps): JSX.Element => { const { isAuthenticated } = useAuth(); const { isActive: isActiveProgressBar, progressBar } = useProgress(); @@ -77,8 +84,19 @@ const Header = ({ {buttons} - {isActiveProgressBar && } + {isActiveProgressBar && !toolbars && } + + {toolbars?.map((toolbarProps, index) => { + const isLastToolbar = toolbars.length - 1 === index; + const { children, ...props } = toolbarProps; + return ( + + {children} + {isActiveProgressBar && isLastToolbar && } + + ); + })} ); }; diff --git a/src/pages/Users/components/UserList/UserGrid.tsx b/src/pages/Users/components/UserList/UserGrid.tsx index 624a44a..0e5bea8 100644 --- a/src/pages/Users/components/UserList/UserGrid.tsx +++ b/src/pages/Users/components/UserList/UserGrid.tsx @@ -5,6 +5,7 @@ import isEmpty from 'lodash/isEmpty'; import './UserGrid.scss'; import { BaseComponentProps } from 'common/components/types'; import { useGetUsers } from 'pages/Users/api/useGetUsers'; +import { filterUsers } from 'pages/Users/utils/users'; import UserCard from './UserCard'; import LoaderSpinner from 'common/components/Loader/LoaderSpinner'; import CardRow from 'common/components/Card/CardRow'; @@ -13,8 +14,13 @@ import EmptyCard from 'common/components/Card/EmptyCard'; /** * Properties for the `UserGrid` component. + * @param {string} [filterBy] - Optional. Filter the collection of users by a + * partial `name` match. + * @see {@link BaseComponentProps} */ -interface UserGridProps extends BaseComponentProps {} +interface UserGridProps extends BaseComponentProps { + filterBy?: string; +} /** * The `UserGrid` component renders a grid of `UserCard`s. Uses the `IonGrid` @@ -23,7 +29,7 @@ interface UserGridProps extends BaseComponentProps {} * @returns JSX * @see {@link IonGrid} */ -const UserGrid = ({ className, testid = 'grid-user' }: UserGridProps): JSX.Element => { +const UserGrid = ({ className, filterBy, testid = 'grid-user' }: UserGridProps): JSX.Element => { const { data: users, isError, isLoading } = useGetUsers(); const baseProps = { @@ -51,8 +57,10 @@ const UserGrid = ({ className, testid = 'grid-user' }: UserGridProps): JSX.Eleme ); } + const filteredUsers = filterUsers(users, filterBy); + // Empty state - if (isEmpty(users)) { + if (isEmpty(filteredUsers)) { return (
@@ -66,8 +74,8 @@ const UserGrid = ({ className, testid = 'grid-user' }: UserGridProps): JSX.Eleme return ( - {users && - users.map((user) => ( + {filteredUsers && + filteredUsers.map((user) => ( diff --git a/src/pages/Users/components/UserList/UserList.tsx b/src/pages/Users/components/UserList/UserList.tsx index 30624cd..5cf99e5 100644 --- a/src/pages/Users/components/UserList/UserList.tsx +++ b/src/pages/Users/components/UserList/UserList.tsx @@ -5,6 +5,7 @@ import isEmpty from 'lodash/isEmpty'; import './UserList.scss'; import { BaseComponentProps } from 'common/components/types'; import { useGetUsers } from 'pages/Users/api/useGetUsers'; +import { filterUsers } from 'pages/Users/utils/users'; import UserListItem from './UserListItem'; import LoaderSpinner from 'common/components/Loader/LoaderSpinner'; import CardRow from 'common/components/Card/CardRow'; @@ -13,10 +14,14 @@ import EmptyCard from 'common/components/Card/EmptyCard'; /** * Properties for the `UserList` component. + * @param {string} [filterBy] - Optional. Filter the collection of users by a + * partial `name` match. * @param {string} [header] - Optional. The list header title. Default: `Users`. * @param {boolean} [showHeader] - Optional. Indicates if the header is shown. Default: `false`. + * @see {@link BaseComponentProps} */ interface UserListProps extends BaseComponentProps { + filterBy?: string; header?: string; showHeader?: boolean; } @@ -29,6 +34,7 @@ interface UserListProps extends BaseComponentProps { */ const UserList = ({ className, + filterBy, header = 'Users', showHeader = false, testid = 'list-user', @@ -60,8 +66,10 @@ const UserList = ({ ); } + const filteredUsers = filterUsers(users, filterBy); + // Empty state - if (isEmpty(users)) { + if (isEmpty(filteredUsers)) { return (
@@ -76,12 +84,12 @@ const UserList = ({ {showHeader && {header}} - {users && - users.map((user, index) => ( + {filteredUsers && + filteredUsers.map((user, index) => ( ))} diff --git a/src/pages/Users/components/UserList/UserListPage.scss b/src/pages/Users/components/UserList/UserListPage.scss index c5f85b2..59c9d51 100644 --- a/src/pages/Users/components/UserList/UserListPage.scss +++ b/src/pages/Users/components/UserList/UserListPage.scss @@ -1,4 +1,13 @@ -.page-user-list { +.ls-page-user-list { + .ls-toolbar-search { + --padding-start: 9px; + --padding-end: 9px; + + ion-searchbar { + --border-radius: 0.25rem; + } + } + .page-header { margin-top: 1rem; } diff --git a/src/pages/Users/components/UserList/UserListPage.tsx b/src/pages/Users/components/UserList/UserListPage.tsx index 598299d..9f55bff 100644 --- a/src/pages/Users/components/UserList/UserListPage.tsx +++ b/src/pages/Users/components/UserList/UserListPage.tsx @@ -5,7 +5,9 @@ import { IonPage, IonRefresher, IonRefresherContent, + IonSearchbar, RefresherEventDetail, + SearchbarCustomEvent, } from '@ionic/react'; import { useState } from 'react'; import { useQueryClient } from '@tanstack/react-query'; @@ -29,17 +31,38 @@ import UserAddModal from '../UserAdd/UserAddModal'; */ export const UserListPage = ({ testid = 'page-user-list' }: PropsWithTestId): JSX.Element => { const [isOpenModal, setIsOpenModal] = useState(false); + const [search, setSearch] = useState(''); const queryClient = useQueryClient(); + /** + * Handle pull to refresh events. + * @param {CustomEvent} event - The refresh event. + */ const handleRefresh = async (event: CustomEvent) => { await queryClient.refetchQueries({ queryKey: [QueryKey.Users], exact: true }); event.detail.complete(); }; + /** + * Handle changes to the search toolbar value as a user types. + * @param {SearchbarCustomEvent} event - The event. + */ + const onInputSearch = (event: SearchbarCustomEvent) => { + setSearch(event.target.value ?? ''); + }; + return ( - + -
+
, + className: 'ls-toolbar-search', + }, + ]} + /> @@ -60,8 +83,8 @@ export const UserListPage = ({ testid = 'page-user-list' }: PropsWithTestId): JS - - + + setIsOpenModal(true)} /> diff --git a/src/pages/Users/utils/users.ts b/src/pages/Users/utils/users.ts new file mode 100644 index 0000000..b5c6e96 --- /dev/null +++ b/src/pages/Users/utils/users.ts @@ -0,0 +1,24 @@ +import filter from 'lodash/filter'; + +import { User } from 'common/models/user'; + +/** + * Filter a collection of `User` objects by performing a case-insensitive partial + * match of the `name` and `email` attributes. + * @param {User[]} [users] - Optional. A collection of `User` objects. + * @param {string} [criteria] - Optional. The criteria by which to filter the + * collection. + * @returns {User[]} - The filtered collection of `User` objects. + */ +export const filterUsers = (users?: User[], criteria?: string): User[] => { + return filter(users, (user) => { + if (criteria) { + const filterCriteria = criteria.trim().toLowerCase(); + let match: boolean = user.name.toLowerCase().includes(filterCriteria); + match ||= user.email.toLowerCase().includes(filterCriteria); + return match; + } else { + return true; + } + }); +}; From a135e7f4073bf29e285c1d23402e3e5f406e5d03 Mon Sep 17 00:00:00 2001 From: Matthew Warman Date: Tue, 24 Sep 2024 06:47:30 -0400 Subject: [PATCH 2/5] Searchbar component --- .../components/Searchbar/Searchbar.scss | 8 +++++ src/common/components/Searchbar/Searchbar.tsx | 35 +++++++++++++++++++ .../components/UserList/UserListPage.scss | 9 ----- .../components/UserList/UserListPage.tsx | 6 ++-- 4 files changed, 46 insertions(+), 12 deletions(-) create mode 100644 src/common/components/Searchbar/Searchbar.scss create mode 100644 src/common/components/Searchbar/Searchbar.tsx diff --git a/src/common/components/Searchbar/Searchbar.scss b/src/common/components/Searchbar/Searchbar.scss new file mode 100644 index 0000000..2756ad3 --- /dev/null +++ b/src/common/components/Searchbar/Searchbar.scss @@ -0,0 +1,8 @@ +ion-searchbar.ls-searchbar { + --border-radius: 0.25rem; +} + +ion-toolbar.ls-toolbar-searchbar { + --padding-start: 9px; + --padding-end: 9px; +} diff --git a/src/common/components/Searchbar/Searchbar.tsx b/src/common/components/Searchbar/Searchbar.tsx new file mode 100644 index 0000000..a385965 --- /dev/null +++ b/src/common/components/Searchbar/Searchbar.tsx @@ -0,0 +1,35 @@ +import { ComponentPropsWithoutRef } from 'react'; +import { IonSearchbar } from '@ionic/react'; +import classNames from 'classnames'; + +import './Searchbar.scss'; +import { PropsWithTestId } from '../types'; + +/** + * Properties for the `Searchbar` component. + * @see {@link PropsWithTestId} + * @see {@link IonSearchbar} + */ +interface SearchbarProps extends PropsWithTestId, ComponentPropsWithoutRef {} + +/** + * The `Searchbar` component renders a standardized `IonSearchbar`. + * @param {SearchbarProps} props - Component properties. + * @returns {JSX.Element} JSX + * @see {@link IonSearchbar} + */ +const Searchbar = ({ + className, + testid = 'ls-searchbar', + ...props +}: SearchbarProps): JSX.Element => { + return ( + + ); +}; + +export default Searchbar; diff --git a/src/pages/Users/components/UserList/UserListPage.scss b/src/pages/Users/components/UserList/UserListPage.scss index 59c9d51..c1246b0 100644 --- a/src/pages/Users/components/UserList/UserListPage.scss +++ b/src/pages/Users/components/UserList/UserListPage.scss @@ -1,13 +1,4 @@ .ls-page-user-list { - .ls-toolbar-search { - --padding-start: 9px; - --padding-end: 9px; - - ion-searchbar { - --border-radius: 0.25rem; - } - } - .page-header { margin-top: 1rem; } diff --git a/src/pages/Users/components/UserList/UserListPage.tsx b/src/pages/Users/components/UserList/UserListPage.tsx index 9f55bff..a4a3015 100644 --- a/src/pages/Users/components/UserList/UserListPage.tsx +++ b/src/pages/Users/components/UserList/UserListPage.tsx @@ -5,7 +5,6 @@ import { IonPage, IonRefresher, IonRefresherContent, - IonSearchbar, RefresherEventDetail, SearchbarCustomEvent, } from '@ionic/react'; @@ -16,6 +15,7 @@ import './UserListPage.scss'; import { PropsWithTestId } from 'common/components/types'; import { QueryKey } from 'common/utils/constants'; import Header from 'common/components/Header/Header'; +import Searchbar from 'common/components/Searchbar/Searchbar'; import Container from 'common/components/Content/Container'; import PageHeader from 'common/components/Content/PageHeader'; import UserList from './UserList'; @@ -58,8 +58,8 @@ export const UserListPage = ({ testid = 'page-user-list' }: PropsWithTestId): JS title="Users" toolbars={[ { - children: , - className: 'ls-toolbar-search', + children: , + className: 'ls-toolbar-searchbar', }, ]} /> From bb85304e2ee94e8ec6a161c618c3ad0e33649556 Mon Sep 17 00:00:00 2001 From: Matthew Warman Date: Tue, 24 Sep 2024 08:09:00 -0400 Subject: [PATCH 3/5] tests --- .../Searchbar/__tests__/Searchbar.test.tsx | 17 ++++++++++++++++ .../UserList/__tests__/UserListPage.test.tsx | 20 ++++++++++++++++++- 2 files changed, 36 insertions(+), 1 deletion(-) create mode 100644 src/common/components/Searchbar/__tests__/Searchbar.test.tsx diff --git a/src/common/components/Searchbar/__tests__/Searchbar.test.tsx b/src/common/components/Searchbar/__tests__/Searchbar.test.tsx new file mode 100644 index 0000000..dac20c1 --- /dev/null +++ b/src/common/components/Searchbar/__tests__/Searchbar.test.tsx @@ -0,0 +1,17 @@ +import { describe, expect, it } from 'vitest'; + +import { render, screen } from 'test/test-utils'; + +import Searchbar from '../Searchbar'; + +describe('Searchbar', () => { + it('should render successfully', async () => { + // ARRANGE + render(); + await screen.findByTestId('ls-searchbar'); + + // ASSERT + expect(screen.getByTestId('ls-searchbar')).toBeDefined(); + expect(screen.getByTestId('ls-searchbar')).toHaveClass('ls-searchbar'); + }); +}); diff --git a/src/pages/Users/components/UserList/__tests__/UserListPage.test.tsx b/src/pages/Users/components/UserList/__tests__/UserListPage.test.tsx index bd4691e..132363b 100644 --- a/src/pages/Users/components/UserList/__tests__/UserListPage.test.tsx +++ b/src/pages/Users/components/UserList/__tests__/UserListPage.test.tsx @@ -1,6 +1,8 @@ import { describe, expect, it } from 'vitest'; +import userEvent from '@testing-library/user-event'; + +import { render, screen, waitFor } from 'test/test-utils'; -import { render, screen } from 'test/test-utils'; import UserListPage from '../UserListPage'; describe('UserListPage', () => { @@ -12,4 +14,20 @@ describe('UserListPage', () => { // ASSERT expect(screen.getByTestId('page-user-list')).toBeDefined(); }); + + it('should filter users', async () => { + // ARRANGE + const user = userEvent.setup(); + render(); + await screen.findByTestId('list-item-user-1'); + + // ACT + await user.click(screen.getByPlaceholderText(/Search/i)); + await user.type(screen.getByPlaceholderText(/Search/i), 'Ervin'); + await waitFor(() => expect(screen.queryByTestId('list-item-user-1')).toBeNull()); + + // ASSERT + expect(screen.queryByTestId('list-item-user-1')).toBeNull(); + expect(screen.getByTestId('list-item-user-2')).toBeDefined(); + }); }); From 51abc99cb9a0ff722e4e897d45a3f055c4a3a99f Mon Sep 17 00:00:00 2001 From: Matthew Warman Date: Tue, 24 Sep 2024 08:30:21 -0400 Subject: [PATCH 4/5] fixes --- src/common/components/Header/Header.tsx | 5 +++-- src/pages/Users/components/UserList/UserGrid.tsx | 3 +-- src/pages/Users/components/UserList/UserList.tsx | 3 +-- src/pages/Users/components/UserList/UserListPage.tsx | 4 ++-- 4 files changed, 7 insertions(+), 8 deletions(-) diff --git a/src/common/components/Header/Header.tsx b/src/common/components/Header/Header.tsx index e8e26f2..c8368b9 100644 --- a/src/common/components/Header/Header.tsx +++ b/src/common/components/Header/Header.tsx @@ -88,12 +88,13 @@ const Header = ({ {toolbars?.map((toolbarProps, index) => { - const isLastToolbar = toolbars.length - 1 === index; + const isLastToolbar: boolean = toolbars.length === index + 1; + const showProgressBar: boolean = isActiveProgressBar && isLastToolbar; const { children, ...props } = toolbarProps; return ( {children} - {isActiveProgressBar && isLastToolbar && } + {showProgressBar && } ); })} diff --git a/src/pages/Users/components/UserList/UserGrid.tsx b/src/pages/Users/components/UserList/UserGrid.tsx index 0e5bea8..2f59931 100644 --- a/src/pages/Users/components/UserList/UserGrid.tsx +++ b/src/pages/Users/components/UserList/UserGrid.tsx @@ -14,8 +14,7 @@ import EmptyCard from 'common/components/Card/EmptyCard'; /** * Properties for the `UserGrid` component. - * @param {string} [filterBy] - Optional. Filter the collection of users by a - * partial `name` match. + * @param {string} [filterBy] - Optional. Critera to filter the list of `Users`. * @see {@link BaseComponentProps} */ interface UserGridProps extends BaseComponentProps { diff --git a/src/pages/Users/components/UserList/UserList.tsx b/src/pages/Users/components/UserList/UserList.tsx index 5cf99e5..fe1a409 100644 --- a/src/pages/Users/components/UserList/UserList.tsx +++ b/src/pages/Users/components/UserList/UserList.tsx @@ -14,8 +14,7 @@ import EmptyCard from 'common/components/Card/EmptyCard'; /** * Properties for the `UserList` component. - * @param {string} [filterBy] - Optional. Filter the collection of users by a - * partial `name` match. + * @param {string} [filterBy] - Optional. Critera to filter the list of `Users`. * @param {string} [header] - Optional. The list header title. Default: `Users`. * @param {boolean} [showHeader] - Optional. Indicates if the header is shown. Default: `false`. * @see {@link BaseComponentProps} diff --git a/src/pages/Users/components/UserList/UserListPage.tsx b/src/pages/Users/components/UserList/UserListPage.tsx index a4a3015..3cbef3b 100644 --- a/src/pages/Users/components/UserList/UserListPage.tsx +++ b/src/pages/Users/components/UserList/UserListPage.tsx @@ -47,7 +47,7 @@ export const UserListPage = ({ testid = 'page-user-list' }: PropsWithTestId): JS * Handle changes to the search toolbar value as a user types. * @param {SearchbarCustomEvent} event - The event. */ - const onInputSearch = (event: SearchbarCustomEvent) => { + const handleInputSearch = (event: SearchbarCustomEvent) => { setSearch(event.target.value ?? ''); }; @@ -58,7 +58,7 @@ export const UserListPage = ({ testid = 'page-user-list' }: PropsWithTestId): JS title="Users" toolbars={[ { - children: , + children: , className: 'ls-toolbar-searchbar', }, ]} From a87b80db29b7beaf72d2a06e53970c5493d9b4ee Mon Sep 17 00:00:00 2001 From: Matthew Warman Date: Tue, 24 Sep 2024 08:41:45 -0400 Subject: [PATCH 5/5] tests --- src/pages/Users/utils/__tests__/users.test.ts | 47 +++++++++++++++++++ 1 file changed, 47 insertions(+) create mode 100644 src/pages/Users/utils/__tests__/users.test.ts diff --git a/src/pages/Users/utils/__tests__/users.test.ts b/src/pages/Users/utils/__tests__/users.test.ts new file mode 100644 index 0000000..ff7eaaf --- /dev/null +++ b/src/pages/Users/utils/__tests__/users.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from 'vitest'; + +import { usersFixture } from '__fixtures__/users'; + +import { filterUsers } from '../users'; + +describe('users', () => { + it('should filter users by name', () => { + // ARRANGE + const result = filterUsers(usersFixture, usersFixture[0].name); + + // ASSERT + expect(result).toBeDefined(); + expect(Array.isArray(result)).toBe(true); + expect(result.length).toBe(1); + }); + + it('should filter users by email', () => { + // ARRANGE + const result = filterUsers(usersFixture, usersFixture[0].email); + + // ASSERT + expect(result).toBeDefined(); + expect(Array.isArray(result)).toBe(true); + expect(result.length).toBe(1); + }); + + it('should not filter users when criteria undefined', () => { + // ARRANGE + const result = filterUsers(usersFixture); + + // ASSERT + expect(result).toBeDefined(); + expect(Array.isArray(result)).toBe(true); + expect(result.length).toBe(usersFixture.length); + }); + + it('should not filter users when criteria empty', () => { + // ARRANGE + const result = filterUsers(usersFixture, ''); + + // ASSERT + expect(result).toBeDefined(); + expect(Array.isArray(result)).toBe(true); + expect(result.length).toBe(usersFixture.length); + }); +});