diff --git a/UNRELEASED.md b/UNRELEASED.md index d04098a0f2d..ccf0079c2f7 100644 --- a/UNRELEASED.md +++ b/UNRELEASED.md @@ -18,6 +18,7 @@ Use [the changelog guidelines](https://git.io/polaris-changelog-guidelines) to f - Made the `action` prop optional on `EmptyState` ([#1583](https://github.com/Shopify/polaris-react/pull/1583)) - Prevented Firefox from showing an extra dotted border on focused buttons ([#1409](https://github.com/Shopify/polaris-react/pull/1409)) +- Added `resolveItemId` prop to `ResourceList` which is used in the new multiselect feature ([#1261](https://github.com/Shopify/polaris-react/pull/1261)) ### Bug fixes diff --git a/src/components/ResourceList/README.md b/src/components/ResourceList/README.md index 1114ff31488..3d27177f716 100644 --- a/src/components/ResourceList/README.md +++ b/src/components/ResourceList/README.md @@ -615,6 +615,129 @@ Use persistent shortcut actions in rare cases when the action cannot be made ava ``` +### Resource list with multiselect + +Allows merchants to select or deselect multiple items at once. + +```jsx +class ResourceListExample extends React.Component { + state = { + selectedItems: [], + }; + + handleSelectionChange = (selectedItems) => { + this.setState({selectedItems}); + }; + + renderItem = (item, _, index) => { + const {id, url, name, location} = item; + const media = ; + + return ( + +

+ {name} +

+
{location}
+
+ ); + }; + + render() { + const resourceName = { + singular: 'customer', + plural: 'customers', + }; + + const items = [ + { + id: 231, + url: 'customers/231', + name: 'Mae Jemison', + location: 'Decatur, USA', + }, + { + id: 246, + url: 'customers/246', + name: 'Ellen Ochoa', + location: 'Los Angeles, USA', + }, + { + id: 276, + url: 'customers/276', + name: 'Joe Smith', + location: 'Arizona, USA', + }, + { + id: 349, + url: 'customers/349', + name: 'Haden Jerado', + location: 'Decatur, USA', + }, + { + id: 419, + url: 'customers/419', + name: 'Tom Thommas', + location: 'Florida, USA', + }, + { + id: 516, + url: 'customers/516', + name: 'Emily Amrak', + location: 'Texas, USA', + }, + ]; + + const promotedBulkActions = [ + { + content: 'Edit customers', + onAction: () => console.log('Todo: implement bulk edit'), + }, + ]; + + const bulkActions = [ + { + content: 'Add tags', + onAction: () => console.log('Todo: implement bulk add tags'), + }, + { + content: 'Remove tags', + onAction: () => console.log('Todo: implement bulk remove tags'), + }, + { + content: 'Delete customers', + onAction: () => console.log('Todo: implement bulk delete'), + }, + ]; + + return ( + + + + ); + } +} + +function resolveItemIds({id}) { + return id; +} +``` + ### Resource list with all of its elements Use as a broad example that includes most props available to resource list. diff --git a/src/components/ResourceList/ResourceList.scss b/src/components/ResourceList/ResourceList.scss index c42a1c64b9e..a9bba742c4b 100644 --- a/src/components/ResourceList/ResourceList.scss +++ b/src/components/ResourceList/ResourceList.scss @@ -236,3 +236,7 @@ $item-wrapper-loading-height: rem(64px); .DisabledPointerEvents { @include disabled-pointer-events; } + +.disableTextSelection { + user-select: none; +} diff --git a/src/components/ResourceList/ResourceList.tsx b/src/components/ResourceList/ResourceList.tsx index bd84057313b..952edbcfb0e 100644 --- a/src/components/ResourceList/ResourceList.tsx +++ b/src/components/ResourceList/ResourceList.tsx @@ -35,6 +35,7 @@ export type Items = any[]; export interface State { selectMode: boolean; loadingPosition: number; + lastSelected: number | null; } export interface Props { @@ -69,9 +70,11 @@ export interface Props { /** Callback when selection is changed */ onSelectionChange?(selectedItems: SelectedItems): void; /** Function to render each list item */ - renderItem(item: any, id: string): React.ReactNode; + renderItem(item: any, id: string, index: number): React.ReactNode; /** Function to customize the unique ID for each item */ idForItem?(item: any, index: number): string; + /** Function to resolve an id from a item */ + resolveItemId?(item: any): string; } export type CombinedProps = Props & WithAppProviderProps; @@ -115,6 +118,7 @@ export class ResourceList extends React.Component { this.state = { selectMode: Boolean(selectedItems && selectedItems.length > 0), loadingPosition: 0, + lastSelected: null, }; } @@ -533,6 +537,7 @@ export class ResourceList extends React.Component { const resourceListClassName = classNames( styles.ResourceList, loading && styles.disabledPointerEvents, + selectMode && styles.disableTextSelection, ); const listMarkup = this.itemsExist() ? ( @@ -617,32 +622,72 @@ export class ResourceList extends React.Component { return (
  • - {renderItem(item, id)} + {renderItem(item, id, index)}
  • ); }; - private handleSelectionChange = (selected: boolean, id: string) => { + private handleMultiSelectionChange = ( + lastSelected: number, + currentSelected: number, + resolveItemId: (item: any) => string, + ) => { + const min = Math.min(lastSelected, currentSelected); + const max = Math.max(lastSelected, currentSelected); + return this.props.items.slice(min, max + 1).map(resolveItemId); + }; + + private handleSelectionChange = ( + selected: boolean, + id: string, + sortOrder: number | undefined, + shiftKey: boolean, + ) => { const { onSelectionChange, selectedItems, items, idForItem = defaultIdForItem, + resolveItemId, } = this.props; + const {lastSelected} = this.state; if (selectedItems == null || onSelectionChange == null) { return; } - const newlySelectedItems = + let newlySelectedItems = selectedItems === SELECT_ALL_ITEMS ? getAllItemsOnPage(items, idForItem) : [...selectedItems]; - if (selected) { - newlySelectedItems.push(id); - } else { - newlySelectedItems.splice(newlySelectedItems.indexOf(id), 1); + if (sortOrder !== undefined) { + this.setState({lastSelected: sortOrder}); + } + + let selectedIds: string[] = [id]; + + if ( + shiftKey && + lastSelected != null && + sortOrder !== undefined && + resolveItemId + ) { + selectedIds = this.handleMultiSelectionChange( + lastSelected, + sortOrder, + resolveItemId, + ); + } + newlySelectedItems = [...new Set([...newlySelectedItems, ...selectedIds])]; + + if (!selected) { + for (let i = 0; i < selectedIds.length; i++) { + newlySelectedItems.splice( + newlySelectedItems.indexOf(selectedIds[i]), + 1, + ); + } } if (newlySelectedItems.length === 0 && !isSmallScreen()) { diff --git a/src/components/ResourceList/components/Item/Item.tsx b/src/components/ResourceList/components/Item/Item.tsx index 6f839ca922e..bdcdfc865aa 100644 --- a/src/components/ResourceList/components/Item/Item.tsx +++ b/src/components/ResourceList/components/Item/Item.tsx @@ -40,6 +40,7 @@ export interface BaseProps { media?: React.ReactElement; persistActions?: boolean; shortcutActions?: DisableableAction[]; + sortOrder?: number; children?: React.ReactNode; } @@ -158,15 +159,16 @@ export class Item extends React.Component { testID="LargerSelectionArea" >
    - +
    + +
    ); @@ -327,18 +329,22 @@ export class Item extends React.Component { private handleLargerSelectionArea = (event: React.MouseEvent) => { stopPropagation(event); - this.handleSelection(!this.state.selected); + this.handleSelection(!this.state.selected, event.nativeEvent.shiftKey); }; - private handleSelection = (value: boolean) => { + private handleSelection = (value: boolean, shiftKey: boolean) => { const { id, + sortOrder, context: {onSelectionChange}, } = this.props; + if (id == null || onSelectionChange == null) { return; } - onSelectionChange(value, id); + + this.setState({focused: true, focusedInner: true}); + onSelectionChange(value, id, sortOrder, shiftKey); }; private handleClick = (event: React.MouseEvent) => { diff --git a/src/components/ResourceList/components/Item/tests/Item.test.tsx b/src/components/ResourceList/components/Item/tests/Item.test.tsx index c4a8b573ced..d4ccf67a5d9 100644 --- a/src/components/ResourceList/components/Item/tests/Item.test.tsx +++ b/src/components/ResourceList/components/Item/tests/Item.test.tsx @@ -270,16 +270,21 @@ describe('', () => { }); it('calls onSelectionChange with the id of the item when clicking the LargerSelectionArea', () => { + const sortOrder = 0; const wrapper = mountWithAppProvider( - + , ); - findByTestID(wrapper, 'LargerSelectionArea').simulate('click'); + findByTestID(wrapper, 'LargerSelectionArea').simulate('click', { + nativeEvent: {shiftKey: false}, + }); expect(mockSelectableContext.onSelectionChange).toHaveBeenCalledWith( true, itemId, + sortOrder, + false, ); }); }); @@ -299,21 +304,27 @@ describe('', () => { it('calls onSelectionChange with the id of the item even if url or onClick is present', () => { const onClick = jest.fn(); + const sortOrder = 0; const wrapper = mountWithAppProvider( - + , ); - findByTestID(wrapper, 'Item-Wrapper').simulate('click'); + findByTestID(wrapper, 'Item-Wrapper').simulate('click', { + nativeEvent: {shiftKey: false}, + }); expect(mockSelectModeContext.onSelectionChange).toHaveBeenCalledWith( true, itemId, + sortOrder, + false, ); }); diff --git a/src/components/ResourceList/tests/ResourceList.test.tsx b/src/components/ResourceList/tests/ResourceList.test.tsx index cef54d22c96..be1f2bc2c1e 100644 --- a/src/components/ResourceList/tests/ResourceList.test.tsx +++ b/src/components/ResourceList/tests/ResourceList.test.tsx @@ -722,6 +722,124 @@ describe('', () => { expect(resourceList.find(BulkActions).prop('selectMode')).toBe(false); }); }); + + describe('multiselect', () => { + it('selects shift selected items if resolveItemId was provided', () => { + function resolveItemId(item: any) { + return item.id; + } + const onSelectionChange = jest.fn(); + const resourceList = mountWithAppProvider( + , + ); + const firstItem = resourceList.find(Item).first(); + findByTestID(firstItem, 'LargerSelectionArea').simulate('click'); + + const lastItem = resourceList.find(Item).last(); + findByTestID(lastItem, 'LargerSelectionArea').simulate('click', { + nativeEvent: {shiftKey: true}, + }); + + expect(onSelectionChange).toHaveBeenCalledWith(['5', '6', '7']); + }); + + it('does not select shift selected items if resolveItemId was not provided', () => { + const onSelectionChange = jest.fn(); + const resourceList = mountWithAppProvider( + , + ); + const firstItem = resourceList.find(Item).first(); + findByTestID(firstItem, 'LargerSelectionArea').simulate('click'); + + const lastItem = resourceList.find(Item).last(); + findByTestID(lastItem, 'LargerSelectionArea').simulate('click', { + nativeEvent: {shiftKey: true}, + }); + + expect(onSelectionChange).toHaveBeenCalledWith(['7']); + }); + + it('does not select shift selected items if sortOrder is not provided', () => { + function resolveItemId(item: any) { + return item.id; + } + + function renderItem(item: any, id: any) { + return ( + +
    Item {id}
    +
    {item.title}
    +
    + ); + } + + const onSelectionChange = jest.fn(); + const resourceList = mountWithAppProvider( + , + ); + const firstItem = resourceList.find(Item).first(); + findByTestID(firstItem, 'LargerSelectionArea').simulate('click'); + + const lastItem = resourceList.find(Item).last(); + findByTestID(lastItem, 'LargerSelectionArea').simulate('click', { + nativeEvent: {shiftKey: true}, + }); + + expect(onSelectionChange).toHaveBeenCalledWith(['7']); + }); + + it('deselects shift selected items if resolveItemId was provided', () => { + const selectedItems = ['6', '7']; + function resolveItemId(item: any) { + return item.id; + } + const onSelectionChange = jest.fn(); + const resourceList = mountWithAppProvider( + , + ); + // Sets {lastSeleced: 0} + const firstItem = resourceList.find(Item).first(); + findByTestID(firstItem, 'LargerSelectionArea').simulate('click'); + + const lastItem = resourceList.find(Item).last(); + findByTestID(lastItem, 'LargerSelectionArea').simulate('click', { + nativeEvent: {shiftKey: true}, + }); + + expect(onSelectionChange).toHaveBeenCalledWith([]); + }); + }); }); function idForItem(item: any) { @@ -736,11 +854,12 @@ function renderCustomMarkup(item: any) { return

    {item.title}

    ; } -function renderItem(item: any, id: any) { +function renderItem(item: any, id: any, index: number) { return (
    Item {id}
    diff --git a/src/components/ResourceList/types.ts b/src/components/ResourceList/types.ts index a184f0ad902..18f29368491 100644 --- a/src/components/ResourceList/types.ts +++ b/src/components/ResourceList/types.ts @@ -11,5 +11,10 @@ export interface ResourceListContext { plural: string; }; loading?: boolean; - onSelectionChange?(selected: boolean, id: string): void; + onSelectionChange?( + selected: boolean, + id: string, + sortNumber: number | undefined, + shiftKey: boolean, + ): void; }