diff --git a/packages/grafana-data/src/types/icon.ts b/packages/grafana-data/src/types/icon.ts index c58967fa1dc..74f9c258a33 100644 --- a/packages/grafana-data/src/types/icon.ts +++ b/packages/grafana-data/src/types/icon.ts @@ -44,6 +44,7 @@ export const availableIconsIndex = { 'check-circle': true, 'check-square': true, circle: true, + 'circle-mono': true, 'clipboard-alt': true, 'clock-nine': true, cloud: true, diff --git a/packages/grafana-ui/src/components/Icon/utils.ts b/packages/grafana-ui/src/components/Icon/utils.ts index f7ead12d92c..d31311a54d5 100644 --- a/packages/grafana-ui/src/components/Icon/utils.ts +++ b/packages/grafana-ui/src/components/Icon/utils.ts @@ -1,6 +1,14 @@ import { IconName, IconSize } from '../../types/icon'; -const alwaysMonoIcons: IconName[] = ['grafana', 'favorite', 'heart-break', 'heart', 'panel-add', 'library-panel']; +const alwaysMonoIcons: IconName[] = [ + 'grafana', + 'favorite', + 'heart-break', + 'heart', + 'panel-add', + 'library-panel', + 'circle-mono', +]; export function getIconSubDir(name: IconName, type: string): string { if (name?.startsWith('gf-')) { diff --git a/packages/grafana-ui/src/components/PanelChrome/PanelChrome.story.tsx b/packages/grafana-ui/src/components/PanelChrome/PanelChrome.story.tsx index ab52fc5b442..d6f4b3d0493 100644 --- a/packages/grafana-ui/src/components/PanelChrome/PanelChrome.story.tsx +++ b/packages/grafana-ui/src/components/PanelChrome/PanelChrome.story.tsx @@ -12,8 +12,6 @@ import { withCenteredStory } from '../../utils/storybook/withCenteredStory'; import { HorizontalGroup, VerticalGroup } from '../Layout/Layout'; import { Menu } from '../Menu/Menu'; -import { PanelChromeInfoState } from './PanelChrome'; - const meta: ComponentMeta = { title: 'Visualizations/PanelChrome', component: PanelChrome, @@ -235,29 +233,11 @@ const ErrorIcon = [ const leftItems = { LoadingIcon, ErrorIcon, Default }; -const titleItems: PanelChromeInfoState[] = [ - { - icon: 'info', - tooltip: - 'Description text with very long descriptive words that describe what is going on in the panel and not beyond. Or maybe beyond, not up to us.', - }, - { - icon: 'external-link-alt', - tooltip: 'wearegoingonanadventure.openanewtab.maybe', - onClick: () => {}, - }, - { - icon: 'clock-nine', - tooltip: 'Time range: 2021-09-01 00:00:00 to 2021-09-01 00:00:00', - onClick: () => {}, - }, - { - icon: 'heart', - tooltip: 'Health of the panel', - }, -]; +const description = + 'Description text with very long descriptive words that describe what is going on in the panel and not beyond. Or maybe beyond, not up to us.'; Basic.argTypes = { + description: { control: { type: 'text' } }, leftItems: { options: Object.keys(leftItems), mapping: leftItems, @@ -276,9 +256,8 @@ Basic.args = { width: 400, height: 200, title: 'Very long title that should get ellipsis when there is no more space', - titleItems, + description, menu, - loadingState: LoadingState.Loading, }; export default meta; diff --git a/packages/grafana-ui/src/components/PanelChrome/PanelChrome.test.tsx b/packages/grafana-ui/src/components/PanelChrome/PanelChrome.test.tsx index 6c4d000b151..2d2169e4812 100644 --- a/packages/grafana-ui/src/components/PanelChrome/PanelChrome.test.tsx +++ b/packages/grafana-ui/src/components/PanelChrome/PanelChrome.test.tsx @@ -49,13 +49,7 @@ it('renders panel with a header with title in place if prop title', () => { it('renders panel with a header if prop titleItems', () => { setup({ - titleItems: [ - { - icon: 'info-circle', - tooltip: 'This is the panel description', - onClick: () => {}, - }, - ], + titleItems: [
This should be a self-contained node
], }); expect(screen.getByTestId('header-container')).toBeInTheDocument(); @@ -63,13 +57,7 @@ it('renders panel with a header if prop titleItems', () => { it('renders panel with a header with icons in place if prop titleItems', () => { setup({ - titleItems: [ - { - icon: 'info-circle', - tooltip: 'This is the panel description', - onClick: () => {}, - }, - ], + titleItems: [
This should be a self-contained node
], }); expect(screen.getByTestId('title-items-container')).toBeInTheDocument(); diff --git a/packages/grafana-ui/src/components/PanelChrome/PanelChrome.tsx b/packages/grafana-ui/src/components/PanelChrome/PanelChrome.tsx index 1b94df2f5f6..d2f2b3716a6 100644 --- a/packages/grafana-ui/src/components/PanelChrome/PanelChrome.tsx +++ b/packages/grafana-ui/src/components/PanelChrome/PanelChrome.tsx @@ -1,18 +1,18 @@ import { css, cx } from '@emotion/css'; import { isEmpty } from 'lodash'; -import React, { CSSProperties, ReactElement, ReactNode } from 'react'; +import React, { CSSProperties, ReactNode, ReactElement } from 'react'; -import { GrafanaTheme2, isIconName, LoadingState } from '@grafana/data'; +import { GrafanaTheme2, LoadingState } from '@grafana/data'; +import { selectors } from '@grafana/e2e-selectors'; import { useStyles2, useTheme2 } from '../../themes'; -import { IconName } from '../../types/icon'; import { Dropdown } from '../Dropdown/Dropdown'; import { Icon } from '../Icon/Icon'; -import { IconButton, IconButtonVariant } from '../IconButton/IconButton'; import { LoadingBar } from '../LoadingBar/LoadingBar'; import { ToolbarButton } from '../ToolbarButton'; -import { PopoverContent, Tooltip } from '../Tooltip'; +import { Tooltip } from '../Tooltip'; +import { PanelDescription } from './PanelDescription'; import { PanelStatus } from './PanelStatus'; interface Status { @@ -20,17 +20,6 @@ interface Status { onClick?: (e: React.SyntheticEvent) => void; } -/** - * @internal - */ -export interface PanelChromeInfoState { - icon: IconName; - label?: string | ReactNode; - tooltip?: PopoverContent; - variant?: IconButtonVariant; - onClick?: () => void; -} - /** * @internal */ @@ -40,7 +29,8 @@ export interface PanelChromeProps { children: (innerWidth: number, innerHeight: number) => ReactNode; padding?: PanelPadding; title?: string; - titleItems?: PanelChromeInfoState[]; + description?: string | (() => string); + titleItems?: ReactNode[]; menu?: ReactElement | (() => ReactElement); /** dragClass, hoverHeader not yet implemented */ // dragClass?: string; @@ -69,6 +59,7 @@ export function PanelChrome({ children, padding = 'md', title = '', + description = '', titleItems = [], menu, // dragClass, @@ -82,7 +73,16 @@ export function PanelChrome({ // To Do rely on hoverHeader prop for header, not separate props // once hoverHeader is implemented - const hasHeader = title.length > 0 || leftItems.length > 0; + // + // Backwards compatibility for having a designated space for the header + + const hasHeader = + hoverHeader === false && + (title.length > 0 || + titleItems.length > 0 || + description !== '' || + loadingState === LoadingState.Streaming || + leftItems.length > 0); const headerHeight = getHeaderHeight(theme, hasHeader); const { contentStyle, innerWidth, innerHeight } = getContentStyle(padding, theme, width, headerHeight, height); @@ -114,8 +114,10 @@ export function PanelChrome({ return null; } }; + + const ariaLabel = title ? selectors.components.Panels.Panel.containerByTitle(title) : 'Panel'; return ( -
+
{showLoading ? : null}
@@ -127,29 +129,19 @@ export function PanelChrome({ )} - {showStreaming && ( -
- - - + + + {titleItems && ( +
+ {titleItems.map((item) => item)}
)} - {titleItems.length > 0 && ( -
- {titleItems - .filter((item) => isIconName(item.icon)) - .map((item, i) => ( -
- {item.onClick ? ( - - ) : ( - - - - )} -
- ))} + {showStreaming && ( +
+ + +
)} @@ -172,7 +164,6 @@ export function PanelChrome({ {renderStatus()}
-
{children(innerWidth, innerHeight)}
@@ -299,5 +290,11 @@ const getStyles = (theme: GrafanaTheme2) => { display: 'flex', alignItems: 'center', }), + titleItems: css({ + display: 'flex', + alignItems: 'center', + overflow: 'hidden', + padding: theme.spacing(1), + }), }; }; diff --git a/packages/grafana-ui/src/components/PanelChrome/PanelDescription.tsx b/packages/grafana-ui/src/components/PanelChrome/PanelDescription.tsx new file mode 100644 index 00000000000..be82080da43 --- /dev/null +++ b/packages/grafana-ui/src/components/PanelChrome/PanelDescription.tsx @@ -0,0 +1,75 @@ +import { css } from '@emotion/css'; +import React from 'react'; + +import { GrafanaTheme2 } from '@grafana/data'; + +import { useTheme2 } from '../../themes'; +import { getFocusStyles, getMouseFocusStyles } from '../../themes/mixins'; +import { Icon } from '../Icon/Icon'; +import { Tooltip } from '../Tooltip'; + +interface Props { + description: string | (() => string); +} + +export function PanelDescription({ description }: Props) { + const theme = useTheme2(); + const styles = getStyles(theme); + + const getDescriptionContent = (): JSX.Element => { + // description + const panelDescription = typeof description === 'function' ? description() : description; + + return ( +
+
+
+ ); + }; + + return description !== '' ? ( + + + + + + ) : null; +} + +const getStyles = (theme: GrafanaTheme2) => { + return { + description: css({ + color: `${theme.colors.text.secondary}`, + backgroundColor: `${theme.colors.background.primary}`, + cursor: 'auto', + border: 'none', + borderRadius: `${theme.shape.borderRadius()}`, + padding: `${theme.spacing(0, 1)}`, + height: ` ${theme.spacing(theme.components.height.md)}`, + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + + '&:focus, &:focus-visible': { + ...getFocusStyles(theme), + zIndex: 1, + }, + '&: focus:not(:focus-visible)': getMouseFocusStyles(theme), + + '&:hover ': { + boxShadow: `${theme.shadows.z1}`, + color: `${theme.colors.text.primary}`, + background: `${theme.colors.background.secondary}`, + }, + + code: { + whiteSpace: 'normal', + wordWrap: 'break-word', + }, + + 'pre > code': { + display: 'block', + }, + }), + }; +}; diff --git a/packages/grafana-ui/src/components/PanelChrome/index.ts b/packages/grafana-ui/src/components/PanelChrome/index.ts index 89f7e995c40..22cf206bfbd 100644 --- a/packages/grafana-ui/src/components/PanelChrome/index.ts +++ b/packages/grafana-ui/src/components/PanelChrome/index.ts @@ -38,6 +38,8 @@ export { type ErrorIndicatorProps as PanelChromeErrorIndicatorProps, } from './ErrorIndicator'; +export { PanelDescription } from './PanelDescription'; + export { usePanelContext, PanelContextProvider, type PanelContext, PanelContextRoot } from './PanelContext'; export * from './types'; diff --git a/packages/grafana-ui/src/components/Tooltip/Tooltip.tsx b/packages/grafana-ui/src/components/Tooltip/Tooltip.tsx index 239002b718b..b5e3a78cab7 100644 --- a/packages/grafana-ui/src/components/Tooltip/Tooltip.tsx +++ b/packages/grafana-ui/src/components/Tooltip/Tooltip.tsx @@ -58,6 +58,7 @@ export const Tooltip = React.memo(({ children, theme, interactive, show, placeme <> {React.cloneElement(children, { ref: setTriggerRef, + tabIndex: 0, // tooltip should be keyboard focusable })} {visible && ( diff --git a/public/app/features/dashboard/dashgrid/PanelHeader/PanelHeaderNotice.tsx b/public/app/features/dashboard/dashgrid/PanelHeader/PanelHeaderNotice.tsx index 18e13ca1ccc..a59680e1290 100644 --- a/public/app/features/dashboard/dashgrid/PanelHeader/PanelHeaderNotice.tsx +++ b/public/app/features/dashboard/dashgrid/PanelHeader/PanelHeaderNotice.tsx @@ -1,7 +1,9 @@ +import { css } from '@emotion/css'; import React, { FC } from 'react'; -import { QueryResultMetaNotice } from '@grafana/data'; -import { Icon, Tooltip } from '@grafana/ui'; +import { GrafanaTheme2, QueryResultMetaNotice } from '@grafana/data'; +import { Icon, ToolbarButton, Tooltip, useStyles2 } from '@grafana/ui'; +import { getFocusStyles, getMouseFocusStyles } from '@grafana/ui/src/themes/mixins'; interface Props { notice: QueryResultMetaNotice; @@ -9,20 +11,67 @@ interface Props { } export const PanelHeaderNotice: FC = ({ notice, onClick }) => { + const styles = useStyles2(getStyles); + const iconName = notice.severity === 'error' || notice.severity === 'warning' ? 'exclamation-triangle' : 'info-circle'; + if (notice.inspect && onClick) { + return ( + onClick(e, notice.inspect!)} + /> + ); + } + + if (notice.link) { + return ( + + + + ); + } + return ( - - {notice.inspect ? ( -
onClick(e, notice.inspect!)}> - -
- ) : ( - - - - )} + + + + ); }; + +const getStyles = (theme: GrafanaTheme2) => ({ + notice: css({ + border: 'none', + borderRadius: theme.shape.borderRadius(), + }), + iconTooltip: css({ + color: `${theme.colors.text.secondary}`, + backgroundColor: `${theme.colors.background.primary}`, + cursor: 'auto', + border: 'none', + borderRadius: `${theme.shape.borderRadius()}`, + padding: `${theme.spacing(0, 1)}`, + height: ` ${theme.spacing(theme.components.height.md)}`, + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + + '&:focus, &:focus-visible': { + ...getFocusStyles(theme), + zIndex: 1, + }, + '&: focus:not(:focus-visible)': getMouseFocusStyles(theme), + + '&:hover ': { + boxShadow: `${theme.shadows.z1}`, + color: `${theme.colors.text.primary}`, + background: `${theme.colors.background.secondary}`, + }, + }), +}); diff --git a/public/app/features/dashboard/dashgrid/PanelHeader/PanelHeaderTitleItems.tsx b/public/app/features/dashboard/dashgrid/PanelHeader/PanelHeaderTitleItems.tsx new file mode 100644 index 00000000000..91967b2ab52 --- /dev/null +++ b/public/app/features/dashboard/dashgrid/PanelHeader/PanelHeaderTitleItems.tsx @@ -0,0 +1,107 @@ +import { css, cx } from '@emotion/css'; +import React from 'react'; + +import { PanelData, GrafanaTheme2, PanelModel, LinkModel, AlertState, DataLink } from '@grafana/data'; +import { Icon, Tooltip, useStyles2 } from '@grafana/ui'; +import { getFocusStyles, getMouseFocusStyles } from '@grafana/ui/src/themes/mixins'; + +import { PanelLinks } from '../PanelLinks'; + +import { PanelHeaderNotices } from './PanelHeaderNotices'; + +export interface Props { + alertState?: string; + data: PanelData; + panelId: number; + onShowPanelLinks?: () => Array>; + panelLinks?: DataLink[]; +} + +export function PanelHeaderTitleItems(props: Props) { + const { alertState, data, panelId, onShowPanelLinks, panelLinks } = props; + const styles = useStyles2(getStyles); + + // panel health + const alertStateItem = ( + + + + + + ); + + const timeshift = ( + <> + + + + {data.request?.timeInfo} + + + + ); + + return ( + <> + {panelLinks && panelLinks.length > 0 && onShowPanelLinks && ( + + )} + + {} + {data.request && data.request.timeInfo && timeshift} + {alertState && alertStateItem} + + ); +} + +const getStyles = (theme: GrafanaTheme2) => { + return { + item: css({ + label: 'panel-header-item', + backgroundColor: `${theme.colors.background.primary}`, + cursor: 'auto', + border: 'none', + borderRadius: `${theme.shape.borderRadius()}`, + padding: `${theme.spacing(0, 1)}`, + height: `${theme.spacing(theme.components.height.md)}`, + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + + '&:focus, &:focus-visible': { + ...getFocusStyles(theme), + zIndex: 1, + }, + '&: focus:not(:focus-visible)': getMouseFocusStyles(theme), + + '&:hover ': { + boxShadow: `${theme.shadows.z1}`, + background: `${theme.colors.background.secondary}`, + }, + }), + ok: css({ + color: theme.colors.success.text, + }), + pending: css({ + color: theme.colors.warning.text, + }), + alerting: css({ + color: theme.colors.error.text, + }), + timeshift: css({ + color: theme.colors.text.link, + + '&:hover': { + color: theme.colors.emphasize(theme.colors.text.link, 0.03), + }, + }), + }; +}; diff --git a/public/app/features/dashboard/dashgrid/PanelLinks.tsx b/public/app/features/dashboard/dashgrid/PanelLinks.tsx new file mode 100644 index 00000000000..706d29fa0ba --- /dev/null +++ b/public/app/features/dashboard/dashgrid/PanelLinks.tsx @@ -0,0 +1,77 @@ +import { css } from '@emotion/css'; +import React from 'react'; + +import { DataLink, GrafanaTheme2, LinkModel } from '@grafana/data'; +import { Dropdown, Icon, Menu, ToolbarButton, useStyles2 } from '@grafana/ui'; +import { getFocusStyles, getMouseFocusStyles } from '@grafana/ui/src/themes/mixins'; + +interface Props { + panelLinks: DataLink[]; + onShowPanelLinks: () => LinkModel[]; +} + +export function PanelLinks({ panelLinks, onShowPanelLinks }: Props) { + const styles = useStyles2(getStyles); + + const getLinksContent = (): JSX.Element => { + const interpolatedLinks = onShowPanelLinks(); + return ( + + {interpolatedLinks?.map((link, idx) => { + return ; + })} + + ); + }; + + if (panelLinks.length === 1) { + const linkModel = onShowPanelLinks()[0]; + return ( + + + + ); + } else { + return ( + + + + ); + } +} + +const getStyles = (theme: GrafanaTheme2) => { + return { + menuTrigger: css({ + border: 'none', + borderRadius: `${theme.shape.borderRadius()}`, + cursor: 'context-menu', + }), + singleLink: css({ + color: theme.colors.text.secondary, + padding: `${theme.spacing(0, 1)}`, + height: ` ${theme.spacing(theme.components.height.md)}`, + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + + '&:focus, &:focus-visible': { + ...getFocusStyles(theme), + zIndex: 1, + }, + '&: focus:not(:focus-visible)': getMouseFocusStyles(theme), + + '&:hover ': { + boxShadow: `${theme.shadows.z1}`, + color: `${theme.colors.text.primary}`, + background: `${theme.colors.background.secondary}`, + }, + }), + }; +}; diff --git a/public/app/features/dashboard/dashgrid/PanelStateWrapper.tsx b/public/app/features/dashboard/dashgrid/PanelStateWrapper.tsx index a6aadfd2a05..0a0c3df1643 100644 --- a/public/app/features/dashboard/dashgrid/PanelStateWrapper.tsx +++ b/public/app/features/dashboard/dashgrid/PanelStateWrapper.tsx @@ -11,17 +11,19 @@ import { EventFilterOptions, FieldConfigSource, getDefaultTimeRange, + LinkModel, LoadingState, PanelData, PanelPlugin, PanelPluginMeta, PluginContextProvider, + renderMarkdown, TimeRange, toDataFrameDTO, toUtc, } from '@grafana/data'; import { selectors } from '@grafana/e2e-selectors'; -import { config, locationService, RefreshEvent } from '@grafana/runtime'; +import { getTemplateSrv, config, locationService, RefreshEvent } from '@grafana/runtime'; import { VizLegendOptions } from '@grafana/schema'; import { ErrorBoundary, @@ -35,6 +37,7 @@ import { PANEL_BORDER } from 'app/core/constants'; import { profiler } from 'app/core/profiler'; import { applyPanelTimeOverrides } from 'app/features/dashboard/utils/panel'; import { InspectTab } from 'app/features/inspector/types'; +import { getPanelLinksSupplier } from 'app/features/panel/panellinks/linkSuppliers'; import { changeSeriesColorConfigFactory } from 'app/plugins/panel/timeseries/overrides/colorSeriesConfigFactory'; import { RenderEvent } from 'app/types/events'; @@ -47,6 +50,7 @@ import { loadSnapshotData } from '../utils/loadSnapshotData'; import { PanelHeader } from './PanelHeader/PanelHeader'; import { PanelHeaderMenuWrapper } from './PanelHeader/PanelHeaderMenuWrapper'; +import { PanelHeaderTitleItems } from './PanelHeader/PanelHeaderTitleItems'; import { seriesVisibilityConfigFactory } from './SeriesVisibilityConfigFactory'; import { liveTimer } from './liveTimer'; @@ -567,6 +571,27 @@ export class PanelStateWrapper extends PureComponent { return !panel.hasTitle(); } + onShowPanelDescription = () => { + const { panel } = this.props; + const descriptionMarkdown = getTemplateSrv().replace(panel.description, panel.scopedVars); + const interpolatedDescription = renderMarkdown(descriptionMarkdown); + return interpolatedDescription; + }; + + onShowPanelLinks = (): LinkModel[] => { + const { panel } = this.props; + const linkSupplier = getPanelLinksSupplier(panel); + if (linkSupplier) { + const panelLinks = linkSupplier && linkSupplier.getLinks(panel.replaceVariables); + return panelLinks; + } + return []; + }; + + onOpenInspector = (e: React.SyntheticEvent, tab: string) => { + e.stopPropagation(); + locationService.partial({ inspect: this.props.panel.id, inspectTab: tab }); + }; onOpenErrorInspect(e: React.SyntheticEvent, tab: string) { e.stopPropagation(); locationService.partial({ inspect: this.props.panel.id, inspectTab: tab }); @@ -590,6 +615,17 @@ export class PanelStateWrapper extends PureComponent { const title = panel.getDisplayTitle(); const padding: PanelPadding = plugin.noPadding ? 'none' : 'md'; + const titleItems = [ + , + ]; + let menu; if (!dashboard.meta.publicDashboardAccessToken) { menu = ( @@ -610,14 +646,16 @@ export class PanelStateWrapper extends PureComponent { this.onOpenErrorInspect(e, InspectTab.Error), }} + description={!!panel.description ? this.onShowPanelDescription : undefined} + titleItems={titleItems} + menu={menu} + padding={padding} > {(innerWidth, innerHeight) => ( <> diff --git a/public/img/icons/mono/circle-mono.svg b/public/img/icons/mono/circle-mono.svg new file mode 100644 index 00000000000..4f6385be522 --- /dev/null +++ b/public/img/icons/mono/circle-mono.svg @@ -0,0 +1 @@ + diff --git a/public/locales/pseudo-LOCALE/grafana.json b/public/locales/pseudo-LOCALE/grafana.json index 983fb298314..21964cfc999 100644 --- a/public/locales/pseudo-LOCALE/grafana.json +++ b/public/locales/pseudo-LOCALE/grafana.json @@ -574,4 +574,4 @@ "option-tooltip": "Cľęäř şęľęčŧįőʼnş" } } -} \ No newline at end of file +}