Navigation: Convert PluginDetails page to use new Page extensions (#58509)

* Added labels

* App page fixes

* Switch to switch

* wip

* Updates

* I am stuck

* Minor tweak

* This props interface could work

* removed change

* use new page extensions in plugin details page

* add link separator, fix action button spacing

* some renaming

* Move PageInfo into it's own folder + add tests

* add support for new props in old page header

* remove PluginDetailsHeader as it's no longer used

* Fix unit tests

* fix some badge alignments

* center align actions

* badge alignment + only show downloads for community/commercial plugins

* better link alignment

* conditionally render description

* move install control warnings to below subtitle + refactor

Co-authored-by: Torkel Ödegaard <torkel@grafana.com>
This commit is contained in:
Ashley Harrison
2022-11-09 14:44:38 +00:00
committed by GitHub
co-authored by Torkel Ödegaard
parent cced175ccf
commit 159607fe6f
19 changed files with 287 additions and 184 deletions
-3
View File
@@ -4389,9 +4389,6 @@ exports[`better eslint`] = {
"public/app/features/plugins/admin/components/PluginDetailsBody.tsx:5381": [
[0, 0, 0, "Do not use any type assertions.", "0"]
],
"public/app/features/plugins/admin/components/PluginDetailsHeader.tsx:5381": [
[0, 0, 0, "Unexpected any. Specify a different type.", "0"]
],
"public/app/features/plugins/admin/components/SearchField.tsx:5381": [
[0, 0, 0, "Unexpected any. Specify a different type.", "0"]
],
@@ -42,8 +42,6 @@ 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 {
@@ -111,7 +111,6 @@ export const PageHeader: FC<Props> = ({ navItem: model, renderTitle, actions, in
: renderHeaderTitle(main.text, main.breadcrumbs ?? [], main.highlightText)}
{info && <PageInfo info={info} />}
{sub && <div className="page-header__sub-title">{sub}</div>}
{main.headerExtra && <main.headerExtra />}
{actions && <div className={styles.actions}>{actions}</div>}
</div>
</div>
@@ -36,7 +36,6 @@ export function PageHeader({ navItem, renderTitle, actions, info, subTitle }: Pr
<div className={styles.actions}>{actions}</div>
</div>
{sub && <div className={styles.subTitle}>{sub}</div>}
{navItem.headerExtra && <navItem.headerExtra />}
</div>
);
}
@@ -44,6 +43,7 @@ export function PageHeader({ navItem, renderTitle, actions, info, subTitle }: Pr
const getStyles = (theme: GrafanaTheme2) => {
return {
topRow: css({
alignItems: 'center',
display: 'flex',
flexDirection: 'row',
flexWrap: 'wrap',
@@ -68,7 +68,7 @@ export function InstallControlsButton({ plugin, pluginStatus, latestCompatibleVe
onConfirm={onUninstall}
onDismiss={hideConfirmModal}
/>
<HorizontalGroup height="auto">
<HorizontalGroup align="flex-start" width="auto" height="auto">
<Button variant="destructive" disabled={isUninstalling} onClick={showConfirmModal}>
{uninstallBtnText}
</Button>
@@ -79,7 +79,7 @@ export function InstallControlsButton({ plugin, pluginStatus, latestCompatibleVe
if (pluginStatus === PluginStatus.UPDATE) {
return (
<HorizontalGroup height="auto">
<HorizontalGroup align="flex-start" width="auto" height="auto">
<Button disabled={isInstalling} onClick={onUpdate}>
{isInstalling ? 'Updating' : 'Update'}
</Button>
@@ -7,36 +7,23 @@ import { HorizontalGroup, Icon, LinkButton, useStyles2 } from '@grafana/ui';
import { contextSrv } from 'app/core/core';
import { AccessControlAction } from 'app/types';
import { getExternalManageLink, isInstallControlsEnabled } from '../../helpers';
import { getExternalManageLink } from '../../helpers';
import { isGrafanaAdmin } from '../../permissions';
import { useIsRemotePluginsAvailable } from '../../state/hooks';
import { CatalogPlugin, PluginStatus, Version } from '../../types';
import { ExternallyManagedButton } from './ExternallyManagedButton';
import { InstallControlsButton } from './InstallControlsButton';
interface Props {
plugin: CatalogPlugin;
pluginStatus: PluginStatus;
latestCompatibleVersion?: Version;
}
export const InstallControls = ({ plugin, latestCompatibleVersion }: Props) => {
export const InstallControlsWarning = ({ plugin, pluginStatus, latestCompatibleVersion }: Props) => {
const styles = useStyles2(getStyles);
const isExternallyManaged = config.pluginAdminExternalManageEnabled;
const hasPermission = contextSrv.hasAccess(AccessControlAction.PluginsInstall, isGrafanaAdmin());
const isRemotePluginsAvailable = useIsRemotePluginsAvailable();
const isCompatible = Boolean(latestCompatibleVersion);
const isInstallControlsDisabled = plugin.isCore || plugin.isDisabled || !isInstallControlsEnabled();
const pluginStatus = plugin.isInstalled
? plugin.hasUpdate
? PluginStatus.UPDATE
: PluginStatus.UNINSTALL
: PluginStatus.INSTALL;
if (isInstallControlsDisabled) {
return null;
}
if (plugin.type === PluginType.renderer) {
return <div className={styles.message}>Renderer plugins cannot be managed by the Plugin Catalog.</div>;
@@ -96,10 +83,6 @@ export const InstallControls = ({ plugin, latestCompatibleVersion }: Props) => {
);
}
if (isExternallyManaged) {
return <ExternallyManagedButton pluginId={plugin.id} pluginStatus={pluginStatus} />;
}
if (!isRemotePluginsAvailable) {
return (
<div className={styles.message}>
@@ -108,13 +91,7 @@ export const InstallControls = ({ plugin, latestCompatibleVersion }: Props) => {
);
}
return (
<InstallControlsButton
plugin={plugin}
pluginStatus={pluginStatus}
latestCompatibleVersion={latestCompatibleVersion}
/>
);
return null;
};
export const getStyles = (theme: GrafanaTheme2) => {
@@ -1 +1,2 @@
export * from './InstallControls';
export { InstallControlsWarning } from './InstallControlsWarning';
export { InstallControlsButton } from './InstallControlsButton';
@@ -0,0 +1,50 @@
import React from 'react';
import { config } from '@grafana/runtime';
import { GetStartedWithPlugin } from '../components/GetStartedWithPlugin';
import { InstallControlsButton } from '../components/InstallControls';
import { ExternallyManagedButton } from '../components/InstallControls/ExternallyManagedButton';
import { getLatestCompatibleVersion, hasInstallControlWarning, isInstallControlsEnabled } from '../helpers';
import { useIsRemotePluginsAvailable } from '../state/hooks';
import { CatalogPlugin, PluginStatus } from '../types';
interface Props {
plugin?: CatalogPlugin;
}
export const PluginActions = ({ plugin }: Props) => {
const isRemotePluginsAvailable = useIsRemotePluginsAvailable();
const latestCompatibleVersion = getLatestCompatibleVersion(plugin?.details?.versions);
if (!plugin || hasInstallControlWarning(plugin, isRemotePluginsAvailable, latestCompatibleVersion)) {
return null;
}
const isExternallyManaged = config.pluginAdminExternalManageEnabled;
const pluginStatus = plugin.isInstalled
? plugin.hasUpdate
? PluginStatus.UPDATE
: PluginStatus.UNINSTALL
: PluginStatus.INSTALL;
const isInstallControlsDisabled = plugin.isCore || plugin.isDisabled || !isInstallControlsEnabled();
return (
<>
{!isInstallControlsDisabled && (
<>
{isExternallyManaged ? (
<ExternallyManagedButton pluginId={plugin.id} pluginStatus={pluginStatus} />
) : (
<InstallControlsButton
plugin={plugin}
latestCompatibleVersion={latestCompatibleVersion}
pluginStatus={pluginStatus}
/>
)}
</>
)}
<GetStartedWithPlugin plugin={plugin} />
</>
);
};
@@ -1,125 +0,0 @@
import { css } from '@emotion/css';
import React from 'react';
import { GrafanaTheme2 } from '@grafana/data';
import { useStyles2, Icon, HorizontalGroup } from '@grafana/ui';
import { getLatestCompatibleVersion } from '../helpers';
import { CatalogPlugin } from '../types';
import { PluginDisabledBadge } from './Badges';
import { GetStartedWithPlugin } from './GetStartedWithPlugin';
import { InstallControls } from './InstallControls';
import { PluginDetailsHeaderDependencies } from './PluginDetailsHeaderDependencies';
import { PluginDetailsHeaderSignature } from './PluginDetailsHeaderSignature';
type Props = {
plugin: CatalogPlugin;
};
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 className={styles.headerContainer}>
{plugin.description && <div className={styles.description}>{plugin.description}</div>}
<div className={styles.headerInformationRow}>
{/* Version */}
{Boolean(version) && <span>Version: {version}</span>}
{/* Org name */}
<span>From: {plugin.orgName}</span>
{/* 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>
)}
{/* Signature information */}
<PluginDetailsHeaderSignature plugin={plugin} />
{plugin.isDisabled && <PluginDisabledBadge error={plugin.error!} />}
<PluginDetailsHeaderDependencies plugin={plugin} latestCompatibleVersion={latestCompatibleVersion} />
</div>
<HorizontalGroup height="auto">
<InstallControls plugin={plugin} latestCompatibleVersion={latestCompatibleVersion} />
<GetStartedWithPlugin plugin={plugin} />
</HorizontalGroup>
</div>
);
}
export const getStyles = (theme: GrafanaTheme2) => {
return {
headerContainer: css`
display: flex;
flex-direction: column;
margin-bottom: ${theme.spacing(1)};
`,
description: css`
margin-bottom: ${theme.spacing(1)};
`,
breadcrumb: css`
font-size: ${theme.typography.h2.fontSize};
li {
display: inline;
list-style: none;
&::after {
content: '/';
padding: 0 0.25ch;
}
&:last-child::after {
content: '';
}
}
`,
headerInformationRow: css`
display: flex;
align-items: center;
margin-bottom: ${theme.spacing(1)};
flex-flow: wrap;
& > * {
&::after {
content: '|';
padding: 0 ${theme.spacing()};
}
&:last-child::after {
content: '';
padding-right: 0;
}
}
a {
&:hover {
text-decoration: underline;
}
}
`,
headerOrgName: css`
font-size: ${theme.typography.h4.fontSize};
`,
signature: css`
margin: ${theme.spacing(3)};
margin-bottom: 0;
`,
textUnderline: css`
text-decoration: underline;
`,
};
};
@@ -31,11 +31,9 @@ export function PluginDetailsHeaderDependencies({
return (
<Stack gap={1}>
<div className={styles.dependencyTitle}>Dependencies:</div>
{/* Grafana dependency */}
{Boolean(grafanaDependency) && (
<div>
<div className={styles.depBadge}>
<Icon name="grafana" className={styles.icon} />
Grafana {grafanaDependency}
</div>
@@ -46,7 +44,7 @@ export function PluginDetailsHeaderDependencies({
<div>
{pluginDependencies.map((p) => {
return (
<span key={p.name}>
<span className={styles.depBadge} key={p.name}>
<Icon name={PluginIconName[p.type]} className={styles.icon} />
{p.name} {p.version}
</span>
@@ -68,6 +66,10 @@ export const getStyles = (theme: GrafanaTheme2) => {
padding: 0;
}
`,
depBadge: css({
display: 'flex',
alignItems: 'flex-start',
}),
icon: css`
color: ${theme.colors.text.secondary};
margin-right: ${theme.spacing(0.5)};
@@ -39,6 +39,8 @@ export const getStyles = (theme: GrafanaTheme2) => {
return {
container: css`
display: flex;
flex-wrap: wrap;
gap: ${theme.spacing(0.5)};
`,
link: css`
display: inline-flex;
@@ -31,10 +31,12 @@ export function PluginSignatureDetailsBadge({ signatureType, signatureOrg = '' }
return (
<>
<DetailsBadge>
<strong className={styles.strong}>Level:&nbsp;</strong>
<Icon size="xs" name={signatureIcon} />
&nbsp;
{signatureTypeText}
<div className={styles.detailsWrapper}>
<strong className={styles.strong}>Level:&nbsp;</strong>
<Icon size="xs" name={signatureIcon} />
&nbsp;
{signatureTypeText}
</div>
</DetailsBadge>
<DetailsBadge>
@@ -47,7 +49,7 @@ export function PluginSignatureDetailsBadge({ signatureType, signatureOrg = '' }
export const DetailsBadge = ({ children }: React.PropsWithChildren<{}>) => {
const styles = useStyles2(getStyles);
return <Badge color="green" className={styles.badge} text={<>{children}</>} />;
return <Badge color="green" className={styles.badge} text={children} />;
};
const getStyles = (theme: GrafanaTheme2) => ({
@@ -55,7 +57,11 @@ const getStyles = (theme: GrafanaTheme2) => ({
background-color: ${theme.colors.background.canvas};
border-color: ${theme.colors.border.strong};
color: ${theme.colors.text.secondary};
margin-left: ${theme.spacing()};
white-space: nowrap;
`,
detailsWrapper: css`
align-items: center;
display: flex;
`,
strong: css`
color: ${theme.colors.text.primary};
@@ -0,0 +1,63 @@
import { css } from '@emotion/css';
import React from 'react';
import { GrafanaTheme2 } from '@grafana/data';
import { useStyles2 } from '@grafana/ui';
import { InstallControlsWarning } from '../components/InstallControls';
import { getLatestCompatibleVersion, hasInstallControlWarning } from '../helpers';
import { useIsRemotePluginsAvailable } from '../state/hooks';
import { CatalogPlugin, PluginStatus } from '../types';
interface Props {
plugin?: CatalogPlugin;
}
export const PluginSubtitle = ({ plugin }: Props) => {
const isRemotePluginsAvailable = useIsRemotePluginsAvailable();
const styles = useStyles2(getStyles);
if (!plugin) {
return null;
}
const latestCompatibleVersion = getLatestCompatibleVersion(plugin.details?.versions);
const pluginStatus = plugin.isInstalled
? plugin.hasUpdate
? PluginStatus.UPDATE
: PluginStatus.UNINSTALL
: PluginStatus.INSTALL;
return (
<div className={styles.subtitle}>
{plugin?.description && <div>{plugin?.description}</div>}
{plugin?.details?.links && plugin.details.links.length > 0 && (
<span>
{plugin.details.links.map((link, index) => (
<React.Fragment key={index}>
{index > 0 && ' | '}
<a href={link.url} className="external-link">
{link.name}
</a>
</React.Fragment>
))}
</span>
)}
{hasInstallControlWarning(plugin, isRemotePluginsAvailable, latestCompatibleVersion) && (
<InstallControlsWarning
plugin={plugin}
pluginStatus={pluginStatus}
latestCompatibleVersion={latestCompatibleVersion}
/>
)}
</div>
);
};
export const getStyles = (theme: GrafanaTheme2) => {
return {
subtitle: css`
display: flex;
flex-direction: column;
gap: ${theme.spacing(1)};
`,
};
};
+24 -1
View File
@@ -1,8 +1,11 @@
import { PluginSignatureStatus, dateTimeParse, PluginError, PluginType, PluginErrorCode } from '@grafana/data';
import { config } from '@grafana/runtime';
import { config, featureEnabled } from '@grafana/runtime';
import { Settings } from 'app/core/config';
import { contextSrv } from 'app/core/core';
import { getBackendSrv } from 'app/core/services/backend_srv';
import { AccessControlAction } from 'app/types';
import { isGrafanaAdmin } from './permissions';
import { CatalogPlugin, LocalPlugin, RemotePlugin, Version } from './types';
export function mergeLocalsAndRemotes(
@@ -269,6 +272,26 @@ export function getLatestCompatibleVersion(versions: Version[] | undefined): Ver
export const isInstallControlsEnabled = () => config.pluginAdminEnabled;
export const hasInstallControlWarning = (
plugin: CatalogPlugin,
isRemotePluginsAvailable: boolean,
latestCompatibleVersion?: Version
) => {
const isExternallyManaged = config.pluginAdminExternalManageEnabled;
const hasPermission = contextSrv.hasAccess(AccessControlAction.PluginsInstall, isGrafanaAdmin());
const isCompatible = Boolean(latestCompatibleVersion);
return (
plugin.type === PluginType.renderer ||
plugin.type === PluginType.secretsmanager ||
(plugin.isEnterprise && !featureEnabled('enterprise.plugins')) ||
plugin.isDev ||
(!hasPermission && !isExternallyManaged) ||
!plugin.isPublished ||
!isCompatible ||
!isRemotePluginsAvailable
);
};
export const isLocalPluginVisible = (p: LocalPlugin) => isPluginVisible(p.id);
export const isRemotePluginVisible = (p: RemotePlugin) => isPluginVisible(p.slug);
@@ -1,4 +1,4 @@
import React, { useMemo } from 'react';
import { useMemo } from 'react';
import { useLocation } from 'react-router-dom';
import { GrafanaPlugin, NavModelItem, PluginIncludeType, PluginType } from '@grafana/data';
@@ -6,7 +6,6 @@ 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, PluginTabIds, PluginTabLabels } from '../types';
@@ -110,9 +109,6 @@ export const usePluginDetailsTabs = (plugin?: CatalogPlugin, pageId?: PluginTabI
},
...navModelChildren,
],
headerExtra: () => {
return plugin ? <PluginDetailsHeader plugin={plugin} /> : null;
},
};
return {
@@ -0,0 +1,85 @@
import { css } from '@emotion/css';
import React from 'react';
import { GrafanaTheme2, PluginSignatureType } from '@grafana/data';
import { PageInfoItem } from '../../../../core/components/Page/types';
import { PluginDisabledBadge } from '../components/Badges';
import { PluginDetailsHeaderDependencies } from '../components/PluginDetailsHeaderDependencies';
import { PluginDetailsHeaderSignature } from '../components/PluginDetailsHeaderSignature';
import { getLatestCompatibleVersion } from '../helpers';
import { CatalogPlugin } from '../types';
export const usePluginInfo = (plugin?: CatalogPlugin): PageInfoItem[] => {
const info: PageInfoItem[] = [];
if (!plugin) {
return info;
}
// Populate info
const latestCompatibleVersion = getLatestCompatibleVersion(plugin.details?.versions);
const version = plugin.installedVersion || latestCompatibleVersion?.version;
if (Boolean(version)) {
info.push({
label: 'Version',
value: version,
});
}
if (Boolean(plugin.orgName)) {
info.push({
label: 'From',
value: plugin.orgName,
});
}
const showDownloads =
!plugin.signatureType ||
plugin.signatureType === PluginSignatureType.community ||
plugin.signatureType === PluginSignatureType.commercial;
if (showDownloads && Boolean(plugin.downloads > 0)) {
info.push({
label: 'Downloads',
value: new Intl.NumberFormat().format(plugin.downloads),
});
}
const pluginDependencies = plugin.details?.pluginDependencies;
const grafanaDependency = plugin.isInstalled
? plugin.details?.grafanaDependency
: latestCompatibleVersion?.grafanaDependency || plugin.details?.grafanaDependency;
const hasNoDependencyInfo = !grafanaDependency && (!pluginDependencies || !pluginDependencies.length);
if (!hasNoDependencyInfo) {
info.push({
label: 'Dependencies',
value: <PluginDetailsHeaderDependencies plugin={plugin} latestCompatibleVersion={latestCompatibleVersion} />,
});
}
if (plugin.isDisabled) {
info.push({
label: 'Status',
value: <PluginDisabledBadge error={plugin.error!} />,
});
}
info.push({
label: 'Signature',
value: <PluginDetailsHeaderSignature plugin={plugin} />,
});
return info;
};
export const getStyles = (theme: GrafanaTheme2) => {
return {
subtitle: css`
display: flex;
flex-direction: column;
gap: ${theme.spacing(1)};
`,
};
};
@@ -0,0 +1,24 @@
import React from 'react';
import { PageInfoItem } from '../../../../core/components/Page/types';
import { PluginActions } from '../components/PluginActions';
import { PluginSubtitle } from '../components/PluginSubtitle';
import { CatalogPlugin } from '../types';
import { usePluginInfo } from './usePluginInfo';
type ReturnType = {
actions: React.ReactNode;
info: PageInfoItem[];
subtitle: React.ReactNode;
};
export const usePluginPageExtensions = (plugin?: CatalogPlugin): ReturnType => {
const info = usePluginInfo(plugin);
return {
actions: <PluginActions plugin={plugin} />,
info,
subtitle: <PluginSubtitle plugin={plugin} />,
};
};
@@ -214,7 +214,7 @@ describe('Plugin details page', () => {
const installedVersion = '1.3.443';
const { queryByText } = renderPluginDetails({ id, installedVersion });
expect(await queryByText(`Version: ${installedVersion}`)).toBeInTheDocument();
expect(await queryByText(`${installedVersion}`)).toBeInTheDocument();
});
it('should display the latest compatible version in the header if a plugin is not installed', async () => {
@@ -230,7 +230,7 @@ describe('Plugin details page', () => {
};
const { findByText, queryByText } = renderPluginDetails({ id, details });
expect(await findByText('Version: 1.1.1')).toBeInTheDocument();
expect(await findByText('1.1.1')).toBeInTheDocument();
expect(queryByText(/>=8.0.0/i)).toBeInTheDocument();
});
@@ -430,9 +430,7 @@ describe('Plugin details page', () => {
});
// Wait for the dependencies part to be loaded
expect(await queryByText(/dependencies:/i)).toBeInTheDocument();
expect(queryByText('Grafana >=8.0.0')).toBeInTheDocument();
expect(await queryByText('Grafana >=8.0.0')).toBeInTheDocument();
});
it('should show a confirm modal when trying to uninstall a plugin', async () => {
@@ -13,6 +13,7 @@ import { PluginDetailsBody } from '../components/PluginDetailsBody';
import { PluginDetailsDisabledError } from '../components/PluginDetailsDisabledError';
import { PluginDetailsSignature } from '../components/PluginDetailsSignature';
import { usePluginDetailsTabs } from '../hooks/usePluginDetailsTabs';
import { usePluginPageExtensions } from '../hooks/usePluginPageExtensions';
import { useGetSingle, useFetchStatus, useFetchDetailsStatus } from '../state/hooks';
import { PluginTabIds } from '../types';
@@ -27,6 +28,7 @@ export default function PluginDetails({ match, queryParams }: Props): JSX.Elemen
const plugin = useGetSingle(pluginId); // fetches the localplugin settings
const { navModel, activePageId } = usePluginDetailsTabs(plugin, queryParams.page as PluginTabIds);
const { actions, info, subtitle } = usePluginPageExtensions(plugin);
const { isLoading: isFetchLoading } = useFetchStatus();
const { isLoading: isFetchDetailsLoading } = useFetchDetailsStatus();
const styles = useStyles2(getStyles);
@@ -51,7 +53,7 @@ export default function PluginDetails({ match, queryParams }: Props): JSX.Elemen
}
return (
<Page navId="plugins" pageNav={navModel}>
<Page navId="plugins" pageNav={navModel} actions={actions} subTitle={subtitle} info={info}>
<Page.Contents>
<TabContent className={styles.tabContent}>
<PluginDetailsSignature plugin={plugin} className={styles.alert} />
@@ -68,6 +70,11 @@ export const getStyles = (theme: GrafanaTheme2) => {
alert: css`
margin-bottom: ${theme.spacing(2)};
`,
subtitle: css`
display: flex;
flex-direction: column;
gap: ${theme.spacing(1)};
`,
// Needed due to block formatting context
tabContent: css`
overflow: auto;