Catalog: Display badges for Angular plugins and disable install if Angular is disabled (#69084)

* Angular deprecation: Add Angular badge in plugin catalog page

* Angular deprecation: Add alert in plugin details page

* Angular deprecation: Disable install button if for Angular plugins

* removed extra console.log

* Add tests for Angular badge

* Add tests for PluginDetailsAngularDeprecation

* Add tests for InstallControlsButton

* Add tests for ExternallyManagedButton

* Table tests

* Catalog: Update angular deprecation message

* PR review feedback

* Update tests

* Update copy for angular tooltip and alert

* Update tests

* Fix test warnings

* Fix angularDetected not being set for remote catalog plugins

* Dynamic alert text based on grafana config

* Moved deprecation message to a separate function

* Removed unused Props in PluginAngularBadge
pull/70816/head
Giuseppe Guerra 2 years ago committed by GitHub
parent ebe5e9c2e6
commit dde4a03544
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 14
      public/app/features/plugins/admin/components/Badges/PluginAngularBadge.tsx
  2. 10
      public/app/features/plugins/admin/components/Badges/PluginUpdateAvailableBadge.tsx
  3. 1
      public/app/features/plugins/admin/components/Badges/index.ts
  4. 48
      public/app/features/plugins/admin/components/InstallControls/ExternallyManagedButton.test.tsx
  5. 11
      public/app/features/plugins/admin/components/InstallControls/ExternallyManagedButton.tsx
  6. 72
      public/app/features/plugins/admin/components/InstallControls/InstallControlsButton.test.tsx
  7. 12
      public/app/features/plugins/admin/components/InstallControls/InstallControlsButton.tsx
  8. 6
      public/app/features/plugins/admin/components/PluginActions.tsx
  9. 40
      public/app/features/plugins/admin/components/PluginDetailsAngularDeprecation.tsx
  10. 64
      public/app/features/plugins/admin/components/PluginDetailsPage.test.tsx
  11. 8
      public/app/features/plugins/admin/components/PluginDetailsPage.tsx
  12. 10
      public/app/features/plugins/admin/components/PluginListItemBadges.test.tsx
  13. 17
      public/app/features/plugins/admin/components/PluginListItemBadges.tsx
  14. 5
      public/app/features/plugins/admin/helpers.ts
  15. 3
      public/app/features/plugins/admin/types.ts

@ -0,0 +1,14 @@
import React from 'react';
import { Badge } from '@grafana/ui';
export function PluginAngularBadge(): React.ReactElement {
return (
<Badge
icon="exclamation-triangle"
text="Angular"
color="orange"
tooltip="This plugin uses deprecated functionality, support for which is being removed."
/>
);
}

@ -1,7 +1,7 @@
import { css } from '@emotion/css';
import React from 'react';
import { GrafanaTheme2, PluginType } from '@grafana/data';
import { GrafanaTheme2 } from '@grafana/data';
import { useStyles2 } from '@grafana/ui';
import { CatalogPlugin } from '../../types';
@ -12,13 +12,7 @@ type Props = {
export function PluginUpdateAvailableBadge({ plugin }: Props): React.ReactElement | null {
const styles = useStyles2(getStyles);
// Currently renderer plugins are not supported by the catalog due to complications related to installation / update / uninstall.
if (plugin.hasUpdate && !plugin.isCore && plugin.type !== PluginType.renderer) {
return <p className={styles.hasUpdate}>Update available!</p>;
}
return null;
return <p className={styles.hasUpdate}>Update available!</p>;
}
export const getStyles = (theme: GrafanaTheme2) => {

@ -2,3 +2,4 @@ export { PluginDisabledBadge } from './PluginDisabledBadge';
export { PluginInstalledBadge } from './PluginInstallBadge';
export { PluginEnterpriseBadge } from './PluginEnterpriseBadge';
export { PluginUpdateAvailableBadge } from './PluginUpdateAvailableBadge';
export { PluginAngularBadge } from './PluginAngularBadge';

@ -0,0 +1,48 @@
import { render, screen } from '@testing-library/react';
import React from 'react';
import { config } from '@grafana/runtime';
import { PluginStatus } from '../../types';
import { ExternallyManagedButton } from './ExternallyManagedButton';
function setup(opts: { angularSupportEnabled: boolean; angularDetected: boolean }) {
config.angularSupportEnabled = opts.angularSupportEnabled;
render(
<ExternallyManagedButton
pluginId={'some-plugin-id'}
angularDetected={opts.angularDetected}
pluginStatus={PluginStatus.INSTALL}
/>
);
}
describe('ExternallyManagedButton', () => {
let oldAngularSupportEnabled = config.angularSupportEnabled;
afterAll(() => {
config.angularSupportEnabled = oldAngularSupportEnabled;
});
describe.each([{ angularSupportEnabled: true }, { angularSupportEnabled: false }])(
'angular support is $angularSupportEnabled',
({ angularSupportEnabled }) => {
it.each([
{ angularDetected: true, expectEnabled: angularSupportEnabled },
{ angularDetected: false, expectEnabled: true },
])('angular detected is $angularDetected', ({ angularDetected, expectEnabled }) => {
setup({ angularSupportEnabled, angularDetected });
const el = screen.getByRole('link');
expect(el).toHaveTextContent(/install/i);
expect(el).toBeVisible();
const linkDisabledStyle = 'pointer-events: none';
if (expectEnabled) {
expect(el).not.toHaveStyle(linkDisabledStyle);
} else {
expect(el).toHaveStyle(linkDisabledStyle);
}
});
}
);
});

@ -1,5 +1,6 @@
import React from 'react';
import { config } from '@grafana/runtime';
import { HorizontalGroup, LinkButton } from '@grafana/ui';
import { getExternalManageLink } from '../../helpers';
@ -8,9 +9,10 @@ import { PluginStatus } from '../../types';
type ExternallyManagedButtonProps = {
pluginId: string;
pluginStatus: PluginStatus;
angularDetected?: boolean;
};
export function ExternallyManagedButton({ pluginId, pluginStatus }: ExternallyManagedButtonProps) {
export function ExternallyManagedButton({ pluginId, pluginStatus, angularDetected }: ExternallyManagedButtonProps) {
const externalManageLink = `${getExternalManageLink(pluginId)}/?tab=installation`;
if (pluginStatus === PluginStatus.UPDATE) {
@ -35,7 +37,12 @@ export function ExternallyManagedButton({ pluginId, pluginStatus }: ExternallyMa
}
return (
<LinkButton href={externalManageLink} target="_blank" rel="noopener noreferrer">
<LinkButton
disabled={!config.angularSupportEnabled && angularDetected}
href={externalManageLink}
target="_blank"
rel="noopener noreferrer"
>
Install via grafana.com
</LinkButton>
);

@ -0,0 +1,72 @@
import { render, screen } from '@testing-library/react';
import React from 'react';
import { TestProvider } from 'test/helpers/TestProvider';
import { PluginSignatureStatus } from '@grafana/data';
import { config } from '@grafana/runtime';
import { CatalogPlugin, PluginStatus } from '../../types';
import { InstallControlsButton } from './InstallControlsButton';
const plugin: CatalogPlugin = {
description: 'The test plugin',
downloads: 5,
id: 'test-plugin',
info: {
logos: { small: '', large: '' },
},
name: 'Testing Plugin',
orgName: 'Test',
popularity: 0,
signature: PluginSignatureStatus.valid,
publishedAt: '2020-09-01',
updatedAt: '2021-06-28',
hasUpdate: false,
isInstalled: false,
isCore: false,
isDev: false,
isEnterprise: false,
isDisabled: false,
isPublished: true,
};
function setup(opts: { angularSupportEnabled: boolean; angularDetected: boolean }) {
config.angularSupportEnabled = opts.angularSupportEnabled;
render(
<TestProvider>
<InstallControlsButton
plugin={{ ...plugin, angularDetected: opts.angularDetected }}
pluginStatus={PluginStatus.INSTALL}
/>
</TestProvider>
);
}
describe('InstallControlsButton', () => {
let oldAngularSupportEnabled = config.angularSupportEnabled;
afterAll(() => {
config.angularSupportEnabled = oldAngularSupportEnabled;
});
describe.each([{ angularSupportEnabled: true }, { angularSupportEnabled: false }])(
'angular support is $angularSupportEnabled',
({ angularSupportEnabled }) => {
it.each([
{ angularDetected: true, expectEnabled: angularSupportEnabled },
{ angularDetected: false, expectEnabled: true },
])('angular detected is $angularDetected', ({ angularDetected, expectEnabled }) => {
setup({ angularSupportEnabled, angularDetected });
const el = screen.getByRole('button');
expect(el).toHaveTextContent(/install/i);
expect(el).toBeVisible();
if (expectEnabled) {
expect(el).toBeEnabled();
} else {
expect(el).toBeDisabled();
}
});
}
);
});

@ -2,7 +2,7 @@ import React, { useEffect, useState } from 'react';
import { useLocation } from 'react-router-dom';
import { AppEvents } from '@grafana/data';
import { locationService } from '@grafana/runtime';
import { config, locationService } from '@grafana/runtime';
import { Button, HorizontalGroup, ConfirmModal } from '@grafana/ui';
import appEvents from 'app/core/app_events';
import { useQueryParams } from 'app/core/hooks/useQueryParams';
@ -17,7 +17,7 @@ type InstallControlsButtonProps = {
plugin: CatalogPlugin;
pluginStatus: PluginStatus;
latestCompatibleVersion?: Version;
setNeedReload: (needReload: boolean) => void;
setNeedReload?: (needReload: boolean) => void;
};
export function InstallControlsButton({
@ -58,7 +58,7 @@ export function InstallControlsButton({
if (!errorInstalling && !('error' in result)) {
appEvents.emit(AppEvents.alertSuccess, [`Installed ${plugin.name}`]);
if (plugin.type === 'app') {
setNeedReload(true);
setNeedReload?.(true);
}
}
};
@ -77,7 +77,7 @@ export function InstallControlsButton({
appEvents.emit(AppEvents.alertSuccess, [`Uninstalled ${plugin.name}`]);
if (plugin.type === 'app') {
dispatch(removePluginFromNavTree({ pluginID: plugin.id }));
setNeedReload(false);
setNeedReload?.(false);
}
}
};
@ -122,9 +122,9 @@ export function InstallControlsButton({
</HorizontalGroup>
);
}
const shouldDisable = isInstalling || errorInstalling || (!config.angularSupportEnabled && plugin.angularDetected);
return (
<Button disabled={isInstalling || errorInstalling} onClick={onInstall}>
<Button disabled={shouldDisable} onClick={onInstall}>
{isInstalling ? 'Installing' : 'Install'}
</Button>
);

@ -42,7 +42,11 @@ export const PluginActions = ({ plugin }: Props) => {
{!isInstallControlsDisabled && (
<>
{isExternallyManaged ? (
<ExternallyManagedButton pluginId={plugin.id} pluginStatus={pluginStatus} />
<ExternallyManagedButton
pluginId={plugin.id}
pluginStatus={pluginStatus}
angularDetected={plugin.angularDetected}
/>
) : (
<InstallControlsButton
plugin={plugin}

@ -0,0 +1,40 @@
import React from 'react';
import { Alert } from '@grafana/ui';
type Props = {
className?: string;
angularSupportEnabled?: boolean;
};
function deprecationMessage(angularSupportEnabled?: boolean): string {
const msg = 'This plugin uses a deprecated, legacy platform based on AngularJS and ';
if (angularSupportEnabled === undefined) {
return msg + ' may be incompatible depending on your Grafana configuration.';
}
if (angularSupportEnabled) {
return msg + ' will stop working in future releases of Grafana.';
}
return msg + ' is incompatible with your current Grafana configuration.';
}
// An Alert showing information about Angular deprecation notice.
// If the plugin does not use Angular (!plugin.angularDetected), it returns null.
export function PluginDetailsAngularDeprecation({
className,
angularSupportEnabled,
}: Props): React.ReactElement | null {
return (
<Alert severity="warning" title="Angular plugin" className={className}>
<p>{deprecationMessage(angularSupportEnabled)}</p>
<a
href="https://grafana.com/docs/grafana/latest/developers/angular_deprecation/"
className="external-link"
target="_blank"
rel="noreferrer"
>
Read more about Angular support deprecation.
</a>
</Alert>
);
}

@ -0,0 +1,64 @@
import { render, screen, act } from '@testing-library/react';
import React from 'react';
import { TestProvider } from 'test/helpers/TestProvider';
import { PluginSignatureStatus } from '@grafana/data';
import { PluginDetailsPage } from './PluginDetailsPage';
jest.mock('../state/hooks', () => ({
__esModule: true,
...jest.requireActual('../state/hooks'),
useGetSingle: jest.fn().mockImplementation((id: string) => {
return {
description: 'The test plugin',
downloads: 5,
id: 'test-plugin',
info: {
logos: { small: '', large: '' },
},
name: 'Testing Plugin',
orgName: 'Test',
popularity: 0,
signature: PluginSignatureStatus.valid,
publishedAt: '2020-09-01',
updatedAt: '2021-06-28',
hasUpdate: false,
isInstalled: false,
isCore: false,
isDev: false,
isEnterprise: false,
isDisabled: false,
isPublished: true,
angularDetected: id === 'angular',
};
}),
}));
describe('PluginDetailsAngularDeprecation', () => {
afterAll(() => {
jest.resetAllMocks();
});
it('renders the component for angular plugins', async () => {
await act(async () =>
render(
<TestProvider>
<PluginDetailsPage pluginId="angular" />
</TestProvider>
)
);
expect(screen.getByText(/angular plugin/i)).toBeVisible();
});
it('does not render the component for non-angular plugins', async () => {
await act(async () =>
render(
<TestProvider>
<PluginDetailsPage pluginId="not-angular" />
</TestProvider>
)
);
expect(screen.queryByText(/angular plugin/i)).toBeNull();
});
});

@ -3,12 +3,14 @@ import React from 'react';
import { useLocation } from 'react-router-dom';
import { GrafanaTheme2, NavModelItem } from '@grafana/data';
import { config } from '@grafana/runtime';
import { useStyles2, TabContent, Alert } from '@grafana/ui';
import { Layout } from '@grafana/ui/src/components/Layout/Layout';
import { Page } from 'app/core/components/Page/Page';
import { AppNotificationSeverity } from 'app/types';
import { Loader } from '../components/Loader';
import { PluginDetailsAngularDeprecation } from '../components/PluginDetailsAngularDeprecation';
import { PluginDetailsBody } from '../components/PluginDetailsBody';
import { PluginDetailsDisabledError } from '../components/PluginDetailsDisabledError';
import { PluginDetailsSignature } from '../components/PluginDetailsSignature';
@ -73,6 +75,12 @@ export function PluginDetailsPage({
<Page navId={navId} pageNav={navModel} actions={actions} subTitle={subtitle} info={info}>
<Page.Contents>
<TabContent className={styles.tabContent}>
{plugin.angularDetected && (
<PluginDetailsAngularDeprecation
className={styles.alert}
angularSupportEnabled={config?.angularSupportEnabled}
/>
)}
<PluginDetailsSignature plugin={plugin} className={styles.alert} />
<PluginDetailsDisabledError plugin={plugin} className={styles.alert} />
<PluginDetailsBody queryParams={Object.fromEntries(queryParams)} plugin={plugin} pageId={activePageId} />

@ -75,4 +75,14 @@ describe('PluginListItemBadges', () => {
render(<PluginListItemBadges plugin={{ ...plugin, hasUpdate: true, installedVersion: '0.0.9' }} />);
expect(screen.getByText(/update available/i)).toBeVisible();
});
it('renders an angular badge (when plugin is angular)', () => {
render(<PluginListItemBadges plugin={{ ...plugin, angularDetected: true }} />);
expect(screen.getByText(/angular/i)).toBeVisible();
});
it('does not render an angular badge (when plugin is not angular)', () => {
render(<PluginListItemBadges plugin={{ ...plugin, angularDetected: false }} />);
expect(screen.queryByText(/angular/i)).toBeNull();
});
});

@ -1,22 +1,32 @@
import React from 'react';
import { PluginType } from '@grafana/data';
import { HorizontalGroup, PluginSignatureBadge } from '@grafana/ui';
import { CatalogPlugin } from '../types';
import { PluginEnterpriseBadge, PluginDisabledBadge, PluginInstalledBadge, PluginUpdateAvailableBadge } from './Badges';
import {
PluginEnterpriseBadge,
PluginDisabledBadge,
PluginInstalledBadge,
PluginUpdateAvailableBadge,
PluginAngularBadge,
} from './Badges';
type PluginBadgeType = {
plugin: CatalogPlugin;
};
export function PluginListItemBadges({ plugin }: PluginBadgeType) {
// Currently renderer plugins are not supported by the catalog due to complications related to installation / update / uninstall.
const hasUpdate = plugin.hasUpdate && !plugin.isCore && plugin.type !== PluginType.renderer;
if (plugin.isEnterprise) {
return (
<HorizontalGroup height="auto" wrap>
<PluginEnterpriseBadge plugin={plugin} />
{plugin.isDisabled && <PluginDisabledBadge error={plugin.error} />}
<PluginUpdateAvailableBadge plugin={plugin} />
{hasUpdate && <PluginUpdateAvailableBadge plugin={plugin} />}
{plugin.angularDetected && <PluginAngularBadge />}
</HorizontalGroup>
);
}
@ -26,7 +36,8 @@ export function PluginListItemBadges({ plugin }: PluginBadgeType) {
<PluginSignatureBadge status={plugin.signature} />
{plugin.isDisabled && <PluginDisabledBadge error={plugin.error} />}
{plugin.isInstalled && <PluginInstalledBadge />}
<PluginUpdateAvailableBadge plugin={plugin} />
{hasUpdate && <PluginUpdateAvailableBadge plugin={plugin} />}
{plugin.angularDetected && <PluginAngularBadge />}
</HorizontalGroup>
);
}

@ -62,6 +62,7 @@ export function mapRemoteToCatalog(plugin: RemotePlugin, error?: PluginError): C
updatedAt,
createdAt: publishedAt,
status,
angularDetected,
} = plugin;
const isDisabled = !!error || isDisabledSecretsPlugin(typeCode);
@ -90,6 +91,7 @@ export function mapRemoteToCatalog(plugin: RemotePlugin, error?: PluginError): C
isEnterprise: status === 'enterprise',
type: typeCode,
error: error?.errorCode,
angularDetected,
};
}
@ -105,6 +107,7 @@ export function mapLocalToCatalog(plugin: LocalPlugin, error?: PluginError): Cat
signatureType,
hasUpdate,
accessControl,
angularDetected,
} = plugin;
const isDisabled = !!error || isDisabledSecretsPlugin(type);
@ -132,6 +135,7 @@ export function mapLocalToCatalog(plugin: LocalPlugin, error?: PluginError): Cat
type,
error: error?.errorCode,
accessControl: accessControl,
angularDetected,
};
}
@ -186,6 +190,7 @@ export function mapToCatalogPlugin(local?: LocalPlugin, remote?: RemotePlugin, e
error: error?.errorCode,
// Only local plugins have access control metadata
accessControl: local?.accessControl,
angularDetected: local?.angularDetected || remote?.angularDetected,
};
}

@ -57,6 +57,7 @@ export interface CatalogPlugin extends WithAccessControlMetadata {
installedVersion?: string;
details?: CatalogPluginDetails;
error?: PluginErrorCode;
angularDetected?: boolean;
}
export interface CatalogPluginDetails {
@ -123,6 +124,7 @@ export type RemotePlugin = {
versionSignedByOrg: string;
versionSignedByOrgName: string;
versionStatus: string;
angularDetected?: boolean;
};
export type LocalPlugin = WithAccessControlMetadata & {
@ -156,6 +158,7 @@ export type LocalPlugin = WithAccessControlMetadata & {
state: string;
type: PluginType;
dependencies: PluginDependencies;
angularDetected: boolean;
};
interface Rel {

Loading…
Cancel
Save