mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
PluginDetails: Make plugin details page look good in topnav (#55571)
* PluginDetails: Make plugin details page look good in topnav * Minor style tweak aligning things * minor refactoring where I moved the logic to decide the default tab into its own hook. * refactor(plugindetails): first pass at using navmodel for usePluginDetailsTabs hook * refactor(plugindetails): move "reset page when uninstalling plugin" to installcontrols this prevents a user from seeing a blank page if they uninstall an app plugin whilst viewing a config page * refactor(plugindetails): remove usage of toIconName and reduce nested if * Trying to fix tests * minor fix * test(plugindetails): update selectors causing failing tests * chore(plugindetails): remove commented out test code * test(plugindetails): clean up - remove unnecesary usage of waitFor Co-authored-by: Marcus Andersson <marcus.andersson@grafana.com> Co-authored-by: Jack Westbrook <jack.westbrook@gmail.com>
This commit is contained in:
@@ -36,6 +36,8 @@ export interface NavModelItem extends NavLinkDTO {
|
||||
highlightId?: string;
|
||||
tabSuffix?: ComponentType<{ className?: string }>;
|
||||
hideFromBreadcrumbs?: boolean;
|
||||
/** To render custom things between title and child tabs */
|
||||
headerExtra?: ComponentType;
|
||||
}
|
||||
|
||||
export enum NavSection {
|
||||
|
||||
@@ -1033,7 +1033,7 @@ func createZip(srcDir, version, variantStr, sfx, grafanaDir string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
//nolint
|
||||
// nolint
|
||||
func createTarball(srcDir, version, variantStr, sfx, grafanaDir string) error {
|
||||
fpath := filepath.Join(grafanaDir, "dist", fmt.Sprintf("grafana%s-%s.%s.tar.gz", sfx, version, variantStr))
|
||||
//nolint:gosec
|
||||
|
||||
@@ -51,10 +51,10 @@ type FakeEvaluator_ConditionEval_Call struct {
|
||||
}
|
||||
|
||||
// ConditionEval is a helper method to define mock.On call
|
||||
// - ctx context.Context
|
||||
// - _a1 *user.SignedInUser
|
||||
// - condition models.Condition
|
||||
// - now time.Time
|
||||
// - ctx context.Context
|
||||
// - _a1 *user.SignedInUser
|
||||
// - condition models.Condition
|
||||
// - now time.Time
|
||||
func (_e *FakeEvaluator_Expecter) ConditionEval(ctx interface{}, _a1 interface{}, condition interface{}, now interface{}) *FakeEvaluator_ConditionEval_Call {
|
||||
return &FakeEvaluator_ConditionEval_Call{Call: _e.mock.On("ConditionEval", ctx, _a1, condition, now)}
|
||||
}
|
||||
@@ -100,10 +100,10 @@ type FakeEvaluator_QueriesAndExpressionsEval_Call struct {
|
||||
}
|
||||
|
||||
// QueriesAndExpressionsEval is a helper method to define mock.On call
|
||||
// - ctx context.Context
|
||||
// - _a1 *user.SignedInUser
|
||||
// - data []models.AlertQuery
|
||||
// - now time.Time
|
||||
// - ctx context.Context
|
||||
// - _a1 *user.SignedInUser
|
||||
// - data []models.AlertQuery
|
||||
// - now time.Time
|
||||
func (_e *FakeEvaluator_Expecter) QueriesAndExpressionsEval(ctx interface{}, _a1 interface{}, data interface{}, now interface{}) *FakeEvaluator_QueriesAndExpressionsEval_Call {
|
||||
return &FakeEvaluator_QueriesAndExpressionsEval_Call{Call: _e.mock.On("QueriesAndExpressionsEval", ctx, _a1, data, now)}
|
||||
}
|
||||
@@ -140,9 +140,9 @@ type FakeEvaluator_Validate_Call struct {
|
||||
}
|
||||
|
||||
// Validate is a helper method to define mock.On call
|
||||
// - ctx context.Context
|
||||
// - _a1 *user.SignedInUser
|
||||
// - condition models.Condition
|
||||
// - ctx context.Context
|
||||
// - _a1 *user.SignedInUser
|
||||
// - condition models.Condition
|
||||
func (_e *FakeEvaluator_Expecter) Validate(ctx interface{}, _a1 interface{}, condition interface{}) *FakeEvaluator_Validate_Call {
|
||||
return &FakeEvaluator_Validate_Call{Call: _e.mock.On("Validate", ctx, _a1, condition)}
|
||||
}
|
||||
|
||||
@@ -108,6 +108,7 @@ function renderHeaderTitle(main: NavModelItem) {
|
||||
<div className="page-header__info-block">
|
||||
{renderTitle(main.text, main.breadcrumbs ?? [], main.highlightText)}
|
||||
{main.subTitle && <div className="page-header__sub-title">{main.subTitle}</div>}
|
||||
{main.headerExtra && <main.headerExtra />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -20,6 +20,7 @@ export function PageHeader({ navItem, subTitle }: Props) {
|
||||
{navItem.text}
|
||||
</h1>
|
||||
{sub && <div className={styles.pageSubTitle}>{sub}</div>}
|
||||
{navItem.headerExtra && <navItem.headerExtra />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -169,7 +169,7 @@ export class ContextSrv {
|
||||
return this.hasPermission(action);
|
||||
}
|
||||
|
||||
hasAccessInMetadata(action: string, object: WithAccessControlMetadata, fallBack: boolean) {
|
||||
hasAccessInMetadata(action: string, object: WithAccessControlMetadata, fallBack: boolean): boolean {
|
||||
if (!this.accessControlEnabled()) {
|
||||
return fallBack;
|
||||
}
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
|
||||
import { AppEvents } from '@grafana/data';
|
||||
import { 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';
|
||||
|
||||
import { useInstallStatus, useUninstallStatus, useInstall, useUninstall } from '../../state/hooks';
|
||||
import { CatalogPlugin, PluginStatus, Version } from '../../types';
|
||||
import { CatalogPlugin, PluginStatus, PluginTabIds, Version } from '../../types';
|
||||
|
||||
type InstallControlsButtonProps = {
|
||||
plugin: CatalogPlugin;
|
||||
@@ -14,6 +17,8 @@ type InstallControlsButtonProps = {
|
||||
};
|
||||
|
||||
export function InstallControlsButton({ plugin, pluginStatus, latestCompatibleVersion }: InstallControlsButtonProps) {
|
||||
const [queryParams] = useQueryParams();
|
||||
const location = useLocation();
|
||||
const { isInstalling, error: errorInstalling } = useInstallStatus();
|
||||
const { isUninstalling, error: errorUninstalling } = useUninstallStatus();
|
||||
const install = useInstall();
|
||||
@@ -34,6 +39,12 @@ export function InstallControlsButton({ plugin, pluginStatus, latestCompatibleVe
|
||||
hideConfirmModal();
|
||||
await uninstall(plugin.id);
|
||||
if (!errorUninstalling) {
|
||||
// If an app plugin is uninstalled we need to reset the active tab when the config / dashboards tabs are removed.
|
||||
const activePageId = queryParams.page;
|
||||
const isViewingAppConfigPage = activePageId !== PluginTabIds.OVERVIEW && activePageId !== PluginTabIds.VERSIONS;
|
||||
if (isViewingAppConfigPage) {
|
||||
locationService.replace(`${location.pathname}?page=${PluginTabIds.OVERVIEW}`);
|
||||
}
|
||||
appEvents.emit(AppEvents.alertSuccess, [`Uninstalled ${plugin.name}`]);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -85,10 +85,7 @@ export function PluginDetailsBody({ plugin, queryParams, pageId }: Props): JSX.E
|
||||
}
|
||||
|
||||
export const getStyles = (theme: GrafanaTheme2) => ({
|
||||
container: css`
|
||||
padding: ${theme.spacing(3, 4)};
|
||||
height: 100%;
|
||||
`,
|
||||
container: css``,
|
||||
readme: css`
|
||||
& img {
|
||||
max-width: 100%;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { css, cx } from '@emotion/css';
|
||||
import { css } from '@emotion/css';
|
||||
import React from 'react';
|
||||
|
||||
import { GrafanaTheme2 } from '@grafana/data';
|
||||
@@ -12,94 +12,54 @@ import { GetStartedWithPlugin } from './GetStartedWithPlugin';
|
||||
import { InstallControls } from './InstallControls';
|
||||
import { PluginDetailsHeaderDependencies } from './PluginDetailsHeaderDependencies';
|
||||
import { PluginDetailsHeaderSignature } from './PluginDetailsHeaderSignature';
|
||||
import { PluginLogo } from './PluginLogo';
|
||||
|
||||
type Props = {
|
||||
currentUrl: string;
|
||||
parentUrl: string;
|
||||
plugin: CatalogPlugin;
|
||||
};
|
||||
|
||||
export function PluginDetailsHeader({ plugin, currentUrl, parentUrl }: Props): React.ReactElement {
|
||||
export function PluginDetailsHeader({ plugin }: Props): React.ReactElement {
|
||||
const styles = useStyles2(getStyles);
|
||||
const latestCompatibleVersion = getLatestCompatibleVersion(plugin.details?.versions);
|
||||
const version = plugin.installedVersion || latestCompatibleVersion?.version;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="page-container">
|
||||
<div className={styles.headerContainer}>
|
||||
<PluginLogo
|
||||
alt={`${plugin.name} logo`}
|
||||
src={plugin.info.logos.small}
|
||||
className={css`
|
||||
object-fit: contain;
|
||||
width: 100%;
|
||||
height: 68px;
|
||||
max-width: 68px;
|
||||
`}
|
||||
/>
|
||||
<div className={styles.headerContainer}>
|
||||
{plugin.description && <div className={styles.description}>{plugin.description}</div>}
|
||||
|
||||
<div className={styles.headerWrapper}>
|
||||
{/* Title & navigation */}
|
||||
<nav className={styles.breadcrumb} aria-label="Breadcrumb">
|
||||
<ol>
|
||||
<li>
|
||||
<a className={styles.textUnderline} href={parentUrl}>
|
||||
Plugins
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href={currentUrl} aria-current="page">
|
||||
{plugin.name}
|
||||
</a>
|
||||
</li>
|
||||
</ol>
|
||||
</nav>
|
||||
<div className={styles.headerInformationRow}>
|
||||
{/* Version */}
|
||||
{Boolean(version) && <span>Version: {version}</span>}
|
||||
|
||||
<div className={styles.headerInformationRow}>
|
||||
{/* Org name */}
|
||||
<span>{plugin.orgName}</span>
|
||||
{/* Org name */}
|
||||
<span>From: {plugin.orgName}</span>
|
||||
|
||||
{/* Links */}
|
||||
{plugin.details?.links.map((link: any) => (
|
||||
<a key={link.name} href={link.url}>
|
||||
<Icon name="external-link-alt" size="sm" /> {link.name}
|
||||
</a>
|
||||
))}
|
||||
{/* Links */}
|
||||
{plugin.details?.links.map((link: any) => (
|
||||
<a key={link.name} href={link.url} className="external-link">
|
||||
{link.name}
|
||||
</a>
|
||||
))}
|
||||
|
||||
{/* Downloads */}
|
||||
{plugin.downloads > 0 && (
|
||||
<span>
|
||||
<Icon name="cloud-download" />
|
||||
{` ${new Intl.NumberFormat().format(plugin.downloads)}`}{' '}
|
||||
</span>
|
||||
)}
|
||||
{/* Downloads */}
|
||||
{plugin.downloads > 0 && (
|
||||
<span>
|
||||
<Icon name="cloud-download" />
|
||||
{` ${new Intl.NumberFormat().format(plugin.downloads)}`}{' '}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Version */}
|
||||
{Boolean(version) && <span>{version}</span>}
|
||||
{/* Signature information */}
|
||||
<PluginDetailsHeaderSignature plugin={plugin} />
|
||||
|
||||
{/* Signature information */}
|
||||
<PluginDetailsHeaderSignature plugin={plugin} />
|
||||
{plugin.isDisabled && <PluginDisabledBadge error={plugin.error!} />}
|
||||
|
||||
{plugin.isDisabled && <PluginDisabledBadge error={plugin.error!} />}
|
||||
</div>
|
||||
|
||||
<PluginDetailsHeaderDependencies
|
||||
plugin={plugin}
|
||||
latestCompatibleVersion={latestCompatibleVersion}
|
||||
className={cx(styles.headerInformationRow, styles.headerInformationRowSecondary)}
|
||||
/>
|
||||
|
||||
<p>{plugin.description}</p>
|
||||
|
||||
<HorizontalGroup height="auto">
|
||||
<InstallControls plugin={plugin} latestCompatibleVersion={latestCompatibleVersion} />
|
||||
<GetStartedWithPlugin plugin={plugin} />
|
||||
</HorizontalGroup>
|
||||
</div>
|
||||
</div>
|
||||
<PluginDetailsHeaderDependencies plugin={plugin} latestCompatibleVersion={latestCompatibleVersion} />
|
||||
</div>
|
||||
|
||||
<HorizontalGroup height="auto">
|
||||
<InstallControls plugin={plugin} latestCompatibleVersion={latestCompatibleVersion} />
|
||||
<GetStartedWithPlugin plugin={plugin} />
|
||||
</HorizontalGroup>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -108,12 +68,11 @@ export const getStyles = (theme: GrafanaTheme2) => {
|
||||
return {
|
||||
headerContainer: css`
|
||||
display: flex;
|
||||
margin-bottom: ${theme.spacing(3)};
|
||||
margin-top: ${theme.spacing(3)};
|
||||
min-height: 120px;
|
||||
flex-direction: column;
|
||||
margin-bottom: ${theme.spacing(1)};
|
||||
`,
|
||||
headerWrapper: css`
|
||||
margin-left: ${theme.spacing(3)};
|
||||
description: css`
|
||||
margin: ${theme.spacing(-1, 0, 1)};
|
||||
`,
|
||||
breadcrumb: css`
|
||||
font-size: ${theme.typography.h2.fontSize};
|
||||
@@ -132,9 +91,9 @@ export const getStyles = (theme: GrafanaTheme2) => {
|
||||
headerInformationRow: css`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-top: ${theme.spacing()};
|
||||
margin-bottom: ${theme.spacing()};
|
||||
margin-bottom: ${theme.spacing(1)};
|
||||
flex-flow: wrap;
|
||||
|
||||
& > * {
|
||||
&::after {
|
||||
content: '|';
|
||||
@@ -145,7 +104,6 @@ export const getStyles = (theme: GrafanaTheme2) => {
|
||||
padding-right: 0;
|
||||
}
|
||||
}
|
||||
font-size: ${theme.typography.h4.fontSize};
|
||||
|
||||
a {
|
||||
&:hover {
|
||||
@@ -153,9 +111,6 @@ export const getStyles = (theme: GrafanaTheme2) => {
|
||||
}
|
||||
}
|
||||
`,
|
||||
headerInformationRowSecondary: css`
|
||||
font-size: ${theme.typography.body.fontSize};
|
||||
`,
|
||||
headerOrgName: css`
|
||||
font-size: ${theme.typography.h4.fontSize};
|
||||
`,
|
||||
|
||||
@@ -2,7 +2,7 @@ import { css } from '@emotion/css';
|
||||
import React from 'react';
|
||||
|
||||
import { GrafanaTheme2 } from '@grafana/data';
|
||||
import { useStyles2, Icon } from '@grafana/ui';
|
||||
import { useStyles2, Icon, Stack } from '@grafana/ui';
|
||||
|
||||
import { Version, CatalogPlugin, PluginIconName } from '../types';
|
||||
|
||||
@@ -29,7 +29,7 @@ export function PluginDetailsHeaderDependencies({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
<Stack gap={1}>
|
||||
<div className={styles.dependencyTitle}>Dependencies:</div>
|
||||
|
||||
{/* Grafana dependency */}
|
||||
@@ -53,14 +53,13 @@ export function PluginDetailsHeaderDependencies({
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
export const getStyles = (theme: GrafanaTheme2) => {
|
||||
return {
|
||||
dependencyTitle: css`
|
||||
font-weight: ${theme.typography.fontWeightBold};
|
||||
margin-right: ${theme.spacing(0.5)};
|
||||
|
||||
&::after {
|
||||
|
||||
@@ -1,104 +1,146 @@
|
||||
import { useMemo } from 'react';
|
||||
import React, { useMemo } from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
|
||||
import { PluginIncludeType, PluginType } from '@grafana/data';
|
||||
import { GrafanaPlugin, NavModelItem, PluginIncludeType, PluginType } from '@grafana/data';
|
||||
import { config } from '@grafana/runtime';
|
||||
import { contextSrv } from 'app/core/core';
|
||||
import { AccessControlAction } from 'app/types';
|
||||
|
||||
import { PluginDetailsHeader } from '../components/PluginDetailsHeader';
|
||||
import { usePluginConfig } from '../hooks/usePluginConfig';
|
||||
import { isOrgAdmin } from '../permissions';
|
||||
import { CatalogPlugin, PluginDetailsTab, PluginTabIds, PluginTabLabels } from '../types';
|
||||
import { CatalogPlugin, PluginTabIds, PluginTabLabels } from '../types';
|
||||
|
||||
type ReturnType = {
|
||||
error: Error | undefined;
|
||||
loading: boolean;
|
||||
tabs: PluginDetailsTab[];
|
||||
defaultTab: string;
|
||||
navModel: NavModelItem;
|
||||
activePageId: PluginTabIds | string;
|
||||
};
|
||||
|
||||
export const usePluginDetailsTabs = (plugin?: CatalogPlugin, defaultTabs: PluginDetailsTab[] = []): ReturnType => {
|
||||
export const usePluginDetailsTabs = (plugin?: CatalogPlugin, pageId?: PluginTabIds): ReturnType => {
|
||||
const { loading, error, value: pluginConfig } = usePluginConfig(plugin);
|
||||
const isPublished = Boolean(plugin?.isPublished);
|
||||
const { pathname } = useLocation();
|
||||
const defaultTab = useDefaultPage(plugin, pluginConfig);
|
||||
const parentUrl = pathname.substring(0, pathname.lastIndexOf('/'));
|
||||
const isPublished = Boolean(plugin?.isPublished);
|
||||
|
||||
const [tabs, defaultTab] = useMemo(() => {
|
||||
const currentPageId = pageId || defaultTab;
|
||||
const navModelChildren = useMemo(() => {
|
||||
const canConfigurePlugins =
|
||||
plugin && contextSrv.hasAccessInMetadata(AccessControlAction.PluginsWrite, plugin, isOrgAdmin());
|
||||
const tabs: PluginDetailsTab[] = [...defaultTabs];
|
||||
let defaultTab;
|
||||
const navModelChildren: NavModelItem[] = [];
|
||||
if (isPublished) {
|
||||
tabs.push({
|
||||
label: PluginTabLabels.VERSIONS,
|
||||
icon: 'history',
|
||||
navModelChildren.push({
|
||||
text: PluginTabLabels.VERSIONS,
|
||||
id: PluginTabIds.VERSIONS,
|
||||
href: `${pathname}?page=${PluginTabIds.VERSIONS}`,
|
||||
icon: 'history',
|
||||
url: `${pathname}?page=${PluginTabIds.VERSIONS}`,
|
||||
active: PluginTabIds.VERSIONS === currentPageId,
|
||||
});
|
||||
}
|
||||
|
||||
// Not extending the tabs with the config pages if the plugin is not installed
|
||||
if (!pluginConfig) {
|
||||
defaultTab = PluginTabIds.OVERVIEW;
|
||||
return [tabs, defaultTab];
|
||||
return navModelChildren;
|
||||
}
|
||||
|
||||
if (config.featureToggles.panelTitleSearch && pluginConfig.meta.type === PluginType.panel) {
|
||||
tabs.push({
|
||||
label: PluginTabLabels.USAGE,
|
||||
navModelChildren.push({
|
||||
text: PluginTabLabels.USAGE,
|
||||
icon: 'list-ul',
|
||||
id: PluginTabIds.USAGE,
|
||||
href: `${pathname}?page=${PluginTabIds.USAGE}`,
|
||||
url: `${pathname}?page=${PluginTabIds.USAGE}`,
|
||||
active: PluginTabIds.USAGE === currentPageId,
|
||||
});
|
||||
}
|
||||
|
||||
if (canConfigurePlugins) {
|
||||
if (pluginConfig.meta.type === PluginType.app) {
|
||||
if (pluginConfig.angularConfigCtrl) {
|
||||
tabs.push({
|
||||
label: 'Config',
|
||||
icon: 'cog',
|
||||
id: PluginTabIds.CONFIG,
|
||||
href: `${pathname}?page=${PluginTabIds.CONFIG}`,
|
||||
});
|
||||
defaultTab = PluginTabIds.CONFIG;
|
||||
}
|
||||
if (!canConfigurePlugins) {
|
||||
return navModelChildren;
|
||||
}
|
||||
|
||||
if (pluginConfig.configPages) {
|
||||
for (const page of pluginConfig.configPages) {
|
||||
tabs.push({
|
||||
label: page.title,
|
||||
icon: page.icon,
|
||||
id: page.id,
|
||||
href: `${pathname}?page=${page.id}`,
|
||||
});
|
||||
if (!defaultTab) {
|
||||
defaultTab = page.id;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (pluginConfig.meta.type === PluginType.app) {
|
||||
if (pluginConfig.angularConfigCtrl) {
|
||||
navModelChildren.push({
|
||||
text: 'Config',
|
||||
icon: 'cog',
|
||||
id: PluginTabIds.CONFIG,
|
||||
url: `${pathname}?page=${PluginTabIds.CONFIG}`,
|
||||
active: PluginTabIds.CONFIG === currentPageId,
|
||||
});
|
||||
}
|
||||
|
||||
if (pluginConfig.meta.includes?.find((include) => include.type === PluginIncludeType.dashboard)) {
|
||||
tabs.push({
|
||||
label: 'Dashboards',
|
||||
icon: 'apps',
|
||||
id: PluginTabIds.DASHBOARDS,
|
||||
href: `${pathname}?page=${PluginTabIds.DASHBOARDS}`,
|
||||
if (pluginConfig.configPages) {
|
||||
for (const configPage of pluginConfig.configPages) {
|
||||
navModelChildren.push({
|
||||
text: configPage.title,
|
||||
icon: configPage.icon,
|
||||
id: configPage.id,
|
||||
url: `${pathname}?page=${configPage.id}`,
|
||||
active: configPage.id === currentPageId,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (pluginConfig.meta.includes?.find((include) => include.type === PluginIncludeType.dashboard)) {
|
||||
navModelChildren.push({
|
||||
text: 'Dashboards',
|
||||
icon: 'apps',
|
||||
id: PluginTabIds.DASHBOARDS,
|
||||
url: `${pathname}?page=${PluginTabIds.DASHBOARDS}`,
|
||||
active: PluginTabIds.DASHBOARDS === currentPageId,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (!defaultTab) {
|
||||
defaultTab = PluginTabIds.OVERVIEW;
|
||||
}
|
||||
return navModelChildren;
|
||||
}, [plugin, pluginConfig, pathname, isPublished, currentPageId]);
|
||||
|
||||
return [tabs, defaultTab];
|
||||
}, [plugin, pluginConfig, defaultTabs, pathname, isPublished]);
|
||||
const navModel: NavModelItem = {
|
||||
text: plugin?.name ?? '',
|
||||
img: plugin?.info.logos.small,
|
||||
breadcrumbs: [{ title: 'Plugins', url: parentUrl }],
|
||||
children: [
|
||||
{
|
||||
text: PluginTabLabels.OVERVIEW,
|
||||
icon: 'file-alt',
|
||||
id: PluginTabIds.OVERVIEW,
|
||||
url: `${pathname}?page=${PluginTabIds.OVERVIEW}`,
|
||||
active: PluginTabIds.OVERVIEW === currentPageId,
|
||||
},
|
||||
...navModelChildren,
|
||||
],
|
||||
headerExtra: () => {
|
||||
return plugin ? <PluginDetailsHeader plugin={plugin} /> : null;
|
||||
},
|
||||
};
|
||||
|
||||
return {
|
||||
error,
|
||||
loading,
|
||||
tabs,
|
||||
defaultTab,
|
||||
navModel,
|
||||
activePageId: currentPageId,
|
||||
};
|
||||
};
|
||||
|
||||
function useDefaultPage(plugin: CatalogPlugin | undefined, pluginConfig: GrafanaPlugin | undefined | null) {
|
||||
if (!plugin || !pluginConfig) {
|
||||
return PluginTabIds.OVERVIEW;
|
||||
}
|
||||
|
||||
const hasAccess = contextSrv.hasAccessInMetadata(AccessControlAction.PluginsWrite, plugin, isOrgAdmin());
|
||||
|
||||
if (!hasAccess || pluginConfig.meta.type !== PluginType.app) {
|
||||
return PluginTabIds.OVERVIEW;
|
||||
}
|
||||
|
||||
if (pluginConfig.angularConfigCtrl) {
|
||||
return PluginTabIds.CONFIG;
|
||||
}
|
||||
|
||||
if (pluginConfig.configPages?.length) {
|
||||
return pluginConfig.configPages[0].id;
|
||||
}
|
||||
|
||||
return PluginTabIds.OVERVIEW;
|
||||
}
|
||||
|
||||
@@ -153,7 +153,6 @@ describe('Plugin details page', () => {
|
||||
<Provider store={store}>
|
||||
<PluginDetailsPage {...props} />
|
||||
</Provider>
|
||||
,
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
@@ -163,7 +162,7 @@ describe('Plugin details page', () => {
|
||||
it('should display an overview (plugin readme) by default', async () => {
|
||||
const { queryByText } = renderPluginDetails({ id });
|
||||
|
||||
await waitFor(() => expect(queryByText(/licensed under the apache 2.0 license/i)).toBeInTheDocument());
|
||||
expect(await queryByText(/licensed under the apache 2.0 license/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should display an app config page by default for installed app plugins', async () => {
|
||||
@@ -197,7 +196,7 @@ describe('Plugin details page', () => {
|
||||
type: PluginType.app,
|
||||
});
|
||||
|
||||
await waitFor(() => expect(queryByText(/custom config page/i)).toBeInTheDocument());
|
||||
expect(await queryByText(/custom config page/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should display the number of downloads in the header', async () => {
|
||||
@@ -208,14 +207,14 @@ describe('Plugin details page', () => {
|
||||
const expected = new Intl.NumberFormat().format(downloads);
|
||||
|
||||
const { queryByText } = renderPluginDetails({ id, downloads });
|
||||
await waitFor(() => expect(queryByText(expected, options)).toBeInTheDocument());
|
||||
expect(await queryByText(expected, options)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should display the installed version if a plugin is installed', async () => {
|
||||
const installedVersion = '1.3.443';
|
||||
const { queryByText } = renderPluginDetails({ id, installedVersion });
|
||||
|
||||
await waitFor(() => expect(queryByText(installedVersion)).toBeInTheDocument());
|
||||
expect(await queryByText(`Version: ${installedVersion}`)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should display the latest compatible version in the header if a plugin is not installed', async () => {
|
||||
@@ -230,40 +229,40 @@ describe('Plugin details page', () => {
|
||||
],
|
||||
};
|
||||
|
||||
const { queryByText } = renderPluginDetails({ id, details });
|
||||
await waitFor(() => expect(queryByText('1.1.1')).toBeInTheDocument());
|
||||
await waitFor(() => expect(queryByText(/>=8.0.0/i)).toBeInTheDocument());
|
||||
const { findByText, queryByText } = renderPluginDetails({ id, details });
|
||||
expect(await findByText('Version: 1.1.1')).toBeInTheDocument();
|
||||
expect(queryByText(/>=8.0.0/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should display description in the header', async () => {
|
||||
const description = 'This is my description';
|
||||
const { queryByText } = renderPluginDetails({ id, description });
|
||||
|
||||
await waitFor(() => expect(queryByText(description)).toBeInTheDocument());
|
||||
expect(await queryByText(description)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should display a "Signed" badge if the plugin signature is verified', async () => {
|
||||
const { queryByText } = renderPluginDetails({ id, signature: PluginSignatureStatus.valid });
|
||||
|
||||
await waitFor(() => expect(queryByText('Signed')).toBeInTheDocument());
|
||||
expect(await queryByText('Signed')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should display a "Missing signature" badge if the plugin signature is missing', async () => {
|
||||
const { queryByText } = renderPluginDetails({ id, signature: PluginSignatureStatus.missing });
|
||||
|
||||
await waitFor(() => expect(queryByText('Missing signature')).toBeInTheDocument());
|
||||
expect(await queryByText('Missing signature')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should display a "Modified signature" badge if the plugin signature is modified', async () => {
|
||||
const { queryByText } = renderPluginDetails({ id, signature: PluginSignatureStatus.modified });
|
||||
|
||||
await waitFor(() => expect(queryByText('Modified signature')).toBeInTheDocument());
|
||||
expect(await queryByText('Modified signature')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should display a "Invalid signature" badge if the plugin signature is invalid', async () => {
|
||||
const { queryByText } = renderPluginDetails({ id, signature: PluginSignatureStatus.invalid });
|
||||
|
||||
await waitFor(() => expect(queryByText('Invalid signature')).toBeInTheDocument());
|
||||
expect(await queryByText('Invalid signature')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should display version history if the plugin is published', async () => {
|
||||
@@ -288,7 +287,7 @@ describe('Plugin details page', () => {
|
||||
},
|
||||
];
|
||||
|
||||
const { queryByText, getByRole } = renderPluginDetails(
|
||||
const { findByRole, queryByText, getByRole } = renderPluginDetails(
|
||||
{
|
||||
id,
|
||||
details: {
|
||||
@@ -300,7 +299,7 @@ describe('Plugin details page', () => {
|
||||
);
|
||||
|
||||
// Check if version information is available
|
||||
await waitFor(() => expect(queryByText(/version history/i)).toBeInTheDocument());
|
||||
expect(await findByRole('tab', { name: `Tab ${PluginTabLabels.VERSIONS}` })).toBeInTheDocument();
|
||||
|
||||
// Check the column headers
|
||||
expect(getByRole('columnheader', { name: /version/i })).toBeInTheDocument();
|
||||
@@ -321,7 +320,7 @@ describe('Plugin details page', () => {
|
||||
it("should display an install button for a plugin that isn't installed", async () => {
|
||||
const { queryByRole } = renderPluginDetails({ id, isInstalled: false });
|
||||
|
||||
await waitFor(() => expect(queryByRole('button', { name: /^install/i })).toBeInTheDocument());
|
||||
expect(await queryByRole('button', { name: /^install/i })).toBeInTheDocument();
|
||||
// Does not display "uninstall" button
|
||||
expect(queryByRole('button', { name: /uninstall/i })).not.toBeInTheDocument();
|
||||
});
|
||||
@@ -329,7 +328,7 @@ describe('Plugin details page', () => {
|
||||
it('should display an uninstall button for an already installed plugin', async () => {
|
||||
const { queryByRole } = renderPluginDetails({ id, isInstalled: true });
|
||||
|
||||
await waitFor(() => expect(queryByRole('button', { name: /uninstall/i })).toBeInTheDocument());
|
||||
expect(await queryByRole('button', { name: /uninstall/i })).toBeInTheDocument();
|
||||
// Does not display "install" button
|
||||
expect(queryByRole('button', { name: /^install/i })).not.toBeInTheDocument();
|
||||
});
|
||||
@@ -338,7 +337,7 @@ describe('Plugin details page', () => {
|
||||
const { queryByRole } = renderPluginDetails({ id, isInstalled: true, hasUpdate: true });
|
||||
|
||||
// Displays an "update" button
|
||||
await waitFor(() => expect(queryByRole('button', { name: /update/i })).toBeInTheDocument());
|
||||
expect(await queryByRole('button', { name: /update/i })).toBeInTheDocument();
|
||||
expect(queryByRole('button', { name: /uninstall/i })).toBeInTheDocument();
|
||||
|
||||
// Does not display "install" button
|
||||
@@ -350,7 +349,7 @@ describe('Plugin details page', () => {
|
||||
|
||||
const { queryByRole } = renderPluginDetails({ id, isInstalled: false, isEnterprise: true });
|
||||
|
||||
await waitFor(() => expect(queryByRole('button', { name: /install/i })).toBeInTheDocument());
|
||||
expect(await queryByRole('button', { name: /install/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should not display install button for enterprise plugins if license is invalid', async () => {
|
||||
@@ -358,7 +357,7 @@ describe('Plugin details page', () => {
|
||||
|
||||
const { queryByRole, queryByText } = renderPluginDetails({ id, isInstalled: true, isEnterprise: true });
|
||||
|
||||
await waitFor(() => expect(queryByRole('button', { name: /install/i })).not.toBeInTheDocument());
|
||||
expect(await queryByRole('button', { name: /install/i })).not.toBeInTheDocument();
|
||||
expect(queryByText(/no valid Grafana Enterprise license detected/i)).toBeInTheDocument();
|
||||
expect(queryByRole('link', { name: /learn more/i })).toBeInTheDocument();
|
||||
});
|
||||
@@ -366,22 +365,22 @@ describe('Plugin details page', () => {
|
||||
it('should not display install / uninstall buttons for core plugins', async () => {
|
||||
const { queryByRole } = renderPluginDetails({ id, isInstalled: true, isCore: true });
|
||||
|
||||
await waitFor(() => expect(queryByRole('button', { name: /update/i })).not.toBeInTheDocument());
|
||||
await waitFor(() => expect(queryByRole('button', { name: /(un)?install/i })).not.toBeInTheDocument());
|
||||
expect(await queryByRole('button', { name: /update/i })).not.toBeInTheDocument();
|
||||
expect(await queryByRole('button', { name: /(un)?install/i })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should not display install / uninstall buttons for disabled plugins', async () => {
|
||||
const { queryByRole } = renderPluginDetails({ id, isInstalled: true, isDisabled: true });
|
||||
|
||||
await waitFor(() => expect(queryByRole('button', { name: /update/i })).not.toBeInTheDocument());
|
||||
await waitFor(() => expect(queryByRole('button', { name: /(un)?install/i })).not.toBeInTheDocument());
|
||||
expect(await queryByRole('button', { name: /update/i })).not.toBeInTheDocument();
|
||||
expect(await queryByRole('button', { name: /(un)?install/i })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should not display install / uninstall buttons for renderer plugins', async () => {
|
||||
const { queryByRole } = renderPluginDetails({ id, type: PluginType.renderer });
|
||||
|
||||
await waitFor(() => expect(queryByRole('button', { name: /update/i })).not.toBeInTheDocument());
|
||||
await waitFor(() => expect(queryByRole('button', { name: /(un)?install/i })).not.toBeInTheDocument());
|
||||
expect(await queryByRole('button', { name: /update/i })).not.toBeInTheDocument();
|
||||
expect(await queryByRole('button', { name: /(un)?install/i })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should display install link with `config.pluginAdminExternalManageEnabled` set to true', async () => {
|
||||
@@ -389,7 +388,7 @@ describe('Plugin details page', () => {
|
||||
|
||||
const { queryByRole } = renderPluginDetails({ id, isInstalled: false });
|
||||
|
||||
await waitFor(() => expect(queryByRole('link', { name: /install via grafana.com/i })).toBeInTheDocument());
|
||||
expect(await queryByRole('link', { name: /install via grafana.com/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should display uninstall link for an installed plugin with `config.pluginAdminExternalManageEnabled` set to true', async () => {
|
||||
@@ -397,7 +396,7 @@ describe('Plugin details page', () => {
|
||||
|
||||
const { queryByRole } = renderPluginDetails({ id, isInstalled: true });
|
||||
|
||||
await waitFor(() => expect(queryByRole('link', { name: /uninstall via grafana.com/i })).toBeInTheDocument());
|
||||
expect(await queryByRole('link', { name: /uninstall via grafana.com/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should display update and uninstall links for a plugin with an available update and `config.pluginAdminExternalManageEnabled` set to true', async () => {
|
||||
@@ -405,7 +404,7 @@ describe('Plugin details page', () => {
|
||||
|
||||
const { queryByRole } = renderPluginDetails({ id, isInstalled: true, hasUpdate: true });
|
||||
|
||||
await waitFor(() => expect(queryByRole('link', { name: /update via grafana.com/i })).toBeInTheDocument());
|
||||
expect(await queryByRole('link', { name: /update via grafana.com/i })).toBeInTheDocument();
|
||||
expect(queryByRole('link', { name: /uninstall via grafana.com/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -417,7 +416,7 @@ describe('Plugin details page', () => {
|
||||
error: PluginErrorCode.modifiedSignature,
|
||||
});
|
||||
|
||||
await waitFor(() => expect(queryByLabelText(selectors.pages.PluginPage.disabledInfo)).toBeInTheDocument());
|
||||
expect(await queryByLabelText(selectors.pages.PluginPage.disabledInfo)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should display grafana dependencies for a plugin if they are available', async () => {
|
||||
@@ -431,7 +430,7 @@ describe('Plugin details page', () => {
|
||||
});
|
||||
|
||||
// Wait for the dependencies part to be loaded
|
||||
await waitFor(() => expect(queryByText(/dependencies:/i)).toBeInTheDocument());
|
||||
expect(await queryByText(/dependencies:/i)).toBeInTheDocument();
|
||||
|
||||
expect(queryByText('Grafana >=8.0.0')).toBeInTheDocument();
|
||||
});
|
||||
@@ -440,7 +439,7 @@ describe('Plugin details page', () => {
|
||||
// @ts-ignore
|
||||
api.uninstallPlugin = jest.fn();
|
||||
|
||||
const { queryByText, getByRole } = renderPluginDetails({
|
||||
const { queryByText, getByRole, findByRole } = renderPluginDetails({
|
||||
id,
|
||||
name: 'Akumuli',
|
||||
isInstalled: true,
|
||||
@@ -460,7 +459,7 @@ describe('Plugin details page', () => {
|
||||
});
|
||||
|
||||
// Wait for the install controls to be loaded
|
||||
await waitFor(() => expect(queryByText(PluginTabLabels.OVERVIEW)).toBeInTheDocument());
|
||||
expect(await findByRole('tab', { name: `Tab ${PluginTabLabels.OVERVIEW}` })).toBeInTheDocument();
|
||||
|
||||
// Open the confirmation modal
|
||||
await userEvent.click(getByRole('button', { name: /uninstall/i }));
|
||||
@@ -496,17 +495,17 @@ describe('Plugin details page', () => {
|
||||
|
||||
// Does not show an Install button
|
||||
rendered = renderPluginDetails({ id }, { pluginsStateOverride });
|
||||
await waitFor(() => expect(rendered.queryByRole('button', { name: /(un)?install/i })).not.toBeInTheDocument());
|
||||
expect(await rendered.queryByRole('button', { name: /(un)?install/i })).not.toBeInTheDocument();
|
||||
rendered.unmount();
|
||||
|
||||
// Does not show a Uninstall button
|
||||
rendered = renderPluginDetails({ id, isInstalled: true }, { pluginsStateOverride });
|
||||
await waitFor(() => expect(rendered.queryByRole('button', { name: /(un)?install/i })).not.toBeInTheDocument());
|
||||
expect(await rendered.queryByRole('button', { name: /(un)?install/i })).not.toBeInTheDocument();
|
||||
rendered.unmount();
|
||||
|
||||
// Does not show an Update button
|
||||
rendered = renderPluginDetails({ id, isInstalled: true, hasUpdate: true }, { pluginsStateOverride });
|
||||
await waitFor(() => expect(rendered.queryByRole('button', { name: /update/i })).not.toBeInTheDocument());
|
||||
expect(await rendered.queryByRole('button', { name: /update/i })).not.toBeInTheDocument();
|
||||
|
||||
// Shows a message to the user
|
||||
// TODO<Import these texts from a single source of truth instead of having them defined in multiple places>
|
||||
@@ -522,15 +521,15 @@ describe('Plugin details page', () => {
|
||||
|
||||
// Should not show an "Install" button
|
||||
rendered = renderPluginDetails({ id, isInstalled: false });
|
||||
await waitFor(() => expect(rendered.queryByRole('button', { name: /^install/i })).not.toBeInTheDocument());
|
||||
expect(await rendered.queryByRole('button', { name: /^install/i })).not.toBeInTheDocument();
|
||||
|
||||
// Should not show an "Uninstall" button
|
||||
rendered = renderPluginDetails({ id, isInstalled: true });
|
||||
await waitFor(() => expect(rendered.queryByRole('button', { name: /^uninstall/i })).not.toBeInTheDocument());
|
||||
expect(await rendered.queryByRole('button', { name: /^uninstall/i })).not.toBeInTheDocument();
|
||||
|
||||
// Should not show an "Update" button
|
||||
rendered = renderPluginDetails({ id, isInstalled: true, hasUpdate: true });
|
||||
await waitFor(() => expect(rendered.queryByRole('button', { name: /^update/i })).not.toBeInTheDocument());
|
||||
expect(await rendered.queryByRole('button', { name: /^update/i })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should display a "Create" button as a post installation step for installed data source plugins', async () => {
|
||||
@@ -703,20 +702,18 @@ describe('Plugin details page', () => {
|
||||
});
|
||||
|
||||
it('should not display versions tab for plugins not published to gcom', async () => {
|
||||
const { queryByText } = renderPluginDetails({
|
||||
const { queryByRole } = renderPluginDetails({
|
||||
name: 'Akumuli',
|
||||
isInstalled: true,
|
||||
type: PluginType.app,
|
||||
isPublished: false,
|
||||
});
|
||||
|
||||
await waitFor(() => expect(queryByText(PluginTabLabels.OVERVIEW)).toBeInTheDocument());
|
||||
|
||||
expect(queryByText(PluginTabLabels.VERSIONS)).toBeNull();
|
||||
expect(await queryByRole('tab', { name: `Tab ${PluginTabLabels.VERSIONS}` })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should not display update for plugins not published to gcom', async () => {
|
||||
const { queryByText, queryByRole } = renderPluginDetails({
|
||||
const { findByRole, queryByRole } = renderPluginDetails({
|
||||
name: 'Akumuli',
|
||||
isInstalled: true,
|
||||
hasUpdate: true,
|
||||
@@ -724,13 +721,13 @@ describe('Plugin details page', () => {
|
||||
isPublished: false,
|
||||
});
|
||||
|
||||
await waitFor(() => expect(queryByText(PluginTabLabels.OVERVIEW)).toBeInTheDocument());
|
||||
expect(await findByRole('tab', { name: `Tab ${PluginTabLabels.OVERVIEW}` })).toBeInTheDocument();
|
||||
|
||||
expect(queryByRole('button', { name: /update/i })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should not display install for plugins not published to gcom', async () => {
|
||||
const { queryByText, queryByRole } = renderPluginDetails({
|
||||
const { findByRole, queryByRole } = renderPluginDetails({
|
||||
name: 'Akumuli',
|
||||
isInstalled: false,
|
||||
hasUpdate: false,
|
||||
@@ -738,13 +735,13 @@ describe('Plugin details page', () => {
|
||||
isPublished: false,
|
||||
});
|
||||
|
||||
await waitFor(() => expect(queryByText(PluginTabLabels.OVERVIEW)).toBeInTheDocument());
|
||||
expect(await findByRole('tab', { name: `Tab ${PluginTabLabels.OVERVIEW}` })).toBeInTheDocument();
|
||||
|
||||
expect(queryByRole('button', { name: /^install/i })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should not display uninstall for plugins not published to gcom', async () => {
|
||||
const { queryByText, queryByRole } = renderPluginDetails({
|
||||
const { findByRole, queryByRole } = renderPluginDetails({
|
||||
name: 'Akumuli',
|
||||
isInstalled: true,
|
||||
hasUpdate: false,
|
||||
@@ -752,7 +749,7 @@ describe('Plugin details page', () => {
|
||||
isPublished: false,
|
||||
});
|
||||
|
||||
await waitFor(() => expect(queryByText(PluginTabLabels.OVERVIEW)).toBeInTheDocument());
|
||||
expect(await findByRole('tab', { name: `Tab ${PluginTabLabels.OVERVIEW}` })).toBeInTheDocument();
|
||||
|
||||
expect(queryByRole('button', { name: /uninstall/i })).not.toBeInTheDocument();
|
||||
});
|
||||
@@ -768,37 +765,35 @@ describe('Plugin details page', () => {
|
||||
});
|
||||
|
||||
it("should not display an install button for a plugin that isn't installed", async () => {
|
||||
const { queryByRole, queryByText } = renderPluginDetails({ id, isInstalled: false });
|
||||
const { queryByRole, findByRole } = renderPluginDetails({ id, isInstalled: false });
|
||||
|
||||
await waitFor(() => expect(queryByText(PluginTabLabels.OVERVIEW)).toBeInTheDocument());
|
||||
expect(await findByRole('tab', { name: `Tab ${PluginTabLabels.OVERVIEW}` })).toBeInTheDocument();
|
||||
|
||||
expect(queryByRole('button', { name: /^install/i })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should not display an uninstall button for an already installed plugin', async () => {
|
||||
const { queryByRole, queryByText } = renderPluginDetails({ id, isInstalled: true });
|
||||
const { queryByRole, findByRole } = renderPluginDetails({ id, isInstalled: true });
|
||||
|
||||
await waitFor(() => expect(queryByText(PluginTabLabels.OVERVIEW)).toBeInTheDocument());
|
||||
expect(await findByRole('tab', { name: `Tab ${PluginTabLabels.OVERVIEW}` })).toBeInTheDocument();
|
||||
|
||||
expect(queryByRole('button', { name: /uninstall/i })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should not display update or uninstall buttons for a plugin with update', async () => {
|
||||
const { queryByRole, queryByText } = renderPluginDetails({ id, isInstalled: true, hasUpdate: true });
|
||||
const { queryByRole, findByRole } = renderPluginDetails({ id, isInstalled: true, hasUpdate: true });
|
||||
|
||||
await waitFor(() => expect(queryByText(PluginTabLabels.OVERVIEW)).toBeInTheDocument());
|
||||
expect(await findByRole('tab', { name: `Tab ${PluginTabLabels.OVERVIEW}` })).toBeInTheDocument();
|
||||
|
||||
expect(queryByRole('button', { name: /update/i })).not.toBeInTheDocument();
|
||||
expect(queryByRole('button', { name: /uninstall/i })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should not display an install button for enterprise plugins if license is valid', async () => {
|
||||
config.licenseInfo.enabledFeatures = { 'enterprise.plugins': true };
|
||||
const { queryByRole, queryByText } = renderPluginDetails({ id, isInstalled: false, isEnterprise: true });
|
||||
const { findByRole, queryByRole } = renderPluginDetails({ id, isInstalled: false, isEnterprise: true });
|
||||
|
||||
await waitFor(() => expect(queryByText(PluginTabLabels.OVERVIEW)).toBeInTheDocument());
|
||||
|
||||
expect(queryByRole('button', { name: /^install/i })).not.toBeInTheDocument();
|
||||
expect(await findByRole('tab', { name: `Tab ${PluginTabLabels.OVERVIEW}` })).toBeInTheDocument();
|
||||
expect(await queryByRole('button', { name: /^install/i })).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
import { css } from '@emotion/css';
|
||||
import React, { useEffect } from 'react';
|
||||
import { usePrevious } from 'react-use';
|
||||
import React from 'react';
|
||||
|
||||
import { GrafanaTheme2 } from '@grafana/data';
|
||||
import { locationService } from '@grafana/runtime';
|
||||
import { useStyles2, TabsBar, TabContent, Tab, Alert, toIconName } from '@grafana/ui';
|
||||
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 { GrafanaRouteComponentProps } from 'app/core/navigation/types';
|
||||
@@ -13,11 +11,10 @@ import { AppNotificationSeverity } from 'app/types';
|
||||
import { Loader } from '../components/Loader';
|
||||
import { PluginDetailsBody } from '../components/PluginDetailsBody';
|
||||
import { PluginDetailsDisabledError } from '../components/PluginDetailsDisabledError';
|
||||
import { PluginDetailsHeader } from '../components/PluginDetailsHeader';
|
||||
import { PluginDetailsSignature } from '../components/PluginDetailsSignature';
|
||||
import { usePluginDetailsTabs } from '../hooks/usePluginDetailsTabs';
|
||||
import { useGetSingle, useFetchStatus, useFetchDetailsStatus } from '../state/hooks';
|
||||
import { PluginTabLabels, PluginTabIds, PluginDetailsTab } from '../types';
|
||||
import { PluginTabIds } from '../types';
|
||||
|
||||
type Props = GrafanaRouteComponentProps<{ pluginId?: string }>;
|
||||
|
||||
@@ -27,35 +24,16 @@ export default function PluginDetails({ match, queryParams }: Props): JSX.Elemen
|
||||
url,
|
||||
} = match;
|
||||
const parentUrl = url.substring(0, url.lastIndexOf('/'));
|
||||
const defaultTabs: PluginDetailsTab[] = [
|
||||
{
|
||||
label: PluginTabLabels.OVERVIEW,
|
||||
icon: 'file-alt',
|
||||
id: PluginTabIds.OVERVIEW,
|
||||
href: `${url}?page=${PluginTabIds.OVERVIEW}`,
|
||||
},
|
||||
];
|
||||
|
||||
const plugin = useGetSingle(pluginId); // fetches the localplugin settings
|
||||
const { tabs, defaultTab } = usePluginDetailsTabs(plugin, defaultTabs);
|
||||
const { navModel, activePageId } = usePluginDetailsTabs(plugin, queryParams.page as PluginTabIds);
|
||||
const { isLoading: isFetchLoading } = useFetchStatus();
|
||||
const { isLoading: isFetchDetailsLoading } = useFetchDetailsStatus();
|
||||
const styles = useStyles2(getStyles);
|
||||
const prevTabs = usePrevious(tabs);
|
||||
const pageId = (queryParams.page as PluginTabIds) || defaultTab;
|
||||
|
||||
// If an app plugin is uninstalled we need to reset the active tab when the config / dashboards tabs are removed.
|
||||
useEffect(() => {
|
||||
const hasUninstalledWithConfigPages = prevTabs && prevTabs.length > tabs.length;
|
||||
const isViewingAConfigPage = pageId !== PluginTabIds.OVERVIEW && pageId !== PluginTabIds.VERSIONS;
|
||||
|
||||
if (hasUninstalledWithConfigPages && isViewingAConfigPage) {
|
||||
locationService.replace(`${url}?page=${PluginTabIds.OVERVIEW}`);
|
||||
}
|
||||
}, [pageId, url, tabs, prevTabs]);
|
||||
|
||||
if (isFetchLoading || isFetchDetailsLoading) {
|
||||
return (
|
||||
<Page>
|
||||
<Page navId="plugins">
|
||||
<Loader />
|
||||
</Page>
|
||||
);
|
||||
@@ -73,32 +51,12 @@ export default function PluginDetails({ match, queryParams }: Props): JSX.Elemen
|
||||
}
|
||||
|
||||
return (
|
||||
<Page>
|
||||
<PluginDetailsHeader currentUrl={`${url}?page=${pageId}`} parentUrl={parentUrl} plugin={plugin} />
|
||||
{/* Tab navigation */}
|
||||
<div>
|
||||
<div className="page-container">
|
||||
<TabsBar hideBorder>
|
||||
{tabs.map((tab: PluginDetailsTab) => {
|
||||
return (
|
||||
<Tab
|
||||
key={tab.label}
|
||||
label={tab.label}
|
||||
href={tab.href}
|
||||
icon={toIconName(tab.icon ?? '')}
|
||||
active={tab.id === pageId}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</TabsBar>
|
||||
</div>
|
||||
</div>
|
||||
<Page navId="plugins" pageNav={navModel}>
|
||||
<Page.Contents>
|
||||
{/* Active tab */}
|
||||
<TabContent className={styles.tabContent}>
|
||||
<PluginDetailsSignature plugin={plugin} className={styles.alert} />
|
||||
<PluginDetailsDisabledError plugin={plugin} className={styles.alert} />
|
||||
<PluginDetailsBody queryParams={queryParams} plugin={plugin} pageId={pageId} />
|
||||
<PluginDetailsBody queryParams={queryParams} plugin={plugin} pageId={activePageId} />
|
||||
</TabContent>
|
||||
</Page.Contents>
|
||||
</Page>
|
||||
@@ -108,8 +66,7 @@ export default function PluginDetails({ match, queryParams }: Props): JSX.Elemen
|
||||
export const getStyles = (theme: GrafanaTheme2) => {
|
||||
return {
|
||||
alert: css`
|
||||
margin: ${theme.spacing(3)};
|
||||
margin-bottom: 0;
|
||||
margin-bottom: ${theme.spacing(2)};
|
||||
`,
|
||||
// Needed due to block formatting context
|
||||
tabContent: css`
|
||||
|
||||
@@ -242,7 +242,7 @@ export type RequestInfo = {
|
||||
|
||||
export type PluginDetailsTab = {
|
||||
label: PluginTabLabels | string;
|
||||
icon?: IconName | string;
|
||||
icon?: IconName;
|
||||
id: PluginTabIds | string;
|
||||
href?: string;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user