Implement filtering of theme searches by color (#13163)

* Implement filtering of theme searches by color
This commit is contained in:
Mathieu Pillard 2024-08-27 13:43:11 +02:00 коммит произвёл GitHub
Родитель dbc7651b00
Коммит f657de8b18
Не найден ключ, соответствующий данной подписи
Идентификатор ключа GPG: B5690EEEBB952194
6 изменённых файлов: 345 добавлений и 4 удалений

Просмотреть файл

@ -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}` });