diff --git a/public/app/features/connections/tabs/ConnectData/ConnectData.tsx b/public/app/features/connections/tabs/ConnectData/ConnectData.tsx index 52e2ac97b0f..cbd2d0a3264 100644 --- a/public/app/features/connections/tabs/ConnectData/ConnectData.tsx +++ b/public/app/features/connections/tabs/ConnectData/ConnectData.tsx @@ -4,7 +4,7 @@ import React, { useMemo, useState } from 'react'; import { PluginType } from '@grafana/data'; import { useStyles2, LoadingPlaceholder } from '@grafana/ui'; import { contextSrv } from 'app/core/core'; -import { useGetAllWithFilters } from 'app/features/plugins/admin/state/hooks'; +import { useGetAll } from 'app/features/plugins/admin/state/hooks'; import { AccessControlAction } from 'app/types'; import { ROUTES } from '../../constants'; @@ -38,10 +38,9 @@ export function AddNewConnection() { setSearchTerm(e.currentTarget.value.toLowerCase()); }; - const { isLoading, error, plugins } = useGetAllWithFilters({ - query: searchTerm, - filterBy: '', - filterByType: PluginType.datasource, + const { isLoading, error, plugins } = useGetAll({ + keyword: searchTerm, + type: PluginType.datasource, }); const cardGridItems = useMemo( diff --git a/public/app/features/plugins/admin/pages/Browse.tsx b/public/app/features/plugins/admin/pages/Browse.tsx index 8a78fc20c4d..ac1856c86cc 100644 --- a/public/app/features/plugins/admin/pages/Browse.tsx +++ b/public/app/features/plugins/admin/pages/Browse.tsx @@ -2,7 +2,7 @@ import { css } from '@emotion/css'; import React, { ReactElement } from 'react'; import { useLocation } from 'react-router-dom'; -import { SelectableValue, GrafanaTheme2 } from '@grafana/data'; +import { SelectableValue, GrafanaTheme2, PluginType } from '@grafana/data'; import { config, locationSearchToObject } from '@grafana/runtime'; import { LoadingPlaceholder, Select, RadioButtonGroup, useStyles2, Tooltip, Field } from '@grafana/ui'; import { Page } from 'app/core/components/Page/Page'; @@ -16,7 +16,7 @@ import { PluginList } from '../components/PluginList'; import { SearchField } from '../components/SearchField'; import { Sorters } from '../helpers'; import { useHistory } from '../hooks/useHistory'; -import { useGetAllWithFilters, useIsRemotePluginsAvailable, useDisplayMode } from '../state/hooks'; +import { useGetAll, useIsRemotePluginsAvailable, useDisplayMode } from '../state/hooks'; import { PluginListDisplayMode } from '../types'; export default function Browse({ route }: GrafanaRouteComponentProps): ReactElement | null { @@ -27,16 +27,19 @@ export default function Browse({ route }: GrafanaRouteComponentProps): ReactElem const styles = useStyles2(getStyles); const history = useHistory(); const remotePluginsAvailable = useIsRemotePluginsAvailable(); - const query = (locationSearch.q as string) || ''; + const keyword = (locationSearch.q as string) || ''; const filterBy = (locationSearch.filterBy as string) || 'installed'; - const filterByType = (locationSearch.filterByType as string) || 'all'; + const filterByType = (locationSearch.filterByType as PluginType | 'all') || 'all'; const sortBy = (locationSearch.sortBy as Sorters) || Sorters.nameAsc; - const { isLoading, error, plugins } = useGetAllWithFilters({ - query, - filterBy, - filterByType, - sortBy, - }); + const { isLoading, error, plugins } = useGetAll( + { + keyword, + type: filterByType !== 'all' ? filterByType : undefined, + isInstalled: filterBy === 'installed' ? true : undefined, + isCore: filterBy === 'installed' ? undefined : false, // We only would like to show core plugins when the user filters to installed plugins + }, + sortBy + ); const filterByOptions = [ { value: 'all', label: 'All' }, { value: 'installed', label: 'Installed' }, @@ -81,7 +84,7 @@ export default function Browse({ route }: GrafanaRouteComponentProps): ReactElem - + {/* Filter by type */} diff --git a/public/app/features/plugins/admin/state/hooks.ts b/public/app/features/plugins/admin/state/hooks.ts index 98fd4a43972..013e0b00b92 100644 --- a/public/app/features/plugins/admin/state/hooks.ts +++ b/public/app/features/plugins/admin/state/hooks.ts @@ -1,4 +1,4 @@ -import { useEffect } from 'react'; +import { useEffect, useMemo } from 'react'; import { PluginError } from '@grafana/data'; import { useDispatch, useSelector } from 'app/types'; @@ -9,48 +9,31 @@ import { CatalogPlugin, PluginListDisplayMode } from '../types'; import { fetchAll, fetchDetails, fetchRemotePlugins, install, uninstall, fetchAllLocal, unsetInstall } from './actions'; import { setDisplayMode } from './reducer'; import { - find, - selectAll, + selectPlugins, selectById, selectIsRequestPending, selectRequestError, selectIsRequestNotFetched, selectDisplayMode, selectPluginErrors, + type PluginFilters, } from './selectors'; -type Filters = { - query?: string; // Note: this will be an escaped regex string as it comes from `FilterInput` - filterBy?: string; - filterByType?: string; - sortBy?: Sorters; -}; - -export const useGetAllWithFilters = ({ - query = '', - filterBy = 'installed', - filterByType = 'all', - sortBy = Sorters.nameAsc, -}: Filters) => { +export const useGetAll = (filters: PluginFilters, sortBy: Sorters = Sorters.nameAsc) => { useFetchAll(); - const filtered = useSelector(find(query, filterBy, filterByType)); + const selector = useMemo(() => selectPlugins(filters), [filters]); + const plugins = useSelector(selector); const { isLoading, error } = useFetchStatus(); - const sortedAndFiltered = sortPlugins(filtered, sortBy); + const sortedPlugins = sortPlugins(plugins, sortBy); return { isLoading, error, - plugins: sortedAndFiltered, + plugins: sortedPlugins, }; }; -export const useGetAll = (): CatalogPlugin[] => { - useFetchAll(); - - return useSelector(selectAll); -}; - export const useGetSingle = (id: string): CatalogPlugin | undefined => { useFetchAll(); useFetchDetails(id); diff --git a/public/app/features/plugins/admin/state/selectors.test.ts b/public/app/features/plugins/admin/state/selectors.test.ts index c038a4930f2..84441ca42af 100644 --- a/public/app/features/plugins/admin/state/selectors.test.ts +++ b/public/app/features/plugins/admin/state/selectors.test.ts @@ -3,64 +3,100 @@ import { configureStore } from 'app/store/configureStore'; import { getCatalogPluginMock, getPluginsStateMock } from '../__mocks__'; -import { find } from './selectors'; +import { selectPlugins } from './selectors'; describe('Plugins Selectors', () => { - describe('find()', () => { + describe('selectPlugins()', () => { const store = configureStore({ plugins: getPluginsStateMock([ - getCatalogPluginMock({ id: 'plugin-1', name: 'Plugin 1', isInstalled: true, type: PluginType.datasource }), - getCatalogPluginMock({ id: 'plugin-2', name: 'Plugin 2', isInstalled: true, type: PluginType.datasource }), - getCatalogPluginMock({ id: 'plugin-3', name: 'Plugin 3', isInstalled: true, type: PluginType.panel }), - getCatalogPluginMock({ id: 'plugin-4', name: 'Plugin 4', isInstalled: false, type: PluginType.panel }), - getCatalogPluginMock({ id: 'plugin-5', name: 'Plugin 5', isInstalled: true, type: PluginType.app }), + getCatalogPluginMock({ + id: 'plugin-1', + name: 'Plugin 1', + isInstalled: true, + type: PluginType.datasource, + isCore: true, + }), + getCatalogPluginMock({ + id: 'plugin-2', + name: 'Plugin 2', + isInstalled: true, + type: PluginType.datasource, + isCore: true, + }), + getCatalogPluginMock({ + id: 'plugin-3', + name: 'Plugin 3', + isInstalled: true, + type: PluginType.panel, + isCore: false, + }), + getCatalogPluginMock({ + id: 'plugin-4', + name: 'Plugin 4', + isInstalled: false, + type: PluginType.panel, + isCore: false, + }), + getCatalogPluginMock({ + id: 'plugin-5', + name: 'Plugin 5', + isInstalled: true, + type: PluginType.app, + isCore: false, + }), ]), }); it('should return all plugins if there are no filters', () => { - const query = ''; - const filterBy = 'all'; - const filterByType = 'all'; - const results = find(query, filterBy, filterByType)(store.getState()); + const results = selectPlugins({})(store.getState()); expect(results).toHaveLength(5); }); - it('should be possible to search only by the "query"', () => { - const query = 'Plugin 3'; - const filterBy = 'all'; - const filterByType = 'all'; - const results = find(query, filterBy, filterByType)(store.getState()); + it('should be possible to search only by the "keyword"', () => { + const results = selectPlugins({ keyword: 'Plugin 3' })(store.getState()); expect(results).toHaveLength(1); expect(results[0].name).toBe('Plugin 3'); }); it('should be possible to search by plugin type', () => { - const query = ''; - const filterBy = 'all'; - const filterByType = PluginType.panel; - const results = find(query, filterBy, filterByType)(store.getState()); + const results = selectPlugins({ type: PluginType.panel })(store.getState()); expect(results).toHaveLength(2); expect(results.map(({ name }) => name)).toEqual(['Plugin 3', 'Plugin 4']); }); - it('should be possible to search by plugin state (installed / all)', () => { - const query = ''; - const filterBy = 'installed'; - const filterByType = 'all'; - const results = find(query, filterBy, filterByType)(store.getState()); + it('should be possible to search for core plugins', () => { + const results = selectPlugins({ isCore: true })(store.getState()); + + expect(results).toHaveLength(2); + expect(results.map(({ name }) => name)).toEqual(['Plugin 1', 'Plugin 2']); + }); + + it('should be possible to exclude core plugins from the search', () => { + const results = selectPlugins({ isCore: false })(store.getState()); + + expect(results).toHaveLength(3); + expect(results.map(({ name }) => name)).toEqual(['Plugin 3', 'Plugin 4', 'Plugin 5']); + }); + + it('should be possible to only search for installed plugins', () => { + const results = selectPlugins({ isInstalled: true })(store.getState()); expect(results).toHaveLength(4); expect(results.map(({ name }) => name)).toEqual(['Plugin 1', 'Plugin 2', 'Plugin 3', 'Plugin 5']); }); + it('should be possible to only search for not yet installed plugins', () => { + const results = selectPlugins({ isInstalled: false })(store.getState()); + + expect(results).toHaveLength(1); + expect(results.map(({ name }) => name)).toEqual(['Plugin 4']); + }); + it('should be possible to search by multiple filters', () => { - const query = '2'; - const filterBy = 'all'; - const filterByType = PluginType.datasource; - const results = find(query, filterBy, filterByType)(store.getState()); + const results = selectPlugins({ keyword: '2', type: PluginType.datasource })(store.getState()); expect(results).toHaveLength(1); expect(results[0].name).toBe('Plugin 2'); diff --git a/public/app/features/plugins/admin/state/selectors.ts b/public/app/features/plugins/admin/state/selectors.ts index f6068facdd8..c8360328d17 100644 --- a/public/app/features/plugins/admin/state/selectors.ts +++ b/public/app/features/plugins/admin/state/selectors.ts @@ -1,8 +1,8 @@ import { createSelector } from '@reduxjs/toolkit'; -import { PluginError, PluginErrorCode, unEscapeStringFromRegex } from '@grafana/data'; +import { PluginError, PluginErrorCode, PluginType, unEscapeStringFromRegex } from '@grafana/data'; -import { RequestStatus, PluginCatalogStoreState, CatalogPlugin } from '../types'; +import { RequestStatus, PluginCatalogStoreState } from '../types'; import { pluginsAdapter } from './reducer'; @@ -14,44 +14,54 @@ export const selectDisplayMode = createSelector(selectRoot, ({ settings }) => se export const { selectAll, selectById } = pluginsAdapter.getSelectors(selectItems); -const findByState = (state: string) => - createSelector(selectAll, (plugins) => - plugins.filter((plugin) => (state === 'installed' ? plugin.isInstalled : !plugin.isCore)) - ); +export type PluginFilters = { + // Searches for a string in certain fields (e.g. "name" or "orgName") + // (Note: this will be an escaped regex string as it comes from `FilterInput`) + keyword?: string; -type PluginFilters = { - state: string; - type: string; + // (Optional, only applied if set) + type?: PluginType; + + // (Optional, only applied if set) + isCore?: boolean; + + // (Optional, only applied if set) + isInstalled?: boolean; + + // (Optional, only applied if set) + isEnterprise?: boolean; }; -const findPluginsByFilters = (filters: PluginFilters) => - createSelector(findByState(filters.state), (plugins) => - plugins.filter((plugin) => filters.type === 'all' || plugin.type === filters.type) - ); +export const selectPlugins = (filters: PluginFilters) => + createSelector(selectAll, (plugins) => { + const keyword = filters.keyword ? unEscapeStringFromRegex(filters.keyword.toLowerCase()) : ''; -const findByKeyword = (plugins: CatalogPlugin[], query: string) => { - if (query === '') { - return plugins; - } + return plugins.filter((plugin) => { + const fieldsToSearchIn = [plugin.name, plugin.orgName].filter(Boolean).map((f) => f.toLowerCase()); - return plugins.filter((plugin) => { - const fields: String[] = []; - if (plugin.name) { - fields.push(plugin.name.toLowerCase()); - } + if (keyword && !fieldsToSearchIn.some((f) => f.includes(keyword))) { + return false; + } - if (plugin.orgName) { - fields.push(plugin.orgName.toLowerCase()); - } + if (filters.type && plugin.type !== filters.type) { + return false; + } - return fields.some((f) => f.includes(unEscapeStringFromRegex(query).toLowerCase())); + if (filters.isInstalled !== undefined && plugin.isInstalled !== filters.isInstalled) { + return false; + } + + if (filters.isCore !== undefined && plugin.isCore !== filters.isCore) { + return false; + } + + if (filters.isEnterprise !== undefined && plugin.isEnterprise !== filters.isEnterprise) { + return false; + } + + return true; + }); }); -}; - -export const find = (searchBy: string, filterBy: string, filterByType: string) => - createSelector(findPluginsByFilters({ state: filterBy, type: filterByType }), (filteredPlugins) => - findByKeyword(filteredPlugins, searchBy) - ); export const selectPluginErrors = createSelector(selectAll, (plugins) => plugins