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
This commit is contained in:
Kerry 2022-10-04 10:12:07 +02:00 committed by GitHub
parent 0ded5e0505
commit c59bbdf917
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 267 additions and 5 deletions

View File

@ -25,7 +25,7 @@ limitations under the License.
display: grid;
grid-gap: $spacing-16;
margin: 0;
padding: 0 $spacing-8;
padding: 0 $spacing-16;
}
.mx_FilteredDeviceList_listItem {

View File

@ -266,8 +266,21 @@ export const FilteredDeviceList =
onFilterChange(filterId === ALL_FILTER_ID ? undefined : filterId as DeviceSecurityVariation);
};
const isAllSelected = selectedDeviceIds.length >= sortedDevices.length;
const toggleSelectAll = () => {
if (isAllSelected) {
setSelectedDeviceIds([]);
} else {
setSelectedDeviceIds(sortedDevices.map(device => device.device_id));
}
};
return <div className='mx_FilteredDeviceList' ref={ref}>
<FilteredDeviceListHeader selectedDeviceCount={selectedDeviceIds.length}>
<FilteredDeviceListHeader
selectedDeviceCount={selectedDeviceIds.length}
isAllSelected={isAllSelected}
toggleSelectAll={toggleSelectAll}
>
{ selectedDeviceIds.length
? <>
<AccessibleButton

View File

@ -17,18 +17,39 @@ limitations under the License.
import React, { HTMLProps } from 'react';
import { _t } from '../../../../languageHandler';
import StyledCheckbox, { CheckboxStyle } from '../../elements/StyledCheckbox';
import { Alignment } from '../../elements/Tooltip';
import TooltipTarget from '../../elements/TooltipTarget';
interface Props extends Omit<HTMLProps<HTMLDivElement>, 'className'> {
selectedDeviceCount: number;
isAllSelected: boolean;
toggleSelectAll: () => void;
children?: React.ReactNode;
}
const FilteredDeviceListHeader: React.FC<Props> = ({
selectedDeviceCount,
isAllSelected,
toggleSelectAll,
children,
...rest
}) => {
const checkboxLabel = isAllSelected ? _t('Deselect all') : _t('Select all');
return <div className='mx_FilteredDeviceListHeader' {...rest}>
<TooltipTarget
label={checkboxLabel}
alignment={Alignment.Top}
>
<StyledCheckbox
kind={CheckboxStyle.Solid}
checked={isAllSelected}
onChange={toggleSelectAll}
id='device-select-all-checkbox'
data-testid='device-select-all-checkbox'
aria-label={checkboxLabel}
/>
</TooltipTarget>
<span className='mx_FilteredDeviceListHeader_label'>
{ selectedDeviceCount > 0
? _t('%(selectedDeviceCount)s sessions selected', { selectedDeviceCount })

View File

@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import { render } from '@testing-library/react';
import { fireEvent, render } from '@testing-library/react';
import React from 'react';
import FilteredDeviceListHeader from '../../../../../src/components/views/settings/devices/FilteredDeviceListHeader';
@ -22,6 +22,8 @@ import FilteredDeviceListHeader from '../../../../../src/components/views/settin
describe('<FilteredDeviceListHeader />', () => {
const defaultProps = {
selectedDeviceCount: 0,
isAllSelected: false,
toggleSelectAll: jest.fn(),
children: <div>test</div>,
['data-testid']: 'test123',
};
@ -32,8 +34,21 @@ describe('<FilteredDeviceListHeader />', () => {
expect(container).toMatchSnapshot();
});
it('renders correctly when all devices are selected', () => {
const { container } = render(getComponent({ isAllSelected: true }));
expect(container).toMatchSnapshot();
});
it('renders correctly when some devices are selected', () => {
const { getByText } = render(getComponent({ selectedDeviceCount: 2 }));
expect(getByText('2 sessions selected')).toBeTruthy();
});
it('clicking checkbox toggles selection', () => {
const toggleSelectAll = jest.fn();
const { getByTestId } = render(getComponent({ toggleSelectAll }));
fireEvent.click(getByTestId('device-select-all-checkbox'));
expect(toggleSelectAll).toHaveBeenCalled();
});
});

View File

@ -1,11 +1,80 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`<FilteredDeviceListHeader /> renders correctly when no devices are selected 1`] = `
exports[`<FilteredDeviceListHeader /> renders correctly when all devices are selected 1`] = `
<div>
<div
class="mx_FilteredDeviceListHeader"
data-testid="test123"
>
<div
tabindex="0"
>
<span
class="mx_Checkbox mx_Checkbox_hasKind mx_Checkbox_kind_solid"
>
<input
aria-label="Deselect all"
checked=""
data-testid="device-select-all-checkbox"
id="device-select-all-checkbox"
type="checkbox"
/>
<label
for="device-select-all-checkbox"
>
<div
class="mx_Checkbox_background"
>
<div
class="mx_Checkbox_checkmark"
/>
</div>
</label>
</span>
</div>
<span
class="mx_FilteredDeviceListHeader_label"
>
Sessions
</span>
<div>
test
</div>
</div>
</div>
`;
exports[`<FilteredDeviceListHeader /> renders correctly when no devices are selected 1`] = `
<div>
<div
class="mx_FilteredDeviceListHeader"
data-testid="test123"
>
<div
tabindex="0"
>
<span
class="mx_Checkbox mx_Checkbox_hasKind mx_Checkbox_kind_solid"
>
<input
aria-label="Select all"
data-testid="device-select-all-checkbox"
id="device-select-all-checkbox"
type="checkbox"
/>
<label
for="device-select-all-checkbox"
>
<div
class="mx_Checkbox_background"
>
<div
class="mx_Checkbox_checkmark"
/>
</div>
</label>
</span>
</div>
<span
class="mx_FilteredDeviceListHeader_label"
>

View File

@ -38,10 +38,17 @@ import {
getMockClientWithEventEmitter,
mkPusher,
mockClientMethodsUser,
mockPlatformPeg,
} from '../../../../../test-utils';
import Modal from '../../../../../../src/Modal';
import LogoutDialog from '../../../../../../src/components/views/dialogs/LogoutDialog';
import { DeviceWithVerification } from '../../../../../../src/components/views/settings/devices/types';
import {
DeviceSecurityVariation,
DeviceWithVerification,
} from '../../../../../../src/components/views/settings/devices/types';
import { INACTIVE_DEVICE_AGE_MS } from '../../../../../../src/components/views/settings/devices/filter';
mockPlatformPeg();
describe('<SessionManagerTab />', () => {
const aliceId = '@alice:server.org';
@ -61,6 +68,11 @@ describe('<SessionManagerTab />', () => {
last_seen_ts: Date.now() - 600000,
};
const alicesInactiveDevice = {
device_id: 'alices_older_mobile_device',
last_seen_ts: Date.now() - (INACTIVE_DEVICE_AGE_MS + 1000),
};
const mockCrossSigningInfo = {
checkDeviceTrust: jest.fn(),
};
@ -108,11 +120,28 @@ describe('<SessionManagerTab />', () => {
fireEvent.click(checkbox);
};
const setFilter = async (
container: HTMLElement,
option: DeviceSecurityVariation | string,
) => await act(async () => {
const dropdown = container.querySelector('[aria-label="Filter devices"]');
fireEvent.click(dropdown as Element);
// tick to let dropdown render
await flushPromisesWithFakeTimers();
fireEvent.click(container.querySelector(`#device-list-filter__${option}`) as Element);
});
const isDeviceSelected = (
getByTestId: ReturnType<typeof render>['getByTestId'],
deviceId: DeviceWithVerification['device_id'],
): boolean => !!(getByTestId(`device-tile-checkbox-${deviceId}`) as HTMLInputElement).checked;
const isSelectAllChecked = (
getByTestId: ReturnType<typeof render>['getByTestId'],
): boolean => !!(getByTestId('device-select-all-checkbox') as HTMLInputElement).checked;
beforeEach(() => {
jest.clearAllMocks();
jest.spyOn(logger, 'error').mockRestore();
@ -811,6 +840,96 @@ describe('<SessionManagerTab />', () => {
// unselected
expect(isDeviceSelected(getByTestId, alicesOlderMobileDevice.device_id)).toBeFalsy();
});
describe('toggling select all', () => {
it('selects all sessions when there is not existing selection', async () => {
const { getByTestId, getByText } = render(getComponent());
await act(async () => {
await flushPromisesWithFakeTimers();
});
fireEvent.click(getByTestId('device-select-all-checkbox'));
// header displayed correctly
expect(getByText('2 sessions selected')).toBeTruthy();
expect(isSelectAllChecked(getByTestId)).toBeTruthy();
// devices selected
expect(isDeviceSelected(getByTestId, alicesMobileDevice.device_id)).toBeTruthy();
expect(isDeviceSelected(getByTestId, alicesOlderMobileDevice.device_id)).toBeTruthy();
});
it('selects all sessions when some sessions are already selected', async () => {
const { getByTestId, getByText } = render(getComponent());
await act(async () => {
await flushPromisesWithFakeTimers();
});
toggleDeviceSelection(getByTestId, alicesMobileDevice.device_id);
fireEvent.click(getByTestId('device-select-all-checkbox'));
// header displayed correctly
expect(getByText('2 sessions selected')).toBeTruthy();
expect(isSelectAllChecked(getByTestId)).toBeTruthy();
// devices selected
expect(isDeviceSelected(getByTestId, alicesMobileDevice.device_id)).toBeTruthy();
expect(isDeviceSelected(getByTestId, alicesOlderMobileDevice.device_id)).toBeTruthy();
});
it('deselects all sessions when all sessions are selected', async () => {
const { getByTestId, getByText } = render(getComponent());
await act(async () => {
await flushPromisesWithFakeTimers();
});
fireEvent.click(getByTestId('device-select-all-checkbox'));
// header displayed correctly
expect(getByText('2 sessions selected')).toBeTruthy();
expect(isSelectAllChecked(getByTestId)).toBeTruthy();
// devices selected
expect(isDeviceSelected(getByTestId, alicesMobileDevice.device_id)).toBeTruthy();
expect(isDeviceSelected(getByTestId, alicesOlderMobileDevice.device_id)).toBeTruthy();
});
it('selects only sessions that are part of the active filter', async () => {
mockClient.getDevices.mockResolvedValue({ devices: [
alicesDevice,
alicesMobileDevice,
alicesInactiveDevice,
] });
const { getByTestId, container } = render(getComponent());
await act(async () => {
await flushPromisesWithFakeTimers();
});
// filter for inactive sessions
await setFilter(container, DeviceSecurityVariation.Inactive);
// select all inactive sessions
fireEvent.click(getByTestId('device-select-all-checkbox'));
expect(isSelectAllChecked(getByTestId)).toBeTruthy();
// sign out of all selected sessions
fireEvent.click(getByTestId('sign-out-selection-cta'));
// only called with session from active filter
expect(mockClient.deleteMultipleDevices).toHaveBeenCalledWith(
[
alicesInactiveDevice.device_id,
],
undefined,
);
});
});
});
it("lets you change the pusher state", async () => {

View File

@ -19,6 +19,31 @@ exports[`<SessionManagerTab /> goes to filtered list from security recommendatio
<div
class="mx_FilteredDeviceListHeader"
>
<div
tabindex="0"
>
<span
class="mx_Checkbox mx_Checkbox_hasKind mx_Checkbox_kind_solid"
>
<input
aria-label="Select all"
data-testid="device-select-all-checkbox"
id="device-select-all-checkbox"
type="checkbox"
/>
<label
for="device-select-all-checkbox"
>
<div
class="mx_Checkbox_background"
>
<div
class="mx_Checkbox_checkmark"
/>
</div>
</label>
</span>
</div>
<span
class="mx_FilteredDeviceListHeader_label"
>