Skip to content

Commit

Permalink
51 Searchbar (#80)
Browse files Browse the repository at this point in the history
* initial user search toolbar

* Searchbar component

* tests

* fixes

* tests
  • Loading branch information
mwarman authored Sep 24, 2024
1 parent f9ad945 commit 2a14184
Show file tree
Hide file tree
Showing 11 changed files with 221 additions and 16 deletions.
21 changes: 20 additions & 1 deletion src/common/components/Header/Header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,13 +25,19 @@ 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,
Pick<ComponentPropsWithoutRef<typeof IonBackButton>, 'defaultHref'> {
backButton?: boolean;
buttons?: ReactNode;
title?: string;
toolbars?: ComponentPropsWithoutRef<typeof IonToolbar>[];
}

const Header = ({
Expand All @@ -40,6 +46,7 @@ const Header = ({
defaultHref,
testid = 'header-app',
title,
toolbars,
}: HeaderProps): JSX.Element => {
const { isAuthenticated } = useAuth();
const { isActive: isActiveProgressBar, progressBar } = useProgress();
Expand Down Expand Up @@ -77,8 +84,20 @@ const Header = ({
{buttons}
</IonButtons>

{isActiveProgressBar && <IonProgressBar {...progressBar} />}
{isActiveProgressBar && !toolbars && <IonProgressBar {...progressBar} />}
</IonToolbar>

{toolbars?.map((toolbarProps, index) => {
const isLastToolbar: boolean = toolbars.length === index + 1;
const showProgressBar: boolean = isActiveProgressBar && isLastToolbar;
const { children, ...props } = toolbarProps;
return (
<IonToolbar key={index} {...props}>
{children}
{showProgressBar && <IonProgressBar {...progressBar} />}
</IonToolbar>
);
})}
</IonHeader>
);
};
Expand Down
8 changes: 8 additions & 0 deletions src/common/components/Searchbar/Searchbar.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
ion-searchbar.ls-searchbar {
--border-radius: 0.25rem;
}

ion-toolbar.ls-toolbar-searchbar {
--padding-start: 9px;
--padding-end: 9px;
}
35 changes: 35 additions & 0 deletions src/common/components/Searchbar/Searchbar.tsx
Original file line number Diff line number Diff line change
@@ -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<typeof IonSearchbar> {}

/**
* 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 (
<IonSearchbar
className={classNames('ls-searchbar', className)}
data-testid={testid}
{...props}
/>
);
};

export default Searchbar;
17 changes: 17 additions & 0 deletions src/common/components/Searchbar/__tests__/Searchbar.test.tsx
Original file line number Diff line number Diff line change
@@ -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(<Searchbar />);
await screen.findByTestId('ls-searchbar');

// ASSERT
expect(screen.getByTestId('ls-searchbar')).toBeDefined();
expect(screen.getByTestId('ls-searchbar')).toHaveClass('ls-searchbar');
});
});
17 changes: 12 additions & 5 deletions src/pages/Users/components/UserList/UserGrid.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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`
Expand All @@ -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 = {
Expand Down Expand Up @@ -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 (
<div {...baseProps}>
<CardRow className="row-message" testid={`${testid}-empty`}>
Expand All @@ -66,8 +73,8 @@ const UserGrid = ({ className, testid = 'grid-user' }: UserGridProps): JSX.Eleme
return (
<IonGrid {...baseProps}>
<IonRow>
{users &&
users.map((user) => (
{filteredUsers &&
filteredUsers.map((user) => (
<IonCol key={user.id} sizeXs="12" sizeMd="6" sizeXl="4">
<UserCard user={user} testid={`${testid}-card-user-${user.id}`} />
</IonCol>
Expand Down
15 changes: 11 additions & 4 deletions src/pages/Users/components/UserList/UserList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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;
}
Expand All @@ -29,6 +33,7 @@ interface UserListProps extends BaseComponentProps {
*/
const UserList = ({
className,
filterBy,
header = 'Users',
showHeader = false,
testid = 'list-user',
Expand Down Expand Up @@ -60,8 +65,10 @@ const UserList = ({
);
}

const filteredUsers = filterUsers(users, filterBy);

// Empty state
if (isEmpty(users)) {
if (isEmpty(filteredUsers)) {
return (
<div {...baseProps}>
<CardRow className="row-message" testid={`${testid}-empty`}>
Expand All @@ -76,12 +83,12 @@ const UserList = ({
<IonList {...baseProps}>
{showHeader && <IonListHeader data-testid={`${testid}-header`}>{header}</IonListHeader>}

{users &&
users.map((user, index) => (
{filteredUsers &&
filteredUsers.map((user, index) => (
<UserListItem
key={user.id}
user={user}
lines={index === users.length - 1 ? 'none' : 'full'}
lines={index === filteredUsers.length - 1 ? 'none' : 'full'}
/>
))}
</IonList>
Expand Down
2 changes: 1 addition & 1 deletion src/pages/Users/components/UserList/UserListPage.scss
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
.page-user-list {
.ls-page-user-list {
.page-header {
margin-top: 1rem;
}
Expand Down
31 changes: 27 additions & 4 deletions src/pages/Users/components/UserList/UserListPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
IonRefresher,
IonRefresherContent,
RefresherEventDetail,
SearchbarCustomEvent,
} from '@ionic/react';
import { useState } from 'react';
import { useQueryClient } from '@tanstack/react-query';
Expand All @@ -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';
Expand All @@ -29,17 +31,38 @@ import UserAddModal from '../UserAdd/UserAddModal';
*/
export const UserListPage = ({ testid = 'page-user-list' }: PropsWithTestId): JSX.Element => {
const [isOpenModal, setIsOpenModal] = useState<boolean>(false);
const [search, setSearch] = useState<string>('');
const queryClient = useQueryClient();

/**
* Handle pull to refresh events.
* @param {CustomEvent} event - The refresh event.
*/
const handleRefresh = async (event: CustomEvent<RefresherEventDetail>) => {
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 (
<IonPage className="page-user-list" data-testid={testid}>
<IonPage className="ls-page-user-list" data-testid={testid}>
<ProgressProvider>
<Header title="Users" />
<Header
title="Users"
toolbars={[
{
children: <Searchbar debounce={500} onIonInput={handleInputSearch} />,
className: 'ls-toolbar-searchbar',
},
]}
/>

<IonContent>
<IonRefresher slot="fixed" onIonRefresh={handleRefresh}>
Expand All @@ -60,8 +83,8 @@ export const UserListPage = ({ testid = 'page-user-list' }: PropsWithTestId): JS
</IonButton>
</IonButtons>
</PageHeader>
<UserList className="ion-hide-md-up" />
<UserGrid className="ion-hide-md-down" />
<UserList className="ion-hide-md-up" filterBy={search} />
<UserGrid className="ion-hide-md-down" filterBy={search} />
</Container>
<UserAddFab className="ion-hide-md-up" onClick={() => setIsOpenModal(true)} />
<UserAddModal isOpen={isOpenModal} setIsOpen={setIsOpenModal} />
Expand Down
Original file line number Diff line number Diff line change
@@ -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', () => {
Expand All @@ -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(<UserListPage />);
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();
});
});
47 changes: 47 additions & 0 deletions src/pages/Users/utils/__tests__/users.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
Loading

0 comments on commit 2a14184

Please sign in to comment.