Skip to content
This repository was archived by the owner on Sep 11, 2024. It is now read-only.

Commit c59bbdf

Browse files
author
Kerry
authored
Device manager - select all devices (#9330)
* add device selection that does nothing * multi select and sign out of sessions * test multiple selection * fix type after rebase * select all sessions
1 parent 0ded5e0 commit c59bbdf

File tree

7 files changed

+266
-4
lines changed

7 files changed

+266
-4
lines changed

res/css/components/views/settings/devices/_FilteredDeviceList.pcss

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ limitations under the License.
2525
display: grid;
2626
grid-gap: $spacing-16;
2727
margin: 0;
28-
padding: 0 $spacing-8;
28+
padding: 0 $spacing-16;
2929
}
3030

3131
.mx_FilteredDeviceList_listItem {

src/components/views/settings/devices/FilteredDeviceList.tsx

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -266,8 +266,21 @@ export const FilteredDeviceList =
266266
onFilterChange(filterId === ALL_FILTER_ID ? undefined : filterId as DeviceSecurityVariation);
267267
};
268268

269+
const isAllSelected = selectedDeviceIds.length >= sortedDevices.length;
270+
const toggleSelectAll = () => {
271+
if (isAllSelected) {
272+
setSelectedDeviceIds([]);
273+
} else {
274+
setSelectedDeviceIds(sortedDevices.map(device => device.device_id));
275+
}
276+
};
277+
269278
return <div className='mx_FilteredDeviceList' ref={ref}>
270-
<FilteredDeviceListHeader selectedDeviceCount={selectedDeviceIds.length}>
279+
<FilteredDeviceListHeader
280+
selectedDeviceCount={selectedDeviceIds.length}
281+
isAllSelected={isAllSelected}
282+
toggleSelectAll={toggleSelectAll}
283+
>
271284
{ selectedDeviceIds.length
272285
? <>
273286
<AccessibleButton

src/components/views/settings/devices/FilteredDeviceListHeader.tsx

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,18 +17,39 @@ limitations under the License.
1717
import React, { HTMLProps } from 'react';
1818

1919
import { _t } from '../../../../languageHandler';
20+
import StyledCheckbox, { CheckboxStyle } from '../../elements/StyledCheckbox';
21+
import { Alignment } from '../../elements/Tooltip';
22+
import TooltipTarget from '../../elements/TooltipTarget';
2023

2124
interface Props extends Omit<HTMLProps<HTMLDivElement>, 'className'> {
2225
selectedDeviceCount: number;
26+
isAllSelected: boolean;
27+
toggleSelectAll: () => void;
2328
children?: React.ReactNode;
2429
}
2530

2631
const FilteredDeviceListHeader: React.FC<Props> = ({
2732
selectedDeviceCount,
33+
isAllSelected,
34+
toggleSelectAll,
2835
children,
2936
...rest
3037
}) => {
38+
const checkboxLabel = isAllSelected ? _t('Deselect all') : _t('Select all');
3139
return <div className='mx_FilteredDeviceListHeader' {...rest}>
40+
<TooltipTarget
41+
label={checkboxLabel}
42+
alignment={Alignment.Top}
43+
>
44+
<StyledCheckbox
45+
kind={CheckboxStyle.Solid}
46+
checked={isAllSelected}
47+
onChange={toggleSelectAll}
48+
id='device-select-all-checkbox'
49+
data-testid='device-select-all-checkbox'
50+
aria-label={checkboxLabel}
51+
/>
52+
</TooltipTarget>
3253
<span className='mx_FilteredDeviceListHeader_label'>
3354
{ selectedDeviceCount > 0
3455
? _t('%(selectedDeviceCount)s sessions selected', { selectedDeviceCount })

test/components/views/settings/devices/FilteredDeviceListHeader-test.tsx

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,14 +14,16 @@ See the License for the specific language governing permissions and
1414
limitations under the License.
1515
*/
1616

17-
import { render } from '@testing-library/react';
17+
import { fireEvent, render } from '@testing-library/react';
1818
import React from 'react';
1919

2020
import FilteredDeviceListHeader from '../../../../../src/components/views/settings/devices/FilteredDeviceListHeader';
2121

2222
describe('<FilteredDeviceListHeader />', () => {
2323
const defaultProps = {
2424
selectedDeviceCount: 0,
25+
isAllSelected: false,
26+
toggleSelectAll: jest.fn(),
2527
children: <div>test</div>,
2628
['data-testid']: 'test123',
2729
};
@@ -32,8 +34,21 @@ describe('<FilteredDeviceListHeader />', () => {
3234
expect(container).toMatchSnapshot();
3335
});
3436

37+
it('renders correctly when all devices are selected', () => {
38+
const { container } = render(getComponent({ isAllSelected: true }));
39+
expect(container).toMatchSnapshot();
40+
});
41+
3542
it('renders correctly when some devices are selected', () => {
3643
const { getByText } = render(getComponent({ selectedDeviceCount: 2 }));
3744
expect(getByText('2 sessions selected')).toBeTruthy();
3845
});
46+
47+
it('clicking checkbox toggles selection', () => {
48+
const toggleSelectAll = jest.fn();
49+
const { getByTestId } = render(getComponent({ toggleSelectAll }));
50+
fireEvent.click(getByTestId('device-select-all-checkbox'));
51+
52+
expect(toggleSelectAll).toHaveBeenCalled();
53+
});
3954
});

test/components/views/settings/devices/__snapshots__/FilteredDeviceListHeader-test.tsx.snap

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,80 @@
11
// Jest Snapshot v1, https://goo.gl/fbAQLP
22

3+
exports[`<FilteredDeviceListHeader /> renders correctly when all devices are selected 1`] = `
4+
<div>
5+
<div
6+
class="mx_FilteredDeviceListHeader"
7+
data-testid="test123"
8+
>
9+
<div
10+
tabindex="0"
11+
>
12+
<span
13+
class="mx_Checkbox mx_Checkbox_hasKind mx_Checkbox_kind_solid"
14+
>
15+
<input
16+
aria-label="Deselect all"
17+
checked=""
18+
data-testid="device-select-all-checkbox"
19+
id="device-select-all-checkbox"
20+
type="checkbox"
21+
/>
22+
<label
23+
for="device-select-all-checkbox"
24+
>
25+
<div
26+
class="mx_Checkbox_background"
27+
>
28+
<div
29+
class="mx_Checkbox_checkmark"
30+
/>
31+
</div>
32+
</label>
33+
</span>
34+
</div>
35+
<span
36+
class="mx_FilteredDeviceListHeader_label"
37+
>
38+
Sessions
39+
</span>
40+
<div>
41+
test
42+
</div>
43+
</div>
44+
</div>
45+
`;
46+
347
exports[`<FilteredDeviceListHeader /> renders correctly when no devices are selected 1`] = `
448
<div>
549
<div
650
class="mx_FilteredDeviceListHeader"
751
data-testid="test123"
852
>
53+
<div
54+
tabindex="0"
55+
>
56+
<span
57+
class="mx_Checkbox mx_Checkbox_hasKind mx_Checkbox_kind_solid"
58+
>
59+
<input
60+
aria-label="Select all"
61+
data-testid="device-select-all-checkbox"
62+
id="device-select-all-checkbox"
63+
type="checkbox"
64+
/>
65+
<label
66+
for="device-select-all-checkbox"
67+
>
68+
<div
69+
class="mx_Checkbox_background"
70+
>
71+
<div
72+
class="mx_Checkbox_checkmark"
73+
/>
74+
</div>
75+
</label>
76+
</span>
77+
</div>
978
<span
1079
class="mx_FilteredDeviceListHeader_label"
1180
>

test/components/views/settings/tabs/user/SessionManagerTab-test.tsx

Lines changed: 120 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,10 +38,17 @@ import {
3838
getMockClientWithEventEmitter,
3939
mkPusher,
4040
mockClientMethodsUser,
41+
mockPlatformPeg,
4142
} from '../../../../../test-utils';
4243
import Modal from '../../../../../../src/Modal';
4344
import LogoutDialog from '../../../../../../src/components/views/dialogs/LogoutDialog';
44-
import { DeviceWithVerification } from '../../../../../../src/components/views/settings/devices/types';
45+
import {
46+
DeviceSecurityVariation,
47+
DeviceWithVerification,
48+
} from '../../../../../../src/components/views/settings/devices/types';
49+
import { INACTIVE_DEVICE_AGE_MS } from '../../../../../../src/components/views/settings/devices/filter';
50+
51+
mockPlatformPeg();
4552

4653
describe('<SessionManagerTab />', () => {
4754
const aliceId = '@alice:server.org';
@@ -61,6 +68,11 @@ describe('<SessionManagerTab />', () => {
6168
last_seen_ts: Date.now() - 600000,
6269
};
6370

71+
const alicesInactiveDevice = {
72+
device_id: 'alices_older_mobile_device',
73+
last_seen_ts: Date.now() - (INACTIVE_DEVICE_AGE_MS + 1000),
74+
};
75+
6476
const mockCrossSigningInfo = {
6577
checkDeviceTrust: jest.fn(),
6678
};
@@ -108,11 +120,28 @@ describe('<SessionManagerTab />', () => {
108120
fireEvent.click(checkbox);
109121
};
110122

123+
const setFilter = async (
124+
container: HTMLElement,
125+
option: DeviceSecurityVariation | string,
126+
) => await act(async () => {
127+
const dropdown = container.querySelector('[aria-label="Filter devices"]');
128+
129+
fireEvent.click(dropdown as Element);
130+
// tick to let dropdown render
131+
await flushPromisesWithFakeTimers();
132+
133+
fireEvent.click(container.querySelector(`#device-list-filter__${option}`) as Element);
134+
});
135+
111136
const isDeviceSelected = (
112137
getByTestId: ReturnType<typeof render>['getByTestId'],
113138
deviceId: DeviceWithVerification['device_id'],
114139
): boolean => !!(getByTestId(`device-tile-checkbox-${deviceId}`) as HTMLInputElement).checked;
115140

141+
const isSelectAllChecked = (
142+
getByTestId: ReturnType<typeof render>['getByTestId'],
143+
): boolean => !!(getByTestId('device-select-all-checkbox') as HTMLInputElement).checked;
144+
116145
beforeEach(() => {
117146
jest.clearAllMocks();
118147
jest.spyOn(logger, 'error').mockRestore();
@@ -811,6 +840,96 @@ describe('<SessionManagerTab />', () => {
811840
// unselected
812841
expect(isDeviceSelected(getByTestId, alicesOlderMobileDevice.device_id)).toBeFalsy();
813842
});
843+
844+
describe('toggling select all', () => {
845+
it('selects all sessions when there is not existing selection', async () => {
846+
const { getByTestId, getByText } = render(getComponent());
847+
848+
await act(async () => {
849+
await flushPromisesWithFakeTimers();
850+
});
851+
852+
fireEvent.click(getByTestId('device-select-all-checkbox'));
853+
854+
// header displayed correctly
855+
expect(getByText('2 sessions selected')).toBeTruthy();
856+
expect(isSelectAllChecked(getByTestId)).toBeTruthy();
857+
858+
// devices selected
859+
expect(isDeviceSelected(getByTestId, alicesMobileDevice.device_id)).toBeTruthy();
860+
expect(isDeviceSelected(getByTestId, alicesOlderMobileDevice.device_id)).toBeTruthy();
861+
});
862+
863+
it('selects all sessions when some sessions are already selected', async () => {
864+
const { getByTestId, getByText } = render(getComponent());
865+
866+
await act(async () => {
867+
await flushPromisesWithFakeTimers();
868+
});
869+
870+
toggleDeviceSelection(getByTestId, alicesMobileDevice.device_id);
871+
872+
fireEvent.click(getByTestId('device-select-all-checkbox'));
873+
874+
// header displayed correctly
875+
expect(getByText('2 sessions selected')).toBeTruthy();
876+
expect(isSelectAllChecked(getByTestId)).toBeTruthy();
877+
878+
// devices selected
879+
expect(isDeviceSelected(getByTestId, alicesMobileDevice.device_id)).toBeTruthy();
880+
expect(isDeviceSelected(getByTestId, alicesOlderMobileDevice.device_id)).toBeTruthy();
881+
});
882+
883+
it('deselects all sessions when all sessions are selected', async () => {
884+
const { getByTestId, getByText } = render(getComponent());
885+
886+
await act(async () => {
887+
await flushPromisesWithFakeTimers();
888+
});
889+
890+
fireEvent.click(getByTestId('device-select-all-checkbox'));
891+
892+
// header displayed correctly
893+
expect(getByText('2 sessions selected')).toBeTruthy();
894+
expect(isSelectAllChecked(getByTestId)).toBeTruthy();
895+
896+
// devices selected
897+
expect(isDeviceSelected(getByTestId, alicesMobileDevice.device_id)).toBeTruthy();
898+
expect(isDeviceSelected(getByTestId, alicesOlderMobileDevice.device_id)).toBeTruthy();
899+
});
900+
901+
it('selects only sessions that are part of the active filter', async () => {
902+
mockClient.getDevices.mockResolvedValue({ devices: [
903+
alicesDevice,
904+
alicesMobileDevice,
905+
alicesInactiveDevice,
906+
] });
907+
const { getByTestId, container } = render(getComponent());
908+
909+
await act(async () => {
910+
await flushPromisesWithFakeTimers();
911+
});
912+
913+
// filter for inactive sessions
914+
await setFilter(container, DeviceSecurityVariation.Inactive);
915+
916+
// select all inactive sessions
917+
fireEvent.click(getByTestId('device-select-all-checkbox'));
918+
919+
expect(isSelectAllChecked(getByTestId)).toBeTruthy();
920+
921+
// sign out of all selected sessions
922+
fireEvent.click(getByTestId('sign-out-selection-cta'));
923+
924+
// only called with session from active filter
925+
expect(mockClient.deleteMultipleDevices).toHaveBeenCalledWith(
926+
[
927+
alicesInactiveDevice.device_id,
928+
],
929+
undefined,
930+
);
931+
});
932+
});
814933
});
815934

816935
it("lets you change the pusher state", async () => {

test/components/views/settings/tabs/user/__snapshots__/SessionManagerTab-test.tsx.snap

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,31 @@ exports[`<SessionManagerTab /> goes to filtered list from security recommendatio
1919
<div
2020
class="mx_FilteredDeviceListHeader"
2121
>
22+
<div
23+
tabindex="0"
24+
>
25+
<span
26+
class="mx_Checkbox mx_Checkbox_hasKind mx_Checkbox_kind_solid"
27+
>
28+
<input
29+
aria-label="Select all"
30+
data-testid="device-select-all-checkbox"
31+
id="device-select-all-checkbox"
32+
type="checkbox"
33+
/>
34+
<label
35+
for="device-select-all-checkbox"
36+
>
37+
<div
38+
class="mx_Checkbox_background"
39+
>
40+
<div
41+
class="mx_Checkbox_checkmark"
42+
/>
43+
</div>
44+
</label>
45+
</span>
46+
</div>
2247
<span
2348
class="mx_FilteredDeviceListHeader_label"
2449
>

0 commit comments

Comments
 (0)