Implement filtering of theme searches by color (#13163)
* Implement filtering of theme searches by color
This commit is contained in:
Родитель
dbc7651b00
Коммит
f657de8b18
|
@ -336,7 +336,7 @@
|
|||
"bundlewatch": [
|
||||
{
|
||||
"path": "./dist/static/amo-!(i18n-)*.js",
|
||||
"maxSize": "363 kB"
|
||||
"maxSize": "364 kB"
|
||||
},
|
||||
{
|
||||
"path": "./dist/static/amo-i18n-*.js",
|
||||
|
|
|
@ -19,6 +19,7 @@ export type SearchFilters = {|
|
|||
author?: string,
|
||||
category?: string,
|
||||
clientApp?: string,
|
||||
color?: string,
|
||||
compatibleWithVersion?: number | string,
|
||||
exclude_addons?: string,
|
||||
guid?: string,
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
/* @flow */
|
||||
import makeClassName from 'classnames';
|
||||
import { oneLine } from 'common-tags';
|
||||
import * as React from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
|
@ -28,7 +29,7 @@ import ExpandableCard from 'amo/components/ExpandableCard';
|
|||
import Select from 'amo/components/Select';
|
||||
import type { AppState } from 'amo/store';
|
||||
import type { SearchFilters as SearchFiltersType } from 'amo/api/search';
|
||||
import type { SelectEvent } from 'amo/types/dom';
|
||||
import type { SelectEvent, TypedElementEvent } from 'amo/types/dom';
|
||||
import type { I18nType } from 'amo/types/i18n';
|
||||
import type { ReactRouterHistoryType } from 'amo/types/router';
|
||||
|
||||
|
@ -56,6 +57,8 @@ type InternalProps = {|
|
|||
|
||||
type SelectOption = {| children: string, value: string |};
|
||||
|
||||
type ColorFilter = {| id: string, color: string, label: string |};
|
||||
|
||||
export class SearchFiltersBase extends React.Component<InternalProps> {
|
||||
onSelectElementChange: (event: SelectEvent) => boolean = (
|
||||
event: SelectEvent,
|
||||
|
@ -112,6 +115,34 @@ export class SearchFiltersBase extends React.Component<InternalProps> {
|
|||
return false;
|
||||
};
|
||||
|
||||
changeColorFilter: (
|
||||
colorValue: string,
|
||||
event: TypedElementEvent<HTMLInputElement>,
|
||||
) => void = (
|
||||
colorValue: string,
|
||||
event: TypedElementEvent<HTMLInputElement>,
|
||||
) => {
|
||||
const { filters } = this.props;
|
||||
const newFilters = { ...filters };
|
||||
event.preventDefault();
|
||||
if (filters.color === colorValue) {
|
||||
delete newFilters.color;
|
||||
} else if (colorValue) {
|
||||
newFilters.color = colorValue;
|
||||
}
|
||||
|
||||
this.doSearch(newFilters);
|
||||
};
|
||||
|
||||
onCustomColorChange: (event: TypedElementEvent<HTMLInputElement>) => void = (
|
||||
event: TypedElementEvent<HTMLInputElement>,
|
||||
) => {
|
||||
const colorValue = event.target.value;
|
||||
if (colorValue && colorValue.startsWith('#')) {
|
||||
this.changeColorFilter(colorValue.substr(1), event);
|
||||
}
|
||||
};
|
||||
|
||||
doSearch(newFilters: SearchFiltersType) {
|
||||
const { clientApp, lang, history, pathname } = this.props;
|
||||
|
||||
|
@ -188,6 +219,58 @@ export class SearchFiltersBase extends React.Component<InternalProps> {
|
|||
];
|
||||
}
|
||||
|
||||
colorFilters(): Array<ColorFilter> {
|
||||
const { i18n } = this.props;
|
||||
|
||||
return [
|
||||
{
|
||||
id: 'black',
|
||||
color: '000000',
|
||||
label: i18n.gettext('Black'),
|
||||
},
|
||||
{
|
||||
id: 'gray',
|
||||
color: '808080',
|
||||
label: i18n.gettext('Gray'),
|
||||
},
|
||||
{
|
||||
id: 'white',
|
||||
color: 'ffffff',
|
||||
label: i18n.gettext('White'),
|
||||
},
|
||||
{
|
||||
id: 'red',
|
||||
color: 'ff0000',
|
||||
label: i18n.gettext('Red'),
|
||||
},
|
||||
{
|
||||
id: 'yellow',
|
||||
color: 'ffff00',
|
||||
label: i18n.gettext('Yellow'),
|
||||
},
|
||||
{
|
||||
id: 'green',
|
||||
color: '00ff00',
|
||||
label: i18n.gettext('Green'),
|
||||
},
|
||||
{
|
||||
id: 'cyan',
|
||||
color: '00ffff',
|
||||
label: i18n.gettext('Cyan'),
|
||||
},
|
||||
{
|
||||
id: 'blue',
|
||||
color: '0000ff',
|
||||
label: i18n.gettext('Blue'),
|
||||
},
|
||||
{
|
||||
id: 'magenta',
|
||||
color: 'ff00ff',
|
||||
label: i18n.gettext('Magenta'),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
render(): React.Node {
|
||||
const { clientApp, filters, i18n } = this.props;
|
||||
|
||||
|
@ -198,6 +281,13 @@ export class SearchFiltersBase extends React.Component<InternalProps> {
|
|||
.filter((field) => field !== SEARCH_SORT_RECOMMENDED)
|
||||
: [''];
|
||||
const selectedSort = selectedSortFields[0];
|
||||
const selectedCustomColor = filters.color
|
||||
? this.colorFilters()
|
||||
.map((colorFilter) => {
|
||||
return colorFilter.color;
|
||||
})
|
||||
.includes(filters.color) === false
|
||||
: false;
|
||||
|
||||
return (
|
||||
<ExpandableCard
|
||||
|
@ -245,6 +335,83 @@ export class SearchFiltersBase extends React.Component<InternalProps> {
|
|||
</div>
|
||||
)}
|
||||
|
||||
{filters.addonType === ADDON_TYPE_STATIC_THEME && (
|
||||
<div>
|
||||
<label
|
||||
className="SearchFilters-label SearchFilters-Color-label"
|
||||
htmlFor="SearchFilters-CustomColor"
|
||||
>
|
||||
{i18n.gettext('Theme Color')}
|
||||
</label>
|
||||
<ul className="SearchFilters-ThemeColors">
|
||||
{this.colorFilters().map((colorFilter) => (
|
||||
<li
|
||||
key={colorFilter.id}
|
||||
title={colorFilter.label}
|
||||
className={makeClassName(
|
||||
'SearchFilter-ThemeColors-ColorItem',
|
||||
`SearchFilter-ThemeColors-ColorItem--${colorFilter.id}`,
|
||||
{
|
||||
'SearchFilter-ThemeColors-ColorItem--active':
|
||||
filters.color === colorFilter.color,
|
||||
},
|
||||
)}
|
||||
>
|
||||
<button
|
||||
aria-describedby={colorFilter.id}
|
||||
type="button"
|
||||
onClick={this.changeColorFilter.bind(
|
||||
null,
|
||||
colorFilter.color,
|
||||
)}
|
||||
>
|
||||
<span id={colorFilter.id} className="visually-hidden">
|
||||
{colorFilter.label}
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
<li
|
||||
className={makeClassName(
|
||||
'SearchFilter-ThemeColors-ColorItem',
|
||||
'SearchFilter-ThemeColors-ColorItem--custom',
|
||||
{
|
||||
'SearchFilter-ThemeColors-ColorItem--active':
|
||||
selectedCustomColor === true,
|
||||
},
|
||||
)}
|
||||
>
|
||||
<input
|
||||
id="SearchFilters-CustomColor"
|
||||
type="color"
|
||||
onChange={this.onCustomColorChange}
|
||||
/>
|
||||
</li>
|
||||
<li
|
||||
title={i18n.gettext('Clear')}
|
||||
className={makeClassName(
|
||||
'SearchFilter-ThemeColors-ColorItem',
|
||||
'SearchFilter-ThemeColors-ColorItem--clear',
|
||||
{
|
||||
'SearchFilter-ThemeColors-ColorItem--hidden':
|
||||
filters.color === undefined,
|
||||
},
|
||||
)}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={this.changeColorFilter.bind(
|
||||
null,
|
||||
filters.color || '',
|
||||
)}
|
||||
>
|
||||
✖
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label
|
||||
className="SearchFilters-Badging-label SearchFilters-label"
|
||||
|
|
|
@ -6,7 +6,7 @@
|
|||
}
|
||||
|
||||
.SearchFilters-select {
|
||||
margin: 0 0 5px;
|
||||
margin: 0 0 7px;
|
||||
}
|
||||
|
||||
.SearchFilters-Recommended-label {
|
||||
|
@ -18,3 +18,109 @@
|
|||
#SearchFilters-Recommended {
|
||||
@include margin-end(10px);
|
||||
}
|
||||
|
||||
.SearchFilters-ThemeColors {
|
||||
display: inline-block;
|
||||
margin: 0 0 7px;
|
||||
padding: 0;
|
||||
|
||||
.SearchFilter-ThemeColors-ColorItem {
|
||||
outline: 2px solid transparent; /* originally had a 1px border instead, but it was too subtle */
|
||||
border: 1px solid $grey-40;
|
||||
margin-right: 4px;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
display: inline-block;
|
||||
border-radius: 50%;
|
||||
vertical-align: top;
|
||||
|
||||
&:hover {
|
||||
outline-color: $action-hover-color;
|
||||
}
|
||||
|
||||
&:has(button:focus, input:focus) {
|
||||
outline-color: $action-active-color;
|
||||
}
|
||||
|
||||
&.SearchFilter-ThemeColors-ColorItem--active {
|
||||
outline-color: $action-active-color;
|
||||
}
|
||||
|
||||
button,
|
||||
input[type='color'] {
|
||||
cursor: pointer;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
opacity: 0; /* hidden, but captures clicks to trigger the color change */
|
||||
padding: 0;
|
||||
border-radius: 50%;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
&.SearchFilter-ThemeColors-ColorItem--hidden {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
&.SearchFilter-ThemeColors-ColorItem--clear {
|
||||
border: 0;
|
||||
margin-right: 0;
|
||||
width: 29px;
|
||||
|
||||
button {
|
||||
opacity: 1;
|
||||
font-size: 125%;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.SearchFilter-ThemeColors-ColorItem--custom {
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
hsl(0deg, 100%, 50%),
|
||||
hsl(45deg, 100%, 50%),
|
||||
hsl(90deg, 100%, 50%),
|
||||
hsl(135deg, 100%, 50%),
|
||||
hsl(180deg, 100%, 50%),
|
||||
hsl(225deg, 100%, 50%),
|
||||
hsl(270deg, 100%, 50%),
|
||||
hsl(315deg, 100%, 50%),
|
||||
hsl(360deg, 100%, 50%)
|
||||
);
|
||||
}
|
||||
|
||||
.SearchFilter-ThemeColors-ColorItem--black {
|
||||
background-color: $black;
|
||||
}
|
||||
|
||||
.SearchFilter-ThemeColors-ColorItem--gray {
|
||||
background-color: $grey-50;
|
||||
}
|
||||
|
||||
.SearchFilter-ThemeColors-ColorItem--white {
|
||||
background-color: $white-100;
|
||||
}
|
||||
|
||||
.SearchFilter-ThemeColors-ColorItem--red {
|
||||
background-color: $red-50;
|
||||
}
|
||||
|
||||
.SearchFilter-ThemeColors-ColorItem--yellow {
|
||||
background-color: $yellow-50;
|
||||
}
|
||||
|
||||
.SearchFilter-ThemeColors-ColorItem--green {
|
||||
background-color: $green-50;
|
||||
}
|
||||
|
||||
.SearchFilter-ThemeColors-ColorItem--cyan {
|
||||
background-color: $teal-50;
|
||||
}
|
||||
|
||||
.SearchFilter-ThemeColors-ColorItem--blue {
|
||||
background-color: $blue-50;
|
||||
}
|
||||
|
||||
.SearchFilter-ThemeColors-ColorItem--magenta {
|
||||
background-color: $magenta-50;
|
||||
}
|
||||
|
|
|
@ -25,6 +25,7 @@ export const paramsToFilter = {
|
|||
appversion: 'compatibleWithVersion',
|
||||
author: 'author',
|
||||
category: 'category',
|
||||
color: 'color',
|
||||
exclude_addons: 'exclude_addons',
|
||||
guid: 'guid',
|
||||
page: 'page',
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
// We shouldn't use `cleanup()` but this test file uses it anyway in
|
||||
// `testWithFilters()`, which should be refactored eventually...
|
||||
// eslint-disable-next-line testing-library/no-manual-cleanup
|
||||
import { cleanup, waitFor } from '@testing-library/react';
|
||||
import { cleanup, fireEvent, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
|
||||
import { setViewContext } from 'amo/actions/viewContext';
|
||||
|
@ -41,6 +41,7 @@ import {
|
|||
getSearchErrorHandlerId,
|
||||
renderPage as defaultRender,
|
||||
screen,
|
||||
within,
|
||||
} from 'tests/unit/helpers';
|
||||
|
||||
describe(__filename, () => {
|
||||
|
@ -696,6 +697,71 @@ describe(__filename, () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('shows the color filter when filtering by Theme Add-on type', async () => {
|
||||
render({ type: ADDON_TYPE_STATIC_THEME });
|
||||
|
||||
expect(
|
||||
screen.getByClassName('SearchFilters-ThemeColors'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not show the color filter when filtering by Extension Add-on type', async () => {
|
||||
render({ type: ADDON_TYPE_EXTENSION });
|
||||
|
||||
expect(
|
||||
screen.queryByClassName('SearchFilters-ThemeColors'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not show the color filter when not filtering by Add-on type', async () => {
|
||||
render();
|
||||
|
||||
expect(
|
||||
screen.queryByClassName('SearchFilters-ThemeColors'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('changes the URL to filter by corresponding color when a color is clicked on', async () => {
|
||||
await render({ type: ADDON_TYPE_STATIC_THEME });
|
||||
const pushSpy = jest.spyOn(history, 'push');
|
||||
|
||||
await userEvent.click(
|
||||
within(
|
||||
screen.getByClassName('SearchFilter-ThemeColors-ColorItem--green'),
|
||||
).getByRole('button'),
|
||||
);
|
||||
|
||||
expect(pushSpy).toHaveBeenCalledWith({
|
||||
pathname: defaultLocation,
|
||||
query: convertFiltersToQueryParams({
|
||||
color: '00ff00',
|
||||
addonType: ADDON_TYPE_STATIC_THEME,
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
it('changes the URL to filter by color when a custom color is entered in the input type=color', async () => {
|
||||
await render({ type: ADDON_TYPE_STATIC_THEME });
|
||||
const pushSpy = jest.spyOn(history, 'push');
|
||||
|
||||
fireEvent.change(
|
||||
within(
|
||||
screen.getByClassName('SearchFilter-ThemeColors-ColorItem--custom'),
|
||||
).getByTagName('input'),
|
||||
{
|
||||
target: { value: '#336699' },
|
||||
},
|
||||
);
|
||||
|
||||
expect(pushSpy).toHaveBeenCalledWith({
|
||||
pathname: defaultLocation,
|
||||
query: convertFiltersToQueryParams({
|
||||
color: '336699',
|
||||
addonType: ADDON_TYPE_STATIC_THEME,
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
it('selects the sort criterion in the sort select', () => {
|
||||
const sort = SEARCH_SORT_TRENDING;
|
||||
render({ location: `${defaultLocation}?sort=${sort}` });
|
||||
|
|
Загрузка…
Ссылка в новой задаче