Plugins: Add filter for plugin type and update search, filter and sort ui (#37301)

* feat(catalog): introduce additive filters for plugin type, installed status and search

* feat(catalog): prefer FilterInput over custom styled search field

* feat(catalog): update Browse page to use new search, filter features

* refactor(catalog): keep filters with usePluginsByFilter hook

* test(catalog): update tests to reflect new filtering and searching

* refactor(catalog): rearrange filterByType radio buttons

Co-authored-by: Ryan McKinley <ryantxu@gmail.com>

* feat(catalog): ntroduce css for responsive filter layout

* refactor(catalog): introduce pluginfilter type and give filter methods better names

* fix(catalog): default q param to empty string so FiterInput doesn't show clear button on load

Co-authored-by: Ryan McKinley <ryantxu@gmail.com>
This commit is contained in:
Jack Westbrook
2021-07-30 13:23:33 +02:00
committed by GitHub
parent 53072bcad1
commit 9494c2cd4f
8 changed files with 248 additions and 145 deletions

View File

@@ -7,10 +7,11 @@ export interface Props {
placeholder?: string;
width?: number;
onChange: (value: string) => void;
onKeyDown?: (event: React.KeyboardEvent<HTMLInputElement>) => void;
autoFocus?: boolean;
}
export const FilterInput: FC<Props> = ({ value, placeholder, width, onChange, autoFocus }) => {
export const FilterInput: FC<Props> = ({ value, placeholder, width, onChange, onKeyDown, autoFocus }) => {
const suffix =
value !== '' ? (
<Button icon="times" fill="text" size="sm" onClick={() => onChange('')}>
@@ -27,6 +28,7 @@ export const FilterInput: FC<Props> = ({ value, placeholder, width, onChange, au
type="text"
value={value ? unEscapeStringFromRegex(value) : ''}
onChange={(event) => onChange(escapeStringForRegex(event.currentTarget.value))}
onKeyDown={onKeyDown}
placeholder={placeholder}
/>
);

View File

@@ -1,30 +1,31 @@
import React from 'react';
import { css } from '@emotion/css';
import { css, cx } from '@emotion/css';
import { useStyles2 } from '@grafana/ui';
import { useTheme2 } from '@grafana/ui';
import { GrafanaTheme2 } from '@grafana/data';
interface HorizontalGroupProps {
children: React.ReactNode;
wrap?: boolean;
className?: string;
}
export const HorizontalGroup = ({ children }: HorizontalGroupProps) => {
const styles = useStyles2(getStyles);
export const HorizontalGroup = ({ children, wrap, className }: HorizontalGroupProps) => {
const theme = useTheme2();
const styles = getStyles(theme, wrap);
return <div className={styles.container}>{children}</div>;
return <div className={cx(styles.container, className)}>{children}</div>;
};
const getStyles = (theme: GrafanaTheme2) => ({
const getStyles = (theme: GrafanaTheme2, wrap?: boolean) => ({
container: css`
display: flex;
align-items: center;
justify-content: space-between;
flex-direction: row;
flex-wrap: ${wrap ? 'wrap' : 'no-wrap'};
& > * {
margin-bottom: ${theme.spacing()};
margin-right: ${theme.spacing()};
}
& > *:first-child {
flex-grow: 1;
}
& > *:last-child {
margin-right: 0;
}

View File

@@ -1,8 +1,6 @@
import React, { useState, useRef } from 'react';
import { css } from '@emotion/css';
import { useStyles2 } from '@grafana/ui';
import { GrafanaTheme2 } from '@grafana/data';
import { useDebounce } from 'react-use';
import { FilterInput } from 'app/core/components/FilterInput/FilterInput';
interface Props {
value?: string;
@@ -30,39 +28,22 @@ const useDebounceWithoutFirstRender = (callBack: () => any, delay = 0, deps: Rea
export const SearchField = ({ value, onSearch }: Props) => {
const [query, setQuery] = useState(value);
const styles = useStyles2(getStyles);
useDebounceWithoutFirstRender(() => onSearch(query ?? ''), 500, [query]);
return (
<input
<FilterInput
value={query}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.keyCode === 13) {
onSearch(e.currentTarget.value);
}
}}
className={styles}
placeholder="Search Grafana plugins"
onChange={(e) => {
setQuery(e.currentTarget.value);
onChange={(value) => {
setQuery(value);
}}
width={46}
/>
);
};
const getStyles = (theme: GrafanaTheme2) => css`
outline: none;
font-size: 20px;
width: 100%;
border-bottom: 2px solid ${theme.colors.border.weak};
background: transparent;
line-height: 38px;
font-weight: 400;
padding: ${theme.spacing(0.5)};
margin: ${theme.spacing(3)} 0;
&::placeholder {
color: ${theme.colors.action.disabledText};
}
`;

View File

@@ -1,6 +1,6 @@
import { config } from '@grafana/runtime';
import { gt } from 'semver';
import { CatalogPlugin, CatalogPluginDetails, LocalPlugin, Plugin, Version } from './types';
import { CatalogPlugin, CatalogPluginDetails, LocalPlugin, Plugin, Version, PluginFilter } from './types';
export function isGrafanaAdmin(): boolean {
return config.bootData.user.isGrafanaAdmin;
@@ -126,22 +126,23 @@ export function getCatalogPluginDetails(
return plugin;
}
export function applySearchFilter(searchBy: string | undefined, plugins: CatalogPlugin[]): CatalogPlugin[] {
if (!searchBy) {
return plugins;
export const isInstalled: PluginFilter = (plugin, query) =>
query === 'installed' ? plugin.isInstalled : !plugin.isCore;
export const isType: PluginFilter = (plugin, query) => query === 'all' || plugin.type === query;
export const matchesKeyword: PluginFilter = (plugin, query) => {
if (!query) {
return true;
}
const fields: String[] = [];
if (plugin.name) {
fields.push(plugin.name.toLowerCase());
}
return plugins.filter((plugin) => {
const fields: String[] = [];
if (plugin.orgName) {
fields.push(plugin.orgName.toLowerCase());
}
if (plugin.name) {
fields.push(plugin.name.toLowerCase());
}
if (plugin.orgName) {
fields.push(plugin.orgName.toLowerCase());
}
return fields.some((f) => f.includes(searchBy.toLowerCase()));
});
}
return fields.some((f) => f.includes(query.toLowerCase()));
};

View File

@@ -1,14 +1,8 @@
import { useMemo } from 'react';
import { useAsync } from 'react-use';
import { CatalogPlugin } from '../types';
import { CatalogPlugin, CatalogPluginsState, PluginsByFilterType, FilteredPluginsState } from '../types';
import { api } from '../api';
import { mapLocalToCatalog, mapRemoteToCatalog, applySearchFilter } from '../helpers';
type CatalogPluginsState = {
loading: boolean;
error?: Error;
plugins: CatalogPlugin[];
};
import { mapLocalToCatalog, mapRemoteToCatalog, isInstalled, isType, matchesKeyword } from '../helpers';
export function usePlugins(): CatalogPluginsState {
const { loading, value, error } = useAsync(async () => {
@@ -52,28 +46,24 @@ export function usePlugins(): CatalogPluginsState {
};
}
type FilteredPluginsState = {
isLoading: boolean;
error?: Error;
plugins: CatalogPlugin[];
const URLFilterHandlers = {
filterBy: isInstalled,
filterByType: isType,
searchBy: matchesKeyword,
};
export const usePluginsByFilter = (searchBy: string, filterBy: string): FilteredPluginsState => {
export const usePluginsByFilter = (queries: PluginsByFilterType): FilteredPluginsState => {
const { loading, error, plugins } = usePlugins();
const installed = useMemo(() => plugins.filter((plugin) => plugin.isInstalled), [plugins]);
if (filterBy === 'installed') {
return {
isLoading: loading,
error,
plugins: applySearchFilter(searchBy, installed),
};
}
const filteredPlugins = plugins.filter((plugin) =>
Object.keys(queries).every((query: keyof PluginsByFilterType) =>
typeof URLFilterHandlers[query] === 'function' ? URLFilterHandlers[query](plugin, queries[query]) : true
)
);
return {
isLoading: loading,
error,
plugins: applySearchFilter(searchBy, plugins),
plugins: filteredPlugins,
};
};

View File

@@ -39,12 +39,12 @@ function setup(path = '/plugins'): RenderResult {
describe('Browse list of plugins', () => {
it('should list installed plugins by default', async () => {
const { getByText, queryByText } = setup('/plugins');
const { queryByText } = setup('/plugins');
await waitFor(() => getByText('Installed'));
await waitFor(() => queryByText('Installed'));
for (const plugin of installed) {
expect(getByText(plugin.name)).toBeInTheDocument();
expect(queryByText(plugin.name)).toBeInTheDocument();
}
for (const plugin of remote) {
@@ -52,33 +52,75 @@ describe('Browse list of plugins', () => {
}
});
it('should list all plugins when filtering by all', async () => {
const plugins = [...installed, ...remote];
const { getByText } = setup('/plugins?filterBy=all');
it('should list all plugins (except core plugins) when filtering by all', async () => {
const { queryByText } = setup('/plugins?filterBy=all?filterByType=all');
await waitFor(() => getByText('All'));
await waitFor(() => expect(queryByText('Diagram')).toBeInTheDocument());
for (const plugin of remote) {
expect(queryByText(plugin.name)).toBeInTheDocument();
}
for (const plugin of plugins) {
expect(getByText(plugin.name)).toBeInTheDocument();
expect(queryByText('Alert Manager')).not.toBeInTheDocument();
});
it('should list installed plugins (including core plugins) when filtering by installed', async () => {
const { queryByText } = setup('/plugins?filterBy=installed');
await waitFor(() => queryByText('Installed'));
for (const plugin of installed) {
expect(queryByText(plugin.name)).toBeInTheDocument();
}
for (const plugin of remote) {
expect(queryByText(plugin.name)).not.toBeInTheDocument();
}
});
it('should only list plugins matching search', async () => {
const { getByText } = setup('/plugins?filterBy=all&q=zabbix');
it('should list enterprise plugins', async () => {
const { queryByText } = setup('/plugins?filterBy=all&q=wavefront');
await waitFor(() => getByText('All'));
expect(getByText('Zabbix')).toBeInTheDocument();
expect(getByText('1 result')).toBeInTheDocument();
await waitFor(() => expect(queryByText('Wavefront')).toBeInTheDocument());
});
it('should list enterprise plugins', async () => {
const { getByText } = setup('/plugins?filterBy=all&q=wavefront');
it('should list only datasource plugins when filtering by datasource', async () => {
const { queryByText } = setup('/plugins?filterBy=all&filterByType=datasource');
await waitFor(() => getByText('All'));
await waitFor(() => expect(queryByText('Wavefront')).toBeInTheDocument());
expect(getByText('Wavefront')).toBeInTheDocument();
expect(getByText('1 result')).toBeInTheDocument();
expect(queryByText('Alert Manager')).not.toBeInTheDocument();
expect(queryByText('Diagram')).not.toBeInTheDocument();
expect(queryByText('Zabbix')).not.toBeInTheDocument();
});
it('should list only panel plugins when filtering by panel', async () => {
const { queryByText } = setup('/plugins?filterBy=all&filterByType=panel');
await waitFor(() => expect(queryByText('Diagram')).toBeInTheDocument());
expect(queryByText('Wavefront')).not.toBeInTheDocument();
expect(queryByText('Alert Manager')).not.toBeInTheDocument();
expect(queryByText('Zabbix')).not.toBeInTheDocument();
});
it('should list only app plugins when filtering by app', async () => {
const { queryByText } = setup('/plugins?filterBy=all&filterByType=app');
await waitFor(() => expect(queryByText('Zabbix')).toBeInTheDocument());
expect(queryByText('Wavefront')).not.toBeInTheDocument();
expect(queryByText('Alert Manager')).not.toBeInTheDocument();
expect(queryByText('Diagram')).not.toBeInTheDocument();
});
it('should only list plugins matching search', async () => {
const { queryByText } = setup('/plugins?filterBy=all&q=zabbix');
await waitFor(() => expect(queryByText('Zabbix')).toBeInTheDocument());
expect(queryByText('Wavefront')).not.toBeInTheDocument();
expect(queryByText('Alert Manager')).not.toBeInTheDocument();
expect(queryByText('Diagram')).not.toBeInTheDocument();
});
});
@@ -114,6 +156,46 @@ const installed: LocalPlugin[] = [
type: 'datasource',
dev: false,
},
{
name: 'Diagram',
type: 'panel',
id: 'jdbranham-diagram-panel',
enabled: true,
pinned: false,
info: {
author: {
name: 'Jeremy Branham',
url: 'https://savantly.net',
},
description: 'Display diagrams and charts with colored metric indicators',
links: [
{
name: 'Project site',
url: 'https://github.com/jdbranham/grafana-diagram',
},
{
name: 'Apache License',
url: 'https://github.com/jdbranham/grafana-diagram/blob/master/LICENSE',
},
],
logos: {
small: 'public/plugins/jdbranham-diagram-panel/img/logo.svg',
large: 'public/plugins/jdbranham-diagram-panel/img/logo.svg',
},
build: {},
version: '1.7.1',
updated: '2021-05-26',
},
latestVersion: '1.7.3',
hasUpdate: true,
defaultNavUrl: '/plugins/jdbranham-diagram-panel/',
category: '',
state: '',
signature: 'unsigned',
signatureType: '',
signatureOrg: '',
dev: false,
},
];
const remote: Plugin[] = [
{

View File

@@ -1,16 +1,16 @@
import React, { ReactElement } from 'react';
import { css } from '@emotion/css';
import { SelectableValue, dateTimeParse } from '@grafana/data';
import { Field, LoadingPlaceholder, Select } from '@grafana/ui';
import { SelectableValue, dateTimeParse, GrafanaTheme2 } from '@grafana/data';
import { LoadingPlaceholder, Select, RadioButtonGroup, useStyles2 } from '@grafana/ui';
import { useLocation } from 'react-router-dom';
import { locationSearchToObject } from '@grafana/runtime';
import { PluginList } from '../components/PluginList';
import { SearchField } from '../components/SearchField';
import { HorizontalGroup } from '../components/HorizontalGroup';
import { useHistory } from '../hooks/useHistory';
import { CatalogPlugin } from '../types';
import { Page as PluginPage } from '../components/Page';
import { HorizontalGroup } from '../components/HorizontalGroup';
import { Page } from 'app/core/components/Page/Page';
import { usePluginsByFilter } from '../hooks/usePlugins';
import { useSelector } from 'react-redux';
@@ -21,12 +21,14 @@ export default function Browse(): ReactElement | null {
const location = useLocation();
const query = locationSearchToObject(location.search);
const navModel = useSelector((state: StoreState) => getNavModel(state.navIndex, 'plugins'));
const styles = useStyles2(getStyles);
const q = query.q as string;
const q = (query.q as string) ?? '';
const filterBy = (query.filterBy as string) ?? 'installed';
const filterByType = (query.filterByType as string) ?? 'all';
const sortBy = (query.sortBy as string) ?? 'name';
const { plugins, isLoading, error } = usePluginsByFilter(q, filterBy);
const { plugins, isLoading, error } = usePluginsByFilter({ searchBy: q, filterBy, filterByType });
const sortedPlugins = plugins.sort(sorters[sortBy]);
const history = useHistory();
@@ -34,12 +36,16 @@ export default function Browse(): ReactElement | null {
history.push({ query: { sortBy: value.value } });
};
const onFilterByChange = (value: SelectableValue<string>) => {
history.push({ query: { filterBy: value.value } });
const onFilterByChange = (value: string) => {
history.push({ query: { filterBy: value } });
};
const onFilterByTypeChange = (value: string) => {
history.push({ query: { filterByType: value } });
};
const onSearch = (q: any) => {
history.push({ query: { filterBy: 'all', q } });
history.push({ query: { filterBy: 'all', filterByType: 'all', q } });
};
// How should we handle errors?
@@ -52,54 +58,75 @@ export default function Browse(): ReactElement | null {
<Page navModel={navModel}>
<Page.Contents>
<PluginPage>
<SearchField value={q} onSearch={onSearch} />
<HorizontalGroup>
<div>
{isLoading ? (
<LoadingPlaceholder
className={css`
margin-bottom: 0;
`}
text="Loading results"
<HorizontalGroup wrap>
<SearchField value={q} onSearch={onSearch} />
<HorizontalGroup wrap className={styles.actionBar}>
<div>
<RadioButtonGroup
value={filterByType}
onChange={onFilterByTypeChange}
options={[
{ value: 'all', label: 'All' },
{ value: 'datasource', label: 'Data sources' },
{ value: 'panel', label: 'Panels' },
{ value: 'app', label: 'Applications' },
]}
/>
) : (
`${sortedPlugins.length} ${sortedPlugins.length > 1 ? 'results' : 'result'}`
)}
</div>
<Field label="Show">
<Select
width={15}
value={filterBy}
onChange={onFilterByChange}
options={[
{ value: 'all', label: 'All' },
{ value: 'installed', label: 'Installed' },
]}
/>
</Field>
<Field label="Sort by">
<Select
width={20}
value={sortBy}
onChange={onSortByChange}
options={[
{ value: 'name', label: 'Name' },
{ value: 'popularity', label: 'Popularity' },
{ value: 'updated', label: 'Updated date' },
{ value: 'published', label: 'Published date' },
{ value: 'downloads', label: 'Downloads' },
]}
/>
</Field>
</div>
<div>
<RadioButtonGroup
value={filterBy}
onChange={onFilterByChange}
options={[
{ value: 'all', label: 'All' },
{ value: 'installed', label: 'Installed' },
]}
/>
</div>
<div>
<Select
width={24}
value={sortBy}
onChange={onSortByChange}
options={[
{ value: 'name', label: 'Sort by name (A-Z)' },
{ value: 'updated', label: 'Sort by updated date' },
{ value: 'published', label: 'Sort by published date' },
{ value: 'downloads', label: 'Sort by downloads' },
]}
/>
</div>
</HorizontalGroup>
</HorizontalGroup>
{!isLoading && <PluginList plugins={sortedPlugins} />}
<div className={styles.listWrap}>
{isLoading ? (
<LoadingPlaceholder
className={css`
margin-bottom: 0;
`}
text="Loading results"
/>
) : (
<PluginList plugins={sortedPlugins} />
)}
</div>
</PluginPage>
</Page.Contents>
</Page>
);
}
const getStyles = (theme: GrafanaTheme2) => ({
actionBar: css`
${theme.breakpoints.up('xl')} {
margin-left: auto;
}
`,
listWrap: css`
margin-top: ${theme.spacing(2)};
`,
});
const sorters: { [name: string]: (a: CatalogPlugin, b: CatalogPlugin) => number } = {
name: (a: CatalogPlugin, b: CatalogPlugin) => a.name.localeCompare(b.name),
updated: (a: CatalogPlugin, b: CatalogPlugin) =>
@@ -107,5 +134,4 @@ const sorters: { [name: string]: (a: CatalogPlugin, b: CatalogPlugin) => number
published: (a: CatalogPlugin, b: CatalogPlugin) =>
dateTimeParse(b.publishedAt).valueOf() - dateTimeParse(a.publishedAt).valueOf(),
downloads: (a: CatalogPlugin, b: CatalogPlugin) => b.downloads - a.downloads,
popularity: (a: CatalogPlugin, b: CatalogPlugin) => b.popularity - a.popularity,
};

View File

@@ -174,3 +174,23 @@ export type PluginDetailsActions =
| {
type: ActionTypes.LOADING | ActionTypes.INFLIGHT | ActionTypes.UNINSTALLED | ActionTypes.UPDATED;
};
export type CatalogPluginsState = {
loading: boolean;
error?: Error;
plugins: CatalogPlugin[];
};
export type FilteredPluginsState = {
isLoading: boolean;
error?: Error;
plugins: CatalogPlugin[];
};
export type PluginsByFilterType = {
searchBy: string;
filterBy: string;
filterByType: string;
};
export type PluginFilter = (plugin: CatalogPlugin, query: string) => boolean;