Plugins Catalog: add support for enable/disable app plugins (#41801)

* refactor(plugins): add empty line between methods

* feat(api): add an API function for updating plugin settings

* feat(plugins): add a "getting started" guide for enabling / disabling app plugins

* test(plugins/admin): add tests for enable/disable functionality

* refactor(plugins/admin): update the name of the test cases

Now that we have multiple type of post-installation steps it probably makes sense.
This commit is contained in:
Levente Balogh
2021-11-18 13:34:44 +01:00
committed by GitHub
parent 6aeecd48a7
commit 9c2a947605
5 changed files with 219 additions and 6 deletions

View File

@@ -48,6 +48,7 @@ class AppRootPage extends Component<Props, State> {
shouldComponentUpdate(nextProps: Props) {
return nextProps.location.pathname.startsWith('/a/');
}
async loadPluginSettings() {
const { params } = this.props.match;
try {

View File

@@ -1,5 +1,5 @@
import { getBackendSrv } from '@grafana/runtime';
import { PluginError, renderMarkdown } from '@grafana/data';
import { PluginError, PluginMeta, renderMarkdown } from '@grafana/data';
import { API_ROOT, GCOM_API_ROOT } from './constants';
import { LocalPlugin, RemotePlugin, CatalogPluginDetails, Version, PluginVersion } from './types';
import { isLocalPluginVisible, isRemotePluginVisible } from './helpers';
@@ -99,6 +99,16 @@ export async function uninstallPlugin(id: string) {
return await getBackendSrv().post(`${API_ROOT}/${id}/uninstall`);
}
export async function updatePluginSettings(id: string, data: Partial<PluginMeta>) {
const response = await getBackendSrv().datasourceRequest({
url: `/api/plugins/${id}/settings`,
method: 'POST',
data,
});
return response?.data;
}
export const api = {
getRemotePlugins,
getInstalledPlugins: getLocalPlugins,

View File

@@ -0,0 +1,62 @@
import { PluginMeta } from '@grafana/data';
import { Button } from '@grafana/ui';
import { usePluginConfig } from '../../hooks/usePluginConfig';
import { updatePluginSettings } from '../../api';
import React from 'react';
import { CatalogPlugin } from '../../types';
type Props = {
plugin: CatalogPlugin;
};
export function GetStartedWithApp({ plugin }: Props): React.ReactElement | null {
const { value: pluginConfig } = usePluginConfig(plugin);
if (!pluginConfig) {
return null;
}
const { enabled, jsonData } = pluginConfig?.meta;
const enable = () =>
updatePluginSettingsAndReload(plugin.id, {
enabled: true,
pinned: true,
jsonData,
});
const disable = () => {
updatePluginSettingsAndReload(plugin.id, {
enabled: false,
pinned: false,
jsonData,
});
};
return (
<>
{!enabled && (
<Button variant="primary" onClick={enable}>
Enable
</Button>
)}
{enabled && (
<Button variant="destructive" onClick={disable}>
Disable
</Button>
)}
</>
);
}
const updatePluginSettingsAndReload = async (id: string, data: Partial<PluginMeta>) => {
try {
await updatePluginSettings(id, data);
// Reloading the page as the plugin meta changes made here wouldn't be propagated throughout the app.
window.location.reload();
} catch (e) {
console.error('Error while updating the plugin', e);
}
};

View File

@@ -2,6 +2,7 @@ import React, { ReactElement } from 'react';
import { PluginType } from '@grafana/data';
import { CatalogPlugin } from '../../types';
import { GetStartedWithDataSource } from './GetStartedWithDataSource';
import { GetStartedWithApp } from './GetStartedWithApp';
type Props = {
plugin: CatalogPlugin;
@@ -15,6 +16,8 @@ export function GetStartedWithPlugin({ plugin }: Props): ReactElement | null {
switch (plugin.type) {
case PluginType.datasource:
return <GetStartedWithDataSource plugin={plugin} />;
case PluginType.app:
return <GetStartedWithApp plugin={plugin} />;
default:
return null;
}

View File

@@ -18,6 +18,7 @@ import {
} from '../types';
import * as api from '../api';
import { fetchRemotePlugins } from '../state/actions';
import { usePluginConfig } from '../hooks/usePluginConfig';
import { PluginErrorCode, PluginSignatureStatus, PluginType, dateTimeFormatTimeAgo } from '@grafana/data';
import { selectors } from '@grafana/e2e-selectors';
@@ -28,6 +29,14 @@ jest.mock('@grafana/runtime', () => {
return mockedRuntime;
});
jest.mock('../hooks/usePluginConfig.tsx', () => ({
usePluginConfig: jest.fn(() => ({
value: {
meta: {},
},
})),
}));
const renderPluginDetails = (
pluginOverride: Partial<CatalogPlugin>,
{
@@ -65,10 +74,17 @@ const renderPluginDetails = (
describe('Plugin details page', () => {
const id = 'my-plugin';
const originalWindowLocation = window.location;
let dateNow: any;
beforeAll(() => {
dateNow = jest.spyOn(Date, 'now').mockImplementation(() => 1609470000000); // 2021-01-01 04:00:00
// Enabling / disabling the plugin is currently reloading the page to propagate the changes
Object.defineProperty(window, 'location', {
configurable: true,
value: { reload: jest.fn() },
});
});
afterEach(() => {
@@ -79,6 +95,7 @@ describe('Plugin details page', () => {
afterAll(() => {
dateNow.mockRestore();
Object.defineProperty(window, 'location', { configurable: true, value: originalWindowLocation });
});
describe('viewed as user with grafana admin permissions', () => {
@@ -442,7 +459,7 @@ describe('Plugin details page', () => {
expect(rendered.getByText(message)).toBeInTheDocument();
});
it('should display post installation step for installed data source plugins', async () => {
it('should display a "Create" button as a post installation step for installed data source plugins', async () => {
const name = 'Akumuli';
const { queryByText } = renderPluginDetails({
name,
@@ -454,7 +471,7 @@ describe('Plugin details page', () => {
expect(queryByText(`Create a ${name} data source`)).toBeInTheDocument();
});
it('should not display post installation step for disabled data source plugins', async () => {
it('should not display a "Create" button as a post installation step for disabled data source plugins', async () => {
const name = 'Akumuli';
const { queryByText } = renderPluginDetails({
name,
@@ -479,16 +496,136 @@ describe('Plugin details page', () => {
expect(queryByText(`Create a ${name} data source`)).toBeNull();
});
it('should not display post installation step for app plugins', async () => {
it('should display an enable button for app plugins that are not enabled as a post installation step', async () => {
const name = 'Akumuli';
const { queryByText } = renderPluginDetails({
// @ts-ignore
usePluginConfig.mockReturnValue({
value: {
meta: {
enabled: false,
pinned: false,
jsonData: {},
},
},
});
const { queryByText, queryByRole } = renderPluginDetails({
name,
isInstalled: true,
type: PluginType.app,
});
await waitFor(() => queryByText('Uninstall'));
expect(queryByText(`Create a ${name} data source`)).toBeNull();
expect(queryByRole('button', { name: /enable/i })).toBeInTheDocument();
expect(queryByRole('button', { name: /disable/i })).not.toBeInTheDocument();
});
it('should display a disable button for app plugins that are enabled as a post installation step', async () => {
const name = 'Akumuli';
// @ts-ignore
usePluginConfig.mockReturnValue({
value: {
meta: {
enabled: true,
pinned: false,
jsonData: {},
},
},
});
const { queryByText, queryByRole } = renderPluginDetails({
name,
isInstalled: true,
type: PluginType.app,
});
await waitFor(() => queryByText('Uninstall'));
expect(queryByRole('button', { name: /disable/i })).toBeInTheDocument();
expect(queryByRole('button', { name: /enable/i })).not.toBeInTheDocument();
});
it('should be possible to enable an app plugin', async () => {
const id = 'akumuli-datasource';
const name = 'Akumuli';
// @ts-ignore
api.updatePluginSettings = jest.fn();
// @ts-ignore
usePluginConfig.mockReturnValue({
value: {
meta: {
enabled: false,
pinned: false,
jsonData: {},
},
},
});
const { queryByText, getByRole } = renderPluginDetails({
id,
name,
isInstalled: true,
type: PluginType.app,
});
// Wait for the header to be loaded
await waitFor(() => queryByText('Uninstall'));
// Click on "Enable"
userEvent.click(getByRole('button', { name: /enable/i }));
// Check if the API request was initiated
expect(api.updatePluginSettings).toHaveBeenCalledTimes(1);
expect(api.updatePluginSettings).toHaveBeenCalledWith(id, {
enabled: true,
pinned: true,
jsonData: {},
});
});
it('should be possible to disable an app plugin', async () => {
const id = 'akumuli-datasource';
const name = 'Akumuli';
// @ts-ignore
api.updatePluginSettings = jest.fn();
// @ts-ignore
usePluginConfig.mockReturnValue({
value: {
meta: {
enabled: true,
pinned: true,
jsonData: {},
},
},
});
const { queryByText, getByRole } = renderPluginDetails({
id,
name,
isInstalled: true,
type: PluginType.app,
});
// Wait for the header to be loaded
await waitFor(() => queryByText('Uninstall'));
// Click on "Disable"
userEvent.click(getByRole('button', { name: /disable/i }));
// Check if the API request was initiated
expect(api.updatePluginSettings).toHaveBeenCalledTimes(1);
expect(api.updatePluginSettings).toHaveBeenCalledWith(id, {
enabled: false,
pinned: false,
jsonData: {},
});
});
it('should not display versions tab for plugins not published to gcom', async () => {