mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
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:
parent
c014f8b806
commit
eb537e2efd
@ -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" >}}).
|
||||
|
@ -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 {
|
||||
|
70
packages/grafana-ui/src/components/Table/CellActions.tsx
Normal file
70
packages/grafana-ui/src/components/Table/CellActions.tsx
Normal 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);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
@ -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;
|
||||
}
|
||||
|
@ -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>
|
||||
);
|
||||
};
|
@ -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;
|
||||
`,
|
||||
};
|
||||
}
|
||||
|
@ -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>
|
||||
);
|
||||
}
|
@ -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};
|
||||
|
@ -39,4 +39,5 @@ export const defaultPanelOptions: PanelOptions = {
|
||||
export const defaultPanelFieldConfig: TableFieldOptions = {
|
||||
displayMode: TableCellDisplayMode.Auto,
|
||||
align: 'auto',
|
||||
inspect: false,
|
||||
};
|
||||
|
@ -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',
|
||||
|
Loading…
Reference in New Issue
Block a user