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 sessionspull/28788/head^2
parent
0ded5e0505
commit
c59bbdf917
|
@ -25,7 +25,7 @@ limitations under the License.
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-gap: $spacing-16;
|
grid-gap: $spacing-16;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0 $spacing-8;
|
padding: 0 $spacing-16;
|
||||||
}
|
}
|
||||||
|
|
||||||
.mx_FilteredDeviceList_listItem {
|
.mx_FilteredDeviceList_listItem {
|
||||||
|
|
|
@ -266,8 +266,21 @@ export const FilteredDeviceList =
|
||||||
onFilterChange(filterId === ALL_FILTER_ID ? undefined : filterId as DeviceSecurityVariation);
|
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}>
|
return <div className='mx_FilteredDeviceList' ref={ref}>
|
||||||
<FilteredDeviceListHeader selectedDeviceCount={selectedDeviceIds.length}>
|
<FilteredDeviceListHeader
|
||||||
|
selectedDeviceCount={selectedDeviceIds.length}
|
||||||
|
isAllSelected={isAllSelected}
|
||||||
|
toggleSelectAll={toggleSelectAll}
|
||||||
|
>
|
||||||
{ selectedDeviceIds.length
|
{ selectedDeviceIds.length
|
||||||
? <>
|
? <>
|
||||||
<AccessibleButton
|
<AccessibleButton
|
||||||
|
|
|
@ -17,18 +17,39 @@ limitations under the License.
|
||||||
import React, { HTMLProps } from 'react';
|
import React, { HTMLProps } from 'react';
|
||||||
|
|
||||||
import { _t } from '../../../../languageHandler';
|
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'> {
|
interface Props extends Omit<HTMLProps<HTMLDivElement>, 'className'> {
|
||||||
selectedDeviceCount: number;
|
selectedDeviceCount: number;
|
||||||
|
isAllSelected: boolean;
|
||||||
|
toggleSelectAll: () => void;
|
||||||
children?: React.ReactNode;
|
children?: React.ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
const FilteredDeviceListHeader: React.FC<Props> = ({
|
const FilteredDeviceListHeader: React.FC<Props> = ({
|
||||||
selectedDeviceCount,
|
selectedDeviceCount,
|
||||||
|
isAllSelected,
|
||||||
|
toggleSelectAll,
|
||||||
children,
|
children,
|
||||||
...rest
|
...rest
|
||||||
}) => {
|
}) => {
|
||||||
|
const checkboxLabel = isAllSelected ? _t('Deselect all') : _t('Select all');
|
||||||
return <div className='mx_FilteredDeviceListHeader' {...rest}>
|
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'>
|
<span className='mx_FilteredDeviceListHeader_label'>
|
||||||
{ selectedDeviceCount > 0
|
{ selectedDeviceCount > 0
|
||||||
? _t('%(selectedDeviceCount)s sessions selected', { selectedDeviceCount })
|
? _t('%(selectedDeviceCount)s sessions selected', { selectedDeviceCount })
|
||||||
|
|
|
@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
|
||||||
limitations under the License.
|
limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { render } from '@testing-library/react';
|
import { fireEvent, render } from '@testing-library/react';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
|
||||||
import FilteredDeviceListHeader from '../../../../../src/components/views/settings/devices/FilteredDeviceListHeader';
|
import FilteredDeviceListHeader from '../../../../../src/components/views/settings/devices/FilteredDeviceListHeader';
|
||||||
|
@ -22,6 +22,8 @@ import FilteredDeviceListHeader from '../../../../../src/components/views/settin
|
||||||
describe('<FilteredDeviceListHeader />', () => {
|
describe('<FilteredDeviceListHeader />', () => {
|
||||||
const defaultProps = {
|
const defaultProps = {
|
||||||
selectedDeviceCount: 0,
|
selectedDeviceCount: 0,
|
||||||
|
isAllSelected: false,
|
||||||
|
toggleSelectAll: jest.fn(),
|
||||||
children: <div>test</div>,
|
children: <div>test</div>,
|
||||||
['data-testid']: 'test123',
|
['data-testid']: 'test123',
|
||||||
};
|
};
|
||||||
|
@ -32,8 +34,21 @@ describe('<FilteredDeviceListHeader />', () => {
|
||||||
expect(container).toMatchSnapshot();
|
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', () => {
|
it('renders correctly when some devices are selected', () => {
|
||||||
const { getByText } = render(getComponent({ selectedDeviceCount: 2 }));
|
const { getByText } = render(getComponent({ selectedDeviceCount: 2 }));
|
||||||
expect(getByText('2 sessions selected')).toBeTruthy();
|
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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -1,11 +1,80 @@
|
||||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
// 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>
|
||||||
<div
|
<div
|
||||||
class="mx_FilteredDeviceListHeader"
|
class="mx_FilteredDeviceListHeader"
|
||||||
data-testid="test123"
|
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
|
<span
|
||||||
class="mx_FilteredDeviceListHeader_label"
|
class="mx_FilteredDeviceListHeader_label"
|
||||||
>
|
>
|
||||||
|
|
|
@ -38,10 +38,17 @@ import {
|
||||||
getMockClientWithEventEmitter,
|
getMockClientWithEventEmitter,
|
||||||
mkPusher,
|
mkPusher,
|
||||||
mockClientMethodsUser,
|
mockClientMethodsUser,
|
||||||
|
mockPlatformPeg,
|
||||||
} from '../../../../../test-utils';
|
} from '../../../../../test-utils';
|
||||||
import Modal from '../../../../../../src/Modal';
|
import Modal from '../../../../../../src/Modal';
|
||||||
import LogoutDialog from '../../../../../../src/components/views/dialogs/LogoutDialog';
|
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 />', () => {
|
describe('<SessionManagerTab />', () => {
|
||||||
const aliceId = '@alice:server.org';
|
const aliceId = '@alice:server.org';
|
||||||
|
@ -61,6 +68,11 @@ describe('<SessionManagerTab />', () => {
|
||||||
last_seen_ts: Date.now() - 600000,
|
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 = {
|
const mockCrossSigningInfo = {
|
||||||
checkDeviceTrust: jest.fn(),
|
checkDeviceTrust: jest.fn(),
|
||||||
};
|
};
|
||||||
|
@ -108,11 +120,28 @@ describe('<SessionManagerTab />', () => {
|
||||||
fireEvent.click(checkbox);
|
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 = (
|
const isDeviceSelected = (
|
||||||
getByTestId: ReturnType<typeof render>['getByTestId'],
|
getByTestId: ReturnType<typeof render>['getByTestId'],
|
||||||
deviceId: DeviceWithVerification['device_id'],
|
deviceId: DeviceWithVerification['device_id'],
|
||||||
): boolean => !!(getByTestId(`device-tile-checkbox-${deviceId}`) as HTMLInputElement).checked;
|
): 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(() => {
|
beforeEach(() => {
|
||||||
jest.clearAllMocks();
|
jest.clearAllMocks();
|
||||||
jest.spyOn(logger, 'error').mockRestore();
|
jest.spyOn(logger, 'error').mockRestore();
|
||||||
|
@ -811,6 +840,96 @@ describe('<SessionManagerTab />', () => {
|
||||||
// unselected
|
// unselected
|
||||||
expect(isDeviceSelected(getByTestId, alicesOlderMobileDevice.device_id)).toBeFalsy();
|
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 () => {
|
it("lets you change the pusher state", async () => {
|
||||||
|
|
|
@ -19,6 +19,31 @@ exports[`<SessionManagerTab /> goes to filtered list from security recommendatio
|
||||||
<div
|
<div
|
||||||
class="mx_FilteredDeviceListHeader"
|
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
|
<span
|
||||||
class="mx_FilteredDeviceListHeader_label"
|
class="mx_FilteredDeviceListHeader_label"
|
||||||
>
|
>
|
||||||
|
|
Loading…
Reference in New Issue