Skip to content

Commit a901c3c

Browse files
Add multiselect
1 parent f82a661 commit a901c3c

File tree

7 files changed

+340
-26
lines changed

7 files changed

+340
-26
lines changed

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 items 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: 256,
667+
url: 'customers/256',
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: 341,
679+
url: 'customers/341',
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: 56 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 {
@@ -68,10 +69,15 @@ export interface Props {
6869
onSortChange?(selected: string, id: string): void;
6970
/** Callback when selection is changed */
7071
onSelectionChange?(selectedItems: SelectedItems): void;
72+
handleMultiSelectionChange?(
73+
lastSelected: number,
74+
currentlySelected: number,
75+
): string[];
7176
/** Function to render each list item */
72-
renderItem(item: any, id: string): React.ReactNode;
77+
renderItem(item: any, id: string, index: number): React.ReactNode;
7378
/** Function to customize the unique ID for each item */
7479
idForItem?(item: any, index: number): string;
80+
resolveItemId?(item: any): string;
7581
}
7682

7783
export type CombinedProps = Props & WithAppProviderProps;
@@ -115,6 +121,7 @@ export class ResourceList extends React.Component<CombinedProps, State> {
115121
this.state = {
116122
selectMode: Boolean(selectedItems && selectedItems.length > 0),
117123
loadingPosition: 0,
124+
lastSelected: null,
118125
};
119126
}
120127

@@ -532,6 +539,7 @@ export class ResourceList extends React.Component<CombinedProps, State> {
532539
const resourceListClassName = classNames(
533540
styles.ResourceList,
534541
loading && styles.disabledPointerEvents,
542+
selectMode && styles.disableTextSelection,
535543
);
536544

537545
const listMarkup = this.itemsExist() ? (
@@ -616,32 +624,72 @@ export class ResourceList extends React.Component<CombinedProps, State> {
616624

617625
return (
618626
<li key={id} className={styles.ItemWrapper}>
619-
{renderItem(item, id)}
627+
{renderItem(item, id, index)}
620628
</li>
621629
);
622630
};
623631

624-
private handleSelectionChange = (selected: boolean, id: string) => {
632+
private handleMultiSelectionChange = (
633+
lastSelected: number,
634+
currentSelected: number,
635+
resolveItemId: (items: any) => string,
636+
) => {
637+
const min = Math.min(lastSelected, currentSelected);
638+
const max = Math.max(lastSelected, currentSelected);
639+
return this.props.items.slice(min, max + 1).map(resolveItemId);
640+
};
641+
642+
private handleSelectionChange = (
643+
selected: boolean,
644+
id: string,
645+
sortOrder: number | undefined,
646+
shiftKey: boolean,
647+
) => {
625648
const {
626649
onSelectionChange,
627650
selectedItems,
628651
items,
629652
idForItem = defaultIdForItem,
653+
resolveItemId,
630654
} = this.props;
655+
const {lastSelected} = this.state;
631656

632657
if (selectedItems == null || onSelectionChange == null) {
633658
return;
634659
}
635660

636-
const newlySelectedItems =
661+
let newlySelectedItems =
637662
selectedItems === SELECT_ALL_ITEMS
638663
? getAllItemsOnPage(items, idForItem)
639664
: [...selectedItems];
640665

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

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

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

Lines changed: 16 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ export interface BaseProps {
3434
media?: React.ReactElement<AvatarProps | ThumbnailProps>;
3535
persistActions?: boolean;
3636
shortcutActions?: DisableableAction[];
37+
sortOrder?: number;
3738
children?: React.ReactNode;
3839
}
3940

@@ -119,15 +120,16 @@ export class Item extends React.PureComponent<CombinedProps, State> {
119120
testID="LargerSelectionArea"
120121
>
121122
<div onClick={stopPropagation} className={styles.CheckboxWrapper}>
122-
<Checkbox
123-
testID="Checkbox"
124-
id={this.checkboxId}
125-
label={label}
126-
labelHidden
127-
onChange={this.handleSelection}
128-
checked={selected}
129-
disabled={loading}
130-
/>
123+
<span onChange={this.handleLargerSelectionArea}>
124+
<Checkbox
125+
testID="Checkbox"
126+
id={this.checkboxId}
127+
label={label}
128+
labelHidden
129+
checked={selected}
130+
disabled={loading}
131+
/>
132+
</span>
131133
</div>
132134
</div>
133135
);
@@ -295,19 +297,21 @@ export class Item extends React.PureComponent<CombinedProps, State> {
295297

296298
private handleLargerSelectionArea = (event: React.MouseEvent<any>) => {
297299
stopPropagation(event);
298-
this.handleSelection(!this.isSelected());
300+
this.handleSelection(!this.isSelected(), event.nativeEvent.shiftKey);
299301
};
300302

301-
private handleSelection = (value: boolean) => {
303+
private handleSelection = (value: boolean, shiftKey: boolean) => {
302304
const {
303305
id,
306+
sortOrder,
304307
context: {onSelectionChange},
305308
} = this.props;
309+
306310
if (id == null || onSelectionChange == null) {
307311
return;
308312
}
309313
this.setState({focused: true, focusedInner: true});
310-
onSelectionChange(value, id);
314+
onSelectionChange(value, id, sortOrder, shiftKey);
311315
};
312316

313317
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
@@ -219,16 +219,21 @@ describe('<Item />', () => {
219219
});
220220

221221
it('calls onSelectionChange with the id of the item when clicking the LargerSelectionArea', () => {
222+
const sortOrder = 0;
222223
const wrapper = mountWithAppProvider(
223224
<Provider value={mockSelectableContext}>
224-
<Item id={itemId} url={url} />
225+
<Item id={itemId} url={url} sortOrder={sortOrder} />
225226
</Provider>,
226227
);
227228

228-
findByTestID(wrapper, 'LargerSelectionArea').simulate('click');
229+
findByTestID(wrapper, 'LargerSelectionArea').simulate('click', {
230+
nativeEvent: {shiftKey: false},
231+
});
229232
expect(mockSelectableContext.onSelectionChange).toHaveBeenCalledWith(
230233
true,
231234
itemId,
235+
sortOrder,
236+
false,
232237
);
233238
});
234239
});
@@ -248,21 +253,27 @@ describe('<Item />', () => {
248253

249254
it('calls onSelectionChange with the id of the item even if url or onClick is present', () => {
250255
const onClick = jest.fn();
256+
const sortOrder = 0;
251257
const wrapper = mountWithAppProvider(
252-
<Provider value={mockSelectableContext}>
258+
<Provider value={mockSelectModeContext}>
253259
<Item
254260
id={itemId}
255261
url={url}
256262
onClick={onClick}
263+
sortOrder={sortOrder}
257264
accessibilityLabel={ariaLabel}
258265
/>
259266
</Provider>,
260267
);
261268

262-
findByTestID(wrapper, 'Item-Wrapper').simulate('click');
269+
findByTestID(wrapper, 'Item-Wrapper').simulate('click', {
270+
nativeEvent: {shiftKey: false},
271+
});
263272
expect(mockSelectModeContext.onSelectionChange).toHaveBeenCalledWith(
264273
true,
265274
itemId,
275+
sortOrder,
276+
false,
266277
);
267278
});
268279

0 commit comments

Comments
 (0)