From 9f94bc34ee30177a10dafa76c0de9462181458fa Mon Sep 17 00:00:00 2001 From: Hector Vergara Date: Wed, 27 Oct 2021 13:53:14 -0300 Subject: [PATCH] improvements: isPagination/isSearchable (#507) * improvements: isPagination/isSearchable --- docs/package.json | 1 + .../pages/ui/primitives/collection/react.mdx | 93 ++++++++++++-- packages/react/__tests__/exports.ts | 1 - .../src/primitives/Collection/Collection.tsx | 117 ++++++++++++++++-- .../Collection/__tests__/Collection.test.tsx | 100 ++++++++++++--- .../Collection/__tests__/utils.test.ts | 61 +++++++++ .../react/src/primitives/Collection/utils.ts | 32 +++++ .../primitives/Pagination/usePagination.ts | 8 +- .../primitives/SearchField/SearchField.tsx | 35 ++++-- .../react/src/primitives/shared/constants.ts | 4 + packages/react/src/primitives/shared/i18n.ts | 3 + .../react/src/primitives/types/collection.ts | 60 ++++++--- .../react/src/primitives/types/pagination.ts | 2 +- .../react/src/primitives/types/searchField.ts | 5 + .../src/theme/css/component/collection.scss | 12 ++ packages/ui/src/theme/css/styles.scss | 1 + yarn.lock | 9 +- 17 files changed, 473 insertions(+), 71 deletions(-) create mode 100644 packages/react/src/primitives/Collection/__tests__/utils.test.ts create mode 100644 packages/react/src/primitives/Collection/utils.ts create mode 100644 packages/ui/src/theme/css/component/collection.scss diff --git a/docs/package.json b/docs/package.json index 6fbfe5a771f..aba16742629 100644 --- a/docs/package.json +++ b/docs/package.json @@ -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", diff --git a/docs/src/pages/ui/primitives/collection/react.mdx b/docs/src/pages/ui/primitives/collection/react.mdx index 128c9bba04f..321811d7132 100644 --- a/docs/src/pages/ui/primitives/collection/react.mdx +++ b/docs/src/pages/ui/primitives/collection/react.mdx @@ -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 @@ -74,11 +83,11 @@ const visitNewZealand = [ -### 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`. @@ -133,10 +142,78 @@ The `list` collection type can be customized with any of following Flex props: ` -#### 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 + + {(country) => ( + + )} + +``` + + + ({ name, emoji }))} + isPaginated + itemsPerPage={12} + > + {(country) => ( + + )} + + + +## Search -TBD +Collections can also be filtered, adding a `isSearchable` property. Pass a custom `searchFilter` function to enhance your search experience (default search filter looks for any string-like property inside of items) + +```jsx +const startsWith = (country, keyword) => country.name.startsWith(keyword) + + + {(country) => ( + + )} + +``` + + + ({ name, emoji }))} + isSearchable + isPaginated + itemsPerPage={9} + searchPlaceholder="Type to search..." + searchFilter={(country, keyword) => + country.name.toLowerCase().startsWith(keyword.toLowerCase()) + } + > + {(country) => ( + + )} + + diff --git a/packages/react/__tests__/exports.ts b/packages/react/__tests__/exports.ts index c92fe0feab6..00e1df7b466 100644 --- a/packages/react/__tests__/exports.ts +++ b/packages/react/__tests__/exports.ts @@ -18,7 +18,6 @@ describe('@aws-amplify/ui-react', () => { "Card", "CheckboxField", "Collection", - "CollectionTypeMap", "ComponentClassNames", "ComponentPropsToStylePropsMap", "ComponentPropsToStylePropsMapKeys", diff --git a/packages/react/src/primitives/Collection/Collection.tsx b/packages/react/src/primitives/Collection/Collection.tsx index e396df05243..2314c475bab 100644 --- a/packages/react/src/primitives/Collection/Collection.tsx +++ b/packages/react/src/primitives/Collection/Collection.tsx @@ -1,16 +1,115 @@ -import * as React from 'react'; -import { CollectionProps } from '../types'; +import classNames from 'classnames'; +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 { ComponentClassNames } from '../shared/constants'; +import { SharedText } from '../shared/i18n'; +import { strHasLength } from '../shared/utils'; +import { + CollectionProps, + GridCollectionProps, + ListCollectionProps, +} from '../types'; +import { getItemsAtPage, itemHasText } from './utils'; -export const Collection = ({ - items, +const DEFAULT_PAGE_SIZE = 10; +const TYPEAHEAD_DELAY_MS = 300; + +const ListCollection = ({ children, - className, direction = 'column', - type = 'list', + items, ...rest -}: CollectionProps) => ( - - {Array.isArray(items) && items.map(children)} +}: ListCollectionProps) => ( + + {Array.isArray(items) ? items.map(children) : null} ); + +const GridCollection = ({ + children, + items, + ...rest +}: GridCollectionProps) => ( + {Array.isArray(items) ? items.map(children) : null} +); + +export const Collection = ({ + className, + isSearchable, + isPaginated, + items, + itemsPerPage = DEFAULT_PAGE_SIZE, + searchFilter = itemHasText, + searchPlaceholder, + type = 'list', + testId, + ...rest +}: CollectionProps): JSX.Element => { + const [searchText, setSearchText] = useState(); + + 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' ? ( + + ) : type === 'grid' ? ( + + ) : null; + + return ( + + {isSearchable ? ( + + onSearch(e.target.value)} + onClear={() => setSearchText('')} + /> + + ) : null} + + {collection} + + {isPaginated ? ( + + + + ) : null} + + ); +}; diff --git a/packages/react/src/primitives/Collection/__tests__/Collection.test.tsx b/packages/react/src/primitives/Collection/__tests__/Collection.test.tsx index 23f90beb4b6..07661143a49 100644 --- a/packages/react/src/primitives/Collection/__tests__/Collection.test.tsx +++ b/packages/react/src/primitives/Collection/__tests__/Collection.test.tsx @@ -3,6 +3,7 @@ import { kebabCase } from 'lodash'; import { Collection } from '../Collection'; import { ComponentPropsToStylePropsMap } from '../../types'; +import { ComponentClassNames } from '../../shared/constants'; const emojis = [ { @@ -19,12 +20,74 @@ const emojis = [ }, ]; +const getElementByClassName = ( + element: HTMLElement, + className: string +) => element.querySelector(`.${className}`); + describe('Collection component', () => { const testList = 'testList'; - it('should render Flex when rendering list collection', async () => { + it('should render a Search box when isSearchable is true', async () => { + render( + + {(item, index) => ( +
+ {item.emoji} +
+ )} +
+ ); + + const collection = await screen.findByTestId('testList'); + const search = getElementByClassName( + collection, + ComponentClassNames.CollectionSearch + ); + + expect(search).not.toBe(null); + }); + + it('should render pagination when isPaginated is true', async () => { + render( + + {(item, index) => ( +
+ {item.emoji} +
+ )} +
+ ); + + const collection = await screen.findByTestId(testList); + const pagination = getElementByClassName( + collection, + ComponentClassNames.CollectionPagination + ); + + expect(pagination).not.toBe(null); + }); + + it('should render Flex when rendering list collection items', async () => { render( - + {(item, index) => (
{item.emoji} @@ -32,13 +95,20 @@ describe('Collection component', () => { )} ); - const collection = (await screen.findByTestId(testList)) as HTMLDivElement; + + const collection = await screen.findByTestId(testList); + const items = getElementByClassName( + collection, + ComponentClassNames.CollectionItems + ); + expect( - collection.style.getPropertyValue( + items.style.getPropertyValue( kebabCase(ComponentPropsToStylePropsMap.direction) ) ).toBe('column'); - expect(collection.children[0].getAttribute('aria-label')).toBe('LOL'); + + expect(items.children[0].getAttribute('aria-label')).toBe('LOL'); }); it('should not throw when items is not an array', () => { @@ -70,15 +140,9 @@ describe('Collection component', () => { expect(collection.classList.contains('custom-collection')).toBe(true); }); - it('can render any arbitrary data-* attribute', async () => { + it('can render arbitrary attributes to items container', async () => { render( - + {(item, index) => (
{item.emoji} @@ -86,7 +150,13 @@ describe('Collection component', () => { )} ); - const view = await screen.findByTestId(testList); - expect(view.dataset['demo']).toBe('true'); + + const collection = await screen.findByTestId(testList); + const items = getElementByClassName( + collection, + ComponentClassNames.CollectionItems + ); + + expect(items.dataset['demo']).toBe('true'); }); }); diff --git a/packages/react/src/primitives/Collection/__tests__/utils.test.ts b/packages/react/src/primitives/Collection/__tests__/utils.test.ts new file mode 100644 index 00000000000..95e9774437c --- /dev/null +++ b/packages/react/src/primitives/Collection/__tests__/utils.test.ts @@ -0,0 +1,61 @@ +import { getItemsAtPage, itemHasText } from '../utils'; + +describe('getItemsAtPage', () => { + const items = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]; + + it('should return expected items', () => { + expect(getItemsAtPage(items, 2, 3)).toStrictEqual([4, 5, 6]); + expect(getItemsAtPage(items, 5, 1)).toStrictEqual([5]); + expect(getItemsAtPage(items, 3, 3)).toStrictEqual([7, 8, 9]); + }); + + it('should return no items if page is lower than 1 ', () => { + expect(getItemsAtPage(items, 0, 3)).toHaveLength(0); + expect(getItemsAtPage(items, -3, 3)).toHaveLength(0); + expect(getItemsAtPage(items, -10, 3)).toHaveLength(0); + }); + + it('should return no items if page is greater than available pages', () => { + expect(getItemsAtPage(items, 5, 3)).toHaveLength(0); + expect(getItemsAtPage(items, 10, 3)).toHaveLength(0); + expect(getItemsAtPage(items, 30, 3)).toHaveLength(0); + }); +}); + +describe('itemsHasText', () => { + it("should return false if item doesn't match provided text", () => { + const result = itemHasText('hello world', 'not found'); + expect(result).toBe(false); + }); + + it('should return true if item matches provided text', () => { + const result = itemHasText('hello world', 'o wo'); + expect(result).toBe(true); + }); + + it('should return true if any of item properties matches provided text', () => { + const obj = { + very: { + nested: { + property: 'this is a secret', + }, + }, + }; + + const result = itemHasText(obj, 'secret'); + expect(result).toBe(true); + }); + + it('should return false if no item properties matches provided text', () => { + const obj = { + very: { + nested: { + property: 'this is a secret', + }, + }, + }; + + const result = itemHasText(obj, 'not found'); + expect(result).toBe(false); + }); +}); diff --git a/packages/react/src/primitives/Collection/utils.ts b/packages/react/src/primitives/Collection/utils.ts new file mode 100644 index 00000000000..23377c5a51d --- /dev/null +++ b/packages/react/src/primitives/Collection/utils.ts @@ -0,0 +1,32 @@ +import { strHasLength } from '../shared/utils'; + +/** + * Slice a collection based on page index (starting at 1) + */ +export const getItemsAtPage = ( + items: T[], + page: number, + itemsPerPage: number +) => { + if (page < 1 || itemsPerPage < 1) { + return []; + } + + 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; +}; diff --git a/packages/react/src/primitives/Pagination/usePagination.ts b/packages/react/src/primitives/Pagination/usePagination.ts index 6a6c9ac90a3..585b66dd824 100644 --- a/packages/react/src/primitives/Pagination/usePagination.ts +++ b/packages/react/src/primitives/Pagination/usePagination.ts @@ -1,11 +1,11 @@ -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); @@ -13,9 +13,11 @@ export const usePagination = ( 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); diff --git a/packages/react/src/primitives/SearchField/SearchField.tsx b/packages/react/src/primitives/SearchField/SearchField.tsx index 785167206b7..a228dcfffdb 100644 --- a/packages/react/src/primitives/SearchField/SearchField.tsx +++ b/packages/react/src/primitives/SearchField/SearchField.tsx @@ -5,21 +5,30 @@ import { ComponentClassNames } from '../shared/constants'; import { TextField } from '../TextField'; import { FieldClearButton } from '../Field'; import { SearchFieldButton } from './SearchFieldButton'; -import { strHasLength } from '../shared/utils'; +import { isFunction, strHasLength } from '../shared/utils'; import { SearchFieldProps, InputProps, Primitive } from '../types'; const ESCAPE_KEY = 'Escape'; const ENTER_KEY = 'Enter'; const DEFAULT_KEYS = [ESCAPE_KEY, ENTER_KEY]; -export const useSearchField = (onSubmit: SearchFieldProps['onSubmit']) => { +export const useSearchField = ({ + onSubmit, + onClear, +}: Partial) => { const [value, setValue] = React.useState(''); - const clearValue = React.useCallback(() => setValue(''), [setValue]); + const onClearHandler = React.useCallback(() => { + setValue(''); + + if (isFunction(onClear)) { + onClear(); + } + }, [setValue, onClear]); const onSubmitHandler = React.useCallback( (value: string) => { - if (onSubmit) { + if (isFunction(onSubmit)) { onSubmit(value); } }, @@ -33,14 +42,14 @@ export const useSearchField = (onSubmit: SearchFieldProps['onSubmit']) => { if (DEFAULT_KEYS.includes(key)) { event.preventDefault(); } + if (key === ESCAPE_KEY) { - clearValue(); - } - if (key === ENTER_KEY) { + onClearHandler(); + } else if (key === ENTER_KEY) { onSubmitHandler(value); } }, - [value, clearValue, onSubmitHandler] + [value, onClearHandler, onSubmitHandler] ); const onInput = React.useCallback( @@ -56,7 +65,7 @@ export const useSearchField = (onSubmit: SearchFieldProps['onSubmit']) => { return { value, - clearValue, + onClearHandler, onKeyDown, onInput, onClick, @@ -70,11 +79,13 @@ export const SearchField: Primitive = ({ label, name = 'q', onSubmit, + onClear, size, ...rest }) => { - const { value, clearValue, onInput, onKeyDown, onClick } = - useSearchField(onSubmit); + const { value, onClearHandler, onInput, onKeyDown, onClick } = useSearchField( + { onSubmit, onClear } + ); return ( = ({ innerEndComponent={ - extends BaseStyleProps { - /* +export interface CollectionWrapperProps extends BaseStyleProps { + /** * Collection type. This will be used to determine collection wrapper component. * @default 'list' */ type?: CollectionType; + /** + * Enable pagination for collection items + */ + isPaginated?: boolean; + + /** + * Page size (when pagination is enabled) + */ + itemsPerPage?: number; + + /** + * Enable collection filtering + */ + isSearchable?: boolean; + + /** + * Custom search filter (when search is enabled) + */ + searchFilter?: (item: unknown, searchText: string) => boolean; + + /** + * Search field placeholder + */ + searchPlaceholder?: string; +} + +export interface CollectionBaseProps { /* * Data source. Items to be repeated over the collection. */ - items: Array; + items: Array; /* * The component to be repeated * Same interface as Array.prototype.map */ - children: (item: CollectionItemType, index: number) => JSX.Element; + children: (item: Item, index: number) => JSX.Element; } -// @TODO Add GridCollectionProps and TableCollectionProps -export type ListCollectionProps = - CollectionBaseProps & FlexProps & { type: 'list' }; +// @TODO Add TableCollectionProps +export type ListCollectionProps = FlexProps & CollectionBaseProps; +export type GridCollectionProps = GridProps & CollectionBaseProps; -export type CollectionProps = - ListCollectionProps; +export type CollectionProps = CollectionWrapperProps & + ( + | ({ type: 'list' } & ListCollectionProps) + | ({ type: 'grid' } & GridCollectionProps) + ); diff --git a/packages/react/src/primitives/types/pagination.ts b/packages/react/src/primitives/types/pagination.ts index bf1fef05399..08379648a4e 100644 --- a/packages/react/src/primitives/types/pagination.ts +++ b/packages/react/src/primitives/types/pagination.ts @@ -9,7 +9,7 @@ interface BasePaginationProps { /** * Index of the current page. (starting from 1) */ - currentPage: number; + currentPage?: number; /** * Total number of available pages. diff --git a/packages/react/src/primitives/types/searchField.ts b/packages/react/src/primitives/types/searchField.ts index 8aaf30d1218..9cacb73abd0 100644 --- a/packages/react/src/primitives/types/searchField.ts +++ b/packages/react/src/primitives/types/searchField.ts @@ -7,6 +7,11 @@ export interface SearchFieldProps extends TextInputFieldProps { */ onSubmit?: (value: string) => void; + /** + * Triggered when search field is cleared + */ + onClear?: () => void; + /** * Visually hide label * @default true diff --git a/packages/ui/src/theme/css/component/collection.scss b/packages/ui/src/theme/css/component/collection.scss new file mode 100644 index 00000000000..a49d1ba2924 --- /dev/null +++ b/packages/ui/src/theme/css/component/collection.scss @@ -0,0 +1,12 @@ +.amplify-collection { + flex-direction: column; +} + +.amplify-collection-search { + flex-direction: row; + justify-content: center; +} + +.amplify-collection-pagination { + justify-content: center; +} diff --git a/packages/ui/src/theme/css/styles.scss b/packages/ui/src/theme/css/styles.scss index efab1334021..7a10155e8b8 100644 --- a/packages/ui/src/theme/css/styles.scss +++ b/packages/ui/src/theme/css/styles.scss @@ -51,6 +51,7 @@ html { @import './component/alert.scss'; @import './component/authenticator.scss'; @import './component/card.scss'; +@import './component/collection.scss'; @import './component/checkbox.scss'; @import './component/checkboxField.scss'; @import './component/divider.scss'; diff --git a/yarn.lock b/yarn.lock index b3b310054a7..7031b254205 100644 --- a/yarn.lock +++ b/yarn.lock @@ -451,8 +451,7 @@ qrcode "^1.4.4" uuid "^8.2.0" -"@aws-amplify/ui-react-v1@npm:@aws-amplify/ui-react", "@aws-amplify/ui-react@^1.2.5": - name "@aws-amplify/ui-react-v1" +"@aws-amplify/ui-react-v1@npm:@aws-amplify/ui-react": version "1.2.21" resolved "https://registry.yarnpkg.com/@aws-amplify/ui-react/-/ui-react-1.2.21.tgz#b723fded8006e7ef212905a0a93ef7ae866a2a05" integrity sha512-+0AQlrLQei66y+gKBhBxNJnx92OeFzZ2Uls9U3vNf2c4ocEtUzErXu/YLjxmYyX9rVxACrRLHd6zuBuA91Ubmw== @@ -6312,6 +6311,7 @@ amazon-cognito-identity-js@5.2.1: resolved "https://codeload.github.com/aws-amplify/docs/tar.gz/8a0d3a0ff2e91ed5caf1ed57ae7fe050a62cab20" dependencies: "@aws-amplify/ui-components" latest + "@aws-amplify/ui-react" "^1.2.5" "@emotion/react" "^11.1.5" "@emotion/styled" "^11.3.0" array-flatten "^3.0.0" @@ -8757,6 +8757,11 @@ cosmiconfig@^7.0.0, cosmiconfig@^7.0.1: path-type "^4.0.0" yaml "^1.10.0" +countries-list@^2.6.1: + version "2.6.1" + resolved "https://registry.yarnpkg.com/countries-list/-/countries-list-2.6.1.tgz#d479757ac873b1e596ccea0a925962d20396c0cb" + integrity sha512-jXM1Nv3U56dPQ1DsUSsEaGmLHburo4fnB7m+1yhWDUVvx5gXCd1ok/y3gXCjXzhqyawG+igcPYcAl4qjkvopaQ== + create-ecdh@^4.0.0: version "4.0.4" resolved "https://registry.yarnpkg.com/create-ecdh/-/create-ecdh-4.0.4.tgz#d6e7f4bffa66736085a0762fd3a632684dabcc4e"