Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

51 Searchbar #80

Merged
merged 5 commits into from
Sep 24, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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