mirror of
https://github.com/pgadmin-org/pgadmin4.git
synced 2025-02-25 18:55:31 -06:00
Added support for visualise the graph using a Line chart in the query tool. Fixes #7485
This commit is contained in:
@@ -105,6 +105,7 @@ class SqlEditorModule(PgAdminModule):
|
||||
'sqleditor.poll',
|
||||
'sqleditor.fetch',
|
||||
'sqleditor.fetch_all',
|
||||
'sqleditor.fetch_all_from_start',
|
||||
'sqleditor.save',
|
||||
'sqleditor.inclusive_filter',
|
||||
'sqleditor.exclusive_filter',
|
||||
@@ -1087,6 +1088,49 @@ def fetch(trans_id, fetch_all=None):
|
||||
)
|
||||
|
||||
|
||||
@blueprint.route(
|
||||
'/fetch_all_from_start/<int:trans_id>', methods=["GET"],
|
||||
endpoint='fetch_all_from_start'
|
||||
)
|
||||
@login_required
|
||||
def fetch_all_from_start(trans_id):
|
||||
"""
|
||||
This function is used to fetch all the records from start and reset
|
||||
the cursor back to it's previous position.
|
||||
"""
|
||||
# Check the transaction and connection status
|
||||
status, error_msg, conn, trans_obj, session_obj = \
|
||||
check_transaction_status(trans_id)
|
||||
|
||||
if error_msg == ERROR_MSG_TRANS_ID_NOT_FOUND:
|
||||
return make_json_response(success=0, errormsg=error_msg,
|
||||
info='DATAGRID_TRANSACTION_REQUIRED',
|
||||
status=404)
|
||||
|
||||
if status and conn is not None and session_obj is not None:
|
||||
# Reset the cursor to start to fetch all the records.
|
||||
conn.reset_cursor_at(0)
|
||||
|
||||
status, result = conn.async_fetchmany_2darray(-1)
|
||||
if not status:
|
||||
status = 'Error'
|
||||
else:
|
||||
status = 'Success'
|
||||
|
||||
# Reset the cursor back to it's actual position
|
||||
conn.reset_cursor_at(trans_obj.get_fetched_row_cnt())
|
||||
else:
|
||||
status = 'NotConnected'
|
||||
result = error_msg
|
||||
|
||||
return make_json_response(
|
||||
data={
|
||||
'status': status,
|
||||
'result': result
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def fetch_pg_types(columns_info, trans_obj):
|
||||
"""
|
||||
This method is used to fetch the pg types, which is required
|
||||
|
||||
@@ -397,6 +397,7 @@ export default class SQLEditor {
|
||||
pgAdmin.Browser.preference_version(pgWindow.pgAdmin.Browser.preference_version());
|
||||
pgAdmin.Browser.triggerPreferencesChange('browser');
|
||||
pgAdmin.Browser.triggerPreferencesChange('sqleditor');
|
||||
pgAdmin.Browser.triggerPreferencesChange('graphs');
|
||||
}
|
||||
}
|
||||
}, 1000);
|
||||
|
||||
@@ -84,7 +84,7 @@ export default function QueryToolComponent({params, pgWindow, pgAdmin, selectedN
|
||||
const containerRef = React.useRef(null);
|
||||
const [qtState, _setQtState] = useState({
|
||||
preferences: {
|
||||
browser: {}, sqleditor: {},
|
||||
browser: {}, sqleditor: {}, graphs: {}, misc: {},
|
||||
},
|
||||
is_new_tab: window.location == window.parent?.location,
|
||||
current_file: null,
|
||||
@@ -217,6 +217,8 @@ export default function QueryToolComponent({params, pgWindow, pgAdmin, selectedN
|
||||
setQtState({preferences: {
|
||||
browser: pgWindow.pgAdmin.Browser.get_preferences_for_module('browser'),
|
||||
sqleditor: pgWindow.pgAdmin.Browser.get_preferences_for_module('sqleditor'),
|
||||
graphs: pgWindow.pgAdmin.Browser.get_preferences_for_module('graphs'),
|
||||
misc: pgWindow.pgAdmin.Browser.get_preferences_for_module('misc'),
|
||||
}});
|
||||
}, []);
|
||||
|
||||
@@ -311,6 +313,9 @@ export default function QueryToolComponent({params, pgWindow, pgAdmin, selectedN
|
||||
pgWindow.pgAdmin.Browser.onPreferencesChange('sqleditor', function() {
|
||||
reflectPreferences();
|
||||
});
|
||||
pgWindow.pgAdmin.Browser.onPreferencesChange('graphs', function() {
|
||||
reflectPreferences();
|
||||
});
|
||||
|
||||
/* WC docker events */
|
||||
panel?.on(window.wcDocker.EVENT.CLOSING, function() {
|
||||
|
||||
@@ -68,6 +68,7 @@ export const QUERY_TOOL_EVENTS = {
|
||||
|
||||
RESET_LAYOUT: 'RESET_LAYOUT',
|
||||
FORCE_CLOSE_PANEL: 'FORCE_CLOSE_PANEL',
|
||||
RESET_GRAPH_VISUALISER: 'RESET_GRAPH_VISUALISER',
|
||||
};
|
||||
|
||||
export const CONNECTION_STATUS = {
|
||||
@@ -95,4 +96,5 @@ export const PANELS = {
|
||||
GEOMETRY: 'id-geometry',
|
||||
NOTIFICATIONS: 'id-notifications',
|
||||
HISTORY: 'id-history',
|
||||
GRAPH_VISUALISER: 'id-graph-visualiser',
|
||||
};
|
||||
|
||||
@@ -0,0 +1,343 @@
|
||||
/////////////////////////////////////////////////////////////
|
||||
//
|
||||
// pgAdmin 4 - PostgreSQL Tools
|
||||
//
|
||||
// Copyright (C) 2013 - 2022, The pgAdmin Development Team
|
||||
// This software is released under the PostgreSQL Licence
|
||||
//
|
||||
//////////////////////////////////////////////////////////////
|
||||
|
||||
import _ from 'lodash';
|
||||
import React, { useContext, useState, useMemo, useEffect } from 'react';
|
||||
import gettext from 'sources/gettext';
|
||||
import Theme from 'sources/Theme';
|
||||
import PropTypes from 'prop-types';
|
||||
import url_for from 'sources/url_for';
|
||||
import Loader from 'sources/components/Loader';
|
||||
import { makeStyles } from '@material-ui/styles';
|
||||
import { Box } from '@material-ui/core';
|
||||
import ShowChartRoundedIcon from '@material-ui/icons/ShowChartRounded';
|
||||
import ZoomOutMapIcon from '@material-ui/icons/ZoomOutMap';
|
||||
import SaveAltIcon from '@material-ui/icons/SaveAlt';
|
||||
import { InputSelect } from '../../../../../../static/js/components/FormComponents';
|
||||
import { DefaultButton, PgButtonGroup, PgIconButton} from '../../../../../../static/js/components/Buttons';
|
||||
import { LineChart, DATA_POINT_STYLE, DATA_POINT_SIZE,
|
||||
CHART_THEME_COLORS, CHART_THEME_COLORS_LENGTH} from 'sources/chartjs';
|
||||
import { QueryToolEventsContext, QueryToolContext } from '../QueryToolComponent';
|
||||
import { QUERY_TOOL_EVENTS, PANELS } from '../QueryToolConstants';
|
||||
import { LayoutHelper } from '../../../../../../static/js/helpers/Layout';
|
||||
|
||||
// Numeric data type used to separate out the options for Y axis.
|
||||
const NUMERIC_TYPES = ['oid', 'smallint', 'integer', 'bigint', 'decimal', 'numeric',
|
||||
'real', 'double precision', 'smallserial', 'serial', 'bigserial'];
|
||||
|
||||
const useStyles = makeStyles((theme)=>({
|
||||
mainContainer: {
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
overflowY: 'scroll',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
},
|
||||
topContainer: {
|
||||
alignItems: 'flex-start',
|
||||
padding: '4px',
|
||||
backgroundColor: theme.otherVars.editorToolbarBg,
|
||||
flexWrap: 'wrap',
|
||||
...theme.mixins.panelBorder.bottom,
|
||||
},
|
||||
displayFlex: {
|
||||
display: 'flex',
|
||||
},
|
||||
graphContainer: {
|
||||
padding: '8px',
|
||||
flexGrow: 1,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
spanLabel: {
|
||||
alignSelf: 'center',
|
||||
marginRight: '4px',
|
||||
},
|
||||
selectCtrl: {
|
||||
minWidth: '200px',
|
||||
},
|
||||
}));
|
||||
|
||||
// This function is used to generate the appropriate graph based on the graphType.
|
||||
function GenerateGraph({graphType, graphData, ...props}) {
|
||||
const queryToolCtx = useContext(QueryToolContext);
|
||||
let showDataPoints = queryToolCtx.preferences.graphs['graph_data_points'];
|
||||
let useDiffPointStyle = queryToolCtx.preferences.graphs['use_diff_point_style'];
|
||||
let showToolTip = queryToolCtx.preferences.graphs['graph_mouse_track'];
|
||||
|
||||
// Below options are used by chartjs while rendering the graph
|
||||
const options = useMemo(()=>({
|
||||
elements: {
|
||||
point: {
|
||||
radius: showDataPoints ? DATA_POINT_SIZE : 0,
|
||||
},
|
||||
},
|
||||
plugins: {
|
||||
legend: {
|
||||
display: true,
|
||||
labels: {
|
||||
usePointStyle: (showDataPoints && useDiffPointStyle) ? true : false
|
||||
},
|
||||
},
|
||||
tooltip: {
|
||||
enabled: showToolTip,
|
||||
},
|
||||
zoom: {
|
||||
pan: {
|
||||
enabled: true,
|
||||
mode: 'x',
|
||||
modifierKey: 'ctrl',
|
||||
},
|
||||
zoom: {
|
||||
drag: {
|
||||
enabled: true,
|
||||
borderColor: 'rgb(54, 162, 235)',
|
||||
borderWidth: 1,
|
||||
backgroundColor: 'rgba(54, 162, 235, 0.3)'
|
||||
},
|
||||
mode: 'xy',
|
||||
},
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
display: true,
|
||||
ticks: {
|
||||
display: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
if (_.isEmpty(graphData.datasets))
|
||||
return null;
|
||||
|
||||
if (graphType == 'L') {
|
||||
return <LineChart options={options} data={graphData} {...props}/>;
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
GenerateGraph.propTypes = {
|
||||
graphType: PropTypes.string,
|
||||
graphData: PropTypes.object,
|
||||
};
|
||||
|
||||
// This function is used to get the data set for the X axis and Y axis
|
||||
function getGraphDataSet(rows, columns, xaxis, yaxis, queryToolCtx) {
|
||||
// Function is used to the find the position of the column
|
||||
function getColumnPosition(colName) {
|
||||
return _.find(columns, (c)=>(c.name==colName))?.pos;
|
||||
}
|
||||
|
||||
let styleIndex = -1, colorIndex = -1;
|
||||
|
||||
return {
|
||||
'labels': rows.map((r, index)=>{
|
||||
let colPosition = getColumnPosition(xaxis);
|
||||
// If row number are selected then label should be the index + 1.
|
||||
if (xaxis === '<Row Number>') {
|
||||
return index + 1;
|
||||
}
|
||||
return r[colPosition];
|
||||
}),
|
||||
|
||||
'datasets': yaxis.map((colName)=>{
|
||||
// Loop is used to set the index for random color array
|
||||
if (colorIndex >= (CHART_THEME_COLORS_LENGTH - 1)) {
|
||||
colorIndex = -1;
|
||||
}
|
||||
colorIndex = colorIndex + 1;
|
||||
|
||||
let color = CHART_THEME_COLORS[queryToolCtx.preferences.misc.theme][colorIndex];
|
||||
let colPosition = getColumnPosition(colName);
|
||||
|
||||
// Loop is used to set the index for DATA_POINT_STYLE array
|
||||
if (styleIndex >= (DATA_POINT_STYLE.length - 1)) {
|
||||
styleIndex = -1;
|
||||
}
|
||||
styleIndex = styleIndex + 1;
|
||||
|
||||
return {
|
||||
label: colName,
|
||||
data: rows.map((r)=>r[colPosition]),
|
||||
backgroundColor: color,
|
||||
borderColor:color,
|
||||
pointHitRadius: DATA_POINT_SIZE,
|
||||
pointHoverRadius: 5,
|
||||
pointStyle: queryToolCtx.preferences.graphs['use_diff_point_style'] ? DATA_POINT_STYLE[styleIndex] : 'circle'
|
||||
};
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
export function GraphVisualiser({initColumns}) {
|
||||
const classes = useStyles();
|
||||
const chartObjRef = React.useRef();
|
||||
const contentRef = React.useRef();
|
||||
const eventBus = useContext(QueryToolEventsContext);
|
||||
const queryToolCtx = useContext(QueryToolContext);
|
||||
const [graphType, setGraphType] = useState('L');
|
||||
const [xaxis, setXAxis] = useState(null);
|
||||
const [yaxis, setYAxis] = useState([]);
|
||||
const [graphData, setGraphData] = useState({'datasets': []});
|
||||
const [loaderText, setLoaderText] = useState('');
|
||||
const [columns, setColumns] = useState(initColumns);
|
||||
const [graphHeight, setGraphHeight] = useState();
|
||||
|
||||
|
||||
// Create X axis options for drop down.
|
||||
let xAxisOptions = useMemo(()=>{
|
||||
let retVal = [{label:gettext('<Row Number>'), value:'<Row Number>'}];
|
||||
columns.forEach((element) => {
|
||||
if (!element.is_array) {
|
||||
retVal.push({label:gettext(element.name), value:element.name});
|
||||
}
|
||||
});
|
||||
return retVal;
|
||||
}, [columns]);
|
||||
|
||||
// Create Y axis options for drop down which must be of numeric type.
|
||||
let yAxisOptions = useMemo(()=>{
|
||||
let retVal = [];
|
||||
columns.forEach((element) => {
|
||||
if (!element.is_array && NUMERIC_TYPES.indexOf(element.type) >= 0) {
|
||||
retVal.push({label:gettext(element.name), value:element.name});
|
||||
}
|
||||
});
|
||||
return retVal;
|
||||
}, [columns]);
|
||||
|
||||
// optionsReload is required to reset the X axis and Y axis option in InputSelect.
|
||||
let optionsReload = useMemo(()=>{
|
||||
return columns.map((c)=>c.name).join('');
|
||||
}, [columns]);
|
||||
|
||||
// Use to register/deregister query execution end event. We need to reset graph
|
||||
// when query is changed and the execution of the query is ended.
|
||||
useEffect(()=>{
|
||||
let timeoutId;
|
||||
const contentResizeObserver = new ResizeObserver(()=>{
|
||||
clearTimeout(timeoutId);
|
||||
if(LayoutHelper.isTabVisible(queryToolCtx.docker, PANELS.GRAPH_VISUALISER)) {
|
||||
timeoutId = setTimeout(function () {
|
||||
setGraphHeight(contentRef.current.offsetHeight);
|
||||
}, 300);
|
||||
}
|
||||
});
|
||||
contentResizeObserver.observe(contentRef.current);
|
||||
|
||||
const resetGraphVisualiser = (newColumns)=>{
|
||||
setGraphData({'datasets': []});
|
||||
setXAxis(null);
|
||||
setYAxis([]);
|
||||
setColumns(newColumns);
|
||||
};
|
||||
|
||||
eventBus.registerListener(QUERY_TOOL_EVENTS.RESET_GRAPH_VISUALISER, resetGraphVisualiser);
|
||||
return ()=>{
|
||||
eventBus.deregisterListener(QUERY_TOOL_EVENTS.RESET_GRAPH_VISUALISER, resetGraphVisualiser);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Generate button callback
|
||||
const onGenerate = async ()=>{
|
||||
setLoaderText(gettext('Fetching all the records...'));
|
||||
|
||||
let url = url_for('sqleditor.fetch_all_from_start', {
|
||||
'trans_id': queryToolCtx.params.trans_id
|
||||
});
|
||||
|
||||
let res = await queryToolCtx.api.get(url);
|
||||
|
||||
// Set the Graph Data
|
||||
setGraphData(
|
||||
getGraphDataSet(res.data.data.result, columns, xaxis, yaxis, queryToolCtx)
|
||||
);
|
||||
|
||||
setLoaderText('');
|
||||
};
|
||||
|
||||
// Reset the zoom to normal
|
||||
const onResetZoom = ()=> {
|
||||
chartObjRef.current.resetZoom();
|
||||
};
|
||||
|
||||
// Download button callback
|
||||
const onDownloadGraph = ()=> {
|
||||
let a = document.createElement('a');
|
||||
a.href = chartObjRef.current.toBase64Image();
|
||||
a.download = 'graph_visualiser-' + new Date().getTime() + '.png';
|
||||
a.click();
|
||||
};
|
||||
|
||||
// This plugin is used to set the background color of the canvas. Very useful
|
||||
// when downloading the graph.
|
||||
const plugin = {
|
||||
beforeDraw: (chart) => {
|
||||
const ctx = chart.canvas.getContext('2d');
|
||||
ctx.save();
|
||||
ctx.globalCompositeOperation = 'destination-over';
|
||||
ctx.fillStyle = getComputedStyle(document.documentElement).getPropertyValue('--color-bg');
|
||||
ctx.fillRect(0, 0, chart.width, chart.height);
|
||||
ctx.restore();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Theme>
|
||||
<Box className={classes.mainContainer}>
|
||||
<Loader message={loaderText} />
|
||||
<Box className={classes.topContainer}>
|
||||
<Box className={classes.displayFlex}>
|
||||
<Box className={classes.displayFlex}>
|
||||
<span className={classes.spanLabel}>{gettext('X Axis')}</span>
|
||||
<InputSelect className={classes.selectCtrl} options={xAxisOptions}
|
||||
onChange={(v)=>setXAxis(v)} value={xaxis} optionsReloadBasis={optionsReload}/>
|
||||
</Box>
|
||||
<Box className={classes.displayFlex} marginLeft="auto">
|
||||
<span className={classes.spanLabel} >{gettext('Graph Type')}</span>
|
||||
<InputSelect className={classes.selectCtrl} controlProps={{allowClear: false}}
|
||||
options={[
|
||||
{label: gettext('Line Chart'), value: 'L'},
|
||||
{label: gettext('<More charts coming soon>'), value: 'L'}
|
||||
]} onChange={(v)=>setGraphType(v)} value={graphType} />
|
||||
</Box>
|
||||
<DefaultButton onClick={onGenerate} startIcon={<ShowChartRoundedIcon />}
|
||||
disabled={ _.isEmpty(xaxis) || yaxis.length <= 0 }>
|
||||
{gettext('Generate')}
|
||||
</DefaultButton>
|
||||
</Box>
|
||||
<Box className={classes.displayFlex}>
|
||||
<span className={classes.spanLabel}>{gettext('Y Axis')}</span>
|
||||
<InputSelect className={classes.selectCtrl} controlProps={{'multiple': true, allowSelectAll: true}}
|
||||
options={yAxisOptions} onChange={(v)=>setYAxis(v)} value={yaxis} optionsReloadBasis={optionsReload}/>
|
||||
</Box>
|
||||
</Box>
|
||||
<Box display="flex" marginLeft="3px" marginTop="3px">
|
||||
<PgButtonGroup size="small">
|
||||
<PgIconButton title={gettext('Zoom to original')} icon={<ZoomOutMapIcon style={{height: '1.2rem'}}/>}
|
||||
onClick={()=>onResetZoom()} disabled={ graphData.datasets.length <= 0 }/>
|
||||
<PgIconButton title={gettext('Download')} icon={<SaveAltIcon style={{height: '1.2rem'}}/>}
|
||||
onClick={onDownloadGraph} disabled={ graphData.datasets.length <= 0 }/>
|
||||
</PgButtonGroup>
|
||||
</Box>
|
||||
<Box ref={contentRef} className={classes.graphContainer}>
|
||||
<Box style={{height:`${graphHeight}px`}}>
|
||||
<GenerateGraph graphType={graphType} graphData={graphData} onInit={(chartObj)=> {
|
||||
chartObjRef.current = chartObj;
|
||||
}} plugins={plugin}/>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
</Theme>
|
||||
);
|
||||
}
|
||||
GraphVisualiser.propTypes = {
|
||||
initColumns: PropTypes.array
|
||||
};
|
||||
@@ -28,6 +28,7 @@ import moment from 'moment';
|
||||
import ConfirmSaveContent from '../dialogs/ConfirmSaveContent';
|
||||
import { makeStyles } from '@material-ui/styles';
|
||||
import EmptyPanelMessage from '../../../../../../static/js/components/EmptyPanelMessage';
|
||||
import { GraphVisualiser } from './GraphVisualiser';
|
||||
|
||||
export class ResultSetUtils {
|
||||
constructor(api, transId, isQueryTool=true) {
|
||||
@@ -919,6 +920,10 @@ export function ResultSet() {
|
||||
rsu.current.transId = queryToolCtx.params.trans_id;
|
||||
}, [queryToolCtx.params.trans_id]);
|
||||
|
||||
useEffect(()=>{
|
||||
eventBus.fireEvent(QUERY_TOOL_EVENTS.RESET_GRAPH_VISUALISER, columns);
|
||||
}, [columns]);
|
||||
|
||||
const fetchMoreRows = async (all=false, callback)=>{
|
||||
if(queryData.has_more_rows) {
|
||||
setIsLoadingMore(true);
|
||||
@@ -1259,6 +1264,22 @@ export function ResultSet() {
|
||||
setRows(newRows);
|
||||
};
|
||||
|
||||
useEffect(()=>{
|
||||
const showGraphVisualiser = async ()=>{
|
||||
LayoutHelper.openTab(queryToolCtx.docker, {
|
||||
id: PANELS.GRAPH_VISUALISER,
|
||||
title: gettext('Graph Visualiser'),
|
||||
content: <GraphVisualiser initColumns={columns} />,
|
||||
closable: true,
|
||||
}, PANELS.MESSAGES, 'after-tab', true);
|
||||
};
|
||||
|
||||
eventBus.registerListener(QUERY_TOOL_EVENTS.TRIGGER_GRAPH_VISUALISER, showGraphVisualiser);
|
||||
return ()=>{
|
||||
eventBus.deregisterListener(QUERY_TOOL_EVENTS.TRIGGER_GRAPH_VISUALISER, showGraphVisualiser);
|
||||
};
|
||||
}, [queryToolCtx.docker, columns]);
|
||||
|
||||
const rowKeyGetter = React.useCallback((row)=>row[rsu.current.clientPK]);
|
||||
return (
|
||||
<Box className={classes.root} ref={containerRef} tabIndex="0">
|
||||
|
||||
@@ -14,6 +14,7 @@ import KeyboardArrowDownIcon from '@material-ui/icons/KeyboardArrowDown';
|
||||
import PlaylistAddRoundedIcon from '@material-ui/icons/PlaylistAddRounded';
|
||||
import FileCopyRoundedIcon from '@material-ui/icons/FileCopyRounded';
|
||||
import DeleteRoundedIcon from '@material-ui/icons/DeleteRounded';
|
||||
import TimelineRoundedIcon from '@material-ui/icons/TimelineRounded';
|
||||
import { PasteIcon, SaveDataIcon } from '../../../../../../static/js/components/ExternalIcon';
|
||||
import GetAppRoundedIcon from '@material-ui/icons/GetAppRounded';
|
||||
import {QUERY_TOOL_EVENTS} from '../QueryToolConstants';
|
||||
@@ -82,6 +83,9 @@ export function ResultSetToolbar({containerRef, canEdit, totalRowCount}) {
|
||||
const downloadResult = useCallback(()=>{
|
||||
eventBus.fireEvent(QUERY_TOOL_EVENTS.TRIGGER_SAVE_RESULTS);
|
||||
}, []);
|
||||
const showGraphVisualiser = useCallback(()=>{
|
||||
eventBus.fireEvent(QUERY_TOOL_EVENTS.TRIGGER_GRAPH_VISUALISER);
|
||||
}, []);
|
||||
|
||||
const openMenu = useCallback((e)=>{
|
||||
setMenuOpenId(e.currentTarget.name);
|
||||
@@ -158,6 +162,10 @@ export function ResultSetToolbar({containerRef, canEdit, totalRowCount}) {
|
||||
onClick={downloadResult} shortcut={queryToolPref.download_results}
|
||||
disabled={buttonsDisabled['save-result']} />
|
||||
</PgButtonGroup>
|
||||
<PgButtonGroup size="small">
|
||||
<PgIconButton title={gettext('Graph Visualiser')} icon={<TimelineRoundedIcon />}
|
||||
onClick={showGraphVisualiser} disabled={buttonsDisabled['save-result']} />
|
||||
</PgButtonGroup>
|
||||
</Box>
|
||||
<PgMenu
|
||||
anchorRef={copyMenuRef}
|
||||
|
||||
Reference in New Issue
Block a user