diff --git a/docs/en_US/editgrid.rst b/docs/en_US/editgrid.rst index c7a5e5fb73f..663c75fb352 100644 --- a/docs/en_US/editgrid.rst +++ b/docs/en_US/editgrid.rst @@ -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. \ No newline at end of file diff --git a/docs/en_US/images/promote_view_edit_data_warning.png b/docs/en_US/images/promote_view_edit_data_warning.png new file mode 100644 index 00000000000..6b3a43dd1f2 Binary files /dev/null and b/docs/en_US/images/promote_view_edit_data_warning.png differ diff --git a/web/pgadmin/preferences/__init__.py b/web/pgadmin/preferences/__init__.py index d45bdb8d961..fd4229208ff 100644 --- a/web/pgadmin/preferences/__init__.py +++ b/web/pgadmin/preferences/__init__.py @@ -20,7 +20,7 @@ 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 @@ def get_exposed_url_endpoints(self): 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 + ) diff --git a/web/pgadmin/preferences/static/js/store.js b/web/pgadmin/preferences/static/js/store.js index f94c5dcb4c2..7804a0ce898 100644 --- a/web/pgadmin/preferences/static/js/store.js +++ b/web/pgadmin/preferences/static/js/store.js @@ -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(); + } }; } diff --git a/web/pgadmin/preferences/tests/preferences_test_data.json b/web/pgadmin/preferences/tests/preferences_test_data.json index a4327dd4dda..294d56c8a47 100644 --- a/web/pgadmin/preferences/tests/preferences_test_data.json +++ b/web/pgadmin/preferences/tests/preferences_test_data.json @@ -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 + } } + ] } diff --git a/web/pgadmin/preferences/tests/test_preferences_update.py b/web/pgadmin/preferences/tests/test_preferences_update.py index 170f813fb35..3cd878670ac 100644 --- a/web/pgadmin/preferences/tests/test_preferences_update.py +++ b/web/pgadmin/preferences/tests/test_preferences_update.py @@ -38,7 +38,10 @@ def setUp(self): 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 @@ def update_preferences(self): 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) diff --git a/web/pgadmin/static/js/helpers/ModalProvider.jsx b/web/pgadmin/static/js/helpers/ModalProvider.jsx index e05ef352a16..75771750f25 100644 --- a/web/pgadmin/static/js/helpers/ModalProvider.jsx +++ b/web/pgadmin/static/js/helpers/ModalProvider.jsx @@ -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?.(); } }; diff --git a/web/pgadmin/tools/sqleditor/__init__.py b/web/pgadmin/tools/sqleditor/__init__.py index acf6f54a811..0fdf3357239 100644 --- a/web/pgadmin/tools/sqleditor/__init__.py +++ b/web/pgadmin/tools/sqleditor/__init__.py @@ -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 diff --git a/web/pgadmin/tools/sqleditor/static/js/components/QueryToolComponent.jsx b/web/pgadmin/tools/sqleditor/static/js/components/QueryToolComponent.jsx index f5a8fb74d57..62dc1392026 100644 --- a/web/pgadmin/tools/sqleditor/static/js/components/QueryToolComponent.jsx +++ b/web/pgadmin/tools/sqleditor/static/js/components/QueryToolComponent.jsx @@ -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(()=>({ diff --git a/web/pgadmin/tools/sqleditor/static/js/components/QueryToolConstants.js b/web/pgadmin/tools/sqleditor/static/js/components/QueryToolConstants.js index e959223ca71..1a5d4e30fcc 100644 --- a/web/pgadmin/tools/sqleditor/static/js/components/QueryToolConstants.js +++ b/web/pgadmin/tools/sqleditor/static/js/components/QueryToolConstants.js @@ -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', diff --git a/web/pgadmin/tools/sqleditor/static/js/components/dialogs/ConfirmPromotionContent.jsx b/web/pgadmin/tools/sqleditor/static/js/components/dialogs/ConfirmPromotionContent.jsx new file mode 100644 index 00000000000..df40aa560ac --- /dev/null +++ b/web/pgadmin/tools/sqleditor/static/js/components/dialogs/ConfirmPromotionContent.jsx @@ -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 ( + + {typeof (text) == 'string' ? HTMLReactParser(text) : text} + + onDataChange(e.target.checked, 'save_user_choice')} /> + + + } onClick={() => { + onClose?.(); + closeModal(); + }} >{gettext('Cancel')} + } 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')} + + + ); +} + +ConfirmPromotionContent.propTypes = { + closeModal: PropTypes.func, + text: PropTypes.string, + onContinue: PropTypes.func, + onClose: PropTypes.func +}; diff --git a/web/pgadmin/tools/sqleditor/static/js/components/sections/MainToolBar.jsx b/web/pgadmin/tools/sqleditor/static/js/components/sections/MainToolBar.jsx index f72ff7c53a5..be35bf41891 100644 --- a/web/pgadmin/tools/sqleditor/static/js/components/sections/MainToolBar.jsx +++ b/web/pgadmin/tools/sqleditor/static/js/components/sections/MainToolBar.jsx @@ -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(()=>{ diff --git a/web/pgadmin/tools/sqleditor/static/js/components/sections/Query.jsx b/web/pgadmin/tools/sqleditor/static/js/components/sections/Query.jsx index d01defc0e3a..9f01c73a080 100644 --- a/web/pgadmin/tools/sqleditor/static/js/components/sections/Query.jsx +++ b/web/pgadmin/tools/sqleditor/static/js/components/sections/Query.jsx @@ -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 ( 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 { editor.current=obj; @@ -530,7 +588,6 @@ export default function Query() { 'cursorActivity': cursorActivity, 'change': change, }} - disabled={!queryToolCtx.params.is_query_tool} autocomplete={true} />; } diff --git a/web/pgadmin/tools/sqleditor/static/js/components/sections/ResultSet.jsx b/web/pgadmin/tools/sqleditor/static/js/components/sections/ResultSet.jsx index 533dad8300f..1a7e9fb2cc6 100644 --- a/web/pgadmin/tools/sqleditor/static/js/components/sections/ResultSet.jsx +++ b/web/pgadmin/tools/sqleditor/static/js/components/sections/ResultSet.jsx @@ -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, ''); diff --git a/web/pgadmin/tools/sqleditor/static/js/show_query_tool.js b/web/pgadmin/tools/sqleditor/static/js/show_query_tool.js index 80fe53ce429..9c9aaeecdb9 100644 --- a/web/pgadmin/tools/sqleditor/static/js/show_query_tool.js +++ b/web/pgadmin/tools/sqleditor/static/js/show_query_tool.js @@ -47,7 +47,7 @@ function hasServerInformations(parentData) { return parentData.server === undefined; } -function generateTitle(pgBrowser, treeIdentifier) { +export function generateTitle(pgBrowser, treeIdentifier) { return getPanelTitle(pgBrowser, treeIdentifier); } diff --git a/web/pgadmin/tools/sqleditor/utils/query_tool_preferences.py b/web/pgadmin/tools/sqleditor/utils/query_tool_preferences.py index 20bca9ad907..86f3f961f2a 100644 --- a/web/pgadmin/tools/sqleditor/utils/query_tool_preferences.py +++ b/web/pgadmin/tools/sqleditor/utils/query_tool_preferences.py @@ -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', diff --git a/web/pgadmin/tools/sqleditor/utils/start_running_query.py b/web/pgadmin/tools/sqleditor/utils/start_running_query.py index 5ce45cd7110..9b910c79a7d 100644 --- a/web/pgadmin/tools/sqleditor/utils/start_running_query.py +++ b/web/pgadmin/tools/sqleditor/utils/start_running_query.py @@ -66,12 +66,14 @@ def execute(self, sql, trans_id, http_session, connect=False): 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 @@ def __retrieve_connection_id(self, trans_obj): 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) diff --git a/web/regression/javascript/__mocks__/bundled_codemirror.js b/web/regression/javascript/__mocks__/bundled_codemirror.js index c71311f0bfa..b6846cc6d6c 100644 --- a/web/regression/javascript/__mocks__/bundled_codemirror.js +++ b/web/regression/javascript/__mocks__/bundled_codemirror.js @@ -31,6 +31,7 @@ const fromTextAreaRet = { 'scrollIntoView': jest.fn(), 'getWrapperElement': ()=>document.createElement('div'), 'on': jest.fn(), + 'off': jest.fn(), 'toTextArea': jest.fn(), }; module.exports = {