Skip to content

Commit a9188f1

Browse files
Merge pull request #1261 from Shopify/rl-ms
[ResourceList] Multiselect
2 parents 83fb11a + 5e34a64 commit a9188f1

File tree

8 files changed

+340
-26
lines changed

8 files changed

+340
-26
lines changed

UNRELEASED.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ Use [the changelog guidelines](https://git.io/polaris-changelog-guidelines) to f
1818

1919
- Made the `action` prop optional on `EmptyState` ([#1583](https://github.com/Shopify/polaris-react/pull/1583))
2020
- Prevented Firefox from showing an extra dotted border on focused buttons ([#1409](https://github.com/Shopify/polaris-react/pull/1409))
21+
- Added `resolveItemId` prop to `ResourceList` which is used in the new multiselect feature ([#1261](https://github.com/Shopify/polaris-react/pull/1261))
2122

2223
### Bug fixes
2324

src/components/ResourceList/README.md

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -615,6 +615,129 @@ Use persistent shortcut actions in rare cases when the action cannot be made ava
615615
</Card>
616616
```
617617

618+
### Resource list with multiselect
619+
620+
Allows merchants to select or deselect multiple items at once.
621+
622+
```jsx
623+
class ResourceListExample extends React.Component {
624+
state = {
625+
selectedItems: [],
626+
};
627+
628+
handleSelectionChange = (selectedItems) => {
629+
this.setState({selectedItems});
630+
};
631+
632+
renderItem = (item, _, index) => {
633+
const {id, url, name, location} = item;
634+
const media = <Avatar customer size="medium" name={name} />;
635+
636+
return (
637+
<ResourceList.Item
638+
id={id}
639+
url={url}
640+
media={media}
641+
sortOrder={index}
642+
accessibilityLabel={`View details for ${name}`}
643+
>
644+
<h3>
645+
<TextStyle variation="strong">{name}</TextStyle>
646+
</h3>
647+
<div>{location}</div>
648+
</ResourceList.Item>
649+
);
650+
};
651+
652+
render() {
653+
const resourceName = {
654+
singular: 'customer',
655+
plural: 'customers',
656+
};
657+
658+
const items = [
659+
{
660+
id: 231,
661+
url: 'customers/231',
662+
name: 'Mae Jemison',
663+
location: 'Decatur, USA',
664+
},
665+
{
666+
id: 246,
667+
url: 'customers/246',
668+
name: 'Ellen Ochoa',
669+
location: 'Los Angeles, USA',
670+
},
671+
{
672+
id: 276,
673+
url: 'customers/276',
674+
name: 'Joe Smith',
675+
location: 'Arizona, USA',
676+
},
677+
{
678+
id: 349,
679+
url: 'customers/349',
680+
name: 'Haden Jerado',
681+
location: 'Decatur, USA',
682+
},
683+
{
684+
id: 419,
685+
url: 'customers/419',
686+
name: 'Tom Thommas',
687+
location: 'Florida, USA',
688+
},
689+
{
690+
id: 516,
691+
url: 'customers/516',
692+
name: 'Emily Amrak',
693+
location: 'Texas, USA',
694+
},
695+
];
696+
697+
const promotedBulkActions = [
698+
{
699+
content: 'Edit customers',
700+
onAction: () => console.log('Todo: implement bulk edit'),
701+
},
702+
];
703+
704+
const bulkActions = [
705+
{
706+
content: 'Add tags',
707+
onAction: () => console.log('Todo: implement bulk add tags'),
708+
},
709+
{
710+
content: 'Remove tags',
711+
onAction: () => console.log('Todo: implement bulk remove tags'),
712+
},
713+
{
714+
content: 'Delete customers',
715+
onAction: () => console.log('Todo: implement bulk delete'),
716+
},
717+
];
718+
719+
return (
720+
<Card>
721+
<ResourceList
722+
resourceName={resourceName}
723+
items={items}
724+
renderItem={this.renderItem}
725+
selectedItems={this.state.selectedItems}
726+
onSelectionChange={this.handleSelectionChange}
727+
promotedBulkActions={promotedBulkActions}
728+
bulkActions={bulkActions}
729+
resolveItemId={resolveItemIds}
730+
/>
731+
</Card>
732+
);
733+
}
734+
}
735+
736+
function resolveItemIds({id}) {
737+
return id;
738+
}
739+
```
740+
618741
### Resource list with all of its elements
619742

620743
Use as a broad example that includes most props available to resource list.

src/components/ResourceList/ResourceList.scss

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -236,3 +236,7 @@ $item-wrapper-loading-height: rem(64px);
236236
.DisabledPointerEvents {
237237
@include disabled-pointer-events;
238238
}
239+
240+
.disableTextSelection {
241+
user-select: none;
242+
}

src/components/ResourceList/ResourceList.tsx

Lines changed: 53 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ export type Items = any[];
3535
export interface State {
3636
selectMode: boolean;
3737
loadingPosition: number;
38+
lastSelected: number | null;
3839
}
3940

4041
export interface Props {
@@ -69,9 +70,11 @@ export interface Props {
6970
/** Callback when selection is changed */
7071
onSelectionChange?(selectedItems: SelectedItems): void;
7172
/** Function to render each list item */
72-
renderItem(item: any, id: string): React.ReactNode;
73+
renderItem(item: any, id: string, index: number): React.ReactNode;
7374
/** Function to customize the unique ID for each item */
7475
idForItem?(item: any, index: number): string;
76+
/** Function to resolve an id from a item */
77+
resolveItemId?(item: any): string;
7578
}
7679

7780
export type CombinedProps = Props & WithAppProviderProps;
@@ -115,6 +118,7 @@ export class ResourceList extends React.Component<CombinedProps, State> {
115118
this.state = {
116119
selectMode: Boolean(selectedItems && selectedItems.length > 0),
117120
loadingPosition: 0,
121+
lastSelected: null,
118122
};
119123
}
120124

@@ -533,6 +537,7 @@ export class ResourceList extends React.Component<CombinedProps, State> {
533537
const resourceListClassName = classNames(
534538
styles.ResourceList,
535539
loading && styles.disabledPointerEvents,
540+
selectMode && styles.disableTextSelection,
536541
);
537542

538543
const listMarkup = this.itemsExist() ? (
@@ -617,32 +622,72 @@ export class ResourceList extends React.Component<CombinedProps, State> {
617622

618623
return (
619624
<li key={id} className={styles.ItemWrapper}>
620-
{renderItem(item, id)}
625+
{renderItem(item, id, index)}
621626
</li>
622627
);
623628
};
624629

625-
private handleSelectionChange = (selected: boolean, id: string) => {
630+
private handleMultiSelectionChange = (
631+
lastSelected: number,
632+
currentSelected: number,
633+
resolveItemId: (item: any) => string,
634+
) => {
635+
const min = Math.min(lastSelected, currentSelected);
636+
const max = Math.max(lastSelected, currentSelected);
637+
return this.props.items.slice(min, max + 1).map(resolveItemId);
638+
};
639+
640+
private handleSelectionChange = (
641+
selected: boolean,
642+
id: string,
643+
sortOrder: number | undefined,
644+
shiftKey: boolean,
645+
) => {
626646
const {
627647
onSelectionChange,
628648
selectedItems,
629649
items,
630650
idForItem = defaultIdForItem,
651+
resolveItemId,
631652
} = this.props;
653+
const {lastSelected} = this.state;
632654

633655
if (selectedItems == null || onSelectionChange == null) {
634656
return;
635657
}
636658

637-
const newlySelectedItems =
659+
let newlySelectedItems =
638660
selectedItems === SELECT_ALL_ITEMS
639661
? getAllItemsOnPage(items, idForItem)
640662
: [...selectedItems];
641663

642-
if (selected) {
643-
newlySelectedItems.push(id);
644-
} else {
645-
newlySelectedItems.splice(newlySelectedItems.indexOf(id), 1);
664+
if (sortOrder !== undefined) {
665+
this.setState({lastSelected: sortOrder});
666+
}
667+
668+
let selectedIds: string[] = [id];
669+
670+
if (
671+
shiftKey &&
672+
lastSelected != null &&
673+
sortOrder !== undefined &&
674+
resolveItemId
675+
) {
676+
selectedIds = this.handleMultiSelectionChange(
677+
lastSelected,
678+
sortOrder,
679+
resolveItemId,
680+
);
681+
}
682+
newlySelectedItems = [...new Set([...newlySelectedItems, ...selectedIds])];
683+
684+
if (!selected) {
685+
for (let i = 0; i < selectedIds.length; i++) {
686+
newlySelectedItems.splice(
687+
newlySelectedItems.indexOf(selectedIds[i]),
688+
1,
689+
);
690+
}
646691
}
647692

648693
if (newlySelectedItems.length === 0 && !isSmallScreen()) {

src/components/ResourceList/components/Item/Item.tsx

Lines changed: 18 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ export interface BaseProps {
4040
media?: React.ReactElement<AvatarProps | ThumbnailProps>;
4141
persistActions?: boolean;
4242
shortcutActions?: DisableableAction[];
43+
sortOrder?: number;
4344
children?: React.ReactNode;
4445
}
4546

@@ -158,15 +159,16 @@ export class Item extends React.Component<CombinedProps, State> {
158159
testID="LargerSelectionArea"
159160
>
160161
<div onClick={stopPropagation} className={styles.CheckboxWrapper}>
161-
<Checkbox
162-
testID="Checkbox"
163-
id={this.checkboxId}
164-
label={label}
165-
labelHidden
166-
onChange={this.handleSelection}
167-
checked={selected}
168-
disabled={loading}
169-
/>
162+
<div onChange={this.handleLargerSelectionArea}>
163+
<Checkbox
164+
testID="Checkbox"
165+
id={this.checkboxId}
166+
label={label}
167+
labelHidden
168+
checked={selected}
169+
disabled={loading}
170+
/>
171+
</div>
170172
</div>
171173
</div>
172174
);
@@ -327,18 +329,22 @@ export class Item extends React.Component<CombinedProps, State> {
327329

328330
private handleLargerSelectionArea = (event: React.MouseEvent<any>) => {
329331
stopPropagation(event);
330-
this.handleSelection(!this.state.selected);
332+
this.handleSelection(!this.state.selected, event.nativeEvent.shiftKey);
331333
};
332334

333-
private handleSelection = (value: boolean) => {
335+
private handleSelection = (value: boolean, shiftKey: boolean) => {
334336
const {
335337
id,
338+
sortOrder,
336339
context: {onSelectionChange},
337340
} = this.props;
341+
338342
if (id == null || onSelectionChange == null) {
339343
return;
340344
}
341-
onSelectionChange(value, id);
345+
346+
this.setState({focused: true, focusedInner: true});
347+
onSelectionChange(value, id, sortOrder, shiftKey);
342348
};
343349

344350
private handleClick = (event: React.MouseEvent<any>) => {

src/components/ResourceList/components/Item/tests/Item.test.tsx

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -270,16 +270,21 @@ describe('<Item />', () => {
270270
});
271271

272272
it('calls onSelectionChange with the id of the item when clicking the LargerSelectionArea', () => {
273+
const sortOrder = 0;
273274
const wrapper = mountWithAppProvider(
274275
<Provider value={mockSelectableContext}>
275-
<Item id={itemId} url={url} />
276+
<Item id={itemId} url={url} sortOrder={sortOrder} />
276277
</Provider>,
277278
);
278279

279-
findByTestID(wrapper, 'LargerSelectionArea').simulate('click');
280+
findByTestID(wrapper, 'LargerSelectionArea').simulate('click', {
281+
nativeEvent: {shiftKey: false},
282+
});
280283
expect(mockSelectableContext.onSelectionChange).toHaveBeenCalledWith(
281284
true,
282285
itemId,
286+
sortOrder,
287+
false,
283288
);
284289
});
285290
});
@@ -299,21 +304,27 @@ describe('<Item />', () => {
299304

300305
it('calls onSelectionChange with the id of the item even if url or onClick is present', () => {
301306
const onClick = jest.fn();
307+
const sortOrder = 0;
302308
const wrapper = mountWithAppProvider(
303-
<Provider value={mockSelectableContext}>
309+
<Provider value={mockSelectModeContext}>
304310
<Item
305311
id={itemId}
306312
url={url}
307313
onClick={onClick}
314+
sortOrder={sortOrder}
308315
accessibilityLabel={ariaLabel}
309316
/>
310317
</Provider>,
311318
);
312319

313-
findByTestID(wrapper, 'Item-Wrapper').simulate('click');
320+
findByTestID(wrapper, 'Item-Wrapper').simulate('click', {
321+
nativeEvent: {shiftKey: false},
322+
});
314323
expect(mockSelectModeContext.onSelectionChange).toHaveBeenCalledWith(
315324
true,
316325
itemId,
326+
sortOrder,
327+
false,
317328
);
318329
});
319330

0 commit comments

Comments
 (0)