Device manager - device list filtering (PSG-648) (#9181)

* add device filtering

* improve dropdown styling

* test device filtering

* update type imports

* fix types

* security card margin

* more specific type for onFilterOptionChange
This commit is contained in:
Kerry 2022-08-16 16:05:10 +02:00 committed by GitHub
parent aa9191bc34
commit 6f2c761fb4
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
11 changed files with 529 additions and 43 deletions

View file

@ -64,7 +64,7 @@ limitations under the License.
margin: 0 0 $spacing-4 0; margin: 0 0 $spacing-4 0;
} }
.mx_DeviceSecurityCard_description { .mx_DeviceSecurityCard_description {
margin: 0 0 $spacing-8 0; margin: 0;
font-size: $font-12px; font-size: $font-12px;
color: $secondary-content; color: $secondary-content;
} }

View file

@ -15,9 +15,45 @@ limitations under the License.
*/ */
.mx_FilteredDeviceList { .mx_FilteredDeviceList {
.mx_Dropdown {
flex: 1 0 80px;
}
}
.mx_FilteredDeviceList_header {
display: flex;
flex-direction: row;
align-items: center;
box-sizing: border-box;
width: 100%;
height: 48px;
padding: 0 $spacing-16;
margin-bottom: $spacing-32;
background-color: $system;
border-radius: 8px;
color: $secondary-content;
}
.mx_FilteredDeviceList_headerLabel {
flex: 1 1 100%;
}
.mx_FilteredDeviceList_list {
list-style-type: none; list-style-type: none;
display: grid; display: grid;
grid-gap: $spacing-16; grid-gap: $spacing-16;
margin: 0; margin: 0;
padding: 0 $spacing-8; padding: 0 $spacing-8;
} }
.mx_FilteredDeviceList_securityCard {
margin-bottom: $spacing-32;
}
.mx_FilteredDeviceList_noResults {
width: 100%;
text-align: center;
margin-bottom: $spacing-32;
}

View file

@ -22,7 +22,7 @@ import { formatDate, formatRelativeTime } from "../../../../DateUtils";
import TooltipTarget from "../../elements/TooltipTarget"; import TooltipTarget from "../../elements/TooltipTarget";
import { Alignment } from "../../elements/Tooltip"; import { Alignment } from "../../elements/Tooltip";
import Heading from "../../typography/Heading"; import Heading from "../../typography/Heading";
import { INACTIVE_DEVICE_AGE_MS, isDeviceInactive } from "./filter"; import { INACTIVE_DEVICE_AGE_DAYS, isDeviceInactive } from "./filter";
import { DeviceWithVerification } from "./types"; import { DeviceWithVerification } from "./types";
export interface DeviceTileProps { export interface DeviceTileProps {
device: DeviceWithVerification; device: DeviceWithVerification;
@ -64,12 +64,11 @@ const getInactiveMetadata = (device: DeviceWithVerification): { id: string, valu
if (!isInactive) { if (!isInactive) {
return undefined; return undefined;
} }
const inactiveAgeDays = Math.round(INACTIVE_DEVICE_AGE_MS / MS_DAY);
return { id: 'inactive', value: ( return { id: 'inactive', value: (
<> <>
<InactiveIcon className="mx_DeviceTile_inactiveIcon" /> <InactiveIcon className="mx_DeviceTile_inactiveIcon" />
{ {
_t('Inactive for %(inactiveAgeDays)s+ days', { inactiveAgeDays }) + _t('Inactive for %(inactiveAgeDays)s+ days', { inactiveAgeDays: INACTIVE_DEVICE_AGE_DAYS }) +
` (${formatLastActivity(device.last_seen_ts)})` ` (${formatLastActivity(device.last_seen_ts)})`
} }
</>), </>),

View file

@ -16,40 +16,178 @@ limitations under the License.
import React from 'react'; import React from 'react';
import { _t } from '../../../../languageHandler';
import AccessibleButton from '../../elements/AccessibleButton';
import Dropdown from '../../elements/Dropdown';
import DeviceSecurityCard from './DeviceSecurityCard';
import DeviceTile from './DeviceTile'; import DeviceTile from './DeviceTile';
import { filterDevicesBySecurityRecommendation } from './filter'; import {
import { DevicesDictionary, DeviceWithVerification } from './types'; filterDevicesBySecurityRecommendation,
INACTIVE_DEVICE_AGE_DAYS,
} from './filter';
import {
DevicesDictionary,
DeviceSecurityVariation,
DeviceWithVerification,
} from './types';
interface Props { interface Props {
devices: DevicesDictionary; devices: DevicesDictionary;
filter?: DeviceSecurityVariation;
onFilterChange: (filter: DeviceSecurityVariation | undefined) => void;
} }
// devices without timestamp metadata should be sorted last // devices without timestamp metadata should be sorted last
const sortDevicesByLatestActivity = (left: DeviceWithVerification, right: DeviceWithVerification) => const sortDevicesByLatestActivity = (left: DeviceWithVerification, right: DeviceWithVerification) =>
(right.last_seen_ts || 0) - (left.last_seen_ts || 0); (right.last_seen_ts || 0) - (left.last_seen_ts || 0);
const getFilteredSortedDevices = (devices: DevicesDictionary) => const getFilteredSortedDevices = (devices: DevicesDictionary, filter: DeviceSecurityVariation) =>
filterDevicesBySecurityRecommendation(Object.values(devices), []) filterDevicesBySecurityRecommendation(Object.values(devices), filter ? [filter] : [])
.sort(sortDevicesByLatestActivity); .sort(sortDevicesByLatestActivity);
const ALL_FILTER_ID = 'ALL';
const FilterSecurityCard: React.FC<{ filter?: DeviceSecurityVariation | string }> = ({ filter }) => {
switch (filter) {
case DeviceSecurityVariation.Verified:
return <div className='mx_FilteredDeviceList_securityCard'>
<DeviceSecurityCard
variation={DeviceSecurityVariation.Verified}
heading={_t('Verified sessions')}
description={_t(
`For best security, sign out from any session` +
` that you don't recognize or use anymore.`,
)}
/>
</div>
;
case DeviceSecurityVariation.Unverified:
return <div className='mx_FilteredDeviceList_securityCard'>
<DeviceSecurityCard
variation={DeviceSecurityVariation.Unverified}
heading={_t('Unverified sessions')}
description={_t(
`Verify your sessions for enhanced secure messaging or sign out`
+ ` from those you don't recognize or use anymore.`,
)}
/>
</div>
;
case DeviceSecurityVariation.Inactive:
return <div className='mx_FilteredDeviceList_securityCard'>
<DeviceSecurityCard
variation={DeviceSecurityVariation.Inactive}
heading={_t('Inactive sessions')}
description={_t(
`Consider signing out from old sessions ` +
`(%(inactiveAgeDays)s days or older) you don't use anymore`,
{ inactiveAgeDays: INACTIVE_DEVICE_AGE_DAYS },
)}
/>
</div>
;
default:
return null;
}
};
const getNoResultsMessage = (filter: DeviceSecurityVariation): string => {
switch (filter) {
case DeviceSecurityVariation.Verified:
return _t('No verified sessions found.');
case DeviceSecurityVariation.Unverified:
return _t('No unverified sessions found.');
case DeviceSecurityVariation.Inactive:
return _t('No inactive sessions found.');
default:
return _t('No sessions found.');
}
};
interface NoResultsProps { filter: DeviceSecurityVariation, clearFilter: () => void}
const NoResults: React.FC<NoResultsProps> = ({ filter, clearFilter }) =>
<div className='mx_FilteredDeviceList_noResults'>
{ getNoResultsMessage(filter) }
{
/* No clear filter button when filter is falsy (ie 'All') */
!!filter &&
<>
&nbsp;
<AccessibleButton
kind='link_inline'
onClick={clearFilter}
data-testid='devices-clear-filter-btn'
>
{ _t('Show all') }
</AccessibleButton>
</>
}
</div>;
/** /**
* Filtered list of devices * Filtered list of devices
* Sorted by latest activity descending * Sorted by latest activity descending
* TODO(kerrya) Filtering to added as part of PSG-648
*/ */
const FilteredDeviceList: React.FC<Props> = ({ devices }) => { const FilteredDeviceList: React.FC<Props> = ({ devices, filter, onFilterChange }) => {
const sortedDevices = getFilteredSortedDevices(devices); const sortedDevices = getFilteredSortedDevices(devices, filter);
return <ol className='mx_FilteredDeviceList'> const options = [
{ sortedDevices.map((device) => { id: ALL_FILTER_ID, label: _t('All') },
<li key={device.device_id}> {
<DeviceTile id: DeviceSecurityVariation.Verified,
device={device} label: _t('Verified'),
/> description: _t('Ready for secure messaging'),
</li>, },
{
id: DeviceSecurityVariation.Unverified,
label: _t('Unverified'),
description: _t('Not ready for secure messaging'),
},
{
id: DeviceSecurityVariation.Inactive,
label: _t('Inactive'),
description: _t(
'Inactive for %(inactiveAgeDays)s days or longer',
{ inactiveAgeDays: INACTIVE_DEVICE_AGE_DAYS },
),
},
];
) } const onFilterOptionChange = (filterId: DeviceSecurityVariation | typeof ALL_FILTER_ID) => {
</ol>; onFilterChange(filterId === ALL_FILTER_ID ? undefined : filterId as DeviceSecurityVariation);
};
return <div className='mx_FilteredDeviceList'>
<div className='mx_FilteredDeviceList_header'>
<span className='mx_FilteredDeviceList_headerLabel'>
{ _t('Sessions') }
</span>
<Dropdown
id='device-list-filter'
label={_t('Filter devices')}
value={filter || ALL_FILTER_ID}
onOptionChange={onFilterOptionChange}
>
{ options.map(({ id, label }) =>
<div data-test-id={`device-filter-option-${id}`} key={id}>{ label }</div>,
) }
</Dropdown>
</div>
{ !!sortedDevices.length
? <FilterSecurityCard filter={filter} />
: <NoResults filter={filter} clearFilter={() => onFilterChange(undefined)} />
}
<ol className='mx_FilteredDeviceList_list'>
{ sortedDevices.map((device) =>
<li key={device.device_id}>
<DeviceTile
device={device}
/>
</li>,
) }
</ol>
</div>
;
}; };
export default FilteredDeviceList; export default FilteredDeviceList;

View file

@ -20,16 +20,19 @@ import { _t } from '../../../../languageHandler';
import AccessibleButton from '../../elements/AccessibleButton'; import AccessibleButton from '../../elements/AccessibleButton';
import SettingsSubsection from '../shared/SettingsSubsection'; import SettingsSubsection from '../shared/SettingsSubsection';
import DeviceSecurityCard from './DeviceSecurityCard'; import DeviceSecurityCard from './DeviceSecurityCard';
import { filterDevicesBySecurityRecommendation, INACTIVE_DEVICE_AGE_MS } from './filter'; import { filterDevicesBySecurityRecommendation, INACTIVE_DEVICE_AGE_DAYS } from './filter';
import { DevicesDictionary, DeviceSecurityVariation } from './types'; import {
DeviceSecurityVariation,
DeviceWithVerification,
DevicesDictionary,
} from './types';
interface Props { interface Props {
devices: DevicesDictionary; devices: DevicesDictionary;
} }
const MS_DAY = 24 * 60 * 60 * 1000;
const SecurityRecommendations: React.FC<Props> = ({ devices }) => { const SecurityRecommendations: React.FC<Props> = ({ devices }) => {
const devicesArray = Object.values(devices); const devicesArray = Object.values<DeviceWithVerification>(devices);
const unverifiedDevicesCount = filterDevicesBySecurityRecommendation( const unverifiedDevicesCount = filterDevicesBySecurityRecommendation(
devicesArray, devicesArray,
@ -44,7 +47,7 @@ const SecurityRecommendations: React.FC<Props> = ({ devices }) => {
return null; return null;
} }
const inactiveAgeDays = INACTIVE_DEVICE_AGE_MS / MS_DAY; const inactiveAgeDays = INACTIVE_DEVICE_AGE_DAYS;
// TODO(kerrya) stubbed until PSG-640/652 // TODO(kerrya) stubbed until PSG-640/652
const noop = () => {}; const noop = () => {};

View file

@ -18,7 +18,9 @@ import { DeviceWithVerification, DeviceSecurityVariation } from "./types";
type DeviceFilterCondition = (device: DeviceWithVerification) => boolean; type DeviceFilterCondition = (device: DeviceWithVerification) => boolean;
const MS_DAY = 24 * 60 * 60 * 1000;
export const INACTIVE_DEVICE_AGE_MS = 7.776e+9; // 90 days export const INACTIVE_DEVICE_AGE_MS = 7.776e+9; // 90 days
export const INACTIVE_DEVICE_AGE_DAYS = INACTIVE_DEVICE_AGE_MS / MS_DAY;
export const isDeviceInactive: DeviceFilterCondition = device => export const isDeviceInactive: DeviceFilterCondition = device =>
!!device.last_seen_ts && device.last_seen_ts < Date.now() - INACTIVE_DEVICE_AGE_MS; !!device.last_seen_ts && device.last_seen_ts < Date.now() - INACTIVE_DEVICE_AGE_MS;

View file

@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import React from 'react'; import React, { useState } from 'react';
import { _t } from "../../../../../languageHandler"; import { _t } from "../../../../../languageHandler";
import { useOwnDevices } from '../../devices/useOwnDevices'; import { useOwnDevices } from '../../devices/useOwnDevices';
@ -22,10 +22,12 @@ import SettingsSubsection from '../../shared/SettingsSubsection';
import FilteredDeviceList from '../../devices/FilteredDeviceList'; import FilteredDeviceList from '../../devices/FilteredDeviceList';
import CurrentDeviceSection from '../../devices/CurrentDeviceSection'; import CurrentDeviceSection from '../../devices/CurrentDeviceSection';
import SecurityRecommendations from '../../devices/SecurityRecommendations'; import SecurityRecommendations from '../../devices/SecurityRecommendations';
import { DeviceSecurityVariation } from '../../devices/types';
import SettingsTab from '../SettingsTab'; import SettingsTab from '../SettingsTab';
const SessionManagerTab: React.FC = () => { const SessionManagerTab: React.FC = () => {
const { devices, currentDeviceId, isLoading } = useOwnDevices(); const { devices, currentDeviceId, isLoading } = useOwnDevices();
const [filter, setFilter] = useState<DeviceSecurityVariation>();
const { [currentDeviceId]: currentDevice, ...otherDevices } = devices; const { [currentDeviceId]: currentDevice, ...otherDevices } = devices;
const shouldShowOtherSessions = Object.keys(otherDevices).length > 0; const shouldShowOtherSessions = Object.keys(otherDevices).length > 0;
@ -46,7 +48,11 @@ const SessionManagerTab: React.FC = () => {
)} )}
data-testid='other-sessions-section' data-testid='other-sessions-section'
> >
<FilteredDeviceList devices={otherDevices} /> <FilteredDeviceList
devices={otherDevices}
filter={filter}
onFilterChange={setFilter}
/>
</SettingsSubsection> </SettingsSubsection>
} }
</SettingsTab>; </SettingsTab>;

View file

@ -1708,13 +1708,26 @@
"This session is ready for secure messaging.": "This session is ready for secure messaging.", "This session is ready for secure messaging.": "This session is ready for secure messaging.",
"Unverified session": "Unverified session", "Unverified session": "Unverified session",
"Verify or sign out from this session for best security and reliability.": "Verify or sign out from this session for best security and reliability.", "Verify or sign out from this session for best security and reliability.": "Verify or sign out from this session for best security and reliability.",
"Security recommendations": "Security recommendations", "Verified sessions": "Verified sessions",
"Improve your account security by following these recommendations": "Improve your account security by following these recommendations", "For best security, sign out from any session that you don't recognize or use anymore.": "For best security, sign out from any session that you don't recognize or use anymore.",
"Unverified sessions": "Unverified sessions", "Unverified sessions": "Unverified sessions",
"Verify your sessions for enhanced secure messaging or sign out from those you don't recognize or use anymore.": "Verify your sessions for enhanced secure messaging or sign out from those you don't recognize or use anymore.", "Verify your sessions for enhanced secure messaging or sign out from those you don't recognize or use anymore.": "Verify your sessions for enhanced secure messaging or sign out from those you don't recognize or use anymore.",
"View all": "View all",
"Inactive sessions": "Inactive sessions", "Inactive sessions": "Inactive sessions",
"Consider signing out from old sessions (%(inactiveAgeDays)s days or older) you don't use anymore": "Consider signing out from old sessions (%(inactiveAgeDays)s days or older) you don't use anymore", "Consider signing out from old sessions (%(inactiveAgeDays)s days or older) you don't use anymore": "Consider signing out from old sessions (%(inactiveAgeDays)s days or older) you don't use anymore",
"No verified sessions found.": "No verified sessions found.",
"No unverified sessions found.": "No unverified sessions found.",
"No inactive sessions found.": "No inactive sessions found.",
"No sessions found.": "No sessions found.",
"Show all": "Show all",
"All": "All",
"Ready for secure messaging": "Ready for secure messaging",
"Not ready for secure messaging": "Not ready for secure messaging",
"Inactive": "Inactive",
"Inactive for %(inactiveAgeDays)s days or longer": "Inactive for %(inactiveAgeDays)s days or longer",
"Filter devices": "Filter devices",
"Security recommendations": "Security recommendations",
"Improve your account security by following these recommendations": "Improve your account security by following these recommendations",
"View all": "View all",
"Unable to remove contact information": "Unable to remove contact information", "Unable to remove contact information": "Unable to remove contact information",
"Remove %(email)s?": "Remove %(email)s?", "Remove %(email)s?": "Remove %(email)s?",
"Invalid Email Address": "Invalid Email Address", "Invalid Email Address": "Invalid Email Address",
@ -2234,7 +2247,6 @@
"Error decrypting video": "Error decrypting video", "Error decrypting video": "Error decrypting video",
"Error processing voice message": "Error processing voice message", "Error processing voice message": "Error processing voice message",
"Add reaction": "Add reaction", "Add reaction": "Add reaction",
"Show all": "Show all",
"Reactions": "Reactions", "Reactions": "Reactions",
"%(reactors)s reacted with %(content)s": "%(reactors)s reacted with %(content)s", "%(reactors)s reacted with %(content)s": "%(reactors)s reacted with %(content)s",
"<reactors/><reactedWith>reacted with %(shortName)s</reactedWith>": "<reactors/><reactedWith>reacted with %(shortName)s</reactedWith>", "<reactors/><reactedWith>reacted with %(shortName)s</reactedWith>": "<reactors/><reactedWith>reacted with %(shortName)s</reactedWith>",

View file

@ -128,7 +128,7 @@ describe('<DevicesPanel />', () => {
await flushPromises(); await flushPromises();
// modal rendering has some weird sleeps // modal rendering has some weird sleeps
await sleep(10); await sleep(100);
expect(mockClient.deleteMultipleDevices).toHaveBeenCalledWith([device2.device_id], undefined); expect(mockClient.deleteMultipleDevices).toHaveBeenCalledWith([device2.device_id], undefined);

View file

@ -15,25 +15,39 @@ limitations under the License.
*/ */
import React from 'react'; import React from 'react';
import { render } from '@testing-library/react'; import { act, fireEvent, render } from '@testing-library/react';
import FilteredDeviceList from '../../../../../src/components/views/settings/devices/FilteredDeviceList'; import FilteredDeviceList from '../../../../../src/components/views/settings/devices/FilteredDeviceList';
import { DeviceSecurityVariation } from '../../../../../src/components/views/settings/devices/types';
import { flushPromises, mockPlatformPeg } from '../../../../test-utils';
mockPlatformPeg();
const MS_DAY = 86400000;
describe('<FilteredDeviceList />', () => { describe('<FilteredDeviceList />', () => {
const noMetaDevice = { device_id: 'no-meta-device', isVerified: true };
const oldDevice = { device_id: 'old', last_seen_ts: new Date(1993, 7, 3, 4).getTime(), isVerified: true };
const newDevice = { const newDevice = {
device_id: 'new', device_id: 'new',
last_seen_ts: new Date().getTime() - 500, last_seen_ts: Date.now() - 500,
last_seen_ip: '123.456.789', last_seen_ip: '123.456.789',
display_name: 'My Device', display_name: 'My Device',
isVerified: true, isVerified: true,
}; };
const unverifiedNoMetadata = { device_id: 'unverified-no-metadata', isVerified: false };
const verifiedNoMetadata = { device_id: 'verified-no-metadata', isVerified: true };
const hundredDaysOld = { device_id: '100-days-old', isVerified: true, last_seen_ts: Date.now() - (MS_DAY * 100) };
const hundredDaysOldUnverified = {
device_id: 'unverified-100-days-old',
isVerified: false,
last_seen_ts: Date.now() - (MS_DAY * 100),
};
const defaultProps = { const defaultProps = {
onFilterChange: jest.fn(),
devices: { devices: {
[noMetaDevice.device_id]: noMetaDevice, [unverifiedNoMetadata.device_id]: unverifiedNoMetadata,
[oldDevice.device_id]: oldDevice, [verifiedNoMetadata.device_id]: verifiedNoMetadata,
[newDevice.device_id]: newDevice, [newDevice.device_id]: newDevice,
[hundredDaysOld.device_id]: hundredDaysOld,
[hundredDaysOldUnverified.device_id]: hundredDaysOldUnverified,
}, },
}; };
const getComponent = (props = {}) => const getComponent = (props = {}) =>
@ -43,14 +57,16 @@ describe('<FilteredDeviceList />', () => {
const { container } = render(getComponent()); const { container } = render(getComponent());
const tiles = container.querySelectorAll('.mx_DeviceTile'); const tiles = container.querySelectorAll('.mx_DeviceTile');
expect(tiles[0].getAttribute('data-testid')).toEqual(`device-tile-${newDevice.device_id}`); expect(tiles[0].getAttribute('data-testid')).toEqual(`device-tile-${newDevice.device_id}`);
expect(tiles[1].getAttribute('data-testid')).toEqual(`device-tile-${oldDevice.device_id}`); expect(tiles[1].getAttribute('data-testid')).toEqual(`device-tile-${hundredDaysOld.device_id}`);
expect(tiles[2].getAttribute('data-testid')).toEqual(`device-tile-${noMetaDevice.device_id}`); expect(tiles[2].getAttribute('data-testid')).toEqual(`device-tile-${hundredDaysOldUnverified.device_id}`);
expect(tiles[3].getAttribute('data-testid')).toEqual(`device-tile-${unverifiedNoMetadata.device_id}`);
expect(tiles[4].getAttribute('data-testid')).toEqual(`device-tile-${verifiedNoMetadata.device_id}`);
}); });
it('updates list order when devices change', () => { it('updates list order when devices change', () => {
const updatedOldDevice = { ...oldDevice, last_seen_ts: new Date().getTime() }; const updatedOldDevice = { ...hundredDaysOld, last_seen_ts: new Date().getTime() };
const updatedDevices = { const updatedDevices = {
[oldDevice.device_id]: updatedOldDevice, [hundredDaysOld.device_id]: updatedOldDevice,
[newDevice.device_id]: newDevice, [newDevice.device_id]: newDevice,
}; };
const { container, rerender } = render(getComponent()); const { container, rerender } = render(getComponent());
@ -59,7 +75,108 @@ describe('<FilteredDeviceList />', () => {
const tiles = container.querySelectorAll('.mx_DeviceTile'); const tiles = container.querySelectorAll('.mx_DeviceTile');
expect(tiles.length).toBe(2); expect(tiles.length).toBe(2);
expect(tiles[0].getAttribute('data-testid')).toEqual(`device-tile-${oldDevice.device_id}`); expect(tiles[0].getAttribute('data-testid')).toEqual(`device-tile-${hundredDaysOld.device_id}`);
expect(tiles[1].getAttribute('data-testid')).toEqual(`device-tile-${newDevice.device_id}`); expect(tiles[1].getAttribute('data-testid')).toEqual(`device-tile-${newDevice.device_id}`);
}); });
it('displays no results message when there are no devices', () => {
const { container } = render(getComponent({ devices: {} }));
expect(container.getElementsByClassName('mx_FilteredDeviceList_noResults')).toMatchSnapshot();
});
describe('filtering', () => {
const setFilter = async (
container: HTMLElement,
option: DeviceSecurityVariation | string,
) => await act(async () => {
const dropdown = container.querySelector('[aria-label="Filter devices"]');
fireEvent.click(dropdown);
// tick to let dropdown render
await flushPromises();
fireEvent.click(container.querySelector(`#device-list-filter__${option}`));
});
it('does not display filter description when filter is falsy', () => {
const { container } = render(getComponent({ filter: undefined }));
const tiles = container.querySelectorAll('.mx_DeviceTile');
expect(container.getElementsByClassName('mx_FilteredDeviceList_securityCard').length).toBeFalsy();
expect(tiles.length).toEqual(5);
});
it('updates filter when prop changes', () => {
const { container, rerender } = render(getComponent({ filter: DeviceSecurityVariation.Verified }));
const tiles = container.querySelectorAll('.mx_DeviceTile');
expect(tiles.length).toEqual(3);
expect(tiles[0].getAttribute('data-testid')).toEqual(`device-tile-${newDevice.device_id}`);
expect(tiles[1].getAttribute('data-testid')).toEqual(`device-tile-${hundredDaysOld.device_id}`);
expect(tiles[2].getAttribute('data-testid')).toEqual(`device-tile-${verifiedNoMetadata.device_id}`);
rerender(getComponent({ filter: DeviceSecurityVariation.Inactive }));
const rerenderedTiles = container.querySelectorAll('.mx_DeviceTile');
expect(rerenderedTiles.length).toEqual(2);
expect(rerenderedTiles[0].getAttribute('data-testid')).toEqual(`device-tile-${hundredDaysOld.device_id}`);
expect(rerenderedTiles[1].getAttribute('data-testid')).toEqual(
`device-tile-${hundredDaysOldUnverified.device_id}`,
);
});
it('calls onFilterChange handler', async () => {
const onFilterChange = jest.fn();
const { container } = render(getComponent({ onFilterChange }));
await setFilter(container, DeviceSecurityVariation.Verified);
expect(onFilterChange).toHaveBeenCalledWith(DeviceSecurityVariation.Verified);
});
it('calls onFilterChange handler correctly when setting filter to All', async () => {
const onFilterChange = jest.fn();
const { container } = render(getComponent({ onFilterChange, filter: DeviceSecurityVariation.Verified }));
await setFilter(container, 'ALL');
// filter is cleared
expect(onFilterChange).toHaveBeenCalledWith(undefined);
});
it.each([
[DeviceSecurityVariation.Verified, [newDevice, hundredDaysOld, verifiedNoMetadata]],
[DeviceSecurityVariation.Unverified, [hundredDaysOldUnverified, unverifiedNoMetadata]],
[DeviceSecurityVariation.Inactive, [hundredDaysOld, hundredDaysOldUnverified]],
])('filters correctly for %s', (filter, expectedDevices) => {
const { container } = render(getComponent({ filter }));
expect(container.getElementsByClassName('mx_FilteredDeviceList_securityCard')).toMatchSnapshot();
const tileDeviceIds = [...container.querySelectorAll('.mx_DeviceTile')]
.map(tile => tile.getAttribute('data-testid'));
expect(tileDeviceIds).toEqual(expectedDevices.map(device => `device-tile-${device.device_id}`));
});
it.each([
[DeviceSecurityVariation.Verified],
[DeviceSecurityVariation.Unverified],
[DeviceSecurityVariation.Inactive],
])('renders no results correctly for %s', (filter) => {
const { container } = render(getComponent({ filter, devices: {} }));
expect(container.getElementsByClassName('mx_FilteredDeviceList_securityCard').length).toBeFalsy();
expect(container.getElementsByClassName('mx_FilteredDeviceList_noResults')).toMatchSnapshot();
});
it('clears filter from no results message', () => {
const onFilterChange = jest.fn();
const { getByTestId } = render(getComponent({
onFilterChange,
filter: DeviceSecurityVariation.Verified,
devices: {
[unverifiedNoMetadata.device_id]: unverifiedNoMetadata,
},
}));
act(() => {
fireEvent.click(getByTestId('devices-clear-filter-btn'));
});
expect(onFilterChange).toHaveBeenCalledWith(undefined);
});
});
}); });

View file

@ -0,0 +1,173 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`<FilteredDeviceList /> displays no results message when there are no devices 1`] = `
HTMLCollection [
<div
class="mx_FilteredDeviceList_noResults"
>
No sessions found.
</div>,
]
`;
exports[`<FilteredDeviceList /> filtering filters correctly for Inactive 1`] = `
HTMLCollection [
<div
class="mx_FilteredDeviceList_securityCard"
>
<div
class="mx_DeviceSecurityCard"
>
<div
class="mx_DeviceSecurityCard_icon Inactive"
>
<div
height="16"
width="16"
/>
</div>
<div
class="mx_DeviceSecurityCard_content"
>
<p
class="mx_DeviceSecurityCard_heading"
>
Inactive sessions
</p>
<p
class="mx_DeviceSecurityCard_description"
>
Consider signing out from old sessions (90 days or older) you don't use anymore
</p>
</div>
</div>
</div>,
]
`;
exports[`<FilteredDeviceList /> filtering filters correctly for Unverified 1`] = `
HTMLCollection [
<div
class="mx_FilteredDeviceList_securityCard"
>
<div
class="mx_DeviceSecurityCard"
>
<div
class="mx_DeviceSecurityCard_icon Unverified"
>
<div
height="16"
width="16"
/>
</div>
<div
class="mx_DeviceSecurityCard_content"
>
<p
class="mx_DeviceSecurityCard_heading"
>
Unverified sessions
</p>
<p
class="mx_DeviceSecurityCard_description"
>
Verify your sessions for enhanced secure messaging or sign out from those you don't recognize or use anymore.
</p>
</div>
</div>
</div>,
]
`;
exports[`<FilteredDeviceList /> filtering filters correctly for Verified 1`] = `
HTMLCollection [
<div
class="mx_FilteredDeviceList_securityCard"
>
<div
class="mx_DeviceSecurityCard"
>
<div
class="mx_DeviceSecurityCard_icon Verified"
>
<div
height="16"
width="16"
/>
</div>
<div
class="mx_DeviceSecurityCard_content"
>
<p
class="mx_DeviceSecurityCard_heading"
>
Verified sessions
</p>
<p
class="mx_DeviceSecurityCard_description"
>
For best security, sign out from any session that you don't recognize or use anymore.
</p>
</div>
</div>
</div>,
]
`;
exports[`<FilteredDeviceList /> filtering renders no results correctly for Inactive 1`] = `
HTMLCollection [
<div
class="mx_FilteredDeviceList_noResults"
>
No inactive sessions found.
 
<div
class="mx_AccessibleButton mx_AccessibleButton_hasKind mx_AccessibleButton_kind_link_inline"
data-testid="devices-clear-filter-btn"
role="button"
tabindex="0"
>
Show all
</div>
</div>,
]
`;
exports[`<FilteredDeviceList /> filtering renders no results correctly for Unverified 1`] = `
HTMLCollection [
<div
class="mx_FilteredDeviceList_noResults"
>
No unverified sessions found.
 
<div
class="mx_AccessibleButton mx_AccessibleButton_hasKind mx_AccessibleButton_kind_link_inline"
data-testid="devices-clear-filter-btn"
role="button"
tabindex="0"
>
Show all
</div>
</div>,
]
`;
exports[`<FilteredDeviceList /> filtering renders no results correctly for Verified 1`] = `
HTMLCollection [
<div
class="mx_FilteredDeviceList_noResults"
>
No verified sessions found.
 
<div
class="mx_AccessibleButton mx_AccessibleButton_hasKind mx_AccessibleButton_kind_link_inline"
data-testid="devices-clear-filter-btn"
role="button"
tabindex="0"
>
Show all
</div>
</div>,
]
`;