diff --git a/src/common/components/Header/Header.tsx b/src/common/components/Header/Header.tsx index 88eb934..c8368b9 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,20 @@ const Header = ({ {buttons} - {isActiveProgressBar && } + {isActiveProgressBar && !toolbars && } + + {toolbars?.map((toolbarProps, index) => { + const isLastToolbar: boolean = toolbars.length === index + 1; + const showProgressBar: boolean = isActiveProgressBar && isLastToolbar; + const { children, ...props } = toolbarProps; + return ( + + {children} + {showProgressBar && } + + ); + })} ); }; 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/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/UserGrid.tsx b/src/pages/Users/components/UserList/UserGrid.tsx index 624a44a..2f59931 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,12 @@ import EmptyCard from 'common/components/Card/EmptyCard'; /** * Properties for the `UserGrid` component. + * @param {string} [filterBy] - Optional. Critera to filter the list of `Users`. + * @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 +28,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 +56,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 +73,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..fe1a409 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,13 @@ import EmptyCard from 'common/components/Card/EmptyCard'; /** * Properties for the `UserList` component. + * @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} */ interface UserListProps extends BaseComponentProps { + filterBy?: string; header?: string; showHeader?: boolean; } @@ -29,6 +33,7 @@ interface UserListProps extends BaseComponentProps { */ const UserList = ({ className, + filterBy, header = 'Users', showHeader = false, testid = 'list-user', @@ -60,8 +65,10 @@ const UserList = ({ ); } + const filteredUsers = filterUsers(users, filterBy); + // Empty state - if (isEmpty(users)) { + if (isEmpty(filteredUsers)) { return (
@@ -76,12 +83,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..c1246b0 100644 --- a/src/pages/Users/components/UserList/UserListPage.scss +++ b/src/pages/Users/components/UserList/UserListPage.scss @@ -1,4 +1,4 @@ -.page-user-list { +.ls-page-user-list { .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..3cbef3b 100644 --- a/src/pages/Users/components/UserList/UserListPage.tsx +++ b/src/pages/Users/components/UserList/UserListPage.tsx @@ -6,6 +6,7 @@ import { IonRefresher, IonRefresherContent, RefresherEventDetail, + SearchbarCustomEvent, } from '@ionic/react'; import { useState } from 'react'; import { useQueryClient } from '@tanstack/react-query'; @@ -14,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'; @@ -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 handleInputSearch = (event: SearchbarCustomEvent) => { + setSearch(event.target.value ?? ''); + }; + return ( - + -
+
, + className: 'ls-toolbar-searchbar', + }, + ]} + /> @@ -60,8 +83,8 @@ export const UserListPage = ({ testid = 'page-user-list' }: PropsWithTestId): JS - - + + setIsOpenModal(true)} /> 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(); + }); }); 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); + }); +}); 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; + } + }); +};