From 9c2a94760529639dbf16db385126373e376f4ea8 Mon Sep 17 00:00:00 2001 From: Levente Balogh Date: Thu, 18 Nov 2021 13:34:44 +0100 Subject: [PATCH] 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. --- public/app/features/plugins/AppRootPage.tsx | 1 + public/app/features/plugins/admin/api.ts | 12 +- .../GetStartedWithApp.tsx | 62 ++++++++ .../GetStartedWithPlugin.tsx | 3 + .../admin/pages/PluginDetails.test.tsx | 147 +++++++++++++++++- 5 files changed, 219 insertions(+), 6 deletions(-) create mode 100644 public/app/features/plugins/admin/components/GetStartedWithPlugin/GetStartedWithApp.tsx diff --git a/public/app/features/plugins/AppRootPage.tsx b/public/app/features/plugins/AppRootPage.tsx index 16c622df85a..5d39c6e4eb6 100644 --- a/public/app/features/plugins/AppRootPage.tsx +++ b/public/app/features/plugins/AppRootPage.tsx @@ -48,6 +48,7 @@ class AppRootPage extends Component { shouldComponentUpdate(nextProps: Props) { return nextProps.location.pathname.startsWith('/a/'); } + async loadPluginSettings() { const { params } = this.props.match; try { diff --git a/public/app/features/plugins/admin/api.ts b/public/app/features/plugins/admin/api.ts index 4f7fd13140e..fbca7d8a193 100644 --- a/public/app/features/plugins/admin/api.ts +++ b/public/app/features/plugins/admin/api.ts @@ -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) { + const response = await getBackendSrv().datasourceRequest({ + url: `/api/plugins/${id}/settings`, + method: 'POST', + data, + }); + + return response?.data; +} + export const api = { getRemotePlugins, getInstalledPlugins: getLocalPlugins, diff --git a/public/app/features/plugins/admin/components/GetStartedWithPlugin/GetStartedWithApp.tsx b/public/app/features/plugins/admin/components/GetStartedWithPlugin/GetStartedWithApp.tsx new file mode 100644 index 00000000000..08ebd079970 --- /dev/null +++ b/public/app/features/plugins/admin/components/GetStartedWithPlugin/GetStartedWithApp.tsx @@ -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 && ( + + )} + + {enabled && ( + + )} + + ); +} + +const updatePluginSettingsAndReload = async (id: string, data: Partial) => { + 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); + } +}; diff --git a/public/app/features/plugins/admin/components/GetStartedWithPlugin/GetStartedWithPlugin.tsx b/public/app/features/plugins/admin/components/GetStartedWithPlugin/GetStartedWithPlugin.tsx index 8184299d98b..aa251e1d07e 100644 --- a/public/app/features/plugins/admin/components/GetStartedWithPlugin/GetStartedWithPlugin.tsx +++ b/public/app/features/plugins/admin/components/GetStartedWithPlugin/GetStartedWithPlugin.tsx @@ -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 ; + case PluginType.app: + return ; default: return null; } diff --git a/public/app/features/plugins/admin/pages/PluginDetails.test.tsx b/public/app/features/plugins/admin/pages/PluginDetails.test.tsx index 3bb2567816c..b199ad581cf 100644 --- a/public/app/features/plugins/admin/pages/PluginDetails.test.tsx +++ b/public/app/features/plugins/admin/pages/PluginDetails.test.tsx @@ -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, { @@ -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 () => {