diff --git a/UNRELEASED.md b/UNRELEASED.md index abbea975388..118d7a47f88 100644 --- a/UNRELEASED.md +++ b/UNRELEASED.md @@ -6,10 +6,8 @@ ### Enhancements -- Added `ReactNode` as an accepted prop type to `primaryAction` on the `Page` component ([#3002](https://github.com/Shopify/polaris-react/pull/3002)) -- Added a `fullWidth` prop to `EmptyState` to support full width layout within a content context ([#2992](https://github.com/Shopify/polaris-react/pull/2992)) -- Added an `emptyState` prop to `ResourceList` to support in context empty states in list views ([#2569](https://github.com/Shopify/polaris-react/pull/2569)) - Set `active` prop of `Popover` to true on keyDown in `ComboBox` to fix `Autocomplete` suggestions not showing when searching and selecting via keyboard ([#3028](https://github.com/Shopify/polaris-react/pull/3028)) +- Changed Resource List to a generic functional component ([#2843](https://github.com/Shopify/polaris-react/pull/2843)) +- Made the `renderItem` function infer the type of the items prop ([#2843](https://github.com/Shopify/polaris-react/pull/2843)) ### Bug fixes diff --git a/src/components/ResourceList/ResourceList.tsx b/src/components/ResourceList/ResourceList.tsx index 801d02c8066..7f940b67580 100644 --- a/src/components/ResourceList/ResourceList.tsx +++ b/src/components/ResourceList/ResourceList.tsx @@ -1,4 +1,11 @@ -import React from 'react'; +import React, { + ReactElement, + useCallback, + useEffect, + useReducer, + useRef, + useState, +} from 'react'; import debounce from 'lodash/debounce'; import {EnableSelectionMinor} from '@shopify/polaris-icons'; @@ -9,19 +16,17 @@ import {EventListener} from '../EventListener'; import {Sticky} from '../Sticky'; import {Spinner} from '../Spinner'; import { - withAppProvider, - WithAppProviderProps, -} from '../../utilities/with-app-provider'; -import { + CheckableButtonKey, + CheckableButtons, ResourceListContext, ResourceListSelectedItems, SELECT_ALL_ITEMS, - CheckableButtonKey, - CheckableButtons, } from '../../utilities/resource-list'; import {Select, SelectOption} from '../Select'; import {EmptySearchResult} from '../EmptySearchResult'; +import {useI18n} from '../../utilities/i18n'; import {ResourceItem} from '../ResourceItem'; +import {useLazyRef} from '../../utilities/use-lazy-ref'; import { BulkActions, @@ -39,19 +44,33 @@ const SMALL_SCREEN_WIDTH = 458; const SMALL_SPINNER_HEIGHT = 28; const LARGE_SPINNER_HEIGHT = 45; -type Items = any[]; +function getAllItemsOnPage( + items: ItemType[], + idForItem: (item: ItemType, index: number) => string, +) { + return items.map((item: ItemType, index: number) => { + return idForItem(item, index); + }); +} + +const isSmallScreen = () => { + return typeof window === 'undefined' + ? false + : window.innerWidth < SMALL_SCREEN_WIDTH; +}; -interface State { - selectMode: boolean; - loadingPosition: number; - lastSelected: number | null; - smallScreen: boolean; - checkableButtons: CheckableButtons; +function defaultIdForItem( + item: ItemType, + index: number, +) { + return Object.prototype.hasOwnProperty.call(item, 'id') + ? item.id + : index.toString(); } -export interface ResourceListProps { +export interface ResourceListProps { /** Item data; each item is passed to renderItem */ - items: Items; + items: ItemType[]; filterControl?: React.ReactNode; /** The markup to display when no resources exist yet. Renders when set and items is empty. */ emptyState?: React.ReactNode; @@ -90,82 +109,101 @@ export interface ResourceListProps { onSortChange?(selected: string, id: string): void; /** Callback when selection is changed */ onSelectionChange?(selectedItems: ResourceListSelectedItems): void; - /** Function to render each list item */ - renderItem(item: any, id: string, index: number): React.ReactNode; + /** Function to render each list item */ + renderItem(item: ItemType, id: string, index: number): React.ReactNode; /** Function to customize the unique ID for each item */ - idForItem?(item: any, index: number): string; + idForItem?(item: ItemType, index: number): string; /** Function to resolve the ids of items */ - resolveItemId?(item: any): string; + resolveItemId?(item: ItemType): string; } -type CombinedProps = ResourceListProps & WithAppProviderProps; - -class ResourceListInner extends React.Component { - static Item = ResourceItem; +type ResourceListType = (( + value: ResourceListProps, +) => ReactElement) & { + Item: typeof ResourceItem; // eslint-disable-next-line import/no-deprecated - static FilterControl = FilterControl; + FilterControl: typeof FilterControl; +}; + +export const ResourceList: ResourceListType = function ResourceList({ + items, + filterControl, + emptyState, + emptySearchState, + resourceName: resourceNameProp, + promotedBulkActions, + bulkActions, + selectedItems = [], + selectable, + hasMoreItems, + loading, + showHeader = false, + totalItemsCount, + sortValue, + sortOptions, + alternateTool, + onSortChange, + onSelectionChange, + renderItem, + idForItem = defaultIdForItem, + resolveItemId, +}: ResourceListProps) { + const i18n = useI18n(); + const [selectMode, setSelectMode] = useState( + Boolean(selectedItems && selectedItems.length > 0), + ); + const [loadingPosition, setLoadingPositionState] = useState(0); + const [lastSelected, setLastSelected] = useState(); + const [smallScreen, setSmallScreen] = useState(isSmallScreen()); + const forceUpdate: (x?: number) => void = useReducer<(x?: number) => number>( + (x = 0) => x + 1, + 0, + )[1]; + + const [checkableButtons, setCheckableButtons] = useState( + new Map(), + ); - private defaultResourceName: {singular: string; plural: string}; - private listRef: React.RefObject = React.createRef(); + const defaultResourceName = useLazyRef(() => ({ + singular: i18n.translate('Polaris.ResourceList.defaultItemSingular'), + plural: i18n.translate('Polaris.ResourceList.defaultItemPlural'), + })); + const listRef: React.RefObject = useRef(null); + + const handleSelectMode = (selectMode: boolean) => { + setSelectMode(selectMode); + if (!selectMode && onSelectionChange) { + onSelectionChange([]); + } + }; - private handleResize = debounce( + const handleResize = debounce( () => { - const {selectedItems} = this.props; - const {selectMode, smallScreen} = this.state; const newSmallScreen = isSmallScreen(); - if ( selectedItems && selectedItems.length === 0 && selectMode && !newSmallScreen ) { - this.handleSelectMode(false); + handleSelectMode(false); } if (smallScreen !== newSmallScreen) { - this.setState({smallScreen: newSmallScreen}); + setSmallScreen(newSmallScreen); } }, 50, {leading: true, trailing: true, maxWait: 50}, ); - constructor(props: CombinedProps) { - super(props); - - const { - selectedItems, - polaris: {intl}, - } = props; - - this.defaultResourceName = { - singular: intl.translate('Polaris.ResourceList.defaultItemSingular'), - plural: intl.translate('Polaris.ResourceList.defaultItemPlural'), - }; - - // eslint-disable-next-line react/state-in-constructor - this.state = { - selectMode: Boolean(selectedItems && selectedItems.length > 0), - loadingPosition: 0, - lastSelected: null, - smallScreen: isSmallScreen(), - checkableButtons: new Map(), - }; - } - - private selectable() { - const {promotedBulkActions, bulkActions, selectable} = this.props; - - return Boolean( - (promotedBulkActions && promotedBulkActions.length > 0) || - (bulkActions && bulkActions.length > 0) || - selectable, - ); - } + const isSelectable = Boolean( + (promotedBulkActions && promotedBulkActions.length > 0) || + (bulkActions && bulkActions.length > 0) || + selectable, + ); - private bulkSelectState(): boolean | 'indeterminate' { - const {selectedItems, items} = this.props; + const bulkSelectState = (): boolean | 'indeterminate' => { let selectState: boolean | 'indeterminate' = 'indeterminate'; if ( !selectedItems || @@ -179,17 +217,13 @@ class ResourceListInner extends React.Component { selectState = true; } return selectState; - } + }; - private headerTitle() { - const { - resourceName = this.defaultResourceName, - items, - polaris: {intl}, - loading, - totalItemsCount, - } = this.props; + const resourceName = resourceNameProp + ? resourceNameProp + : defaultResourceName.current; + const headerTitle = () => { const itemsCount = items.length; const resource = !loading && @@ -198,64 +232,53 @@ class ResourceListInner extends React.Component { : resourceName.plural; if (loading) { - return intl.translate('Polaris.ResourceList.loading', {resource}); + return i18n.translate('Polaris.ResourceList.loading', {resource}); } else if (totalItemsCount) { - return intl.translate('Polaris.ResourceList.showingTotalCount', { + return i18n.translate('Polaris.ResourceList.showingTotalCount', { itemsCount, totalItemsCount, resource, }); } else { - return intl.translate('Polaris.ResourceList.showing', { + return i18n.translate('Polaris.ResourceList.showing', { itemsCount, resource, }); } - } - - private bulkActionsLabel() { - const { - selectedItems = [], - items, - polaris: {intl}, - } = this.props; + }; + const bulkActionsLabel = () => { const selectedItemsCount = selectedItems === SELECT_ALL_ITEMS ? `${items.length}+` : selectedItems.length; - return intl.translate('Polaris.ResourceList.selected', { + return i18n.translate('Polaris.ResourceList.selected', { selectedItemsCount, }); - } - - private bulkActionsAccessibilityLabel() { - const { - resourceName = this.defaultResourceName, - selectedItems = [], - items, - polaris: {intl}, - } = this.props; + }; + const bulkActionsAccessibilityLabel = () => { const selectedItemsCount = selectedItems.length; const totalItemsCount = items.length; const allSelected = selectedItemsCount === totalItemsCount; if (totalItemsCount === 1 && allSelected) { - return intl.translate( + return i18n.translate( 'Polaris.ResourceList.a11yCheckboxDeselectAllSingle', - {resourceNameSingular: resourceName.singular}, + { + resourceNameSingular: resourceName.singular, + }, ); } else if (totalItemsCount === 1) { - return intl.translate( + return i18n.translate( 'Polaris.ResourceList.a11yCheckboxSelectAllSingle', { resourceNameSingular: resourceName.singular, }, ); } else if (allSelected) { - return intl.translate( + return i18n.translate( 'Polaris.ResourceList.a11yCheckboxDeselectAllMultiple', { itemsLength: items.length, @@ -263,7 +286,7 @@ class ResourceListInner extends React.Component { }, ); } else { - return intl.translate( + return i18n.translate( 'Polaris.ResourceList.a11yCheckboxSelectAllMultiple', { itemsLength: items.length, @@ -271,360 +294,67 @@ class ResourceListInner extends React.Component { }, ); } - } - - private paginatedSelectAllText() { - const { - hasMoreItems, - selectedItems, - items, - resourceName = this.defaultResourceName, - polaris: {intl}, - } = this.props; - - if (!this.selectable() || !hasMoreItems) { + }; + + const paginatedSelectAllText = () => { + if (!isSelectable || !hasMoreItems) { return; } if (selectedItems === SELECT_ALL_ITEMS) { - return intl.translate('Polaris.ResourceList.allItemsSelected', { + return i18n.translate('Polaris.ResourceList.allItemsSelected', { itemsLength: items.length, resourceNamePlural: resourceName.plural, }); } - } - - private paginatedSelectAllAction() { - const { - hasMoreItems, - selectedItems, - items, - resourceName = this.defaultResourceName, - polaris: {intl}, - } = this.props; - - if (!this.selectable() || !hasMoreItems) { + }; + + const paginatedSelectAllAction = () => { + if (!isSelectable || !hasMoreItems) { return; } const actionText = selectedItems === SELECT_ALL_ITEMS - ? intl.translate('Polaris.Common.undo') - : intl.translate('Polaris.ResourceList.selectAllItems', { + ? i18n.translate('Polaris.Common.undo') + : i18n.translate('Polaris.ResourceList.selectAllItems', { itemsLength: items.length, resourceNamePlural: resourceName.plural, }); return { content: actionText, - onAction: this.handleSelectAllItemsInStore, + onAction: handleSelectAllItemsInStore, }; - } - - private emptySearchResultText() { - const { - polaris: {intl}, - resourceName = this.defaultResourceName, - } = this.props; - - return { - title: intl.translate('Polaris.ResourceList.emptySearchResultTitle', { - resourceNamePlural: resourceName.plural, - }), - description: intl.translate( - 'Polaris.ResourceList.emptySearchResultDescription', - ), - }; - } - - // eslint-disable-next-line @typescript-eslint/member-ordering - componentDidMount() { - this.forceUpdate(); - if (this.props.loading) { - this.setLoadingPosition(); - } - } - - // eslint-disable-next-line @typescript-eslint/member-ordering - componentDidUpdate({ - loading: prevLoading, - items: prevItems, - selectedItems: prevSelectedItems, - }: ResourceListProps) { - const {selectedItems, loading} = this.props; - - if ( - this.listRef.current && - this.itemsExist() && - !this.itemsExist(prevItems) - ) { - this.forceUpdate(); - } + }; - if (loading && !prevLoading) { - this.setLoadingPosition(); - } + const emptySearchResultText = { + title: i18n.translate('Polaris.ResourceList.emptySearchResultTitle', { + resourceNamePlural: resourceName.plural, + }), + description: i18n.translate( + 'Polaris.ResourceList.emptySearchResultDescription', + ), + }; - if (selectedItems && selectedItems.length > 0 && !this.state.selectMode) { - // eslint-disable-next-line react/no-did-update-set-state - this.setState({selectMode: true}); - return; - } + const handleSelectAllItemsInStore = () => { + const newlySelectedItems = + selectedItems === SELECT_ALL_ITEMS + ? getAllItemsOnPage(items, idForItem) + : SELECT_ALL_ITEMS; - if ( - prevSelectedItems && - prevSelectedItems.length > 0 && - (!selectedItems || selectedItems.length === 0) && - !isSmallScreen() - ) { - // eslint-disable-next-line react/no-did-update-set-state - this.setState({selectMode: false}); + if (onSelectionChange) { + onSelectionChange(newlySelectedItems); } - } - - // eslint-disable-next-line @typescript-eslint/member-ordering - render() { - const { - items, - promotedBulkActions, - bulkActions, - filterControl, - emptyState, - emptySearchState, - loading, - showHeader = false, - sortOptions, - sortValue, - alternateTool, - selectedItems, - resourceName = this.defaultResourceName, - onSortChange, - polaris: {intl}, - } = this.props; - const {selectMode, loadingPosition, smallScreen} = this.state; - const filterControlMarkup = filterControl ? ( -
{filterControl}
- ) : null; - - const bulkActionsMarkup = this.selectable() ? ( -
- -
- ) : null; - - const sortingSelectMarkup = - sortOptions && sortOptions.length > 0 && !alternateTool ? ( -
- +
+ ) : null; -function defaultIdForItem(item: any, index: number) { - return Object.prototype.hasOwnProperty.call(item, 'id') - ? item.id - : index.toString(); -} + const alternateToolMarkup = + alternateTool && !sortingSelectMarkup ? ( +
{alternateTool}
+ ) : null; -function isSmallScreen() { - return typeof window === 'undefined' - ? false - : window.innerWidth < SMALL_SCREEN_WIDTH; -} + const headerTitleMarkup = ( +
+ {headerTitle()} +
+ ); + + const selectButtonMarkup = isSelectable ? ( +
+ +
+ ) : null; + + const checkableButtonMarkup = isSelectable ? ( +
+ +
+ ) : null; + + const needsHeader = + isSelectable || (sortOptions && sortOptions.length > 0) || alternateTool; + + const headerWrapperOverlay = loading ? ( +
+ ) : null; + + const showEmptyState = emptyState && !itemsExist && !loading; + + const showEmptySearchState = + !showEmptyState && filterControl && !itemsExist && !loading; + + const headerMarkup = !showEmptyState && + !showEmptySearchState && + (showHeader || needsHeader) && + listRef.current && ( +
+ + {(isSticky: boolean) => { + const headerClassName = classNames( + styles.HeaderWrapper, + sortOptions && + sortOptions.length > 0 && + !alternateTool && + styles['HeaderWrapper-hasSort'], + alternateTool && styles['HeaderWrapper-hasAlternateTool'], + isSelectable && styles['HeaderWrapper-hasSelect'], + loading && styles['HeaderWrapper-disabled'], + isSelectable && + selectMode && + styles['HeaderWrapper-inSelectMode'], + isSticky && styles['HeaderWrapper-isSticky'], + ); + return ( +
+ + {headerWrapperOverlay} +
+ {headerTitleMarkup} + {checkableButtonMarkup} + {alternateToolMarkup} + {sortingSelectMarkup} + {selectButtonMarkup} +
+ {bulkActionsMarkup} +
+ ); + }} +
+
+ ); + + const emptySearchStateMarkup = showEmptySearchState + ? emptySearchState || ( +
+ +
+ ) + : null; + + const emptyStateMarkup = showEmptyState ? emptyState : null; + + const defaultTopPadding = 8; + const topPadding = loadingPosition > 0 ? loadingPosition : defaultTopPadding; + const spinnerStyle = {paddingTop: `${topPadding}px`}; + + const spinnerSize = items.length < 2 ? 'small' : 'large'; + + const loadingOverlay = loading ? ( + +
+ +
+
+ + ) : null; + + const className = classNames( + styles.ItemWrapper, + loading && styles['ItemWrapper-isLoading'], + ); + const loadingWithoutItemsMarkup = + loading && !itemsExist ? ( +
+ {loadingOverlay} +
+ ) : null; + + const resourceListClassName = classNames( + styles.ResourceList, + loading && styles.disabledPointerEvents, + selectMode && styles.disableTextSelection, + ); + + const listMarkup = itemsExist ? ( +
    + {loadingOverlay} + {items.map(renderItemWithId)} +
+ ) : null; + + const context = { + selectable: isSelectable, + selectedItems, + selectMode, + resourceName, + loading, + onSelectionChange: handleSelectionChange, + registerCheckableButtons: handleCheckableButtonRegistration, + }; + + return ( + +
+ {filterControlMarkup} + {headerMarkup} + {listMarkup} + {emptySearchStateMarkup} + {emptyStateMarkup} + {loadingWithoutItemsMarkup} +
+
+ ); +}; -export const ResourceList = withAppProvider()( - ResourceListInner, -); +ResourceList.Item = ResourceItem; +// eslint-disable-next-line import/no-deprecated +ResourceList.FilterControl = FilterControl; diff --git a/src/components/ResourceList/tests/ResourceList.test.tsx b/src/components/ResourceList/tests/ResourceList.test.tsx index 04e90ca1d1b..da4eed90db2 100644 --- a/src/components/ResourceList/tests/ResourceList.test.tsx +++ b/src/components/ResourceList/tests/ResourceList.test.tsx @@ -28,6 +28,7 @@ const itemsWithID = [ {id: '6', name: 'item 2', url: 'www.test.com', title: 'title 2'}, {id: '7', name: 'item 3', url: 'www.test.com', title: 'title 3'}, ]; + const allSelectedIDs = ['5', '6', '7']; const promotedBulkActions = [{content: 'action'}, {content: 'action 2'}]; const bulkActions = [{content: 'action 3'}, {content: 'action 4'}]; @@ -89,6 +90,7 @@ describe('', () => { promotedBulkActions={promotedBulkActions} />, ); + expect(resourceList.find(BulkActions).exists()).toBe(true); }); @@ -942,7 +944,7 @@ describe('', () => { expect(deselectAllCheckbox.getDOMNode()).toBe(document.activeElement); }); - it('focuses the plain CheckableButton checkbox when items are selected and the deselect Checkable button the is clicked', () => { + it('focuses the plain CheckableButton checkbox when items are selected and the deselect Checkable button is clicked', () => { const resourceList = mountWithAppProvider(