PanelEdit: Adds a table view toggle to quickly view data in table form (#33753)

* PanelEdit: Adds raw data toggle to quickly be able to view data in table form

* With transforms

* Updated name for toggle

* refactoring and added e2e test

* Support options

* fixing e2e
This commit is contained in:
Torkel Ödegaard 2021-05-07 17:09:06 +02:00 committed by GitHub
parent 7038f17a08
commit 724cbcd745
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
16 changed files with 196 additions and 25 deletions

View File

@ -69,6 +69,14 @@ e2e.scenario({
e2e.components.PluginVisualization.item('Time series').should('be.visible');
e2e.components.PluginVisualization.current().should((e) => expect(e).to.contain('Time series'));
// Check that table view works
e2e.components.PanelEditor.toggleTableView().click({ force: true });
e2e.components.Panels.Visualization.Table.header()
.should('be.visible')
.within(() => {
cy.contains('A-series').should('be.visible');
});
// Change to Text panel
e2e.components.PluginVisualization.item('Text').scrollIntoView().should('be.visible').click();
e2e.components.PanelEditor.toggleVizPicker().should((e) => expect(e).to.contain('Text'));

View File

@ -1,5 +1,5 @@
import { DataFrame, TIME_SERIES_VALUE_FIELD_NAME, FieldType } from '../types';
import { getFieldDisplayName } from './fieldState';
import { getFieldDisplayName, getFrameDisplayName } from './fieldState';
import { toDataFrame } from '../dataframe';
interface TitleScenario {
@ -14,6 +14,44 @@ function checkScenario(scenario: TitleScenario): string {
return getFieldDisplayName(field, frame, scenario.frames);
}
describe('getFrameDisplayName', () => {
it('Should return frame name if set', () => {
const frame = toDataFrame({
name: 'Series A',
fields: [{ name: 'Field 1' }],
});
expect(getFrameDisplayName(frame)).toBe('Series A');
});
it('Should return field name', () => {
const frame = toDataFrame({
fields: [{ name: 'Field 1' }],
});
expect(getFrameDisplayName(frame)).toBe('Field 1');
});
it('Should return all field names', () => {
const frame = toDataFrame({
fields: [{ name: 'Field A' }, { name: 'Field B' }],
});
expect(getFrameDisplayName(frame)).toBe('Field A, Field B');
});
it('Should return labels if single field with labels', () => {
const frame = toDataFrame({
fields: [{ name: 'value', labels: { server: 'A' } }],
});
expect(getFrameDisplayName(frame)).toBe('{server="A"}');
});
it('Should return field names when labels object exist but has no keys', () => {
const frame = toDataFrame({
fields: [{ name: 'value', labels: {} }],
});
expect(getFrameDisplayName(frame)).toBe('value');
});
});
describe('Check field state calculations (displayName and id)', () => {
it('should use field name if no frame name', () => {
const title = checkScenario({

View File

@ -10,7 +10,13 @@ export function getFrameDisplayName(frame: DataFrame, index?: number) {
}
// Single field with tags
const valuesWithLabels = frame.fields.filter((f) => f.labels !== undefined);
const valuesWithLabels: Field[] = [];
for (const field of frame.fields) {
if (field.labels && Object.keys(field.labels).length > 0) {
valuesWithLabels.push(field);
}
}
if (valuesWithLabels.length === 1) {
return formatLabels(valuesWithLabels[0].labels!);
}

View File

@ -50,6 +50,9 @@ export const Components = {
Text: {
container: () => '.markdown-html',
},
Table: {
header: 'table header',
},
},
},
VizLegend: {
@ -80,6 +83,7 @@ export const Components = {
applyButton: 'panel editor apply',
toggleVizPicker: 'toggle-viz-picker',
toggleVizOptions: 'toggle-viz-options',
toggleTableView: 'toggle-table-view',
},
PanelInspector: {
Data: {

View File

@ -15,7 +15,7 @@ export interface PanelRendererProps<P extends object = any, F extends object = a
title: string;
fieldConfig?: FieldConfigSource<F>;
options?: P;
onOptionsChange: (options: P) => void;
onOptionsChange?: (options: P) => void;
onChangeTimeRange?: (timeRange: AbsoluteTimeRange) => void;
timeZone?: string;
width: number;

View File

@ -57,7 +57,6 @@ const getRadioButtonStyles = stylesFactory((theme: GrafanaTheme2, size: RadioBut
const textColor = theme.colors.text.secondary;
const textColorHover = theme.colors.text.primary;
const bg = theme.colors.background.primary;
// remove the group inner padding (set on RadioButtonGroup)
const labelHeight = height * theme.spacing.gridSize - 4 - 2;
@ -99,7 +98,7 @@ const getRadioButtonStyles = stylesFactory((theme: GrafanaTheme2, size: RadioBut
color: ${textColor};
padding: ${theme.spacing(0, padding)};
border-radius: ${theme.shape.borderRadius()};
background: ${bg};
background: transparent;
cursor: pointer;
z-index: 1;
flex: ${fullWidth ? `1 0 0` : 'none'};

View File

@ -37,7 +37,7 @@ export const PanelChrome: React.FC<PanelChromeProps> = ({
const { contentStyle, innerWidth, innerHeight } = getContentStyle(padding, theme, width, headerHeight, height);
const headerStyles: CSSProperties = {
height: theme.panelHeaderHeight,
height: headerHeight,
};
const containerStyles: CSSProperties = { width, height };

View File

@ -37,6 +37,12 @@ export const Controlled = () => {
</InlineField>
</InlineFieldRow>
</div>
<div style={{ marginBottom: '32px' }}>
<div>just inline switch with show label</div>
<span>
<InlineSwitch label="Raw data" showLabel={true} value={checked} disabled={disabled} onChange={onChange} />
</span>
</div>
</div>
);
};

View File

@ -42,16 +42,27 @@ export const Switch = React.forwardRef<HTMLInputElement, Props>(
Switch.displayName = 'Switch';
export const InlineSwitch = React.forwardRef<HTMLInputElement, Props>(({ transparent, ...props }, ref) => {
const theme = useTheme2();
const styles = getSwitchStyles(theme, transparent);
export interface InlineSwitchProps extends Props {
showLabel?: boolean;
}
return (
<div className={styles.inlineContainer}>
<Switch {...props} ref={ref} />
</div>
);
});
export const InlineSwitch = React.forwardRef<HTMLInputElement, InlineSwitchProps>(
({ transparent, showLabel, label, value, id, ...props }, ref) => {
const theme = useTheme2();
const styles = getSwitchStyles(theme, transparent);
return (
<div className={styles.inlineContainer}>
{showLabel && (
<label htmlFor={id} className={cx(styles.inlineLabel, value && styles.inlineLabelEnabled)}>
{label}
</label>
)}
<Switch {...props} id={id} label={label} ref={ref} value={value} />
</div>
);
}
);
InlineSwitch.displayName = 'Switch';
@ -129,11 +140,19 @@ const getSwitchStyles = stylesFactory((theme: GrafanaTheme2, transparent?: boole
inlineContainer: css`
padding: ${theme.spacing(0, 1)};
height: ${theme.spacing(theme.components.height.md)};
display: flex;
display: inline-flex;
align-items: center;
background: ${transparent ? 'transparent' : theme.components.input.background};
border: 1px solid ${transparent ? 'transparent' : theme.components.input.borderColor};
border-radius: ${theme.shape.borderRadius()};
`,
inlineLabel: css`
cursor: pointer;
padding-right: ${theme.spacing(1)};
color: ${theme.colors.text.secondary};
`,
inlineLabelEnabled: css`
color: ${theme.colors.text.primary};
`,
};
});

View File

@ -27,8 +27,10 @@ import { CustomScrollbar } from '../CustomScrollbar/CustomScrollbar';
import { Filter } from './Filter';
import { TableCell } from './TableCell';
import { useStyles2 } from '../../themes';
import { selectors } from '@grafana/e2e-selectors';
const COLUMN_MIN_WIDTH = 150;
const e2eSelectorsTable = selectors.components.Panels.Visualization.Table;
export interface Props {
ariaLabel?: string;
@ -202,7 +204,12 @@ export const Table: FC<Props> = memo((props: Props) => {
{headerGroups.map((headerGroup: HeaderGroup) => {
const { key, ...headerGroupProps } = headerGroup.getHeaderGroupProps();
return (
<div className={tableStyles.thead} {...headerGroupProps} key={key}>
<div
className={tableStyles.thead}
{...headerGroupProps}
key={key}
aria-label={e2eSelectorsTable.header}
>
{headerGroup.headers.map((column: Column, index: number) =>
renderHeaderCell(column, tableStyles, data.fields[index])
)}

View File

@ -9,6 +9,7 @@ import { FieldConfigSource, GrafanaTheme } from '@grafana/data';
import { selectors } from '@grafana/e2e-selectors';
import {
HorizontalGroup,
InlineSwitch,
ModalsController,
PageToolbar,
RadioButtonGroup,
@ -38,6 +39,7 @@ import {
} from './state/actions';
import { updateTimeZoneForSession } from 'app/features/profile/state/reducers';
import { toggleTableView } from './state/reducers';
import { getPanelEditorTabs } from './state/selectors';
import { getPanelStateById } from '../../state/selectors';
@ -59,6 +61,7 @@ import {
saveAndRefreshLibraryPanel,
} from '../../../library-panels/utils';
import { notifyApp } from '../../../../core/actions';
import { PanelEditorTableView } from './PanelEditorTableView';
interface OwnProps {
dashboard: DashboardModel;
@ -75,6 +78,7 @@ const mapStateToProps = (state: StoreState) => {
panel,
initDone: state.panelEditor.initDone,
uiState: state.panelEditor.ui,
tableViewEnabled: state.panelEditor.tableViewEnabled,
variables: getVariables(state),
};
};
@ -87,6 +91,7 @@ const mapDispatchToProps = {
discardPanelChanges,
updatePanelEditorUIState,
updateTimeZoneForSession,
toggleTableView,
notifyApp,
};
@ -218,13 +223,17 @@ export class PanelEditorUnconnected extends PureComponent<Props> {
});
};
onToggleTableView = () => {
this.props.toggleTableView();
};
onTogglePanelOptions = () => {
const { uiState, updatePanelEditorUIState } = this.props;
updatePanelEditorUIState({ isPanelOptionsVisible: !uiState.isPanelOptionsVisible });
};
renderPanel = (styles: EditorStyles) => {
const { dashboard, panel, uiState, plugin, tab } = this.props;
const { dashboard, panel, uiState, plugin, tab, tableViewEnabled } = this.props;
const tabs = getPanelEditorTabs(tab, plugin);
return (
@ -236,6 +245,11 @@ export class PanelEditorUnconnected extends PureComponent<Props> {
if (width < 3 || height < 3) {
return null;
}
if (tableViewEnabled) {
return <PanelEditorTableView width={width} height={height} panel={panel} />;
}
return (
<div className={styles.centeringContainer} style={{ width, height }}>
<div style={calculatePanelSize(uiState.mode, width, height, panel)} data-panelid={panel.editSourceId}>
@ -290,12 +304,21 @@ export class PanelEditorUnconnected extends PureComponent<Props> {
}
renderPanelToolbar(styles: EditorStyles) {
const { dashboard, uiState, variables, updateTimeZoneForSession, panel } = this.props;
const { dashboard, uiState, variables, updateTimeZoneForSession, panel, tableViewEnabled } = this.props;
return (
<div className={styles.panelToolbar}>
<HorizontalGroup justify={variables.length > 0 ? 'space-between' : 'flex-end'} align="flex-start">
{this.renderTemplateVariables(styles)}
<HorizontalGroup>
<InlineSwitch
label="Table view"
showLabel={true}
id="table-view"
value={tableViewEnabled}
onClick={this.onToggleTableView}
aria-label={selectors.components.PanelEditor.toggleTableView}
/>
<RadioButtonGroup value={uiState.mode} options={displayModes} onChange={this.onDisplayModeChange} />
<DashNavTimeControls dashboard={dashboard} onChangeTimeZone={updateTimeZoneForSession} />
{!uiState.isPanelOptionsVisible && <VisualizationButton panel={panel} />}

View File

@ -0,0 +1,40 @@
import { PanelChrome } from '@grafana/ui';
import { PanelRenderer } from 'app/features/panel/PanelRenderer';
import React, { useState } from 'react';
import { PanelModel } from '../../state';
import { usePanelLatestData } from './usePanelLatestData';
import { PanelOptions } from 'app/plugins/panel/table/models.gen';
interface Props {
width: number;
height: number;
panel: PanelModel;
}
export function PanelEditorTableView({ width, height, panel }: Props) {
const { data } = usePanelLatestData(panel, { withTransforms: true, withFieldConfig: false }, true);
const [options, setOptions] = useState<PanelOptions>({
frameIndex: 0,
showHeader: true,
});
if (!data) {
return null;
}
return (
<PanelChrome width={width} height={height} padding="none">
{(innerWidth, innerHeight) => (
<PanelRenderer
title="Raw data"
pluginId="table"
width={innerWidth}
height={innerHeight}
data={data}
options={options}
onOptionsChange={setOptions}
/>
)}
</PanelChrome>
);
}

View File

@ -66,6 +66,7 @@ export function getPanelFrameCategory(props: OptionPaneRenderProps): OptionsPane
return (
<Switch
value={panel.transparent}
id="Transparent background"
onChange={(e) => onPanelConfigChange('transparent', e.currentTarget.checked)}
/>
);

View File

@ -35,6 +35,7 @@ export interface PanelEditorState {
isOpen: boolean;
ui: PanelEditorUIState;
isVizPickerOpen: boolean;
tableViewEnabled: boolean;
}
export const initialState = (): PanelEditorState => {
@ -58,6 +59,7 @@ export const initialState = (): PanelEditorState => {
shouldDiscardChanges: false,
isOpen: false,
isVizPickerOpen: false,
tableViewEnabled: false,
ui: {
...DEFAULT_PANEL_EDITOR_UI_STATE,
...migratedState,
@ -101,10 +103,14 @@ const pluginsSlice = createSlice({
state.ui.isPanelOptionsVisible = true;
}
},
toggleTableView(state) {
state.tableViewEnabled = !state.tableViewEnabled;
},
closeCompleted: (state) => {
state.isOpen = false;
state.initDone = false;
state.isVizPickerOpen = false;
state.tableViewEnabled = false;
},
},
});
@ -116,6 +122,7 @@ export const {
closeCompleted,
setPanelEditorUIState,
toggleVizPicker,
toggleTableView,
} = pluginsSlice.actions;
export const panelEditorReducer = pluginsSlice.reducer;

View File

@ -21,10 +21,23 @@ export enum DisplayMode {
Exact = 2,
}
export enum PanelEditTableToggle {
Off = 0,
Table = 1,
}
export const displayModes = [
{ value: DisplayMode.Fill, label: 'Fill', description: 'Use all available space' },
{ value: DisplayMode.Fit, label: 'Fit', description: 'Fit in the space keeping ratio' },
{ value: DisplayMode.Exact, label: 'Exact', description: 'Make same size as the dashboard' },
{ value: DisplayMode.Exact, label: 'Actual', description: 'Make same size as on the dashboard' },
];
export const panelEditTableModes = [
{
value: PanelEditTableToggle.Off,
label: 'Visualization',
description: 'Show using selected visualization',
},
{ value: PanelEditTableToggle.Table, label: 'Table', description: 'Show raw data in table form' },
];
/** @internal */

View File

@ -16,15 +16,15 @@ export function PanelRenderer<P extends object = any, F extends object = any>(pr
width,
height,
title,
onOptionsChange,
onOptionsChange = () => {},
onChangeTimeRange = () => {},
fieldConfig: config = { defaults: {}, overrides: [] },
} = props;
const [fieldConfig, setFieldConfig] = useState<FieldConfigSource>(config);
const { value: plugin, error, loading } = useAsync(() => importPanelPlugin(pluginId), [pluginId]);
const defaultOptions = useOptionDefaults(plugin, options, fieldConfig);
const dataWithOverrides = useFieldOverrides(plugin, defaultOptions, data, timeZone);
const optionsWithDefaults = useOptionDefaults(plugin, options, fieldConfig);
const dataWithOverrides = useFieldOverrides(plugin, optionsWithDefaults, data, timeZone);
if (error) {
return <div>Failed to load plugin: {error.message}</div>;
@ -51,7 +51,7 @@ export function PanelRenderer<P extends object = any, F extends object = any>(pr
title={title}
timeRange={dataWithOverrides.timeRange}
timeZone={timeZone}
options={options}
options={optionsWithDefaults!.options}
fieldConfig={fieldConfig}
transparent={false}
width={width}