Allow users to convert View/Edit table into a Query tool to enable editing the SQL generated. #5908

This commit is contained in:
Nikhil Mohite 2023-12-19 15:52:57 +05:30 committed by GitHub
parent 4db13facf7
commit 04580652ab
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
18 changed files with 296 additions and 20 deletions

View File

@ -222,3 +222,18 @@ To delete a row from the grid, click the trash icon.
:maxdepth: 2
viewdata_filter
Promote View/Edit Data to Query Tool
************************************
A View/Edit Data tab can be converted to a Query Tool Tab just by editing the query. Once you start editing, it will ask if you really want to move away from View/Edit.
.. image:: images/promote_view_edit_data_warning.png
:alt: Promote View/Edit Data tab to Query Tool tab warning
:align: center
You can disable the dialog by selecting the "Don't Ask again" checkbox. If you wish to resume the confirmation dialog, you can do it from "Prefrences -> Query Tool -> Editor -> Show View/Edit Data Promotion Warning?"
Once you chose to continue, you won't be able to use the features of View/Edit mode like the filter and sorting options, limit, etc. It is a one-way conversion. It will be a query tool now.

Binary file not shown.

After

Width:  |  Height:  |  Size: 99 KiB

View File

@ -20,7 +20,7 @@ from flask_security import login_required
from pgadmin.utils import PgAdminModule
from pgadmin.utils.ajax import success_return, \
make_response as ajax_response, internal_server_error
from pgadmin.utils.menu import MenuItem
from pgadmin.utils.ajax import make_json_response
from pgadmin.utils.preferences import Preferences
from pgadmin.utils.constants import MIMETYPE_APP_JS
from pgadmin.browser.server_groups import ServerGroupModule as sgm
@ -47,7 +47,9 @@ class PreferencesModule(PgAdminModule):
return [
'preferences.index',
'preferences.get_by_name',
'preferences.get_all'
'preferences.get_all',
'preferences.update_pref'
]
@ -245,3 +247,30 @@ def save():
**domain)
return response
@blueprint.route("/update", methods=["PUT"], endpoint="update_pref")
@login_required
def update():
"""
Update a specific preference.
"""
pref_data = get_data()
pref_data = json.loads(pref_data['pref_data'])
for data in pref_data:
if data['name'] in ['vw_edt_tab_title_placeholder',
'qt_tab_title_placeholder',
'debugger_tab_title_placeholder'] \
and data['value'].isspace():
data['value'] = ''
pref_module = Preferences.module(data['module'])
pref = pref_module.preference(data['name'])
# set user preferences
pref.set(data['value'])
return make_json_response(
data={'data': 'Success'},
status=200
)

View File

@ -13,6 +13,12 @@ const usePreferences = create((set, get)=>({
get().data, {'module': module, 'name': preference}
);
},
setPreference: (data)=> {
// Update Preferences and then refresh cache.
getApiInstance().put(url_for('preferences.update_pref'), data).then(()=> {
preferenceChangeBroadcast.postMessage('refresh');
});
},
getPreferencesForModule: function(module) {
let preferences = {};
_.forEach(
@ -62,6 +68,9 @@ export function setupPreferenceBroadcast() {
if(ev.data == 'sync') {
broadcast(usePreferences.getState());
}
if(ev.data == 'refresh') {
usePreferences.getState().cache();
}
};
}

View File

@ -18,10 +18,22 @@
"url": "/preferences/",
"is_positive_test": true,
"mocking_required": false,
"update_spec_pref": false,
"mock_data": {},
"expected_data": {
"status_code": 200
}
},{
"name": "Update specific preference",
"url": "/preferences/update_pref",
"is_positive_test": true,
"mocking_required": false,
"mock_data": {},
"update_spec_pref": true,
"expected_data": {
"status_code": 200
}
}
]
}

View File

@ -38,7 +38,10 @@ class GetPreferencesTest(BaseTestGenerator):
parent_node_dict['preferences'] = response.data
def runTest(self):
self.update_preferences()
if self.update_spec_pref:
self.update_preference()
else:
self.update_preferences()
def update_preferences(self):
if 'preferences' in parent_node_dict:
@ -58,3 +61,12 @@ class GetPreferencesTest(BaseTestGenerator):
self.assertTrue(response.status_code, 200)
else:
self.fail('Preferences not found')
def update_preference(self):
updated_data = [{'name': 'view_edit_promotion_warning',
'value': False,
'module': 'sqleditor'}]
response = self.tester.put(self.url,
data=json.dumps(updated_data),
content_type='html/json')
self.assertTrue(response.status_code, 200)

View File

@ -271,7 +271,7 @@ function ModalContainer({ id, title, content, dialogHeight, dialogWidth, onClose
return;
}
useModalRef.closeModal(id);
if(reason == 'escapeKeyDown') {
if(reason == 'escapeKeyDown' || reason == undefined) {
onClose?.();
}
};

View File

@ -415,7 +415,11 @@ def _connect(conn, **kwargs):
def _init_sqleditor(trans_id, connect, sgid, sid, did, dbname=None, **kwargs):
# Create asynchronous connection using random connection id.
conn_id = str(secrets.choice(range(1, 9999999)))
conn_id = kwargs['conn_id'] if 'conn_id' in kwargs else str(
secrets.choice(range(1, 9999999)))
if 'conn_id' in kwargs:
kwargs.pop('conn_id')
conn_id_ac = str(secrets.choice(range(1, 9999999)))
manager = get_driver(PG_DEFAULT_DRIVER).connection_manager(sid)
@ -425,7 +429,7 @@ def _init_sqleditor(trans_id, connect, sgid, sid, did, dbname=None, **kwargs):
try:
command_obj = ObjectRegistry.get_object(
'query_tool', conn_id=conn_id, sgid=sgid, sid=sid, did=did,
conn_id_ac=conn_id_ac
conn_id_ac=conn_id_ac, **kwargs
)
except Exception as e:
current_app.logger.error(e)
@ -868,6 +872,24 @@ def start_query_tool(trans_id):
)
connect = 'connect' in request.args and request.args['connect'] == '1'
if 'gridData' in session and str(trans_id) in session['gridData']:
data = pickle.loads(session['gridData'][str(trans_id)]['command_obj'])
if data.object_type == 'table':
manager = get_driver(PG_DEFAULT_DRIVER).connection_manager(
data.sid)
default_conn = manager.connection(conn_id=data.conn_id,
did=data.did)
kwargs = {
'user': default_conn.manager.user,
'role': default_conn.manager.role,
'password': default_conn.manager.password,
'conn_id': data.conn_id
}
is_error, errmsg, conn_id, version = _init_sqleditor(
trans_id, connect, data.sgid, data.sid, data.did, **kwargs)
if is_error:
return errmsg
return StartRunningQuery(blueprint, current_app.logger).execute(
sql, trans_id, session, connect

View File

@ -102,6 +102,7 @@ export default function QueryToolComponent({params, pgWindow, pgAdmin, selectedN
title: _.unescape(params.title),
is_query_tool: params.is_query_tool == 'true' ? true : false,
node_name: retrieveNodeName(selectedNodeInfo),
dbname: _.unescape(params.database_name) || getDatabaseLabel(selectedNodeInfo)
},
connection_list: [{
sgid: params.sgid,
@ -746,7 +747,37 @@ export default function QueryToolComponent({params, pgWindow, pgAdmin, selectedN
modal: modal,
params: qtState.params,
preferences: qtState.preferences,
mainContainerRef: containerRef
mainContainerRef: containerRef,
toggleQueryTool: () => setQtState((prev)=>{
return {
...prev,
params: {
...prev.params,
is_query_tool: true
}
};
}),
updateTitle: (title) => {
setPanelTitle(qtPanelDocker, qtPanelId, title, qtState, isDirtyRef.current);
setQtState((prev) => {
// Update connection Title
let newConnList = [...prev.connection_list];
newConnList.forEach((conn) => {
if (conn.sgid == params.sgid && conn.sid == params.sid && conn.did == params.did) {
conn.title = title;
conn.conn_title = title;
}
});
return {
...prev,
params: {
...prev.params,
title: title
},
connection_list: newConnList,
};
});
},
}), [qtState.params, qtState.preferences, containerRef.current]);
const queryToolConnContextValue = React.useMemo(()=>({

View File

@ -29,6 +29,7 @@ export const QUERY_TOOL_EVENTS = {
COPY_DATA: 'COPY_DATA',
SET_LIMIT_VALUE: 'SET_LIMIT_VALUE',
PROMOTE_TO_QUERY_TOOL: 'PROMOTE_TO_QUERY_TOOL',
SET_CONNECTION_STATUS: 'SET_CONNECTION_STATUS',
EXECUTION_START: 'EXECUTION_START',
EXECUTION_END: 'EXECUTION_END',

View File

@ -0,0 +1,63 @@
import React, { useState } from 'react';
import { useModalStyles } from '../../../../../../static/js/helpers/ModalProvider';
import gettext from 'sources/gettext';
import { Box, makeStyles } from '@material-ui/core';
import { DefaultButton, PrimaryButton } from '../../../../../../static/js/components/Buttons';
import CloseIcon from '@material-ui/icons/CloseRounded';
import HTMLReactParser from 'html-react-parser';
import PropTypes from 'prop-types';
import CheckRounded from '@material-ui/icons/CheckRounded';
import { InputCheckbox } from '../../../../../../static/js/components/FormComponents';
const useStyles = makeStyles(() => ({
saveChoice: {
margin: '10px 0 10px 10px',
}
}));
export default function ConfirmPromotionContent({ onContinue, onClose, closeModal, text }) {
const [formData, setFormData] = useState({
save_user_choice: false
});
const onDataChange = (e, id) => {
let val = e;
if (e?.target) {
val = e.target.value;
}
setFormData((prev) => ({ ...prev, [id]: val }));
};
const modalClasses = useModalStyles();
const classes = useStyles();
return (
<Box display="flex" flexDirection="column" height="100%">
<Box flexGrow="1" p={2}>{typeof (text) == 'string' ? HTMLReactParser(text) : text}</Box>
<Box className={classes.saveChoice}>
<InputCheckbox controlProps={{ label: gettext('Don\'t ask again') }} value={formData['save_user_choice']}
onChange={(e) => onDataChange(e.target.checked, 'save_user_choice')} />
</Box>
<Box className={modalClasses.footer}>
<DefaultButton data-test="close" startIcon={<CloseIcon />} onClick={() => {
onClose?.();
closeModal();
}} >{gettext('Cancel')}</DefaultButton>
<PrimaryButton data-test="Continue" className={modalClasses.margin} startIcon={<CheckRounded />} onClick={() => {
let postFormData = new FormData();
postFormData.append('pref_data', JSON.stringify([{ 'name': 'view_edit_promotion_warning', 'value': !formData.save_user_choice, 'module': 'sqleditor' }]));
onContinue?.(postFormData);
closeModal();
}} autoFocus={true} >{gettext('Continue')}</PrimaryButton>
</Box>
</Box>
);
}
ConfirmPromotionContent.propTypes = {
closeModal: PropTypes.func,
text: PropTypes.string,
onContinue: PropTypes.func,
onClose: PropTypes.func
};

View File

@ -269,6 +269,16 @@ export function MainToolBar({containerRef, onFilterClick, onManageMacros}) {
eventBus.registerListener(QUERY_TOOL_EVENTS.SET_LIMIT_VALUE, (l)=>{
setLimit(l);
});
eventBus.registerListener(QUERY_TOOL_EVENTS.PROMOTE_TO_QUERY_TOOL, ()=>{
setDisableButton('filter', true);
setDisableButton('limit', true);
setDisableButton('execute', false);
setDisableButton('execute-options', false);
});
}, []);
useEffect(()=>{

View File

@ -20,6 +20,10 @@ import { isMac } from '../../../../../../static/js/keyboard_shortcuts';
import { checkTrojanSource } from '../../../../../../static/js/utils';
import { parseApiError } from '../../../../../../static/js/api_instance';
import { usePgAdmin } from '../../../../../../static/js/BrowserComponent';
import ConfirmPromotionContent from '../dialogs/ConfirmPromotionContent';
import usePreferences from '../../../../../../preferences/static/js/store';
import { getTitle } from '../../sqleditor_title';
const useStyles = makeStyles(()=>({
sql: {
@ -246,6 +250,7 @@ export default function Query() {
const markedLine = React.useRef(0);
const marker = React.useRef();
const pgAdmin = usePgAdmin();
const preferencesStore = usePreferences();
const removeHighlightError = (cmObj)=>{
// Remove already existing marker
@ -340,7 +345,7 @@ export default function Query() {
query = query || editor.current?.getValue() || '';
}
if(query) {
eventBus.fireEvent(QUERY_TOOL_EVENTS.EXECUTION_START, query, explainObject, external);
eventBus.fireEvent(QUERY_TOOL_EVENTS.EXECUTION_START, query, explainObject, external, null);
}
} else {
eventBus.fireEvent(QUERY_TOOL_EVENTS.EXECUTION_START, null, null);
@ -427,6 +432,9 @@ export default function Query() {
});
eventBus.registerListener(QUERY_TOOL_EVENTS.EDITOR_SET_SQL, (value, focus=true)=>{
focus && editor.current?.focus();
if(!queryToolCtx.params.is_query_tool){
lastSavedText.current = value;
}
editor.current?.setValue(value);
if (value == '' && editor.current) {
editor.current.state.autoCompleteList = [];
@ -470,7 +478,7 @@ export default function Query() {
};
eventBus.registerListener(QUERY_TOOL_EVENTS.EDITOR_LAST_FOCUS, lastFocus);
setTimeout(()=>{
editor.current.focus();
(queryToolCtx.params.is_query_tool|| queryToolCtx.preferences.view_edit_promotion_warning) && editor.current.focus();
}, 250);
}, []);
@ -507,7 +515,7 @@ export default function Query() {
);
}, [queryToolCtx.params.trans_id]);
const isDirty = ()=>(queryToolCtx.params.is_query_tool && lastSavedText.current !== editor.current.getValue());
const isDirty = ()=>(lastSavedText.current !== editor.current.getValue());
const cursorActivity = useCallback(_.debounce((cmObj)=>{
const c = cmObj.getCursor();
@ -517,8 +525,58 @@ export default function Query() {
const change = useCallback(()=>{
eventBus.fireEvent(QUERY_TOOL_EVENTS.QUERY_CHANGED, isDirty());
if(!queryToolCtx.params.is_query_tool && isDirty()){
if(queryToolCtx.preferences.sqleditor.view_edit_promotion_warning){
checkViewEditDataPromotion();
} else {
promoteToQueryTool();
}
}
}, []);
const closePromotionWarning = (closeModal)=>{
if(isDirty()) {
editor.current.undo();
closeModal?.();
}
};
const checkViewEditDataPromotion = () => {
queryToolCtx.modal.showModal(gettext('Promote to Query Tool'), (closeModal) =>{
return (<ConfirmPromotionContent
closeModal={closeModal}
text={'Manually editing the query will cause this View/Edit Data tab to be converted to a Query Tool tab. You will be able to edit the query text freely, but no longer be able to use the toolbar buttons for sorting and filtering data. </br> Do you wish to continue?'}
onContinue={(formData)=>{
promoteToQueryTool();
let cursor = editor.current.getCursor();
editor.current.setValue(editor.current.getValue());
editor.current.setCursor(cursor);
editor.current.focus();
let title = getTitle(pgAdmin, queryToolCtx.preferences.browser, null,null,queryToolCtx.params.server_name, queryToolCtx.params.dbname, queryToolCtx.params.user);
queryToolCtx.updateTitle(title);
preferencesStore.setPreference(formData);
return true;
}}
onClose={()=>{
closePromotionWarning(closeModal);
}}
/>);
}, {
onClose:()=>{
closePromotionWarning();
}
});
};
const promoteToQueryTool = () => {
if(!queryToolCtx.params.is_query_tool){
queryToolCtx.toggleQueryTool();
queryToolCtx.params.is_query_tool = true;
eventBus.fireEvent(QUERY_TOOL_EVENTS.PROMOTE_TO_QUERY_TOOL);
}
};
return <CodeMirror
currEditor={(obj)=>{
editor.current=obj;
@ -530,7 +588,6 @@ export default function Query() {
'cursorActivity': cursorActivity,
'change': change,
}}
disabled={!queryToolCtx.params.is_query_tool}
autocomplete={true}
/>;
}

View File

@ -182,7 +182,7 @@ export class ResultSetUtils {
}
async startExecution(query, explainObject, onIncorrectSQL, flags={
isQueryTool: true, external: false, reconnect: false,
isQueryTool: true, external: false, reconnect: false
}) {
let startTime = new Date();
this.eventBus.fireEvent(QUERY_TOOL_EVENTS.SET_MESSAGE, '');

View File

@ -47,7 +47,7 @@ function hasServerInformations(parentData) {
return parentData.server === undefined;
}
function generateTitle(pgBrowser, treeIdentifier) {
export function generateTitle(pgBrowser, treeIdentifier) {
return getPanelTitle(pgBrowser, treeIdentifier);
}

View File

@ -181,6 +181,17 @@ def register_query_tool_preferences(self):
)
)
self.view_edit_promotion_warning = self.preference.register(
'Editor', 'view_edit_promotion_warning',
gettext("Show View/Edit Data Promotion Warning?"),
'boolean', True,
category_label=PREF_LABEL_OPTIONS,
help_str=gettext(
'If set to True, View/Edit Data tool will show promote to '
'Query tool confirm dialog on query edit.'
)
)
self.csv_quoting = self.preference.register(
'CSV_output', 'csv_quoting',
gettext("CSV quoting"), 'options', 'strings',

View File

@ -66,12 +66,14 @@ class StartRunningQuery:
manager = get_driver(
PG_DEFAULT_DRIVER).connection_manager(
transaction_object.sid)
conn = manager.connection(did=transaction_object.did,
database=transaction_object.dbname,
conn_id=self.connection_id,
auto_reconnect=False,
use_binary_placeholder=True,
array_to_string=True)
conn = manager.connection(
did=transaction_object.did,
conn_id=self.connection_id,
auto_reconnect=False,
use_binary_placeholder=True,
array_to_string=True,
**({"database": transaction_object.dbname} if hasattr(
transaction_object,'dbname') else {}))
except (ConnectionLost, SSHTunnelConnectionLost, CryptKeyMissing):
raise
except Exception as e:
@ -126,7 +128,8 @@ class StartRunningQuery:
def __execute_query(self, conn, session_obj, sql, trans_id, trans_obj):
# on successful connection set the connection id to the
# transaction object
trans_obj.set_connection_id(self.connection_id)
if hasattr(trans_obj, 'set_connection_id'):
trans_obj.set_connection_id(self.connection_id)
StartRunningQuery.save_transaction_in_session(session_obj,
trans_id, trans_obj)

View File

@ -31,6 +31,7 @@ const fromTextAreaRet = {
'scrollIntoView': jest.fn(),
'getWrapperElement': ()=>document.createElement('div'),
'on': jest.fn(),
'off': jest.fn(),
'toTextArea': jest.fn(),
};
module.exports = {