Device manager - expandable session details in device list (PSG-644) (#9188)

* add expandable device details to session list

* test device expansion in filtered list

* test expanded device id management from sessionmanager tab

* i18n

* update snapshot

* update snapshots

* use css instead of br
This commit is contained in:
Kerry 2022-08-17 11:58:34 +02:00 committed by GitHub
parent fecc03289d
commit e5fedfcd74
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
13 changed files with 155 additions and 12 deletions

View file

@ -32,6 +32,9 @@ limitations under the License.
margin-bottom: $spacing-16; margin-bottom: $spacing-16;
border-bottom: 1px solid $quinary-content; border-bottom: 1px solid $quinary-content;
display: grid;
grid-gap: $spacing-16;
&:last-child { &:last-child {
padding-bottom: 0; padding-bottom: 0;
border-bottom: 0; border-bottom: 0;
@ -48,7 +51,6 @@ limitations under the License.
color: $secondary-content; color: $secondary-content;
width: 100%; width: 100%;
margin-top: $spacing-20;
border-spacing: 0; border-spacing: 0;

View file

@ -48,6 +48,11 @@ limitations under the License.
padding: 0 $spacing-8; padding: 0 $spacing-8;
} }
.mx_FilteredDeviceList_listItem {
display: flex;
flex-direction: column;
}
.mx_FilteredDeviceList_securityCard { .mx_FilteredDeviceList_securityCard {
margin-bottom: $spacing-32; margin-bottom: $spacing-32;
} }

View file

@ -49,7 +49,7 @@ const DeviceDetails: React.FC<Props> = ({ device }) => {
], ],
}, },
]; ];
return <div className='mx_DeviceDetails'> return <div className='mx_DeviceDetails' data-testid={`device-detail-${device.device_id}`}>
<section className='mx_DeviceDetails_section'> <section className='mx_DeviceDetails_section'>
<Heading size='h3'>{ device.display_name ?? device.device_id }</Heading> <Heading size='h3'>{ device.display_name ?? device.device_id }</Heading>
<DeviceVerificationStatusCard device={device} /> <DeviceVerificationStatusCard device={device} />

View file

@ -18,6 +18,7 @@ import classNames from 'classnames';
import React from 'react'; import React from 'react';
import { Icon as CaretIcon } from '../../../../../res/img/feather-customised/dropdown-arrow.svg'; import { Icon as CaretIcon } from '../../../../../res/img/feather-customised/dropdown-arrow.svg';
import { _t } from '../../../../languageHandler';
import AccessibleButton from '../../elements/AccessibleButton'; import AccessibleButton from '../../elements/AccessibleButton';
interface Props { interface Props {
@ -28,6 +29,7 @@ interface Props {
const DeviceExpandDetailsButton: React.FC<Props> = ({ isExpanded, onClick, ...rest }) => { const DeviceExpandDetailsButton: React.FC<Props> = ({ isExpanded, onClick, ...rest }) => {
return <AccessibleButton return <AccessibleButton
{...rest} {...rest}
aria-label={_t('Toggle device details')}
kind='icon' kind='icon'
className={classNames('mx_DeviceExpandDetailsButton', { className={classNames('mx_DeviceExpandDetailsButton', {
mx_DeviceExpandDetailsButton_expanded: isExpanded, mx_DeviceExpandDetailsButton_expanded: isExpanded,

View file

@ -19,6 +19,8 @@ import React from 'react';
import { _t } from '../../../../languageHandler'; import { _t } from '../../../../languageHandler';
import AccessibleButton from '../../elements/AccessibleButton'; import AccessibleButton from '../../elements/AccessibleButton';
import Dropdown from '../../elements/Dropdown'; import Dropdown from '../../elements/Dropdown';
import DeviceDetails from './DeviceDetails';
import DeviceExpandDetailsButton from './DeviceExpandDetailsButton';
import DeviceSecurityCard from './DeviceSecurityCard'; import DeviceSecurityCard from './DeviceSecurityCard';
import DeviceTile from './DeviceTile'; import DeviceTile from './DeviceTile';
import { import {
@ -33,8 +35,10 @@ import {
interface Props { interface Props {
devices: DevicesDictionary; devices: DevicesDictionary;
expandedDeviceIds: DeviceWithVerification['device_id'][];
filter?: DeviceSecurityVariation; filter?: DeviceSecurityVariation;
onFilterChange: (filter: DeviceSecurityVariation | undefined) => void; onFilterChange: (filter: DeviceSecurityVariation | undefined) => void;
onDeviceExpandToggle: (deviceId: DeviceWithVerification['device_id']) => void;
} }
// devices without timestamp metadata should be sorted last // devices without timestamp metadata should be sorted last
@ -123,11 +127,35 @@ const NoResults: React.FC<NoResultsProps> = ({ filter, clearFilter }) =>
} }
</div>; </div>;
const DeviceListItem: React.FC<{
device: DeviceWithVerification;
isExpanded: boolean;
onDeviceExpandToggle: () => void;
}> = ({
device, isExpanded, onDeviceExpandToggle,
}) => <li className='mx_FilteredDeviceList_listItem'>
<DeviceTile
device={device}
>
<DeviceExpandDetailsButton
isExpanded={isExpanded}
onClick={onDeviceExpandToggle}
/>
</DeviceTile>
{ isExpanded && <DeviceDetails device={device} /> }
</li>;
/** /**
* Filtered list of devices * Filtered list of devices
* Sorted by latest activity descending * Sorted by latest activity descending
*/ */
const FilteredDeviceList: React.FC<Props> = ({ devices, filter, onFilterChange }) => { const FilteredDeviceList: React.FC<Props> = ({
devices,
filter,
expandedDeviceIds,
onFilterChange,
onDeviceExpandToggle,
}) => {
const sortedDevices = getFilteredSortedDevices(devices, filter); const sortedDevices = getFilteredSortedDevices(devices, filter);
const options = [ const options = [
@ -177,13 +205,12 @@ const FilteredDeviceList: React.FC<Props> = ({ devices, filter, onFilterChange }
: <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) => { sortedDevices.map((device) => <DeviceListItem
<li key={device.device_id}> key={device.device_id}
<DeviceTile device={device}
device={device} isExpanded={expandedDeviceIds.includes(device.device_id)}
/> onDeviceExpandToggle={() => onDeviceExpandToggle(device.device_id)}
</li>, />,
) } ) }
</ol> </ol>
</div> </div>

View file

@ -22,12 +22,21 @@ 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 { DeviceSecurityVariation, DeviceWithVerification } 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 [filter, setFilter] = useState<DeviceSecurityVariation>();
const [expandedDeviceIds, setExpandedDeviceIds] = useState([]);
const onDeviceExpandToggle = (deviceId: DeviceWithVerification['device_id']): void => {
if (expandedDeviceIds.includes(deviceId)) {
setExpandedDeviceIds(expandedDeviceIds.filter(id => id !== deviceId));
} else {
setExpandedDeviceIds([...expandedDeviceIds, deviceId]);
}
};
const { [currentDeviceId]: currentDevice, ...otherDevices } = devices; const { [currentDeviceId]: currentDevice, ...otherDevices } = devices;
const shouldShowOtherSessions = Object.keys(otherDevices).length > 0; const shouldShowOtherSessions = Object.keys(otherDevices).length > 0;
@ -51,7 +60,9 @@ const SessionManagerTab: React.FC = () => {
<FilteredDeviceList <FilteredDeviceList
devices={otherDevices} devices={otherDevices}
filter={filter} filter={filter}
expandedDeviceIds={expandedDeviceIds}
onFilterChange={setFilter} onFilterChange={setFilter}
onDeviceExpandToggle={onDeviceExpandToggle}
/> />
</SettingsSubsection> </SettingsSubsection>
} }

View file

@ -1701,6 +1701,7 @@
"Device": "Device", "Device": "Device",
"IP address": "IP address", "IP address": "IP address",
"Session details": "Session details", "Session details": "Session details",
"Toggle device details": "Toggle device details",
"Inactive for %(inactiveAgeDays)s+ days": "Inactive for %(inactiveAgeDays)s+ days", "Inactive for %(inactiveAgeDays)s+ days": "Inactive for %(inactiveAgeDays)s+ days",
"Verified": "Verified", "Verified": "Verified",
"Unverified": "Unverified", "Unverified": "Unverified",

View file

@ -42,6 +42,8 @@ describe('<FilteredDeviceList />', () => {
}; };
const defaultProps = { const defaultProps = {
onFilterChange: jest.fn(), onFilterChange: jest.fn(),
onDeviceExpandToggle: jest.fn(),
expandedDeviceIds: [],
devices: { devices: {
[unverifiedNoMetadata.device_id]: unverifiedNoMetadata, [unverifiedNoMetadata.device_id]: unverifiedNoMetadata,
[verifiedNoMetadata.device_id]: verifiedNoMetadata, [verifiedNoMetadata.device_id]: verifiedNoMetadata,
@ -179,4 +181,27 @@ describe('<FilteredDeviceList />', () => {
expect(onFilterChange).toHaveBeenCalledWith(undefined); expect(onFilterChange).toHaveBeenCalledWith(undefined);
}); });
}); });
describe('device details', () => {
it('renders expanded devices with device details', () => {
const expandedDeviceIds = [newDevice.device_id, hundredDaysOld.device_id];
const { container, getByTestId } = render(getComponent({ expandedDeviceIds }));
expect(container.getElementsByClassName('mx_DeviceDetails').length).toBeTruthy();
expect(getByTestId(`device-detail-${newDevice.device_id}`)).toBeTruthy();
expect(getByTestId(`device-detail-${hundredDaysOld.device_id}`)).toBeTruthy();
});
it('clicking toggle calls onDeviceExpandToggle', () => {
const onDeviceExpandToggle = jest.fn();
const { getByTestId } = render(getComponent({ onDeviceExpandToggle }));
act(() => {
const tile = getByTestId(`device-tile-${hundredDaysOld.device_id}`);
const toggle = tile.querySelector('[aria-label="Toggle device details"]');
fireEvent.click(toggle);
});
expect(onDeviceExpandToggle).toHaveBeenCalledWith(hundredDaysOld.device_id);
});
});
}); });

View file

@ -4,6 +4,7 @@ exports[`<CurrentDeviceSection /> displays device details on toggle click 1`] =
HTMLCollection [ HTMLCollection [
<div <div
class="mx_DeviceDetails" class="mx_DeviceDetails"
data-testid="device-detail-alices_device"
> >
<section <section
class="mx_DeviceDetails_section" class="mx_DeviceDetails_section"
@ -164,6 +165,7 @@ exports[`<CurrentDeviceSection /> renders device and correct security card when
class="mx_DeviceTile_actions" class="mx_DeviceTile_actions"
> >
<div <div
aria-label="Toggle device details"
class="mx_AccessibleButton mx_DeviceExpandDetailsButton mx_AccessibleButton_hasKind mx_AccessibleButton_kind_icon" class="mx_AccessibleButton mx_DeviceExpandDetailsButton mx_AccessibleButton_hasKind mx_AccessibleButton_kind_icon"
data-testid="current-session-toggle-details" data-testid="current-session-toggle-details"
role="button" role="button"
@ -249,6 +251,7 @@ exports[`<CurrentDeviceSection /> renders device and correct security card when
class="mx_DeviceTile_actions" class="mx_DeviceTile_actions"
> >
<div <div
aria-label="Toggle device details"
class="mx_AccessibleButton mx_DeviceExpandDetailsButton mx_AccessibleButton_hasKind mx_AccessibleButton_kind_icon" class="mx_AccessibleButton mx_DeviceExpandDetailsButton mx_AccessibleButton_hasKind mx_AccessibleButton_kind_icon"
data-testid="current-session-toggle-details" data-testid="current-session-toggle-details"
role="button" role="button"

View file

@ -4,6 +4,7 @@ exports[`<DeviceDetails /> renders a verified device 1`] = `
<div> <div>
<div <div
class="mx_DeviceDetails" class="mx_DeviceDetails"
data-testid="device-detail-my-device"
> >
<section <section
class="mx_DeviceDetails_section" class="mx_DeviceDetails_section"
@ -108,6 +109,7 @@ exports[`<DeviceDetails /> renders device with metadata 1`] = `
<div> <div>
<div <div
class="mx_DeviceDetails" class="mx_DeviceDetails"
data-testid="device-detail-my-device"
> >
<section <section
class="mx_DeviceDetails_section" class="mx_DeviceDetails_section"
@ -216,6 +218,7 @@ exports[`<DeviceDetails /> renders device without metadata 1`] = `
<div> <div>
<div <div
class="mx_DeviceDetails" class="mx_DeviceDetails"
data-testid="device-detail-my-device"
> >
<section <section
class="mx_DeviceDetails_section" class="mx_DeviceDetails_section"

View file

@ -4,6 +4,7 @@ exports[`<DeviceExpandDetailsButton /> renders when expanded 1`] = `
Object { Object {
"container": <div> "container": <div>
<div <div
aria-label="Toggle device details"
class="mx_AccessibleButton mx_DeviceExpandDetailsButton mx_DeviceExpandDetailsButton_expanded mx_AccessibleButton_hasKind mx_AccessibleButton_kind_icon" class="mx_AccessibleButton mx_DeviceExpandDetailsButton mx_DeviceExpandDetailsButton_expanded mx_AccessibleButton_hasKind mx_AccessibleButton_kind_icon"
role="button" role="button"
tabindex="0" tabindex="0"
@ -20,6 +21,7 @@ exports[`<DeviceExpandDetailsButton /> renders when not expanded 1`] = `
Object { Object {
"container": <div> "container": <div>
<div <div
aria-label="Toggle device details"
class="mx_AccessibleButton mx_DeviceExpandDetailsButton mx_AccessibleButton_hasKind mx_AccessibleButton_kind_icon" class="mx_AccessibleButton mx_DeviceExpandDetailsButton mx_AccessibleButton_hasKind mx_AccessibleButton_kind_icon"
role="button" role="button"
tabindex="0" tabindex="0"

View file

@ -15,7 +15,7 @@ limitations under the License.
*/ */
import React from 'react'; import React from 'react';
import { render } from '@testing-library/react'; import { fireEvent, render } from '@testing-library/react';
import { act } from 'react-dom/test-utils'; import { act } from 'react-dom/test-utils';
import { DeviceInfo } from 'matrix-js-sdk/src/crypto/deviceinfo'; import { DeviceInfo } from 'matrix-js-sdk/src/crypto/deviceinfo';
import { logger } from 'matrix-js-sdk/src/logger'; import { logger } from 'matrix-js-sdk/src/logger';
@ -192,4 +192,63 @@ describe('<SessionManagerTab />', () => {
expect(getByTestId('other-sessions-section')).toBeTruthy(); expect(getByTestId('other-sessions-section')).toBeTruthy();
}); });
describe('device detail expansion', () => {
it('renders no devices expanded by default', async () => {
mockClient.getDevices.mockResolvedValue({
devices: [alicesDevice, alicesOlderMobileDevice, alicesMobileDevice],
});
const { getByTestId } = render(getComponent());
await act(async () => {
await flushPromisesWithFakeTimers();
});
const otherSessionsSection = getByTestId('other-sessions-section');
// no expanded device details
expect(otherSessionsSection.getElementsByClassName('mx_DeviceDetails').length).toBeFalsy();
});
it('toggles device expansion on click', async () => {
mockClient.getDevices.mockResolvedValue({
devices: [alicesDevice, alicesOlderMobileDevice, alicesMobileDevice],
});
const { getByTestId, queryByTestId } = render(getComponent());
await act(async () => {
await flushPromisesWithFakeTimers();
});
act(() => {
const tile = getByTestId(`device-tile-${alicesOlderMobileDevice.device_id}`);
const toggle = tile.querySelector('[aria-label="Toggle device details"]');
fireEvent.click(toggle);
});
// device details are expanded
expect(getByTestId(`device-detail-${alicesOlderMobileDevice.device_id}`)).toBeTruthy();
act(() => {
const tile = getByTestId(`device-tile-${alicesMobileDevice.device_id}`);
const toggle = tile.querySelector('[aria-label="Toggle device details"]');
fireEvent.click(toggle);
});
// both device details are expanded
expect(getByTestId(`device-detail-${alicesOlderMobileDevice.device_id}`)).toBeTruthy();
expect(getByTestId(`device-detail-${alicesMobileDevice.device_id}`)).toBeTruthy();
act(() => {
const tile = getByTestId(`device-tile-${alicesMobileDevice.device_id}`);
const toggle = tile.querySelector('[aria-label="Toggle device details"]');
fireEvent.click(toggle);
});
// alicesMobileDevice was toggled off
expect(queryByTestId(`device-detail-${alicesMobileDevice.device_id}`)).toBeFalsy();
// alicesOlderMobileDevice stayed open
expect(getByTestId(`device-detail-${alicesOlderMobileDevice.device_id}`)).toBeTruthy();
});
});
}); });

View file

@ -41,6 +41,7 @@ exports[`<SessionManagerTab /> renders current session section with a verified s
class="mx_DeviceTile_actions" class="mx_DeviceTile_actions"
> >
<div <div
aria-label="Toggle device details"
class="mx_AccessibleButton mx_DeviceExpandDetailsButton mx_AccessibleButton_hasKind mx_AccessibleButton_kind_icon" class="mx_AccessibleButton mx_DeviceExpandDetailsButton mx_AccessibleButton_hasKind mx_AccessibleButton_kind_icon"
data-testid="current-session-toggle-details" data-testid="current-session-toggle-details"
role="button" role="button"
@ -124,6 +125,7 @@ exports[`<SessionManagerTab /> renders current session section with an unverifie
class="mx_DeviceTile_actions" class="mx_DeviceTile_actions"
> >
<div <div
aria-label="Toggle device details"
class="mx_AccessibleButton mx_DeviceExpandDetailsButton mx_AccessibleButton_hasKind mx_AccessibleButton_kind_icon" class="mx_AccessibleButton mx_DeviceExpandDetailsButton mx_AccessibleButton_hasKind mx_AccessibleButton_kind_icon"
data-testid="current-session-toggle-details" data-testid="current-session-toggle-details"
role="button" role="button"
@ -195,6 +197,7 @@ exports[`<SessionManagerTab /> sets device verification status correctly 1`] = `
class="mx_DeviceTile_actions" class="mx_DeviceTile_actions"
> >
<div <div
aria-label="Toggle device details"
class="mx_AccessibleButton mx_DeviceExpandDetailsButton mx_AccessibleButton_hasKind mx_AccessibleButton_kind_icon" class="mx_AccessibleButton mx_DeviceExpandDetailsButton mx_AccessibleButton_hasKind mx_AccessibleButton_kind_icon"
data-testid="current-session-toggle-details" data-testid="current-session-toggle-details"
role="button" role="button"