Skip to content

Commit 660dbf8

Browse files
Add multiselect
1 parent f82a661 commit 660dbf8

File tree

6 files changed

+217
-26
lines changed

6 files changed

+217
-26
lines changed

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

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

Lines changed: 120 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -679,6 +679,124 @@ describe('<ResourceList />', () => {
679679
expect(resourceList.find(BulkActions).prop('selectMode')).toBe(true);
680680
});
681681
});
682+
683+
describe('multiselect', () => {
684+
it('selects shift selected items if resolveItemId was provided', () => {
685+
function resolveItemId(item: any) {
686+
return item.id;
687+
}
688+
const onSelectionChange = jest.fn();
689+
const resourceList = mountWithAppProvider(
690+
<ResourceList
691+
items={itemsWithID}
692+
selectedItems={[]}
693+
promotedBulkActions={promotedBulkActions}
694+
renderItem={renderItem}
695+
onSelectionChange={onSelectionChange}
696+
resolveItemId={resolveItemId}
697+
/>,
698+
);
699+
const firstItem = resourceList.find(Item).first();
700+
findByTestID(firstItem, 'LargerSelectionArea').simulate('click');
701+
702+
const lastItem = resourceList.find(Item).last();
703+
findByTestID(lastItem, 'LargerSelectionArea').simulate('click', {
704+
nativeEvent: {shiftKey: true},
705+
});
706+
707+
expect(onSelectionChange).toBeCalledWith(['5', '6', '7']);
708+
});
709+
710+
it('does not select shift selected items if resolveItemId was not provided', () => {
711+
const onSelectionChange = jest.fn();
712+
const resourceList = mountWithAppProvider(
713+
<ResourceList
714+
items={itemsWithID}
715+
selectedItems={[]}
716+
promotedBulkActions={promotedBulkActions}
717+
renderItem={renderItem}
718+
onSelectionChange={onSelectionChange}
719+
/>,
720+
);
721+
const firstItem = resourceList.find(Item).first();
722+
findByTestID(firstItem, 'LargerSelectionArea').simulate('click');
723+
724+
const lastItem = resourceList.find(Item).last();
725+
findByTestID(lastItem, 'LargerSelectionArea').simulate('click', {
726+
nativeEvent: {shiftKey: true},
727+
});
728+
729+
expect(onSelectionChange).toBeCalledWith(['7']);
730+
});
731+
732+
it('does not select shift selected items if sortOrder is not provided', () => {
733+
function resolveItemId(item: any) {
734+
return item.id;
735+
}
736+
737+
function renderItem(item: any, id: any) {
738+
return (
739+
<ResourceList.Item
740+
id={id}
741+
url={item.url}
742+
accessibilityLabel={`View details for ${item.title}`}
743+
>
744+
<div>Item {id}</div>
745+
<div>{item.title}</div>
746+
</ResourceList.Item>
747+
);
748+
}
749+
750+
const onSelectionChange = jest.fn();
751+
const resourceList = mountWithAppProvider(
752+
<ResourceList
753+
items={itemsWithID}
754+
selectedItems={[]}
755+
promotedBulkActions={promotedBulkActions}
756+
renderItem={renderItem}
757+
onSelectionChange={onSelectionChange}
758+
resolveItemId={resolveItemId}
759+
/>,
760+
);
761+
const firstItem = resourceList.find(Item).first();
762+
findByTestID(firstItem, 'LargerSelectionArea').simulate('click');
763+
764+
const lastItem = resourceList.find(Item).last();
765+
findByTestID(lastItem, 'LargerSelectionArea').simulate('click', {
766+
nativeEvent: {shiftKey: true},
767+
});
768+
769+
expect(onSelectionChange).toBeCalledWith(['7']);
770+
});
771+
772+
it('deselects shift selected items if resolveItemId was provided', () => {
773+
const selectedItems = ['6', '7'];
774+
function resolveItemId(item: any) {
775+
return item.id;
776+
}
777+
const onSelectionChange = jest.fn();
778+
const resourceList = mountWithAppProvider(
779+
<ResourceList
780+
items={itemsWithID}
781+
selectedItems={selectedItems}
782+
promotedBulkActions={promotedBulkActions}
783+
renderItem={renderItem}
784+
onSelectionChange={onSelectionChange}
785+
resolveItemId={resolveItemId}
786+
/>,
787+
);
788+
// Sets {lastSeleced: 0}
789+
const firstItem = resourceList.find(Item).first();
790+
findByTestID(firstItem, 'LargerSelectionArea').simulate('click');
791+
792+
const lastItem = resourceList.find(Item).last();
793+
findByTestID(lastItem, 'LargerSelectionArea').simulate('click', {
794+
nativeEvent: {shiftKey: true},
795+
});
796+
797+
expect(onSelectionChange).toBeCalledWith([]);
798+
});
799+
});
682800
});
683801

684802
function idForItem(item: any) {
@@ -693,11 +811,12 @@ function renderCustomMarkup(item: any) {
693811
return <p>{item.title}</p>;
694812
}
695813

696-
function renderItem(item: any, id: any) {
814+
function renderItem(item: any, id: any, index: number) {
697815
return (
698816
<ResourceList.Item
699817
id={id}
700818
url={item.url}
819+
sortOrder={index}
701820
accessibilityLabel={`View details for ${item.title}`}
702821
>
703822
<div>Item {id}</div>

src/components/ResourceList/types.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,5 +11,10 @@ export interface ResourceListContext {
1111
plural: string;
1212
};
1313
loading?: boolean;
14-
onSelectionChange?(selected: boolean, id: string): void;
14+
onSelectionChange?(
15+
selected: boolean,
16+
id: string,
17+
sortNumber: number | undefined,
18+
shiftKey: boolean,
19+
): void;
1520
}

0 commit comments

Comments
 (0)