Device manager - scroll to filtered list from security recommendations (PSG-640) ()

* scroll to filtered list from security recommendations

* test sessionmanager scroll to

* stable snapshot

* fix strict errors

* prtidy

* use smooth scrolling
This commit is contained in:
Kerry 2022-08-31 14:34:22 +02:00 committed by GitHub
parent 0d6a550c33
commit 54a66bd242
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
8 changed files with 201 additions and 91 deletions

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, { ForwardedRef, forwardRef } from 'react';
import { _t } from '../../../../languageHandler'; import { _t } from '../../../../languageHandler';
import AccessibleButton from '../../elements/AccessibleButton'; import AccessibleButton from '../../elements/AccessibleButton';
@ -150,70 +150,69 @@ const DeviceListItem: React.FC<{
* Filtered list of devices * Filtered list of devices
* Sorted by latest activity descending * Sorted by latest activity descending
*/ */
const FilteredDeviceList: React.FC<Props> = ({ export const FilteredDeviceList =
devices, forwardRef(({
filter, devices,
expandedDeviceIds, filter,
onFilterChange, expandedDeviceIds,
onDeviceExpandToggle, onFilterChange,
}) => { onDeviceExpandToggle,
const sortedDevices = getFilteredSortedDevices(devices, filter); }: Props, ref: ForwardedRef<HTMLDivElement>) => {
const sortedDevices = getFilteredSortedDevices(devices, filter);
const options: FilterDropdownOption<DeviceFilterKey>[] = [ const options: FilterDropdownOption<DeviceFilterKey>[] = [
{ id: ALL_FILTER_ID, label: _t('All') }, { id: ALL_FILTER_ID, label: _t('All') },
{ {
id: DeviceSecurityVariation.Verified, id: DeviceSecurityVariation.Verified,
label: _t('Verified'), label: _t('Verified'),
description: _t('Ready for secure messaging'), description: _t('Ready for secure messaging'),
}, },
{ {
id: DeviceSecurityVariation.Unverified, id: DeviceSecurityVariation.Unverified,
label: _t('Unverified'), label: _t('Unverified'),
description: _t('Not ready for secure messaging'), description: _t('Not ready for secure messaging'),
}, },
{ {
id: DeviceSecurityVariation.Inactive, id: DeviceSecurityVariation.Inactive,
label: _t('Inactive'), label: _t('Inactive'),
description: _t( description: _t(
'Inactive for %(inactiveAgeDays)s days or longer', 'Inactive for %(inactiveAgeDays)s days or longer',
{ inactiveAgeDays: INACTIVE_DEVICE_AGE_DAYS }, { inactiveAgeDays: INACTIVE_DEVICE_AGE_DAYS },
), ),
}, },
]; ];
const onFilterOptionChange = (filterId: DeviceFilterKey) => { const onFilterOptionChange = (filterId: DeviceFilterKey) => {
onFilterChange(filterId === ALL_FILTER_ID ? undefined : filterId as DeviceSecurityVariation); onFilterChange(filterId === ALL_FILTER_ID ? undefined : filterId as DeviceSecurityVariation);
}; };
return <div className='mx_FilteredDeviceList'> return <div className='mx_FilteredDeviceList' ref={ref}>
<div className='mx_FilteredDeviceList_header'> <div className='mx_FilteredDeviceList_header'>
<span className='mx_FilteredDeviceList_headerLabel'> <span className='mx_FilteredDeviceList_headerLabel'>
{ _t('Sessions') } { _t('Sessions') }
</span> </span>
<FilterDropdown<DeviceFilterKey> <FilterDropdown<DeviceFilterKey>
id='device-list-filter' id='device-list-filter'
label={_t('Filter devices')} label={_t('Filter devices')}
value={filter || ALL_FILTER_ID} value={filter || ALL_FILTER_ID}
onOptionChange={onFilterOptionChange} onOptionChange={onFilterOptionChange}
options={options} options={options}
selectedLabel={_t('Show')} selectedLabel={_t('Show')}
/> />
</div> </div>
{ !!sortedDevices.length { !!sortedDevices.length
? <FilterSecurityCard filter={filter} /> ? <FilterSecurityCard filter={filter} />
: <NoResults filter={filter} clearFilter={() => onFilterChange(undefined)} /> : <NoResults filter={filter} clearFilter={() => onFilterChange(undefined)} />
} }
<ol className='mx_FilteredDeviceList_list'> <ol className='mx_FilteredDeviceList_list'>
{ sortedDevices.map((device) => <DeviceListItem { sortedDevices.map((device) => <DeviceListItem
key={device.device_id} key={device.device_id}
device={device} device={device}
isExpanded={expandedDeviceIds.includes(device.device_id)} isExpanded={expandedDeviceIds.includes(device.device_id)}
onDeviceExpandToggle={() => onDeviceExpandToggle(device.device_id)} onDeviceExpandToggle={() => onDeviceExpandToggle(device.device_id)}
/>, />,
) } ) }
</ol> </ol>
</div> </div>;
; });
};
export default FilteredDeviceList;

View file

@ -29,9 +29,13 @@ import {
interface Props { interface Props {
devices: DevicesDictionary; devices: DevicesDictionary;
goToFilteredList: (filter: DeviceSecurityVariation) => void;
} }
const SecurityRecommendations: React.FC<Props> = ({ devices }) => { const SecurityRecommendations: React.FC<Props> = ({
devices,
goToFilteredList,
}) => {
const devicesArray = Object.values<DeviceWithVerification>(devices); const devicesArray = Object.values<DeviceWithVerification>(devices);
const unverifiedDevicesCount = filterDevicesBySecurityRecommendation( const unverifiedDevicesCount = filterDevicesBySecurityRecommendation(
@ -49,9 +53,6 @@ const SecurityRecommendations: React.FC<Props> = ({ devices }) => {
const inactiveAgeDays = INACTIVE_DEVICE_AGE_DAYS; const inactiveAgeDays = INACTIVE_DEVICE_AGE_DAYS;
// TODO(kerrya) stubbed until PSG-640/652
const noop = () => {};
return <SettingsSubsection return <SettingsSubsection
heading={_t('Security recommendations')} heading={_t('Security recommendations')}
description={_t('Improve your account security by following these recommendations')} description={_t('Improve your account security by following these recommendations')}
@ -69,7 +70,8 @@ const SecurityRecommendations: React.FC<Props> = ({ devices }) => {
> >
<AccessibleButton <AccessibleButton
kind='link_inline' kind='link_inline'
onClick={noop} onClick={() => goToFilteredList(DeviceSecurityVariation.Unverified)}
data-testid='unverified-devices-cta'
> >
{ _t('View all') + ` (${unverifiedDevicesCount})` } { _t('View all') + ` (${unverifiedDevicesCount})` }
</AccessibleButton> </AccessibleButton>
@ -90,7 +92,8 @@ const SecurityRecommendations: React.FC<Props> = ({ devices }) => {
> >
<AccessibleButton <AccessibleButton
kind='link_inline' kind='link_inline'
onClick={noop} onClick={() => goToFilteredList(DeviceSecurityVariation.Inactive)}
data-testid='inactive-devices-cta'
> >
{ _t('View all') + ` (${inactiveDevicesCount})` } { _t('View all') + ` (${inactiveDevicesCount})` }
</AccessibleButton> </AccessibleButton>

View file

@ -14,12 +14,12 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import React, { useState } from 'react'; import React, { useEffect, useRef, useState } from 'react';
import { _t } from "../../../../../languageHandler"; import { _t } from "../../../../../languageHandler";
import { useOwnDevices } from '../../devices/useOwnDevices'; import { useOwnDevices } from '../../devices/useOwnDevices';
import SettingsSubsection from '../../shared/SettingsSubsection'; 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, DeviceWithVerification } from '../../devices/types'; import { DeviceSecurityVariation, DeviceWithVerification } from '../../devices/types';
@ -28,7 +28,9 @@ 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 [filter, setFilter] = useState<DeviceSecurityVariation>();
const [expandedDeviceIds, setExpandedDeviceIds] = useState([]); const [expandedDeviceIds, setExpandedDeviceIds] = useState<DeviceWithVerification['device_id'][]>([]);
const filteredDeviceListRef = useRef<HTMLDivElement>(null);
const scrollIntoViewTimeoutRef = useRef<ReturnType<typeof setTimeout>>();
const onDeviceExpandToggle = (deviceId: DeviceWithVerification['device_id']): void => { const onDeviceExpandToggle = (deviceId: DeviceWithVerification['device_id']): void => {
if (expandedDeviceIds.includes(deviceId)) { if (expandedDeviceIds.includes(deviceId)) {
@ -38,11 +40,29 @@ const SessionManagerTab: React.FC = () => {
} }
}; };
const onGoToFilteredList = (filter: DeviceSecurityVariation) => {
setFilter(filter);
// @TODO(kerrya) clear selection when added in PSG-659
clearTimeout(scrollIntoViewTimeoutRef.current);
// wait a tick for the filtered section to rerender with different height
scrollIntoViewTimeoutRef.current =
window.setTimeout(() => filteredDeviceListRef.current?.scrollIntoView({
// align element to top of scrollbox
block: 'start',
inline: 'nearest',
behavior: 'smooth',
}));
};
const { [currentDeviceId]: currentDevice, ...otherDevices } = devices; const { [currentDeviceId]: currentDevice, ...otherDevices } = devices;
const shouldShowOtherSessions = Object.keys(otherDevices).length > 0; const shouldShowOtherSessions = Object.keys(otherDevices).length > 0;
useEffect(() => () => {
clearTimeout(scrollIntoViewTimeoutRef.current);
}, [scrollIntoViewTimeoutRef]);
return <SettingsTab heading={_t('Sessions')}> return <SettingsTab heading={_t('Sessions')}>
<SecurityRecommendations devices={devices} /> <SecurityRecommendations devices={devices} goToFilteredList={onGoToFilteredList} />
<CurrentDeviceSection <CurrentDeviceSection
device={currentDevice} device={currentDevice}
isLoading={isLoading} isLoading={isLoading}
@ -63,6 +83,7 @@ const SessionManagerTab: React.FC = () => {
expandedDeviceIds={expandedDeviceIds} expandedDeviceIds={expandedDeviceIds}
onFilterChange={setFilter} onFilterChange={setFilter}
onDeviceExpandToggle={onDeviceExpandToggle} onDeviceExpandToggle={onDeviceExpandToggle}
ref={filteredDeviceListRef}
/> />
</SettingsSubsection> </SettingsSubsection>
} }

View file

@ -17,7 +17,7 @@ limitations under the License.
import React from 'react'; import React from 'react';
import { act, fireEvent, 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 { DeviceSecurityVariation } from '../../../../../src/components/views/settings/devices/types';
import { flushPromises, mockPlatformPeg } from '../../../../test-utils'; import { flushPromises, mockPlatformPeg } from '../../../../test-utils';

View file

@ -15,9 +15,10 @@ 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 SecurityRecommendations from '../../../../../src/components/views/settings/devices/SecurityRecommendations'; import SecurityRecommendations from '../../../../../src/components/views/settings/devices/SecurityRecommendations';
import { DeviceSecurityVariation } from '../../../../../src/components/views/settings/devices/types';
const MS_DAY = 24 * 60 * 60 * 1000; const MS_DAY = 24 * 60 * 60 * 1000;
describe('<SecurityRecommendations />', () => { describe('<SecurityRecommendations />', () => {
@ -32,6 +33,7 @@ describe('<SecurityRecommendations />', () => {
const defaultProps = { const defaultProps = {
devices: {}, devices: {},
goToFilteredList: jest.fn(),
}; };
const getComponent = (props = {}) => const getComponent = (props = {}) =>
(<SecurityRecommendations {...defaultProps} {...props} />); (<SecurityRecommendations {...defaultProps} {...props} />);
@ -69,4 +71,36 @@ describe('<SecurityRecommendations />', () => {
const { container } = render(getComponent({ devices })); const { container } = render(getComponent({ devices }));
expect(container).toMatchSnapshot(); expect(container).toMatchSnapshot();
}); });
it('clicking view all unverified devices button works', () => {
const goToFilteredList = jest.fn();
const devices = {
[verifiedNoMetadata.device_id]: verifiedNoMetadata,
[hundredDaysOld.device_id]: hundredDaysOld,
[unverifiedNoMetadata.device_id]: unverifiedNoMetadata,
};
const { getByTestId } = render(getComponent({ devices, goToFilteredList }));
act(() => {
fireEvent.click(getByTestId('unverified-devices-cta'));
});
expect(goToFilteredList).toHaveBeenCalledWith(DeviceSecurityVariation.Unverified);
});
it('clicking view all inactive devices button works', () => {
const goToFilteredList = jest.fn();
const devices = {
[verifiedNoMetadata.device_id]: verifiedNoMetadata,
[hundredDaysOld.device_id]: hundredDaysOld,
[unverifiedNoMetadata.device_id]: unverifiedNoMetadata,
};
const { getByTestId } = render(getComponent({ devices, goToFilteredList }));
act(() => {
fireEvent.click(getByTestId('inactive-devices-cta'));
});
expect(goToFilteredList).toHaveBeenCalledWith(DeviceSecurityVariation.Inactive);
});
}); });

View file

@ -48,6 +48,7 @@ exports[`<SecurityRecommendations /> renders both cards when user has both unver
> >
<div <div
class="mx_AccessibleButton mx_AccessibleButton_hasKind mx_AccessibleButton_kind_link_inline" class="mx_AccessibleButton mx_AccessibleButton_hasKind mx_AccessibleButton_kind_link_inline"
data-testid="unverified-devices-cta"
role="button" role="button"
tabindex="0" tabindex="0"
> >
@ -88,6 +89,7 @@ exports[`<SecurityRecommendations /> renders both cards when user has both unver
> >
<div <div
class="mx_AccessibleButton mx_AccessibleButton_hasKind mx_AccessibleButton_kind_link_inline" class="mx_AccessibleButton mx_AccessibleButton_hasKind mx_AccessibleButton_kind_link_inline"
data-testid="inactive-devices-cta"
role="button" role="button"
tabindex="0" tabindex="0"
> >
@ -149,6 +151,7 @@ exports[`<SecurityRecommendations /> renders inactive devices section when user
> >
<div <div
class="mx_AccessibleButton mx_AccessibleButton_hasKind mx_AccessibleButton_kind_link_inline" class="mx_AccessibleButton mx_AccessibleButton_hasKind mx_AccessibleButton_kind_link_inline"
data-testid="unverified-devices-cta"
role="button" role="button"
tabindex="0" tabindex="0"
> >
@ -189,6 +192,7 @@ exports[`<SecurityRecommendations /> renders inactive devices section when user
> >
<div <div
class="mx_AccessibleButton mx_AccessibleButton_hasKind mx_AccessibleButton_kind_link_inline" class="mx_AccessibleButton mx_AccessibleButton_hasKind mx_AccessibleButton_kind_link_inline"
data-testid="inactive-devices-cta"
role="button" role="button"
tabindex="0" tabindex="0"
> >
@ -250,6 +254,7 @@ exports[`<SecurityRecommendations /> renders unverified devices section when use
> >
<div <div
class="mx_AccessibleButton mx_AccessibleButton_hasKind mx_AccessibleButton_kind_link_inline" class="mx_AccessibleButton mx_AccessibleButton_hasKind mx_AccessibleButton_kind_link_inline"
data-testid="unverified-devices-cta"
role="button" role="button"
tabindex="0" tabindex="0"
> >
@ -290,6 +295,7 @@ exports[`<SecurityRecommendations /> renders unverified devices section when use
> >
<div <div
class="mx_AccessibleButton mx_AccessibleButton_hasKind mx_AccessibleButton_kind_link_inline" class="mx_AccessibleButton mx_AccessibleButton_hasKind mx_AccessibleButton_kind_link_inline"
data-testid="inactive-devices-cta"
role="button" role="button"
tabindex="0" tabindex="0"
> >

View file

@ -193,6 +193,23 @@ describe('<SessionManagerTab />', () => {
expect(getByTestId('other-sessions-section')).toBeTruthy(); expect(getByTestId('other-sessions-section')).toBeTruthy();
}); });
it('goes to filtered list from security recommendations', async () => {
mockClient.getDevices.mockResolvedValue({ devices: [alicesDevice, alicesMobileDevice] });
const { getByTestId, container } = render(getComponent());
await act(async () => {
await flushPromisesWithFakeTimers();
});
fireEvent.click(getByTestId('unverified-devices-cta'));
// our session manager waits a tick for rerender
await flushPromisesWithFakeTimers();
// unverified filter is set
expect(container.querySelector('.mx_FilteredDeviceList_header')).toMatchSnapshot();
});
describe('device detail expansion', () => { describe('device detail expansion', () => {
it('renders no devices expanded by default', async () => { it('renders no devices expanded by default', async () => {
mockClient.getDevices.mockResolvedValue({ mockClient.getDevices.mockResolvedValue({
@ -220,30 +237,24 @@ describe('<SessionManagerTab />', () => {
await flushPromisesWithFakeTimers(); await flushPromisesWithFakeTimers();
}); });
act(() => { const tile1 = getByTestId(`device-tile-${alicesOlderMobileDevice.device_id}`);
const tile = getByTestId(`device-tile-${alicesOlderMobileDevice.device_id}`); const toggle1 = tile1.querySelector('[aria-label="Toggle device details"]') as Element;
const toggle = tile.querySelector('[aria-label="Toggle device details"]'); fireEvent.click(toggle1);
fireEvent.click(toggle);
});
// device details are expanded // device details are expanded
expect(getByTestId(`device-detail-${alicesOlderMobileDevice.device_id}`)).toBeTruthy(); expect(getByTestId(`device-detail-${alicesOlderMobileDevice.device_id}`)).toBeTruthy();
act(() => { const tile2 = getByTestId(`device-tile-${alicesMobileDevice.device_id}`);
const tile = getByTestId(`device-tile-${alicesMobileDevice.device_id}`); const toggle2 = tile2.querySelector('[aria-label="Toggle device details"]') as Element;
const toggle = tile.querySelector('[aria-label="Toggle device details"]'); fireEvent.click(toggle2);
fireEvent.click(toggle);
});
// both device details are expanded // both device details are expanded
expect(getByTestId(`device-detail-${alicesOlderMobileDevice.device_id}`)).toBeTruthy(); expect(getByTestId(`device-detail-${alicesOlderMobileDevice.device_id}`)).toBeTruthy();
expect(getByTestId(`device-detail-${alicesMobileDevice.device_id}`)).toBeTruthy(); expect(getByTestId(`device-detail-${alicesMobileDevice.device_id}`)).toBeTruthy();
act(() => { const tile3 = getByTestId(`device-tile-${alicesMobileDevice.device_id}`);
const tile = getByTestId(`device-tile-${alicesMobileDevice.device_id}`); const toggle3 = tile3.querySelector('[aria-label="Toggle device details"]') as Element;
const toggle = tile.querySelector('[aria-label="Toggle device details"]'); fireEvent.click(toggle3);
fireEvent.click(toggle);
});
// alicesMobileDevice was toggled off // alicesMobileDevice was toggled off
expect(queryByTestId(`device-detail-${alicesMobileDevice.device_id}`)).toBeFalsy(); expect(queryByTestId(`device-detail-${alicesMobileDevice.device_id}`)).toBeFalsy();

View file

@ -1,5 +1,41 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP // Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`<SessionManagerTab /> goes to filtered list from security recommendations 1`] = `
<div
class="mx_FilteredDeviceList_header"
>
<span
class="mx_FilteredDeviceList_headerLabel"
>
Sessions
</span>
<div
class="mx_Dropdown mx_FilterDropdown"
>
<div
aria-describedby="device-list-filter_value"
aria-expanded="false"
aria-haspopup="listbox"
aria-label="Filter devices"
aria-owns="device-list-filter_input"
class="mx_AccessibleButton mx_Dropdown_input mx_no_textinput"
role="button"
tabindex="0"
>
<div
class="mx_Dropdown_option"
id="device-list-filter_value"
>
Show: Unverified
</div>
<span
class="mx_Dropdown_arrow"
/>
</div>
</div>
</div>
`;
exports[`<SessionManagerTab /> renders current session section with a verified session 1`] = ` exports[`<SessionManagerTab /> renders current session section with a verified session 1`] = `
<div <div
class="mx_SettingsSubsection" class="mx_SettingsSubsection"