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

<Collection> improvements: isPagination/isSearchable #507

Merged
merged 9 commits into from
Oct 27, 2021
Merged
Show file tree
Hide file tree
Changes from 6 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
1 change: 1 addition & 0 deletions docs/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
"aws-amplify-react": "latest",
"aws-amplify": "latest",
"classnames": "^2.3.1",
"countries-list": "^2.6.1",
"gray-matter": "^4.0.3",
"lodash": "^4.17.21",
"mdx-prism": "^0.3.3",
Expand Down
93 changes: 85 additions & 8 deletions docs/src/pages/ui/primitives/collection/react.mdx
Original file line number Diff line number Diff line change
@@ -1,8 +1,17 @@
import { Card, Collection, View, Text, Heading } from '@aws-amplify/ui-react';
import {
Button,
Card,
Collection,
Flex,
View,
Text,
Heading,
} from '@aws-amplify/ui-react';
import { countries } from 'countries-list';
import { CollectionDemo, ListCollectionExample } from './demo';
import { Example } from '@/components/Example';

A Collection wraps Flex and Grid components, and provides a way to display items in a collection from a data source. They come in `paginated` and `infiniteScrolling` versions. Both versions need paginated data.
A Collection wraps Flex and Grid components, and provides a way to display items in a collection from a data source.

## Demo

Expand Down Expand Up @@ -74,11 +83,11 @@ const visitNewZealand = [
</Collection>
</Example>

### Collection types
## Collection types

Collection `type` options include `list`, `grid`, and `table`.

**List collection type**
### List

The `list` collection type can be customized with any of following Flex props: `alignItems`, `alignContent`, `direction`, `gap`, `justifyContent`, `wrap`.

Expand Down Expand Up @@ -133,10 +142,78 @@ The `list` collection type can be customized with any of following Flex props: `
</Collection>
</Example>

#### Grid collection type
## Pagination

TBD
A Collection can be paginated, adding a special `isPaginated` property. Change the page size passing a `itemsPerPage` property (default: 10).

#### Table collection type
```jsx
<Collection type="list" items={countries} isPaginated itemsPerPage={12}>
{(country) => (
<Button>
{country.emoji} {country.name}
</Button>
)}
</Collection>
```

<Example>
<Collection
type="list"
direction="row"
wrap="wrap"
items={Object.values(countries).map(({ name, emoji }) => ({ name, emoji }))}
isPaginated
itemsPerPage={12}
>
{(country) => (
<Button grow="1">
{country.emoji} {country.name}
</Button>
)}
</Collection>
</Example>

## Search

TBD
Collections can also be filtered, adding a `isSearchable` property. Pass a custom `searchFilter` function to enhance your search experience.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we explain what the default search function is, or do they have to provide a search function?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fixed


```jsx
const startsWith = (country, keyword) => country.name.startsWith(keyword)

<Collection
type="grid"
items={countries}
itemsPerPage={9}
isSearchable
searchFilter={startsWith}
searchPlaceholder="Type to search..."
>
{(country) => (
<Button grow="1">
{country.emoji} {country.name}
</Button>
)}
</Collection>
```

<Example>
<Collection
type="grid"
templateColumns="1fr 1fr 1fr"
gap="15px"
items={Object.values(countries).map(({ name, emoji }) => ({ name, emoji }))}
isSearchable
isPaginated
itemsPerPage={9}
searchPlaceholder="Type to search..."
searchFilter={(country, keyword) =>
country.name.toLowerCase().startsWith(keyword.toLowerCase())
}
>
{(country) => (
<Button grow="1">
{country.emoji} {country.name}
</Button>
)}
</Collection>
</Example>
1 change: 0 additions & 1 deletion packages/react/__tests__/exports.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,6 @@ describe('@aws-amplify/ui-react', () => {
"Card",
"CheckboxField",
"Collection",
"CollectionTypeMap",
"ComponentClassNames",
"ComponentPropsToStylePropsMap",
"ComponentPropsToStylePropsMapKeys",
Expand Down
99 changes: 91 additions & 8 deletions packages/react/src/primitives/Collection/Collection.tsx
Original file line number Diff line number Diff line change
@@ -1,18 +1,101 @@
import * as React from 'react';
import { CollectionProps } from '../types';
import debounce from 'lodash/debounce';
import { useCallback, useState } from 'react';
import { Flex } from '../Flex';
import { Grid } from '../Grid';
import { Pagination, usePagination } from '../Pagination';
import { SearchField } from '../SearchField';
import { strHasLength } from '../shared/utils';
import {
CollectionProps,
GridCollectionProps,
ListCollectionProps,
} from '../types';
import { getItemsAtPage, itemHasText } from './utils';

export const Collection = <CollectionItemType,>({
const DEFAULT_PAGE_SIZE = 10;
const TYPEAHEAD_DELAY_MS = 300;

const ListCollection = <Item,>({
children,
items,
...rest
}: ListCollectionProps<Item>) => (
<Flex {...rest}>{Array.isArray(items) && items.map(children)}</Flex>
);

const GridCollection = <Item,>({
children,
className,
direction = 'column',
items,
...rest
}: GridCollectionProps<Item>) => (
<Grid {...rest}>{Array.isArray(items) && items.map(children)}</Grid>
);

export const Collection = <Item,>({
isSearchable,
isPaginated,
items,
itemsPerPage = DEFAULT_PAGE_SIZE,
searchFilter = itemHasText,
searchPlaceholder,
type = 'list',
...rest
}: CollectionProps<CollectionItemType>): JSX.Element => {
}: CollectionProps<Item>): JSX.Element => {
const [searchText, setSearchText] = useState<string>();

const onSearch = useCallback(debounce(setSearchText, TYPEAHEAD_DELAY_MS), [
setSearchText,
]);

// Make sure that items are iterable
items = Array.isArray(items) ? items : [];

// Filter items by text
if (isSearchable && strHasLength(searchText)) {
items = items.filter((item) => searchFilter(item, searchText));
}

// Pagination
const pagination = usePagination({
totalPages: Math.floor(items.length / itemsPerPage),
});

if (isPaginated) {
items = getItemsAtPage(items, pagination.currentPage, itemsPerPage);
}

const collection =
type === 'list' ? (
<ListCollection items={items} {...rest} />
) : type === 'grid' ? (
<GridCollection items={items} {...rest} />
) : null;

if (!isSearchable && !isPaginated) {
return collection;
}

return (
<Flex direction={direction} className={className} {...rest}>
{Array.isArray(items) && items.map(children)}
<Flex direction="column">
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These style props should be moved to CSS so they aren't render time styling. Should also provide a class name and take in a class name for customers to hook into.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

good idea, will add in next commit

{isSearchable && (
<Flex direction="row" justifyContent="center">
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same comment as above on moving style props styling to CSS file.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

done in next revision

<SearchField
size="small"
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should this be customizable by the user? What if the customer wants a different size or variation?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

removed in next revision

label="Search"
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This string should be in the i18n file so we don't have to go looking for it later.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

done in next revision

placeholder={searchPlaceholder}
onChange={(e) => onSearch(e.target.value)}
onClear={() => setSearchText('')}
/>
</Flex>
)}

{collection}

{isPaginated && (
<Flex justifyContent="center">
<Pagination {...pagination} />
</Flex>
)}
</Flex>
);
};
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { kebabCase } from 'lodash';

import { Collection } from '../Collection';
import { ComponentPropsToStylePropsMap } from '../../types';
import { ComponentClassNames } from '../../shared/constants';

const emojis = [
{
Expand All @@ -22,9 +23,52 @@ const emojis = [
describe('Collection component', () => {
const testList = 'testList';

it('should render a Search box when isSearchable is true', async () => {
render(
<Collection testId={testList} type="list" items={emojis} isSearchable>
{(item, index) => (
<div key={index} aria-label={item.title}>
{item.emoji}
</div>
)}
</Collection>
);

const searchField = await screen.findByRole('searchbox');
expect(searchField).not.toBe(undefined);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you check what findByRole returns if field is not found? I seem to remember it returning null rather than undefined when not found.

});

it('should render pagination when isPaginated is true', async () => {
render(
<Collection
testId={testList}
type="list"
items={emojis}
isPaginated
itemsPerPage={1}
>
{(item, index) => (
<div key={index} aria-label={item.title}>
{item.emoji}
</div>
)}
</Collection>
);

const navigation = await screen.findByRole('navigation');

expect(navigation.classList).toContain(ComponentClassNames.Pagination);
expect(navigation).not.toBe(undefined);
});

it('should render Flex when rendering list collection', async () => {
render(
<Collection testId={testList} type="list" items={emojis}>
<Collection
testId={testList}
type="list"
direction="column"
items={emojis}
>
{(item, index) => (
<div key={index} aria-label={item.title}>
{item.emoji}
Expand Down
28 changes: 28 additions & 0 deletions packages/react/src/primitives/Collection/utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { strHasLength } from '../shared/utils';

/**
* Slice a collection based on page index (starting at 1)
*/
export const getItemsAtPage = <T>(
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Add unit tests for these?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

added in next version

items: T[],
page: number,
itemsPerPage: number
) => {
const startIndex = (page - 1) * itemsPerPage;
return items.slice(startIndex, startIndex + itemsPerPage);
};

/**
* Recursively find a keyword within an object (case insensitive)
*/
export const itemHasText = (item: unknown, text: string): boolean => {
if (strHasLength(item)) {
return item.toLowerCase().includes(text.toLowerCase());
}

if (typeof item === 'object') {
return Object.values(item).some((subItem) => itemHasText(subItem, text));
}

return false;
};
2 changes: 2 additions & 0 deletions packages/react/src/primitives/Pagination/Pagination.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ export const Pagination: React.FC<PaginationProps> = (props) => {
onNext,
onPrevious,
onChange,
role = 'navigation',
...rest
} = props;

Expand All @@ -31,6 +32,7 @@ export const Pagination: React.FC<PaginationProps> = (props) => {
return (
<View
as="nav"
role={role}
className={classNames(ComponentClassNames.Pagination, className)}
{...rest}
>
Expand Down
8 changes: 5 additions & 3 deletions packages/react/src/primitives/Pagination/usePagination.ts
Original file line number Diff line number Diff line change
@@ -1,21 +1,23 @@
import { useCallback, useState } from 'react';
import { useCallback, useEffect, useState } from 'react';

import { UsePaginationProps, UsePaginationResult } from '../types/pagination';

export const usePagination = (
props: UsePaginationProps
): UsePaginationResult => {
let { currentPage: initialPage, totalPages, siblingCount = 1 } = props;
let { currentPage: initialPage = 1, totalPages, siblingCount = 1 } = props;

// The current page should not be less than 1
initialPage = Math.max(initialPage, 1);
// The sibling count should not be less than 1
siblingCount = Math.max(siblingCount, 1);
// The total pages should be always greater than current page
totalPages = Math.max(initialPage, totalPages);

const [currentPage, setCurrentPage] = useState(initialPage);

// Reset current page if initialPage or totalPages changes
useEffect(() => setCurrentPage(initialPage), [initialPage, totalPages]);

const onNext = useCallback(() => {
if (currentPage < totalPages) {
setCurrentPage(currentPage + 1);
Expand Down
Loading