mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
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:
93
public/app/features/plugins/admin/state/actions.ts
Normal file
93
public/app/features/plugins/admin/state/actions.ts
Normal 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;
|
||||
};
|
||||
};
|
||||
106
public/app/features/plugins/admin/state/hooks.ts
Normal file
106
public/app/features/plugins/admin/state/hooks.ts
Normal 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
|
||||
};
|
||||
89
public/app/features/plugins/admin/state/reducer.ts
Normal file
89
public/app/features/plugins/admin/state/reducer.ts
Normal 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,
|
||||
};
|
||||
}),
|
||||
});
|
||||
62
public/app/features/plugins/admin/state/selectors.ts
Normal file
62
public/app/features/plugins/admin/state/selectors.ts
Normal 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);
|
||||
Reference in New Issue
Block a user