Plugins Catalog: migrate state handling to Redux (#38876)

* feat(Plugins/Catalog): start adding necessary apis

* feat(PLugins/Catalog): add extra helpers for merging local & remote plugins

* feat(Plugins/Catalog): add plugin details as an optional field of CatalogPlugin

* feat(PLugins/Catalog): add scaffolding for the new redux model

* feat(PLugins/Catalog): export reducers based on a feature-flag

* refactor(Plugins/Admin): rename api methods

* feat(Plugin/Catalog): add an api method for fetching a single plugin

* feat(Plugins/Admin): try cleaning stuff around plugin fetching

* ffeat(Plugins/Catalog): return the catalog reducer when the feature flag is set

* refactor(Plugins/Admin): fix typings

* feat(Plugins/Admin): use the new reducer for the browse page

* feat(catalog): introduce selectors to search and filter plugins list

* refactor(Plugins/Details): rename page prop type

* refactor(Plugins/Admin): add a const for a state prefix

* refactor(Plugins/Admin): use the state prefix in the actions

* feat(Plugins/Admin): add types for the requests

* refactor(Plugins/Admin): add request info to the reducer

* refactor(Plugins/Admin): add request handling to the hooks & selectors

* refactor(Plugins/Details): start using the data stored in Redux

* refactor(Plugins/Admin): rename selector to start with "select"

* fix(Plugins/Admin): only fetch plugins once

* refactor(Plugins/Admin): make the tab selection work in details

* refactor(catalog): put back loading and error states in plugin list

* refactor(Plugins/Admin): use CatalogPlugin for <PluginDetailsSignature />

* feat(Plugins/Admin): add an api method for fetching plugin details

* refactor(Plugins/Admin): add action for updating the details

* irefactor(Plugins/Admin): show basic plugin details info

* refactor(Plugin Details): migrate the plugin details header

* refactor(Plugins/Admin): make the config and dashboards tabs work

* refactor(Plugins/Admin): add old reducer state to the new one

* feat(catalog): introduce actions, reducers and hooks for install & uninstall

* refactor(catalog): wire up InstallControls component to redux

* refactor(catalog): move parentUrl inside PluginDetailsHeader and uncomment InstallControls

* feat(catalog): introduce code for plugin updates to install action

* refactor(Plugins/Admin): add backward compatible actions

* test(catalog): update PluginDetails and Browse tests to work with catalog store

* refactor(Plugins/Admin): make the dashboards and panels work again

* refactor(Plugins/Admin): fix linter and typescript errors

* fix(Plugins/Admin): put the local-only plugins to the beginning of the list

* fix(Plugins/Admin): fix the mocks in the tests for PluginDetails

* refactor(Plugins/Admin): remove unecessary hook usePluginsByFilter()

* refactor(Plugins/Admin): extract the useTabs() hook to its own file

* refactor(Plugins/Admin): remove unused helpers and types

* fix(Plugins/Admin): show the first tab when uninstalling an app plugin

This can cause the user to find themselves on a dissappeared tab, as the
config and dashboards tabs are removed.

* fix(catalog): correct logic for checking if activeTabIndex is greater than total tabs

* fix(Plugins/Admin): fix race-condition between fetching plugin details and all plugins

* fix(Plugins): fix strict type errors

* chore(catalog): remove todos

* feat(catalog): render an alert in PluginDetails when a plugin cannot be found

* feat(catalog): use the proper store state

* refactor(Plugins/Admin): fetch local and remote plugins in parallell

Co-authored-by: Marcus Andersson <marcus.andersson@grafana.com>

* style(catalog): fix prettier error in api

* fix(catalog): prevent throwing error if InstallControlsButton is unmounted during install

* refactor(Plugins/Admin): add a separate hook for filtering & sorting plugins

Co-authored-by: Jack Westbrook <jack.westbrook@gmail.com>
Co-authored-by: Marcus Andersson <marcus.andersson@grafana.com>
This commit is contained in:
Levente Balogh
2021-09-09 12:20:35 +02:00
committed by GitHub
parent e4ca6f2445
commit 1133e56006
26 changed files with 781 additions and 518 deletions

View File

@@ -0,0 +1,93 @@
import { createAsyncThunk, Update } from '@reduxjs/toolkit';
import { getBackendSrv } from '@grafana/runtime';
import { PanelPlugin } from '@grafana/data';
import { StoreState, ThunkResult } from 'app/types';
import { importPanelPlugin } from 'app/features/plugins/plugin_loader';
import { getCatalogPlugins, getPluginDetails, installPlugin, uninstallPlugin } from '../api';
import { STATE_PREFIX } from '../constants';
import { CatalogPlugin } from '../types';
export const fetchAll = createAsyncThunk(`${STATE_PREFIX}/fetchAll`, async (_, thunkApi) => {
try {
return await getCatalogPlugins();
} catch (e) {
return thunkApi.rejectWithValue('Unknown error.');
}
});
export const fetchDetails = createAsyncThunk(`${STATE_PREFIX}/fetchDetails`, async (id: string, thunkApi) => {
try {
const details = await getPluginDetails(id);
return {
id,
changes: { details },
} as Update<CatalogPlugin>;
} catch (e) {
return thunkApi.rejectWithValue('Unknown error.');
}
});
export const install = createAsyncThunk(
`${STATE_PREFIX}/install`,
async ({ id, version, isUpdating = false }: { id: string; version: string; isUpdating?: boolean }, thunkApi) => {
const changes = isUpdating ? { isInstalled: true, hasUpdate: false } : { isInstalled: true };
try {
await installPlugin(id, version);
return {
id,
changes,
} as Update<CatalogPlugin>;
} catch (e) {
return thunkApi.rejectWithValue('Unknown error.');
}
}
);
export const uninstall = createAsyncThunk(`${STATE_PREFIX}/uninstall`, async (id: string, thunkApi) => {
try {
await uninstallPlugin(id);
return {
id,
changes: { isInstalled: false },
} as Update<CatalogPlugin>;
} catch (e) {
return thunkApi.rejectWithValue('Unknown error.');
}
});
// We need this to be backwards-compatible with other parts of Grafana.
// (Originally in "public/app/features/plugins/state/actions.ts")
// TODO<remove once the "plugin_admin_enabled" feature flag is removed>
export const loadPluginDashboards = createAsyncThunk(`${STATE_PREFIX}/loadPluginDashboards`, async (_, thunkApi) => {
const state = thunkApi.getState() as StoreState;
const dataSourceType = state.dataSources.dataSource.type;
const url = `api/plugins/${dataSourceType}/dashboards`;
return getBackendSrv().get(url);
});
// We need this to be backwards-compatible with other parts of Grafana.
// (Originally in "public/app/features/plugins/state/actions.ts")
// It cannot be constructed with `createAsyncThunk()` as we need the return value on the call-site,
// and we cannot easily change the call-site to unwrap the result.
// TODO<remove once the "plugin_admin_enabled" feature flag is removed>
export const loadPanelPlugin = (id: string): ThunkResult<Promise<PanelPlugin>> => {
return async (dispatch, getStore) => {
let plugin = getStore().plugins.panels[id];
if (!plugin) {
plugin = await importPanelPlugin(id);
// second check to protect against raise condition
if (!getStore().plugins.panels[id]) {
dispatch({
type: `${STATE_PREFIX}/loadPanelPlugin/fulfilled`,
payload: plugin,
});
}
}
return plugin;
};
};

View File

@@ -0,0 +1,106 @@
import { useEffect } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { fetchAll, fetchDetails, install, uninstall } from './actions';
import { CatalogPlugin, PluginCatalogStoreState } from '../types';
import {
find,
selectAll,
selectById,
selectIsRequestPending,
selectRequestError,
selectIsRequestNotFetched,
} from './selectors';
import { sortPlugins, Sorters } from '../helpers';
type Filters = {
query?: string;
filterBy?: string;
filterByType?: string;
sortBy?: Sorters;
};
export const useGetAllWithFilters = ({
query = '',
filterBy = 'installed',
filterByType = 'all',
sortBy = Sorters.nameAsc,
}: Filters) => {
useFetchAll();
const filtered = useSelector(find(query, filterBy, filterByType));
const { isLoading, error } = useFetchStatus();
const sortedAndFiltered = sortPlugins(filtered, sortBy);
return {
isLoading,
error,
plugins: sortedAndFiltered,
};
};
export const useGetAll = (): CatalogPlugin[] => {
useFetchAll();
return useSelector(selectAll);
};
export const useGetSingle = (id: string): CatalogPlugin | undefined => {
useFetchAll();
useFetchDetails(id);
return useSelector((state: PluginCatalogStoreState) => selectById(state, id));
};
export const useInstall = () => {
const dispatch = useDispatch();
return (id: string, version: string, isUpdating?: boolean) => dispatch(install({ id, version, isUpdating }));
};
export const useUninstall = () => {
const dispatch = useDispatch();
return (id: string) => dispatch(uninstall(id));
};
export const useFetchStatus = () => {
const isLoading = useSelector(selectIsRequestPending(fetchAll.typePrefix));
const error = useSelector(selectRequestError(fetchAll.typePrefix));
return { isLoading, error };
};
export const useInstallStatus = () => {
const isInstalling = useSelector(selectIsRequestPending(install.typePrefix));
const error = useSelector(selectRequestError(install.typePrefix));
return { isInstalling, error };
};
export const useUninstallStatus = () => {
const isUninstalling = useSelector(selectIsRequestPending(uninstall.typePrefix));
const error = useSelector(selectRequestError(uninstall.typePrefix));
return { isUninstalling, error };
};
// Only fetches in case they were not fetched yet
export const useFetchAll = () => {
const dispatch = useDispatch();
const isNotFetched = useSelector(selectIsRequestNotFetched(fetchAll.typePrefix));
useEffect(() => {
isNotFetched && dispatch(fetchAll());
}, []); // eslint-disable-line
};
export const useFetchDetails = (id: string) => {
const dispatch = useDispatch();
const plugin = useSelector((state: PluginCatalogStoreState) => selectById(state, id));
const isNotFetching = !useSelector(selectIsRequestPending(fetchDetails.typePrefix));
const shouldFetch = isNotFetching && plugin && !plugin.details;
useEffect(() => {
shouldFetch && dispatch(fetchDetails(id));
}, [plugin]); // eslint-disable-line
};

View File

@@ -0,0 +1,89 @@
import { createSlice, createEntityAdapter, AnyAction } from '@reduxjs/toolkit';
import { fetchAll, fetchDetails, install, uninstall, loadPluginDashboards } from './actions';
import { CatalogPlugin, ReducerState, RequestStatus } from '../types';
import { STATE_PREFIX } from '../constants';
export const pluginsAdapter = createEntityAdapter<CatalogPlugin>();
const isPendingRequest = (action: AnyAction) => new RegExp(`${STATE_PREFIX}\/(.*)\/pending`).test(action.type);
const isFulfilledRequest = (action: AnyAction) => new RegExp(`${STATE_PREFIX}\/(.*)\/fulfilled`).test(action.type);
const isRejectedRequest = (action: AnyAction) => new RegExp(`${STATE_PREFIX}\/(.*)\/rejected`).test(action.type);
// Extract the trailing '/pending', '/rejected', or '/fulfilled'
const getOriginalActionType = (type: string) => {
const separator = type.lastIndexOf('/');
return type.substring(0, separator);
};
export const { reducer } = createSlice({
name: 'plugins',
initialState: {
items: pluginsAdapter.getInitialState(),
requests: {},
// Backwards compatibility
// (we need to have the following fields in the store as well to be backwards compatible with other parts of Grafana)
// TODO<remove once the "plugin_admin_enabled" feature flag is removed>
plugins: [],
errors: [],
searchQuery: '',
hasFetched: false,
dashboards: [],
isLoadingPluginDashboards: false,
panels: {},
} as ReducerState,
reducers: {},
extraReducers: (builder) =>
builder
// Fetch All
.addCase(fetchAll.fulfilled, (state, action) => {
pluginsAdapter.upsertMany(state.items, action.payload);
})
// Fetch Details
.addCase(fetchDetails.fulfilled, (state, action) => {
pluginsAdapter.updateOne(state.items, action.payload);
})
// Install
.addCase(install.fulfilled, (state, action) => {
pluginsAdapter.updateOne(state.items, action.payload);
})
// Uninstall
.addCase(uninstall.fulfilled, (state, action) => {
pluginsAdapter.updateOne(state.items, action.payload);
})
// Load a panel plugin (backward-compatibility)
// TODO<remove once the "plugin_admin_enabled" feature flag is removed>
.addCase(`${STATE_PREFIX}/loadPanelPlugin/fulfilled`, (state, action: AnyAction) => {
state.panels[action.payload.meta!.id] = action.payload;
})
// Start loading panel dashboards (backward-compatibility)
// TODO<remove once the "plugin_admin_enabled" feature flag is removed>
.addCase(loadPluginDashboards.pending, (state, action) => {
state.isLoadingPluginDashboards = true;
state.dashboards = [];
})
// Load panel dashboards (backward-compatibility)
// TODO<remove once the "plugin_admin_enabled" feature flag is removed>
.addCase(loadPluginDashboards.fulfilled, (state, action) => {
state.isLoadingPluginDashboards = false;
state.dashboards = action.payload;
})
.addMatcher(isPendingRequest, (state, action) => {
state.requests[getOriginalActionType(action.type)] = {
status: RequestStatus.Pending,
};
})
.addMatcher(isFulfilledRequest, (state, action) => {
state.requests[getOriginalActionType(action.type)] = {
status: RequestStatus.Fulfilled,
};
})
.addMatcher(isRejectedRequest, (state, action) => {
state.requests[getOriginalActionType(action.type)] = {
status: RequestStatus.Rejected,
error: action.payload,
};
}),
});

View File

@@ -0,0 +1,62 @@
import { createSelector } from 'reselect';
import { RequestStatus, PluginCatalogStoreState } from '../types';
import { pluginsAdapter } from './reducer';
export const selectRoot = (state: PluginCatalogStoreState) => state.plugins;
export const selectItems = createSelector(selectRoot, ({ items }) => items);
export const { selectAll, selectById } = pluginsAdapter.getSelectors(selectItems);
const selectInstalled = (filterBy: string) =>
createSelector(selectAll, (plugins) =>
plugins.filter((plugin) => (filterBy === 'installed' ? plugin.isInstalled : !plugin.isCore))
);
const findByInstallAndType = (filterBy: string, filterByType: string) =>
createSelector(selectInstalled(filterBy), (plugins) =>
plugins.filter((plugin) => filterByType === 'all' || plugin.type === filterByType)
);
const findByKeyword = (searchBy: string) =>
createSelector(selectAll, (plugins) => {
if (searchBy === '') {
return [];
}
return plugins.filter((plugin) => {
const fields: String[] = [];
if (plugin.name) {
fields.push(plugin.name.toLowerCase());
}
if (plugin.orgName) {
fields.push(plugin.orgName.toLowerCase());
}
return fields.some((f) => f.includes(searchBy.toLowerCase()));
});
});
export const find = (searchBy: string, filterBy: string, filterByType: string) =>
createSelector(
findByInstallAndType(filterBy, filterByType),
findByKeyword(searchBy),
(filteredPlugins, searchedPlugins) => {
return searchBy === '' ? filteredPlugins : searchedPlugins;
}
);
export const selectRequest = (actionType: string) =>
createSelector(selectRoot, ({ requests = {} }) => requests[actionType]);
export const selectIsRequestPending = (actionType: string) =>
createSelector(selectRequest(actionType), (request) => request?.status === RequestStatus.Pending);
export const selectRequestError = (actionType: string) =>
createSelector(selectRequest(actionType), (request) =>
request?.status === RequestStatus.Rejected ? request?.error : null
);
export const selectIsRequestNotFetched = (actionType: string) =>
createSelector(selectRequest(actionType), (request) => request === undefined);