Skip to content
This repository was archived by the owner on Sep 11, 2024. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
15 changes: 14 additions & 1 deletion src/components/views/settings/devices/FilteredDeviceList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 })
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,14 +14,16 @@ 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';

describe('<FilteredDeviceListHeader />', () => {
const defaultProps = {
selectedDeviceCount: 0,
isAllSelected: false,
toggleSelectAll: jest.fn(),
children: <div>test</div>,
['data-testid']: 'test123',
};
Expand All @@ -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();
});
});
Original file line number Diff line number Diff line change
@@ -1,11 +1,80 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP

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"
>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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(),
};
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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 () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"
>
Expand Down