Skip to content
This repository was archived by the owner on Sep 11, 2024. It is now read-only.

Commit 50f6986

Browse files
author
Kerry
authored
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
1 parent 825a0af commit 50f6986

File tree

10 files changed

+389
-20
lines changed

10 files changed

+389
-20
lines changed

res/css/_components.pcss

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
@import "./components/views/beacon/_RoomLiveShareWarning.pcss";
1717
@import "./components/views/beacon/_ShareLatestLocation.pcss";
1818
@import "./components/views/beacon/_StyledLiveBeaconIcon.pcss";
19+
@import "./components/views/elements/_FilterDropdown.pcss";
1920
@import "./components/views/location/_EnableLiveShare.pcss";
2021
@import "./components/views/location/_LiveDurationDropdown.pcss";
2122
@import "./components/views/location/_LocationShareMenu.pcss";
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
/*
2+
Copyright 2022 The Matrix.org Foundation C.I.C.
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
17+
.mx_FilterDropdown {
18+
.mx_Dropdown_menu {
19+
margin-top: $spacing-4;
20+
left: unset;
21+
right: -$spacing-12;
22+
width: 232px;
23+
24+
border: 1px solid $quinary-content;
25+
border-radius: 8px;
26+
box-shadow: 0px 1px 3px rgba(23, 25, 28, 0.05);
27+
28+
.mx_Dropdown_option_highlight {
29+
background-color: $system;
30+
}
31+
}
32+
33+
.mx_Dropdown_input {
34+
height: 24px;
35+
background-color: $quinary-content;
36+
border-color: $quinary-content;
37+
color: $secondary-content;
38+
border-radius: 4px;
39+
40+
&:focus {
41+
border-color: $quinary-content;
42+
}
43+
}
44+
45+
.mx_Dropdown_arrow {
46+
background: $secondary-content;
47+
}
48+
}
49+
50+
.mx_FilterDropdown_option {
51+
position: relative;
52+
width: 100%;
53+
box-sizing: border-box;
54+
padding: $spacing-8 0 $spacing-8 $spacing-20;
55+
56+
font-size: $font-12px;
57+
line-height: $font-15px;
58+
color: $primary-content;
59+
}
60+
61+
.mx_FilterDropdown_optionSelectedIcon {
62+
height: 14px;
63+
width: 14px;
64+
position: absolute;
65+
top: $spacing-8;
66+
left: 0;
67+
}
68+
69+
.mx_FilterDropdown_optionLabel {
70+
font-weight: $font-semi-bold;
71+
display: block;
72+
}
73+
74+
.mx_FilterDropdown_optionDescription {
75+
color: $secondary-content;
76+
margin-top: $spacing-4;
77+
}
Lines changed: 1 addition & 1 deletion
Loading

src/components/views/elements/Dropdown.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,7 @@ class MenuOption extends React.Component<IMenuOptionProps> {
6868
}
6969
}
7070

71-
interface IProps {
71+
export interface DropdownProps {
7272
id: string;
7373
// ARIA label
7474
label: string;
@@ -108,13 +108,13 @@ interface IState {
108108
* but somewhat simpler as react-select is 79KB of minified
109109
* javascript.
110110
*/
111-
export default class Dropdown extends React.Component<IProps, IState> {
111+
export default class Dropdown extends React.Component<DropdownProps, IState> {
112112
private readonly buttonRef = createRef<HTMLDivElement>();
113113
private dropdownRootElement: HTMLDivElement = null;
114114
private ignoreEvent: MouseEvent = null;
115115
private childrenByKey: Record<string, ReactNode> = {};
116116

117-
constructor(props: IProps) {
117+
constructor(props: DropdownProps) {
118118
super(props);
119119

120120
this.reindexChildren(this.props.children);
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
/*
2+
Copyright 2022 The Matrix.org Foundation C.I.C.
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
17+
import React from 'react';
18+
import classNames from 'classnames';
19+
20+
import { Icon as CheckmarkIcon } from '../../../../res/img/element-icons/roomlist/checkmark.svg';
21+
import Dropdown, { DropdownProps } from './Dropdown';
22+
23+
export type FilterDropdownOption<FilterKeysType extends string> = {
24+
id: FilterKeysType;
25+
label: string;
26+
description?: string;
27+
};
28+
type FilterDropdownProps<FilterKeysType extends string> = Omit<DropdownProps, 'children'> & {
29+
value: FilterKeysType;
30+
options: FilterDropdownOption<FilterKeysType>[];
31+
// A label displayed before the selected value
32+
// in the dropdown input
33+
selectedLabel?: string;
34+
};
35+
36+
const getSelectedFilterOptionComponent = <FilterKeysType extends string>(
37+
options: FilterDropdownOption<FilterKeysType>[], selectedLabel?: string,
38+
) => (filterKey: FilterKeysType) => {
39+
const option = options.find(({ id }) => id === filterKey);
40+
if (!option) {
41+
return null;
42+
}
43+
if (selectedLabel) {
44+
return `${selectedLabel}: ${option.label}`;
45+
}
46+
return option.label;
47+
};
48+
49+
/**
50+
* Dropdown styled for list filtering
51+
*/
52+
export const FilterDropdown = <FilterKeysType extends string = string>(
53+
{
54+
value,
55+
options,
56+
selectedLabel,
57+
className,
58+
...restProps
59+
}: FilterDropdownProps<FilterKeysType>,
60+
): React.ReactElement<FilterDropdownProps<FilterKeysType>> => {
61+
return <Dropdown
62+
{...restProps}
63+
value={value}
64+
className={classNames('mx_FilterDropdown', className)}
65+
getShortOption={getSelectedFilterOptionComponent<FilterKeysType>(options, selectedLabel)}
66+
>
67+
{ options.map(({ id, label, description }) =>
68+
<div
69+
className='mx_FilterDropdown_option'
70+
data-testid={`filter-option-${id}`}
71+
key={id}
72+
>
73+
{ id === value && <CheckmarkIcon className='mx_FilterDropdown_optionSelectedIcon' /> }
74+
<span className='mx_FilterDropdown_optionLabel'>
75+
{ label }
76+
</span>
77+
{
78+
!!description
79+
&& <span
80+
className='mx_FilterDropdown_optionDescription'
81+
>{ description }</span>
82+
}
83+
</div>,
84+
) }
85+
</Dropdown>;
86+
};

src/components/views/settings/devices/FilteredDeviceList.tsx

Lines changed: 12 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ import React from 'react';
1818

1919
import { _t } from '../../../../languageHandler';
2020
import AccessibleButton from '../../elements/AccessibleButton';
21-
import Dropdown from '../../elements/Dropdown';
21+
import { FilterDropdown, FilterDropdownOption } from '../../elements/FilterDropdown';
2222
import DeviceDetails from './DeviceDetails';
2323
import DeviceExpandDetailsButton from './DeviceExpandDetailsButton';
2424
import DeviceSecurityCard from './DeviceSecurityCard';
@@ -45,13 +45,14 @@ interface Props {
4545
const sortDevicesByLatestActivity = (left: DeviceWithVerification, right: DeviceWithVerification) =>
4646
(right.last_seen_ts || 0) - (left.last_seen_ts || 0);
4747

48-
const getFilteredSortedDevices = (devices: DevicesDictionary, filter: DeviceSecurityVariation) =>
48+
const getFilteredSortedDevices = (devices: DevicesDictionary, filter?: DeviceSecurityVariation) =>
4949
filterDevicesBySecurityRecommendation(Object.values(devices), filter ? [filter] : [])
5050
.sort(sortDevicesByLatestActivity);
5151

5252
const ALL_FILTER_ID = 'ALL';
53+
type DeviceFilterKey = DeviceSecurityVariation | typeof ALL_FILTER_ID;
5354

54-
const FilterSecurityCard: React.FC<{ filter?: DeviceSecurityVariation | string }> = ({ filter }) => {
55+
const FilterSecurityCard: React.FC<{ filter?: DeviceFilterKey }> = ({ filter }) => {
5556
switch (filter) {
5657
case DeviceSecurityVariation.Verified:
5758
return <div className='mx_FilteredDeviceList_securityCard'>
@@ -95,7 +96,7 @@ const FilterSecurityCard: React.FC<{ filter?: DeviceSecurityVariation | string }
9596
}
9697
};
9798

98-
const getNoResultsMessage = (filter: DeviceSecurityVariation): string => {
99+
const getNoResultsMessage = (filter?: DeviceSecurityVariation): string => {
99100
switch (filter) {
100101
case DeviceSecurityVariation.Verified:
101102
return _t('No verified sessions found.');
@@ -107,7 +108,7 @@ const getNoResultsMessage = (filter: DeviceSecurityVariation): string => {
107108
return _t('No sessions found.');
108109
}
109110
};
110-
interface NoResultsProps { filter: DeviceSecurityVariation, clearFilter: () => void}
111+
interface NoResultsProps { filter?: DeviceSecurityVariation, clearFilter: () => void}
111112
const NoResults: React.FC<NoResultsProps> = ({ filter, clearFilter }) =>
112113
<div className='mx_FilteredDeviceList_noResults'>
113114
{ getNoResultsMessage(filter) }
@@ -158,7 +159,7 @@ const FilteredDeviceList: React.FC<Props> = ({
158159
}) => {
159160
const sortedDevices = getFilteredSortedDevices(devices, filter);
160161

161-
const options = [
162+
const options: FilterDropdownOption<DeviceFilterKey>[] = [
162163
{ id: ALL_FILTER_ID, label: _t('All') },
163164
{
164165
id: DeviceSecurityVariation.Verified,
@@ -180,7 +181,7 @@ const FilteredDeviceList: React.FC<Props> = ({
180181
},
181182
];
182183

183-
const onFilterOptionChange = (filterId: DeviceSecurityVariation | typeof ALL_FILTER_ID) => {
184+
const onFilterOptionChange = (filterId: DeviceFilterKey) => {
184185
onFilterChange(filterId === ALL_FILTER_ID ? undefined : filterId as DeviceSecurityVariation);
185186
};
186187

@@ -189,16 +190,14 @@ const FilteredDeviceList: React.FC<Props> = ({
189190
<span className='mx_FilteredDeviceList_headerLabel'>
190191
{ _t('Sessions') }
191192
</span>
192-
<Dropdown
193+
<FilterDropdown<DeviceFilterKey>
193194
id='device-list-filter'
194195
label={_t('Filter devices')}
195196
value={filter || ALL_FILTER_ID}
196197
onOptionChange={onFilterOptionChange}
197-
>
198-
{ options.map(({ id, label }) =>
199-
<div data-test-id={`device-filter-option-${id}`} key={id}>{ label }</div>,
200-
) }
201-
</Dropdown>
198+
options={options}
199+
selectedLabel={_t('Show')}
200+
/>
202201
</div>
203202
{ !!sortedDevices.length
204203
? <FilterSecurityCard filter={filter} />

src/i18n/strings/en_EN.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1734,6 +1734,7 @@
17341734
"Inactive": "Inactive",
17351735
"Inactive for %(inactiveAgeDays)s days or longer": "Inactive for %(inactiveAgeDays)s days or longer",
17361736
"Filter devices": "Filter devices",
1737+
"Show": "Show",
17371738
"Security recommendations": "Security recommendations",
17381739
"Improve your account security by following these recommendations": "Improve your account security by following these recommendations",
17391740
"View all": "View all",
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
/*
2+
Copyright 2022 The Matrix.org Foundation C.I.C.
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
17+
import { act, fireEvent, render } from '@testing-library/react';
18+
import React from 'react';
19+
20+
import { FilterDropdown } from '../../../../src/components/views/elements/FilterDropdown';
21+
import { flushPromises, mockPlatformPeg } from '../../../test-utils';
22+
23+
mockPlatformPeg();
24+
25+
describe('<FilterDropdown />', () => {
26+
const options = [
27+
{ id: 'one', label: 'Option one' },
28+
{ id: 'two', label: 'Option two', description: 'with description' },
29+
];
30+
const defaultProps = {
31+
className: 'test',
32+
value: 'one',
33+
options,
34+
id: 'test',
35+
label: 'test label',
36+
onOptionChange: jest.fn(),
37+
};
38+
const getComponent = (props = {}): JSX.Element =>
39+
(<FilterDropdown {...defaultProps} {...props} />);
40+
41+
const openDropdown = async (container: HTMLElement): Promise<void> => await act(async () => {
42+
const button = container.querySelector('[role="button"]');
43+
expect(button).toBeTruthy();
44+
fireEvent.click(button as Element);
45+
await flushPromises();
46+
});
47+
48+
it('renders selected option', () => {
49+
const { container } = render(getComponent());
50+
expect(container).toMatchSnapshot();
51+
});
52+
53+
it('renders when selected option is not in options', () => {
54+
const { container } = render(getComponent({ value: 'oops' }));
55+
expect(container).toMatchSnapshot();
56+
});
57+
58+
it('renders selected option with selectedLabel', () => {
59+
const { container } = render(getComponent({ selectedLabel: 'Show' }));
60+
expect(container).toMatchSnapshot();
61+
});
62+
63+
it('renders dropdown options in menu', async () => {
64+
const { container } = render(getComponent());
65+
await openDropdown(container);
66+
expect(container.querySelector('.mx_Dropdown_menu')).toMatchSnapshot();
67+
});
68+
});

0 commit comments

Comments
 (0)