TablePanel: Add cell inspect option (#45620)

* TablePanel: Add cell preview option

* Review comments

* Change modal title

* Review

* Review 2

* Docs
This commit is contained in:
Dominik Prokop 2022-02-28 06:35:05 -08:00 committed by GitHub
parent c014f8b806
commit eb537e2efd
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 247 additions and 108 deletions

View File

@ -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" >}}).

View File

@ -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 {

View File

@ -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<HTMLButtonElement>) =>
onCellFilterAdded({ key: field.name, operator: FILTER_FOR_OPERATOR, value: cell.value }),
[cell, field, onCellFilterAdded]
);
const onFilterOut = useCallback(
(event: React.MouseEvent<HTMLButtonElement>) =>
onCellFilterAdded({ key: field.name, operator: FILTER_OUT_OPERATOR, value: cell.value }),
[cell, field, onCellFilterAdded]
);
return (
<>
<div className={`cellActions ${isRightAligned ? 'cellActionsLeft' : ''}`}>
<HorizontalGroup spacing="xs">
{inspectEnabled && (
<IconButton
name="eye"
tooltip="Inspect value"
onClick={() => {
setIsInspecting(true);
}}
{...commonButtonProps}
/>
)}
{showFilters && (
<IconButton name={'search-plus'} onClick={onFilterFor} tooltip="Filter for value" {...commonButtonProps} />
)}
{showFilters && (
<IconButton name={'search-minus'} onClick={onFilterOut} tooltip="Filter out value" {...commonButtonProps} />
)}
</HorizontalGroup>
</div>
{isInspecting && (
<TableCellInspectModal
mode={previewMode}
value={cell.value}
onDismiss={() => {
setIsInspecting(false);
}}
/>
)}
</>
);
}

View File

@ -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<TableCellProps> = (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<TableCellProps> = (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<TableCellProps> = (props) => {
{value}
</a>
)}
{showFilters && cell.value !== undefined && <FilterActions {...props} />}
{showActions && <CellActions {...props} previewMode="text" />}
</div>
);
};
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;
}

View File

@ -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<TableCellProps> = ({ cell, field, tableStyles, onCellFilterAdded }) => {
const onFilterFor = useCallback(
(event: React.MouseEvent<HTMLDivElement>) =>
onCellFilterAdded({ key: field.name, operator: FILTER_FOR_OPERATOR, value: cell.value }),
[cell, field, onCellFilterAdded]
);
const onFilterOut = useCallback(
(event: React.MouseEvent<HTMLDivElement>) =>
onCellFilterAdded({ key: field.name, operator: FILTER_OUT_OPERATOR, value: cell.value }),
[cell, field, onCellFilterAdded]
);
return (
<div className={tableStyles.filterWrapper}>
<div className={tableStyles.filterItem}>
<Tooltip content="Filter for value" placement="top">
<Icon name={'search-plus'} onClick={onFilterFor} />
</Tooltip>
</div>
<div className={tableStyles.filterItem}>
<Tooltip content="Filter out value" placement="top">
<Icon name={'search-minus'} onClick={onFilterOut} />
</Tooltip>
</div>
</div>
);
};

View File

@ -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 = <JSONTooltip value={value} />;
return (
<Tooltip placement="auto-start" content={content} theme="info" interactive>
<div {...cellProps} className={tableStyles.cellContainer}>
<div className={cx(tableStyles.cellText, txt)}>{displayValue}</div>
</div>
</Tooltip>
);
}
interface PopupProps {
value: any;
}
function JSONTooltip(props: PopupProps): JSX.Element {
const styles = useStyles2(getStyles);
return (
<div className={styles.container}>
<div>
<JSONFormatter json={props.value} open={4} className={styles.json} />
</div>
<div {...cellProps} className={inspectEnabled ? tableStyles.cellContainerNoOverflow : tableStyles.cellContainer}>
<div className={cx(tableStyles.cellText, txt)}>{displayValue}</div>
{inspectEnabled && <CellActions {...props} previewMode="code" />}
</div>
);
}
function getStyles(theme: GrafanaTheme2) {
return {
container: css`
padding: ${theme.spacing(0.5)};
`,
json: css`
width: fit-content;
max-height: 70vh;
overflow-y: auto;
`,
};
}

View File

@ -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<number>();
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 (
<Modal onDismiss={onDismiss} isOpen={true} title="Inspect value">
{mode === 'code' ? (
<CodeEditor
width="100%"
height={500}
language="json"
showLineNumbers={true}
showMiniMap={(text && text.length) > 100}
value={text}
readOnly={true}
/>
) : (
<pre>{text}</pre>
)}
<Modal.ButtonRow>
<ClipboardButton getText={() => text} onClipboardCopy={() => setIsInClipboard(true)}>
{!isInClipboard ? (
'Copy to Clipboard'
) : (
<>
<Icon name="check" />
Copied to clipboard
</>
)}
</ClipboardButton>
</Modal.ButtonRow>
</Modal>
);
}

View File

@ -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};

View File

@ -39,4 +39,5 @@ export const defaultPanelOptions: PanelOptions = {
export const defaultPanelFieldConfig: TableFieldOptions = {
displayMode: TableCellDisplayMode.Auto,
align: 'auto',
inspect: false,
};

View File

@ -75,6 +75,21 @@ export const plugin = new PanelPlugin<PanelOptions, TableFieldOptions>(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',