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.
pull/41876/head
Levente Balogh 4 years ago committed by GitHub
parent 6aeecd48a7
commit 9c2a947605
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 1
      public/app/features/plugins/AppRootPage.tsx
  2. 12
      public/app/features/plugins/admin/api.ts
  3. 62
      public/app/features/plugins/admin/components/GetStartedWithPlugin/GetStartedWithApp.tsx
  4. 3
      public/app/features/plugins/admin/components/GetStartedWithPlugin/GetStartedWithPlugin.tsx
  5. 147
      public/app/features/plugins/admin/pages/PluginDetails.test.tsx

@ -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 {

@ -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,

@ -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);
}
};

@ -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;
}

@ -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 () => {

Loading…
Cancel
Save