mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
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:
@@ -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}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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};
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -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()));
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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[] = [
|
||||
{
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user