Device manager - updated dropdown style in filtered device list (PSG-689) (#9226)

* add FilterDropdown wrapper on Dropdown for filter styles

* test and fix strict errors

* fix comment
This commit is contained in:
Kerry 2022-08-30 19:11:33 +02:00 committed by GitHub
parent 825a0af4a9
commit 50f6986f6c
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
10 changed files with 389 additions and 20 deletions

View file

@ -16,6 +16,7 @@
@import "./components/views/beacon/_RoomLiveShareWarning.pcss"; @import "./components/views/beacon/_RoomLiveShareWarning.pcss";
@import "./components/views/beacon/_ShareLatestLocation.pcss"; @import "./components/views/beacon/_ShareLatestLocation.pcss";
@import "./components/views/beacon/_StyledLiveBeaconIcon.pcss"; @import "./components/views/beacon/_StyledLiveBeaconIcon.pcss";
@import "./components/views/elements/_FilterDropdown.pcss";
@import "./components/views/location/_EnableLiveShare.pcss"; @import "./components/views/location/_EnableLiveShare.pcss";
@import "./components/views/location/_LiveDurationDropdown.pcss"; @import "./components/views/location/_LiveDurationDropdown.pcss";
@import "./components/views/location/_LocationShareMenu.pcss"; @import "./components/views/location/_LocationShareMenu.pcss";

View file

@ -0,0 +1,77 @@
/*
Copyright 2022 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
.mx_FilterDropdown {
.mx_Dropdown_menu {
margin-top: $spacing-4;
left: unset;
right: -$spacing-12;
width: 232px;
border: 1px solid $quinary-content;
border-radius: 8px;
box-shadow: 0px 1px 3px rgba(23, 25, 28, 0.05);
.mx_Dropdown_option_highlight {
background-color: $system;
}
}
.mx_Dropdown_input {
height: 24px;
background-color: $quinary-content;
border-color: $quinary-content;
color: $secondary-content;
border-radius: 4px;
&:focus {
border-color: $quinary-content;
}
}
.mx_Dropdown_arrow {
background: $secondary-content;
}
}
.mx_FilterDropdown_option {
position: relative;
width: 100%;
box-sizing: border-box;
padding: $spacing-8 0 $spacing-8 $spacing-20;
font-size: $font-12px;
line-height: $font-15px;
color: $primary-content;
}
.mx_FilterDropdown_optionSelectedIcon {
height: 14px;
width: 14px;
position: absolute;
top: $spacing-8;
left: 0;
}
.mx_FilterDropdown_optionLabel {
font-weight: $font-semi-bold;
display: block;
}
.mx_FilterDropdown_optionDescription {
color: $secondary-content;
margin-top: $spacing-4;
}

View file

@ -1,3 +1,3 @@
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg"> <svg width="18" height="18" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M4 8.81818L7.125 12L14 5" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/> <path d="M4 8.81818L7.125 12L14 5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 221 B

After

Width:  |  Height:  |  Size: 228 B

View file

@ -68,7 +68,7 @@ class MenuOption extends React.Component<IMenuOptionProps> {
} }
} }
interface IProps { export interface DropdownProps {
id: string; id: string;
// ARIA label // ARIA label
label: string; label: string;
@ -108,13 +108,13 @@ interface IState {
* but somewhat simpler as react-select is 79KB of minified * but somewhat simpler as react-select is 79KB of minified
* javascript. * javascript.
*/ */
export default class Dropdown extends React.Component<IProps, IState> { export default class Dropdown extends React.Component<DropdownProps, IState> {
private readonly buttonRef = createRef<HTMLDivElement>(); private readonly buttonRef = createRef<HTMLDivElement>();
private dropdownRootElement: HTMLDivElement = null; private dropdownRootElement: HTMLDivElement = null;
private ignoreEvent: MouseEvent = null; private ignoreEvent: MouseEvent = null;
private childrenByKey: Record<string, ReactNode> = {}; private childrenByKey: Record<string, ReactNode> = {};
constructor(props: IProps) { constructor(props: DropdownProps) {
super(props); super(props);
this.reindexChildren(this.props.children); this.reindexChildren(this.props.children);

View file

@ -0,0 +1,86 @@
/*
Copyright 2022 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import React from 'react';
import classNames from 'classnames';
import { Icon as CheckmarkIcon } from '../../../../res/img/element-icons/roomlist/checkmark.svg';
import Dropdown, { DropdownProps } from './Dropdown';
export type FilterDropdownOption<FilterKeysType extends string> = {
id: FilterKeysType;
label: string;
description?: string;
};
type FilterDropdownProps<FilterKeysType extends string> = Omit<DropdownProps, 'children'> & {
value: FilterKeysType;
options: FilterDropdownOption<FilterKeysType>[];
// A label displayed before the selected value
// in the dropdown input
selectedLabel?: string;
};
const getSelectedFilterOptionComponent = <FilterKeysType extends string>(
options: FilterDropdownOption<FilterKeysType>[], selectedLabel?: string,
) => (filterKey: FilterKeysType) => {
const option = options.find(({ id }) => id === filterKey);
if (!option) {
return null;
}
if (selectedLabel) {
return `${selectedLabel}: ${option.label}`;
}
return option.label;
};
/**
* Dropdown styled for list filtering
*/
export const FilterDropdown = <FilterKeysType extends string = string>(
{
value,
options,
selectedLabel,
className,
...restProps
}: FilterDropdownProps<FilterKeysType>,
): React.ReactElement<FilterDropdownProps<FilterKeysType>> => {
return <Dropdown
{...restProps}
value={value}
className={classNames('mx_FilterDropdown', className)}
getShortOption={getSelectedFilterOptionComponent<FilterKeysType>(options, selectedLabel)}
>
{ options.map(({ id, label, description }) =>
<div
className='mx_FilterDropdown_option'
data-testid={`filter-option-${id}`}
key={id}
>
{ id === value && <CheckmarkIcon className='mx_FilterDropdown_optionSelectedIcon' /> }
<span className='mx_FilterDropdown_optionLabel'>
{ label }
</span>
{
!!description
&& <span
className='mx_FilterDropdown_optionDescription'
>{ description }</span>
}
</div>,
) }
</Dropdown>;
};

View file

@ -18,7 +18,7 @@ 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 { FilterDropdown, FilterDropdownOption } from '../../elements/FilterDropdown';
import DeviceDetails from './DeviceDetails'; import DeviceDetails from './DeviceDetails';
import DeviceExpandDetailsButton from './DeviceExpandDetailsButton'; import DeviceExpandDetailsButton from './DeviceExpandDetailsButton';
import DeviceSecurityCard from './DeviceSecurityCard'; import DeviceSecurityCard from './DeviceSecurityCard';
@ -45,13 +45,14 @@ interface Props {
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, filter: DeviceSecurityVariation) => const getFilteredSortedDevices = (devices: DevicesDictionary, filter?: DeviceSecurityVariation) =>
filterDevicesBySecurityRecommendation(Object.values(devices), filter ? [filter] : []) filterDevicesBySecurityRecommendation(Object.values(devices), filter ? [filter] : [])
.sort(sortDevicesByLatestActivity); .sort(sortDevicesByLatestActivity);
const ALL_FILTER_ID = 'ALL'; const ALL_FILTER_ID = 'ALL';
type DeviceFilterKey = DeviceSecurityVariation | typeof ALL_FILTER_ID;
const FilterSecurityCard: React.FC<{ filter?: DeviceSecurityVariation | string }> = ({ filter }) => { const FilterSecurityCard: React.FC<{ filter?: DeviceFilterKey }> = ({ filter }) => {
switch (filter) { switch (filter) {
case DeviceSecurityVariation.Verified: case DeviceSecurityVariation.Verified:
return <div className='mx_FilteredDeviceList_securityCard'> return <div className='mx_FilteredDeviceList_securityCard'>
@ -95,7 +96,7 @@ const FilterSecurityCard: React.FC<{ filter?: DeviceSecurityVariation | string }
} }
}; };
const getNoResultsMessage = (filter: DeviceSecurityVariation): string => { const getNoResultsMessage = (filter?: DeviceSecurityVariation): string => {
switch (filter) { switch (filter) {
case DeviceSecurityVariation.Verified: case DeviceSecurityVariation.Verified:
return _t('No verified sessions found.'); return _t('No verified sessions found.');
@ -107,7 +108,7 @@ const getNoResultsMessage = (filter: DeviceSecurityVariation): string => {
return _t('No sessions found.'); return _t('No sessions found.');
} }
}; };
interface NoResultsProps { filter: DeviceSecurityVariation, clearFilter: () => void} interface NoResultsProps { filter?: DeviceSecurityVariation, clearFilter: () => void}
const NoResults: React.FC<NoResultsProps> = ({ filter, clearFilter }) => const NoResults: React.FC<NoResultsProps> = ({ filter, clearFilter }) =>
<div className='mx_FilteredDeviceList_noResults'> <div className='mx_FilteredDeviceList_noResults'>
{ getNoResultsMessage(filter) } { getNoResultsMessage(filter) }
@ -158,7 +159,7 @@ const FilteredDeviceList: React.FC<Props> = ({
}) => { }) => {
const sortedDevices = getFilteredSortedDevices(devices, filter); const sortedDevices = getFilteredSortedDevices(devices, filter);
const options = [ const options: FilterDropdownOption<DeviceFilterKey>[] = [
{ id: ALL_FILTER_ID, label: _t('All') }, { id: ALL_FILTER_ID, label: _t('All') },
{ {
id: DeviceSecurityVariation.Verified, id: DeviceSecurityVariation.Verified,
@ -180,7 +181,7 @@ const FilteredDeviceList: React.FC<Props> = ({
}, },
]; ];
const onFilterOptionChange = (filterId: DeviceSecurityVariation | typeof ALL_FILTER_ID) => { const onFilterOptionChange = (filterId: DeviceFilterKey) => {
onFilterChange(filterId === ALL_FILTER_ID ? undefined : filterId as DeviceSecurityVariation); onFilterChange(filterId === ALL_FILTER_ID ? undefined : filterId as DeviceSecurityVariation);
}; };
@ -189,16 +190,14 @@ const FilteredDeviceList: React.FC<Props> = ({
<span className='mx_FilteredDeviceList_headerLabel'> <span className='mx_FilteredDeviceList_headerLabel'>
{ _t('Sessions') } { _t('Sessions') }
</span> </span>
<Dropdown <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.map(({ id, label }) => selectedLabel={_t('Show')}
<div data-test-id={`device-filter-option-${id}`} key={id}>{ label }</div>, />
) }
</Dropdown>
</div> </div>
{ !!sortedDevices.length { !!sortedDevices.length
? <FilterSecurityCard filter={filter} /> ? <FilterSecurityCard filter={filter} />

View file

@ -1734,6 +1734,7 @@
"Inactive": "Inactive", "Inactive": "Inactive",
"Inactive for %(inactiveAgeDays)s days or longer": "Inactive for %(inactiveAgeDays)s days or longer", "Inactive for %(inactiveAgeDays)s days or longer": "Inactive for %(inactiveAgeDays)s days or longer",
"Filter devices": "Filter devices", "Filter devices": "Filter devices",
"Show": "Show",
"Security recommendations": "Security recommendations", "Security recommendations": "Security recommendations",
"Improve your account security by following these recommendations": "Improve your account security by following these recommendations", "Improve your account security by following these recommendations": "Improve your account security by following these recommendations",
"View all": "View all", "View all": "View all",

View file

@ -0,0 +1,68 @@
/*
Copyright 2022 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { act, fireEvent, render } from '@testing-library/react';
import React from 'react';
import { FilterDropdown } from '../../../../src/components/views/elements/FilterDropdown';
import { flushPromises, mockPlatformPeg } from '../../../test-utils';
mockPlatformPeg();
describe('<FilterDropdown />', () => {
const options = [
{ id: 'one', label: 'Option one' },
{ id: 'two', label: 'Option two', description: 'with description' },
];
const defaultProps = {
className: 'test',
value: 'one',
options,
id: 'test',
label: 'test label',
onOptionChange: jest.fn(),
};
const getComponent = (props = {}): JSX.Element =>
(<FilterDropdown {...defaultProps} {...props} />);
const openDropdown = async (container: HTMLElement): Promise<void> => await act(async () => {
const button = container.querySelector('[role="button"]');
expect(button).toBeTruthy();
fireEvent.click(button as Element);
await flushPromises();
});
it('renders selected option', () => {
const { container } = render(getComponent());
expect(container).toMatchSnapshot();
});
it('renders when selected option is not in options', () => {
const { container } = render(getComponent({ value: 'oops' }));
expect(container).toMatchSnapshot();
});
it('renders selected option with selectedLabel', () => {
const { container } = render(getComponent({ selectedLabel: 'Show' }));
expect(container).toMatchSnapshot();
});
it('renders dropdown options in menu', async () => {
const { container } = render(getComponent());
await openDropdown(container);
expect(container.querySelector('.mx_Dropdown_menu')).toMatchSnapshot();
});
});

View file

@ -0,0 +1,137 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`<FilterDropdown /> renders dropdown options in menu 1`] = `
<div
class="mx_Dropdown_menu"
id="test_listbox"
role="listbox"
>
<div
aria-selected="false"
class="mx_Dropdown_option"
id="test__one"
role="option"
>
<div
class="mx_FilterDropdown_option"
data-testid="filter-option-one"
>
<div
class="mx_FilterDropdown_optionSelectedIcon"
/>
<span
class="mx_FilterDropdown_optionLabel"
>
Option one
</span>
</div>
</div>
<div
aria-selected="false"
class="mx_Dropdown_option"
id="test__two"
role="option"
>
<div
class="mx_FilterDropdown_option"
data-testid="filter-option-two"
>
<span
class="mx_FilterDropdown_optionLabel"
>
Option two
</span>
<span
class="mx_FilterDropdown_optionDescription"
>
with description
</span>
</div>
</div>
</div>
`;
exports[`<FilterDropdown /> renders selected option 1`] = `
<div>
<div
class="mx_Dropdown mx_FilterDropdown test"
>
<div
aria-describedby="test_value"
aria-expanded="false"
aria-haspopup="listbox"
aria-label="test label"
aria-owns="test_input"
class="mx_AccessibleButton mx_Dropdown_input mx_no_textinput"
role="button"
tabindex="0"
>
<div
class="mx_Dropdown_option"
id="test_value"
>
Option one
</div>
<span
class="mx_Dropdown_arrow"
/>
</div>
</div>
</div>
`;
exports[`<FilterDropdown /> renders selected option with selectedLabel 1`] = `
<div>
<div
class="mx_Dropdown mx_FilterDropdown test"
>
<div
aria-describedby="test_value"
aria-expanded="false"
aria-haspopup="listbox"
aria-label="test label"
aria-owns="test_input"
class="mx_AccessibleButton mx_Dropdown_input mx_no_textinput"
role="button"
tabindex="0"
>
<div
class="mx_Dropdown_option"
id="test_value"
>
Show: Option one
</div>
<span
class="mx_Dropdown_arrow"
/>
</div>
</div>
</div>
`;
exports[`<FilterDropdown /> renders when selected option is not in options 1`] = `
<div>
<div
class="mx_Dropdown mx_FilterDropdown test"
>
<div
aria-describedby="test_value"
aria-expanded="false"
aria-haspopup="listbox"
aria-label="test label"
aria-owns="test_input"
class="mx_AccessibleButton mx_Dropdown_input mx_no_textinput"
role="button"
tabindex="0"
>
<div
class="mx_Dropdown_option"
id="test_value"
/>
<span
class="mx_Dropdown_arrow"
/>
</div>
</div>
</div>
`;

View file

@ -94,11 +94,11 @@ describe('<FilteredDeviceList />', () => {
) => await act(async () => { ) => await act(async () => {
const dropdown = container.querySelector('[aria-label="Filter devices"]'); const dropdown = container.querySelector('[aria-label="Filter devices"]');
fireEvent.click(dropdown); fireEvent.click(dropdown as Element);
// tick to let dropdown render // tick to let dropdown render
await flushPromises(); await flushPromises();
fireEvent.click(container.querySelector(`#device-list-filter__${option}`)); fireEvent.click(container.querySelector(`#device-list-filter__${option}`) as Element);
}); });
it('does not display filter description when filter is falsy', () => { it('does not display filter description when filter is falsy', () => {
@ -198,7 +198,7 @@ describe('<FilteredDeviceList />', () => {
act(() => { act(() => {
const tile = getByTestId(`device-tile-${hundredDaysOld.device_id}`); const tile = getByTestId(`device-tile-${hundredDaysOld.device_id}`);
const toggle = tile.querySelector('[aria-label="Toggle device details"]'); const toggle = tile.querySelector('[aria-label="Toggle device details"]');
fireEvent.click(toggle); fireEvent.click(toggle as Element);
}); });
expect(onDeviceExpandToggle).toHaveBeenCalledWith(hundredDaysOld.device_id); expect(onDeviceExpandToggle).toHaveBeenCalledWith(hundredDaysOld.device_id);