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

Commit

Permalink
Device manager - updated dropdown style in filtered device list (PSG-…
Browse files Browse the repository at this point in the history
…689) (#9226)

* add FilterDropdown wrapper on Dropdown for filter styles

* test and fix strict errors

* fix comment
  • Loading branch information
Kerry authored Aug 30, 2022
1 parent 825a0af commit 50f6986
Show file tree
Hide file tree
Showing 10 changed files with 389 additions and 20 deletions.
1 change: 1 addition & 0 deletions res/css/_components.pcss
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
@import "./components/views/beacon/_RoomLiveShareWarning.pcss";
@import "./components/views/beacon/_ShareLatestLocation.pcss";
@import "./components/views/beacon/_StyledLiveBeaconIcon.pcss";
@import "./components/views/elements/_FilterDropdown.pcss";
@import "./components/views/location/_EnableLiveShare.pcss";
@import "./components/views/location/_LiveDurationDropdown.pcss";
@import "./components/views/location/_LocationShareMenu.pcss";
Expand Down
77 changes: 77 additions & 0 deletions res/css/components/views/elements/_FilterDropdown.pcss
Original file line number Diff line number Diff line change
@@ -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;
}
2 changes: 1 addition & 1 deletion res/img/element-icons/roomlist/checkmark.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
6 changes: 3 additions & 3 deletions src/components/views/elements/Dropdown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ class MenuOption extends React.Component<IMenuOptionProps> {
}
}

interface IProps {
export interface DropdownProps {
id: string;
// ARIA label
label: string;
Expand Down Expand Up @@ -108,13 +108,13 @@ interface IState {
* but somewhat simpler as react-select is 79KB of minified
* 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 dropdownRootElement: HTMLDivElement = null;
private ignoreEvent: MouseEvent = null;
private childrenByKey: Record<string, ReactNode> = {};

constructor(props: IProps) {
constructor(props: DropdownProps) {
super(props);

this.reindexChildren(this.props.children);
Expand Down
86 changes: 86 additions & 0 deletions src/components/views/elements/FilterDropdown.tsx
Original file line number Diff line number Diff line change
@@ -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>;
};
25 changes: 12 additions & 13 deletions src/components/views/settings/devices/FilteredDeviceList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ import React from 'react';

import { _t } from '../../../../languageHandler';
import AccessibleButton from '../../elements/AccessibleButton';
import Dropdown from '../../elements/Dropdown';
import { FilterDropdown, FilterDropdownOption } from '../../elements/FilterDropdown';
import DeviceDetails from './DeviceDetails';
import DeviceExpandDetailsButton from './DeviceExpandDetailsButton';
import DeviceSecurityCard from './DeviceSecurityCard';
Expand All @@ -45,13 +45,14 @@ interface Props {
const sortDevicesByLatestActivity = (left: DeviceWithVerification, right: DeviceWithVerification) =>
(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] : [])
.sort(sortDevicesByLatestActivity);

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) {
case DeviceSecurityVariation.Verified:
return <div className='mx_FilteredDeviceList_securityCard'>
Expand Down Expand Up @@ -95,7 +96,7 @@ const FilterSecurityCard: React.FC<{ filter?: DeviceSecurityVariation | string }
}
};

const getNoResultsMessage = (filter: DeviceSecurityVariation): string => {
const getNoResultsMessage = (filter?: DeviceSecurityVariation): string => {
switch (filter) {
case DeviceSecurityVariation.Verified:
return _t('No verified sessions found.');
Expand All @@ -107,7 +108,7 @@ const getNoResultsMessage = (filter: DeviceSecurityVariation): string => {
return _t('No sessions found.');
}
};
interface NoResultsProps { filter: DeviceSecurityVariation, clearFilter: () => void}
interface NoResultsProps { filter?: DeviceSecurityVariation, clearFilter: () => void}
const NoResults: React.FC<NoResultsProps> = ({ filter, clearFilter }) =>
<div className='mx_FilteredDeviceList_noResults'>
{ getNoResultsMessage(filter) }
Expand Down Expand Up @@ -158,7 +159,7 @@ const FilteredDeviceList: React.FC<Props> = ({
}) => {
const sortedDevices = getFilteredSortedDevices(devices, filter);

const options = [
const options: FilterDropdownOption<DeviceFilterKey>[] = [
{ id: ALL_FILTER_ID, label: _t('All') },
{
id: DeviceSecurityVariation.Verified,
Expand All @@ -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);
};

Expand All @@ -189,16 +190,14 @@ const FilteredDeviceList: React.FC<Props> = ({
<span className='mx_FilteredDeviceList_headerLabel'>
{ _t('Sessions') }
</span>
<Dropdown
<FilterDropdown<DeviceFilterKey>
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>
options={options}
selectedLabel={_t('Show')}
/>
</div>
{ !!sortedDevices.length
? <FilterSecurityCard filter={filter} />
Expand Down
1 change: 1 addition & 0 deletions src/i18n/strings/en_EN.json
Original file line number Diff line number Diff line change
Expand Up @@ -1734,6 +1734,7 @@
"Inactive": "Inactive",
"Inactive for %(inactiveAgeDays)s days or longer": "Inactive for %(inactiveAgeDays)s days or longer",
"Filter devices": "Filter devices",
"Show": "Show",
"Security recommendations": "Security recommendations",
"Improve your account security by following these recommendations": "Improve your account security by following these recommendations",
"View all": "View all",
Expand Down
68 changes: 68 additions & 0 deletions test/components/views/elements/FilterDropdown-test.tsx
Original file line number Diff line number Diff line change
@@ -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();
});
});
Loading

0 comments on commit 50f6986

Please sign in to comment.