From eb537e2efdbddfe7500fa5ef941607ed60044bc9 Mon Sep 17 00:00:00 2001 From: Dominik Prokop Date: Mon, 28 Feb 2022 06:35:05 -0800 Subject: [PATCH] TablePanel: Add cell inspect option (#45620) * TablePanel: Add cell preview option * Review comments * Change modal title * Review * Review 2 * Docs --- docs/sources/visualizations/table/_index.md | 6 ++ .../grafana-schema/src/schema/graph.gen.ts | 2 + .../src/components/Table/CellActions.tsx | 70 ++++++++++++++++ .../src/components/Table/DefaultCell.tsx | 26 +++--- .../src/components/Table/FilterActions.tsx | 31 ------- .../src/components/Table/JSONViewCell.tsx | 48 ++--------- .../Table/TableCellInspectModal.tsx | 75 +++++++++++++++++ .../grafana-ui/src/components/Table/styles.ts | 81 ++++++++++++------- public/app/plugins/panel/table/models.gen.ts | 1 + public/app/plugins/panel/table/module.tsx | 15 ++++ 10 files changed, 247 insertions(+), 108 deletions(-) create mode 100644 packages/grafana-ui/src/components/Table/CellActions.tsx delete mode 100644 packages/grafana-ui/src/components/Table/FilterActions.tsx create mode 100644 packages/grafana-ui/src/components/Table/TableCellInspectModal.tsx diff --git a/docs/sources/visualizations/table/_index.md b/docs/sources/visualizations/table/_index.md index 8a43c64df62..57d8589d59a 100644 --- a/docs/sources/visualizations/table/_index.md +++ b/docs/sources/visualizations/table/_index.md @@ -96,6 +96,12 @@ If you have a field value that is an image URL or a base64 encoded image you can {{< figure src="/static/img/docs/v73/table_hover.gif" max-width="900px" caption="Table hover" >}} +## Cell value inspect + +Enables value inspection from table cell. The raw value is presented in a modal window. + +> **Note:** Cell value inspection is only available when cell display mode is set to Auto, Color text, Color background or JSON View. + ## Column filter You can temporarily change how column data is displayed. For example, you can order values from highest to lowest or hide specific values. For more information, refer to [Filter table columns]({{< relref "./filter-table-columns.md" >}}). diff --git a/packages/grafana-schema/src/schema/graph.gen.ts b/packages/grafana-schema/src/schema/graph.gen.ts index 623d750a250..f055541e9ce 100644 --- a/packages/grafana-schema/src/schema/graph.gen.ts +++ b/packages/grafana-schema/src/schema/graph.gen.ts @@ -283,6 +283,7 @@ export enum BarGaugeDisplayMode { export interface TableFieldOptions { align: string; displayMode: TableCellDisplayMode; + inspect: boolean; hidden?: boolean; minWidth?: number; width?: number; @@ -292,6 +293,7 @@ export interface TableFieldOptions { export const defaultTableFieldOptions: TableFieldOptions = { align: 'auto', displayMode: TableCellDisplayMode.Auto, + inspect: false, }; export interface VizTooltipOptions { diff --git a/packages/grafana-ui/src/components/Table/CellActions.tsx b/packages/grafana-ui/src/components/Table/CellActions.tsx new file mode 100644 index 00000000000..bf140fca42d --- /dev/null +++ b/packages/grafana-ui/src/components/Table/CellActions.tsx @@ -0,0 +1,70 @@ +import React, { useCallback, useState } from 'react'; +import { IconSize } from '../../types/icon'; +import { IconButton } from '../IconButton/IconButton'; +import { HorizontalGroup } from '../Layout/Layout'; +import { TooltipPlacement } from '../Tooltip'; +import { TableCellInspectModal } from './TableCellInspectModal'; +import { FILTER_FOR_OPERATOR, FILTER_OUT_OPERATOR, TableCellProps, TableFieldOptions } from './types'; +import { getTextAlign } from './utils'; + +interface CellActionProps extends TableCellProps { + previewMode: 'text' | 'code'; +} + +export function CellActions({ field, cell, previewMode, onCellFilterAdded }: CellActionProps) { + const [isInspecting, setIsInspecting] = useState(false); + + const isRightAligned = getTextAlign(field) === 'flex-end'; + const showFilters = Boolean(field.config.filterable) && cell.value !== undefined; + const inspectEnabled = Boolean((field.config.custom as TableFieldOptions)?.inspect); + const commonButtonProps = { + size: 'sm' as IconSize, + tooltipPlacement: 'top' as TooltipPlacement, + }; + + const onFilterFor = useCallback( + (event: React.MouseEvent) => + onCellFilterAdded({ key: field.name, operator: FILTER_FOR_OPERATOR, value: cell.value }), + [cell, field, onCellFilterAdded] + ); + const onFilterOut = useCallback( + (event: React.MouseEvent) => + onCellFilterAdded({ key: field.name, operator: FILTER_OUT_OPERATOR, value: cell.value }), + [cell, field, onCellFilterAdded] + ); + + return ( + <> +
+ + {inspectEnabled && ( + { + setIsInspecting(true); + }} + {...commonButtonProps} + /> + )} + {showFilters && ( + + )} + {showFilters && ( + + )} + +
+ + {isInspecting && ( + { + setIsInspecting(false); + }} + /> + )} + + ); +} diff --git a/packages/grafana-ui/src/components/Table/DefaultCell.tsx b/packages/grafana-ui/src/components/Table/DefaultCell.tsx index 503dbcd4da3..476cd60b492 100644 --- a/packages/grafana-ui/src/components/Table/DefaultCell.tsx +++ b/packages/grafana-ui/src/components/Table/DefaultCell.tsx @@ -1,15 +1,16 @@ import React, { FC, ReactElement } from 'react'; import { DisplayValue, Field, formattedValueToString } from '@grafana/data'; -import { TableCellDisplayMode, TableCellProps } from './types'; +import { TableCellDisplayMode, TableCellProps, TableFieldOptions } from './types'; import tinycolor from 'tinycolor2'; import { TableStyles } from './styles'; -import { FilterActions } from './FilterActions'; import { getTextColorForBackground, getCellLinks } from '../../utils'; +import { CellActions } from './CellActions'; export const DefaultCell: FC = (props) => { const { field, cell, tableStyles, row, cellProps } = props; + const inspectEnabled = Boolean((field.config.custom as TableFieldOptions)?.inspect); const displayValue = field.display!(cell.value); let value: string | ReactElement; @@ -19,8 +20,9 @@ export const DefaultCell: FC = (props) => { value = formattedValueToString(displayValue); } - const cellStyle = getCellStyle(tableStyles, field, displayValue); const showFilters = field.config.filterable; + const showActions = (showFilters && cell.value !== undefined) || inspectEnabled; + const cellStyle = getCellStyle(tableStyles, field, displayValue, inspectEnabled); const { link, onClick } = getCellLinks(field, row); @@ -32,20 +34,25 @@ export const DefaultCell: FC = (props) => { {value} )} - {showFilters && cell.value !== undefined && } + {showActions && } ); }; -function getCellStyle(tableStyles: TableStyles, field: Field, displayValue: DisplayValue) { +function getCellStyle( + tableStyles: TableStyles, + field: Field, + displayValue: DisplayValue, + disableOverflowOnHover = false +) { if (field.config.custom?.displayMode === TableCellDisplayMode.ColorText) { - return tableStyles.buildCellContainerStyle(displayValue.color); + return tableStyles.buildCellContainerStyle(displayValue.color, undefined, !disableOverflowOnHover); } if (field.config.custom?.displayMode === TableCellDisplayMode.ColorBackgroundSolid) { const bgColor = tinycolor(displayValue.color); const textColor = getTextColorForBackground(displayValue.color!); - return tableStyles.buildCellContainerStyle(textColor, bgColor.toRgbString()); + return tableStyles.buildCellContainerStyle(textColor, bgColor.toRgbString(), !disableOverflowOnHover); } if (field.config.custom?.displayMode === TableCellDisplayMode.ColorBackground) { @@ -59,9 +66,10 @@ function getCellStyle(tableStyles: TableStyles, field: Field, displayValue: Disp return tableStyles.buildCellContainerStyle( textColor, - `linear-gradient(120deg, ${bgColor2}, ${displayValue.color})` + `linear-gradient(120deg, ${bgColor2}, ${displayValue.color})`, + !disableOverflowOnHover ); } - return tableStyles.cellContainer; + return disableOverflowOnHover ? tableStyles.cellContainerNoOverflow : tableStyles.cellContainer; } diff --git a/packages/grafana-ui/src/components/Table/FilterActions.tsx b/packages/grafana-ui/src/components/Table/FilterActions.tsx deleted file mode 100644 index 21dc07dab90..00000000000 --- a/packages/grafana-ui/src/components/Table/FilterActions.tsx +++ /dev/null @@ -1,31 +0,0 @@ -import React, { FC, useCallback } from 'react'; -import { FILTER_FOR_OPERATOR, FILTER_OUT_OPERATOR, TableCellProps } from './types'; -import { Icon, Tooltip } from '..'; - -export const FilterActions: FC = ({ cell, field, tableStyles, onCellFilterAdded }) => { - const onFilterFor = useCallback( - (event: React.MouseEvent) => - onCellFilterAdded({ key: field.name, operator: FILTER_FOR_OPERATOR, value: cell.value }), - [cell, field, onCellFilterAdded] - ); - const onFilterOut = useCallback( - (event: React.MouseEvent) => - onCellFilterAdded({ key: field.name, operator: FILTER_OUT_OPERATOR, value: cell.value }), - [cell, field, onCellFilterAdded] - ); - - return ( -
-
- - - -
-
- - - -
-
- ); -}; diff --git a/packages/grafana-ui/src/components/Table/JSONViewCell.tsx b/packages/grafana-ui/src/components/Table/JSONViewCell.tsx index e2cfe1d87f1..57fda2b325c 100644 --- a/packages/grafana-ui/src/components/Table/JSONViewCell.tsx +++ b/packages/grafana-ui/src/components/Table/JSONViewCell.tsx @@ -1,15 +1,12 @@ import React from 'react'; import { css, cx } from '@emotion/css'; import { isString } from 'lodash'; -import { Tooltip } from '../Tooltip/Tooltip'; -import { JSONFormatter } from '../JSONFormatter/JSONFormatter'; -import { useStyles2 } from '../../themes'; -import { TableCellProps } from './types'; -import { GrafanaTheme2 } from '@grafana/data'; +import { TableCellProps, TableFieldOptions } from './types'; +import { CellActions } from './CellActions'; export function JSONViewCell(props: TableCellProps): JSX.Element { - const { cell, tableStyles, cellProps } = props; - + const { cell, tableStyles, cellProps, field } = props; + const inspectEnabled = Boolean((field.config.custom as TableFieldOptions)?.inspect); const txt = css` cursor: pointer; font-family: monospace; @@ -26,41 +23,10 @@ export function JSONViewCell(props: TableCellProps): JSX.Element { displayValue = JSON.stringify(value, null, ' '); } - const content = ; - - return ( - -
-
{displayValue}
-
-
- ); -} - -interface PopupProps { - value: any; -} - -function JSONTooltip(props: PopupProps): JSX.Element { - const styles = useStyles2(getStyles); return ( -
-
- -
+
+
{displayValue}
+ {inspectEnabled && }
); } - -function getStyles(theme: GrafanaTheme2) { - return { - container: css` - padding: ${theme.spacing(0.5)}; - `, - json: css` - width: fit-content; - max-height: 70vh; - overflow-y: auto; - `, - }; -} diff --git a/packages/grafana-ui/src/components/Table/TableCellInspectModal.tsx b/packages/grafana-ui/src/components/Table/TableCellInspectModal.tsx new file mode 100644 index 00000000000..a5d204fe058 --- /dev/null +++ b/packages/grafana-ui/src/components/Table/TableCellInspectModal.tsx @@ -0,0 +1,75 @@ +import { isString } from 'lodash'; +import React, { useEffect, useState } from 'react'; +import { ClipboardButton } from '../ClipboardButton/ClipboardButton'; +import { Icon } from '../Icon/Icon'; +import { Modal } from '../Modal/Modal'; +import { CodeEditor } from '../Monaco/CodeEditor'; + +interface TableCellInspectModalProps { + value: any; + onDismiss: () => void; + mode: 'code' | 'text'; +} + +export function TableCellInspectModal({ value, onDismiss, mode }: TableCellInspectModalProps) { + const [isInClipboard, setIsInClipboard] = useState(false); + const timeoutRef = React.useRef(); + + useEffect(() => { + if (isInClipboard) { + timeoutRef.current = window.setTimeout(() => { + setIsInClipboard(false); + }, 2000); + } + + return () => { + if (timeoutRef.current) { + window.clearTimeout(timeoutRef.current); + } + }; + }, [isInClipboard]); + + let displayValue = value; + if (isString(value)) { + try { + value = JSON.parse(value); + } catch {} // ignore errors + } else { + displayValue = JSON.stringify(value, null, ' '); + } + let text = displayValue; + + if (mode === 'code') { + text = JSON.stringify(value, null, ' '); + } + + return ( + + {mode === 'code' ? ( + 100} + value={text} + readOnly={true} + /> + ) : ( +
{text}
+ )} + + text} onClipboardCopy={() => setIsInClipboard(true)}> + {!isInClipboard ? ( + 'Copy to Clipboard' + ) : ( + <> + + Copied to clipboard + + )} + + +
+ ); +} diff --git a/packages/grafana-ui/src/components/Table/styles.ts b/packages/grafana-ui/src/components/Table/styles.ts index 76adbf40729..d11a186dd58 100644 --- a/packages/grafana-ui/src/components/Table/styles.ts +++ b/packages/grafana-ui/src/components/Table/styles.ts @@ -1,4 +1,4 @@ -import { css, cx } from '@emotion/css'; +import { css, CSSObject } from '@emotion/css'; import { GrafanaTheme2 } from '@grafana/data'; import { getScrollbarWidth } from '../../utils'; @@ -14,8 +14,27 @@ export const getTableStyles = (theme: GrafanaTheme2) => { const rowHoverBg = theme.colors.emphasize(theme.colors.background.primary, 0.03); const lastChildExtraPadding = Math.max(getScrollbarWidth(), cellPadding); - const buildCellContainerStyle = (color?: string, background?: string) => { + const buildCellContainerStyle = (color?: string, background?: string, overflowOnHover?: boolean) => { + const cellActionsOverflow: CSSObject = { + margin: theme.spacing(0, -0.5, 0, 0.5), + }; + const cellActionsNoOverflow: CSSObject = { + position: 'absolute', + top: 0, + right: 0, + margin: 'auto', + }; + + const onHoverOverflow: CSSObject = { + overflow: 'visible', + width: 'auto !important', + boxShadow: `0 0 2px ${theme.colors.primary.main}`, + background: background ?? rowHoverBg, + zIndex: 1, + }; + return css` + label: ${overflowOnHover ? 'cellContainerOverflow' : 'cellContainerNoOverflow'}; padding: ${cellPadding}px; width: 100%; height: 100%; @@ -33,19 +52,42 @@ export const getTableStyles = (theme: GrafanaTheme2) => { } &:hover { - overflow: visible; - width: auto !important; - box-shadow: 0 0 2px ${theme.colors.primary.main}; - background: ${background ?? rowHoverBg}; - z-index: 1; - - .cell-filter-actions  { - display: inline-flex; + ${overflowOnHover && onHoverOverflow}; + .cellActions { + visibility: visible; + opacity: 1; + width: auto; } } + a { color: inherit; } + + .cellActions { + display: flex; + ${overflowOnHover ? cellActionsOverflow : cellActionsNoOverflow} + visibility: hidden; + opacity: 0; + width: 0; + align-items: center; + height: 100%; + padding: ${theme.spacing(1, 0.5, 1, 0.5)}; + background: ${background ? 'none' : theme.colors.emphasize(theme.colors.background.primary, 0.03)}; + + svg { + color: ${color}; + } + } + + .cellActionsLeft { + right: auto !important; + left: 0; + } + + .cellActionsTransparent { + background: none; + } `; }; @@ -102,7 +144,8 @@ export const getTableStyles = (theme: GrafanaTheme2) => { display: flex; margin-right: ${theme.spacing(0.5)}; `, - cellContainer: buildCellContainerStyle(), + cellContainer: buildCellContainerStyle(undefined, undefined, true), + cellContainerNoOverflow: buildCellContainerStyle(undefined, undefined, false), cellText: css` overflow: hidden; text-overflow: ellipsis; @@ -161,22 +204,6 @@ export const getTableStyles = (theme: GrafanaTheme2) => { opacity: 1; } `, - filterWrapper: cx( - css` - label: filterWrapper; - display: none; - justify-content: flex-end; - flex-grow: 1; - opacity: 0.6; - padding-left: ${theme.spacing(0.25)}; - `, - 'cell-filter-actions' - ), - filterItem: css` - label: filterItem; - cursor: pointer; - padding: 0 ${theme.spacing(0.025)}; - `, typeIcon: css` margin-right: ${theme.spacing(1)}; color: ${theme.colors.text.secondary}; diff --git a/public/app/plugins/panel/table/models.gen.ts b/public/app/plugins/panel/table/models.gen.ts index a1eee4e24f8..413ccdf20e6 100644 --- a/public/app/plugins/panel/table/models.gen.ts +++ b/public/app/plugins/panel/table/models.gen.ts @@ -39,4 +39,5 @@ export const defaultPanelOptions: PanelOptions = { export const defaultPanelFieldConfig: TableFieldOptions = { displayMode: TableCellDisplayMode.Auto, align: 'auto', + inspect: false, }; diff --git a/public/app/plugins/panel/table/module.tsx b/public/app/plugins/panel/table/module.tsx index dbf33ba6187..f6cbc78a82f 100644 --- a/public/app/plugins/panel/table/module.tsx +++ b/public/app/plugins/panel/table/module.tsx @@ -75,6 +75,21 @@ export const plugin = new PanelPlugin(TablePane }, defaultValue: defaultPanelFieldConfig.displayMode, }) + .addBooleanSwitch({ + path: 'inspect', + name: 'Cell value inspect', + description: 'Enable cell value inspection in a modal window', + defaultValue: false, + showIf: (cfg) => { + return ( + cfg.displayMode === TableCellDisplayMode.Auto || + cfg.displayMode === TableCellDisplayMode.JSONView || + cfg.displayMode === TableCellDisplayMode.ColorText || + cfg.displayMode === TableCellDisplayMode.ColorBackground || + cfg.displayMode === TableCellDisplayMode.ColorBackgroundSolid + ); + }, + }) .addBooleanSwitch({ path: 'filterable', name: 'Column filter',