mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
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:
parent
7038f17a08
commit
724cbcd745
@ -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'));
|
||||
|
@ -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({
|
||||
|
@ -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!);
|
||||
}
|
||||
|
@ -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: {
|
||||
|
@ -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;
|
||||
|
@ -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'};
|
||||
|
@ -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 };
|
||||
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
@ -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};
|
||||
`,
|
||||
};
|
||||
});
|
||||
|
@ -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])
|
||||
)}
|
||||
|
@ -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} />}
|
||||
|
@ -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>
|
||||
);
|
||||
}
|
@ -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)}
|
||||
/>
|
||||
);
|
||||
|
@ -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;
|
||||
|
@ -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 */
|
||||
|
@ -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}
|
||||
|
Loading…
Reference in New Issue
Block a user