From 7828c9b2a18b8a3904818a87765b324d47ac43af Mon Sep 17 00:00:00 2001 From: Dave Page Date: Thu, 15 Oct 2026 17:29:43 +0100 Subject: [PATCH] Restore the per-tab toolbar options when Query Tool tabs are restored on relaunch. #9603 (#10593) --- .../js/components/QueryToolComponent.jsx | 9 + .../js/components/QueryToolConstants.js | 1 + .../js/components/sections/MainToolBar.jsx | 87 ++++++++ .../static/js/components/sections/Query.jsx | 3 +- .../sqleditor/MainToolBarRestore.spec.js | 197 ++++++++++++++++++ 5 files changed, 296 insertions(+), 1 deletion(-) create mode 100644 web/regression/javascript/sqleditor/MainToolBarRestore.spec.js diff --git a/web/pgadmin/tools/sqleditor/static/js/components/QueryToolComponent.jsx b/web/pgadmin/tools/sqleditor/static/js/components/QueryToolComponent.jsx index f89b5f9e2..4cc98eee0 100644 --- a/web/pgadmin/tools/sqleditor/static/js/components/QueryToolComponent.jsx +++ b/web/pgadmin/tools/sqleditor/static/js/components/QueryToolComponent.jsx @@ -171,6 +171,10 @@ export default function QueryToolComponent({params, pgWindow, pgAdmin, selectedN }; const isDirtyRef = useRef(false); // usefull when conn change. const qtStateRef = useRef(qtState); + // The toolbar's check menu options (execute, explain and open file + // options) for this tab, kept up to date by MainToolBar so that they are + // saved and restored with the rest of the tab's state. + const toolbarOptionsRef = useRef(null); const eventBus = useRef(eventBusObj || (new EventBus())); const docker = useRef(null); const api = useMemo(()=>getApiInstance(), []); @@ -343,6 +347,10 @@ export default function QueryToolComponent({params, pgWindow, pgAdmin, selectedN }, })); } + const toolbarOptions = toolContent.connectionInfo?.toolbar_options; + if (toolbarOptions && typeof toolbarOptions === 'object') { + eventBus.current.fireEvent(QUERY_TOOL_EVENTS.RESTORE_TOOLBAR_OPTIONS, toolbarOptions); + } if (toolContent?.modifiedExternally) { toolContent = await fmUtilsObj.warnFileReload(toolContent?.fileName, toolContent.data, ''); } @@ -1014,6 +1022,7 @@ export default function QueryToolComponent({params, pgWindow, pgAdmin, selectedN eol: qtState.eol, connection_list: qtState.connection_list, current_file: qtState.current_file, + toolbarOptionsRef: toolbarOptionsRef, toggleQueryTool: () => { let panel = qtPanelDocker?.find(qtPanelId); if (!panel) { diff --git a/web/pgadmin/tools/sqleditor/static/js/components/QueryToolConstants.js b/web/pgadmin/tools/sqleditor/static/js/components/QueryToolConstants.js index ee2277aae..c3c6fa28b 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 = { TRIGGER_GRAPH_VISUALISER: 'TRIGGER_GRAPH_VISUALISER', TRIGGER_SELECT_ALL: 'TRIGGER_SELECT_ALL', TRIGGER_SAVE_QUERY_TOOL_DATA: 'TRIGGER_SAVE_QUERY_TOOL_DATA', + RESTORE_TOOLBAR_OPTIONS: 'RESTORE_TOOLBAR_OPTIONS', TRIGGER_GET_QUERY_CONTENT: 'TRIGGER_GET_QUERY_CONTENT', TRIGGER_SAVE_BINARY_DATA: 'TRIGGER_SAVE_BINARY_DATA', 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 2683e4a18..5cac5d3dd 100644 --- a/web/pgadmin/tools/sqleditor/static/js/components/sections/MainToolBar.jsx +++ b/web/pgadmin/tools/sqleditor/static/js/components/sections/MainToolBar.jsx @@ -34,6 +34,7 @@ import CustomPropTypes from '../../../../../../static/js/custom_prop_types'; import ConfirmTransactionContent from '../dialogs/ConfirmTransactionContent'; import { LayoutDocker } from '../../../../../../static/js/helpers/Layout'; import CloseRunningDialog from '../dialogs/CloseRunningDialog'; +import { useApplicationState } from '../../../../../../settings/static/ApplicationStateProvider'; const StyledBox = styled(Box)(({theme}) => ({ padding: '2px 4px', @@ -52,6 +53,30 @@ function changeQueryExecutionSettings(type, api, transId, value) { return api.post(url, JSON.stringify(value)); } +// Execute options that also live on the server side transaction object, so +// they must be sent to the server when changed. +const EXECUTION_OPTIONS = ['auto_commit', 'auto_rollback', 'server_cursor']; + +// Check menu options that are saved and restored with the tab's state. +export const RESTORABLE_TOOLBAR_OPTIONS = [ + ...EXECUTION_OPTIONS, + 'open_in_new_tab', + 'explain_verbose', 'explain_costs', 'explain_buffers', 'explain_timing', + 'explain_summary', 'explain_settings', 'explain_wal', 'explain_generic_plan', + 'explain_memory', 'explain_serialize_text', 'explain_serialize_binary', +]; + +// Keep only the known options with boolean values from saved tab state. +export function getRestorableToolbarOptions(options) { + return _.pickBy(_.pick(options ?? {}, RESTORABLE_TOOLBAR_OPTIONS), _.isBoolean); +} + +// The execute options whose restored value differs from the current one. +export function getChangedExecutionOptions(restored, current) { + return _.pickBy(_.pick(restored, EXECUTION_OPTIONS), + (val, key)=>val !== Boolean(current?.[key])); +} + export function MainToolBar({containerRef, onFilterClick, onManageMacros, onAddToMacros}) { const eventBus = useContext(QueryToolEventsContext); const queryToolCtx = useContext(QueryToolContext); @@ -73,6 +98,10 @@ export function MainToolBar({containerRef, onFilterClick, onManageMacros, onAddT }); const {openMenuName, toggleMenu, onMenuClose} = usePgMenuGroup(); const [checkedMenuItems, setCheckedMenuItems] = React.useState({}); + const checkedMenuItemsRef = React.useRef(checkedMenuItems); + // Restored execute options waiting for the connection to be established. + const [pendingExecOptions, setPendingExecOptions] = useState(null); + const {isSaveToolDataEnabled} = useApplicationState() ?? {}; /* Menu button refs */ const saveAsMenuRef = React.useRef(null); const openInNewTabMenuRef = React.useRef(null); @@ -153,8 +182,66 @@ export function MainToolBar({containerRef, onFilterClick, onManageMacros, onAddT ...otherVars, }; }); + if(isSaveToolDataEnabled?.('sqleditor')) { + eventBus.fireEvent(QUERY_TOOL_EVENTS.TRIGGER_SAVE_QUERY_TOOL_DATA); + } }, []); + useEffect(()=>{ + checkedMenuItemsRef.current = checkedMenuItems; + if(queryToolCtx.toolbarOptionsRef) { + queryToolCtx.toolbarOptionsRef.current = checkedMenuItems; + } + }, [checkedMenuItems]); + + useEffect(()=>{ + const restoreToolbarOptions = (options)=>{ + let restored = getRestorableToolbarOptions(options); + // Setting execute options on a View/Edit Data transaction would + // promote it to a Query Tool, so only restore them for a Query Tool. + if(!queryToolCtx.params.is_query_tool) { + restored = _.omit(restored, EXECUTION_OPTIONS); + } + const changedExecOptions = getChangedExecutionOptions(restored, checkedMenuItemsRef.current); + if(!_.isEmpty(changedExecOptions)) { + if('server_cursor' in changedExecOptions) { + queryToolCtx.updateServerCursor({server_cursor: changedExecOptions.server_cursor}); + } + setPendingExecOptions((prev)=>({...prev, ...changedExecOptions})); + } + setCheckedMenuItems((prev)=>({...prev, ...restored})); + }; + eventBus.registerListener(QUERY_TOOL_EVENTS.RESTORE_TOOLBAR_OPTIONS, restoreToolbarOptions); + return ()=>{ + eventBus.deregisterListener(QUERY_TOOL_EVENTS.RESTORE_TOOLBAR_OPTIONS, restoreToolbarOptions); + }; + }, []); + + useEffect(()=>{ + /* The transaction is created with the preference values, so restored + execute options can only be applied once it exists. */ + if(_.isEmpty(pendingExecOptions) || !queryToolConnCtx.connected) { + return; + } + setPendingExecOptions(null); + Object.entries(pendingExecOptions).forEach(([option, value])=>{ + changeQueryExecutionSettings(option, queryToolCtx.api, queryToolCtx.params.trans_id, value) + .catch((error)=>{ + setCheckedMenuItems((prev)=>({...prev, [option]: !value})); + if(option === 'server_cursor') { + queryToolCtx.updateServerCursor({server_cursor: !value}); + } + // Save the reverted value so it is not restored again next time. + if(isSaveToolDataEnabled?.('sqleditor')) { + eventBus.fireEvent(QUERY_TOOL_EVENTS.TRIGGER_SAVE_QUERY_TOOL_DATA); + } + eventBus.fireEvent(QUERY_TOOL_EVENTS.HANDLE_API_ERROR, error, { + checkTransaction: true, + }); + }); + }); + }, [pendingExecOptions, queryToolConnCtx.connected]); + const openFile = useCallback(()=>{ confirmDiscard(()=>{ eventBus.fireEvent(QUERY_TOOL_EVENTS.TRIGGER_LOAD_FILE, Boolean(checkedMenuItems['open_in_new_tab'])); 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 d2759ea51..edbf51053 100644 --- a/web/pgadmin/tools/sqleditor/static/js/components/sections/Query.jsx +++ b/web/pgadmin/tools/sqleditor/static/js/components/sections/Query.jsx @@ -411,7 +411,8 @@ export default function Query({onTextSelect, setQtStatePartial}) { let connectionInfo = { connection_list: currentConnList, open_file_name: currentFile, - is_editor_dirty: editor.current.isDirty() + is_editor_dirty: editor.current.isDirty(), + toolbar_options: queryToolCtx.toolbarOptionsRef?.current, }; saveToolData('sqleditor', connectionInfo, currentTransId, editor.current.getValue()); setSaveQtData(false); diff --git a/web/regression/javascript/sqleditor/MainToolBarRestore.spec.js b/web/regression/javascript/sqleditor/MainToolBarRestore.spec.js new file mode 100644 index 000000000..8d7d88f8d --- /dev/null +++ b/web/regression/javascript/sqleditor/MainToolBarRestore.spec.js @@ -0,0 +1,197 @@ +///////////////////////////////////////////////////////////// +// +// pgAdmin 4 - PostgreSQL Tools +// +// Copyright (C) 2013 - 2026, The pgAdmin Development Team +// This software is released under the PostgreSQL Licence +// +////////////////////////////////////////////////////////////// + +jest.mock('sources/url_for', () => ({ + __esModule: true, + default: jest.fn((endpoint, args) => `/mock/${endpoint}/${args?.trans_id}`), +})); + +// Avoid pulling in everything QueryToolComponent imports. +jest.mock('../../../pgadmin/tools/sqleditor/static/js/components/QueryToolComponent.jsx', () => { + const React = require('react'); + return { + QueryToolContext: React.createContext(null), + QueryToolConnectionContext: React.createContext(null), + QueryToolEventsContext: React.createContext(null), + }; +}); + +const mockIsSaveToolDataEnabled = jest.fn(() => true); +jest.mock('../../../pgadmin/settings/static/ApplicationStateProvider', () => ({ + useApplicationState: () => ({isSaveToolDataEnabled: mockIsSaveToolDataEnabled}), +})); + +import React from 'react'; +import { render, act, waitFor } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import { withTheme } from '../fake_theme'; +import EventBus from '../../../pgadmin/static/js/helpers/EventBus'; +import { + MainToolBar, getRestorableToolbarOptions, getChangedExecutionOptions, +} from '../../../pgadmin/tools/sqleditor/static/js/components/sections/MainToolBar'; +import { + QueryToolContext, QueryToolConnectionContext, QueryToolEventsContext, +} from '../../../pgadmin/tools/sqleditor/static/js/components/QueryToolComponent.jsx'; +import { QUERY_TOOL_EVENTS } from '../../../pgadmin/tools/sqleditor/static/js/components/QueryToolConstants'; + +const sqleditorPrefs = { + auto_commit: true, + auto_rollback: false, + server_cursor: false, + open_in_new_tab: false, + explain_verbose: false, + explain_costs: false, + explain_buffers: false, + explain_timing: false, + explain_summary: false, + explain_settings: false, + explain_wal: false, + explain_generic_plan: false, + explain_memory: false, +}; + +describe('getRestorableToolbarOptions', () => { + it('keeps only known options with boolean values', () => { + expect(getRestorableToolbarOptions({ + auto_commit: false, + open_in_new_tab: true, + explain_verbose: 'yes', + unknown_option: true, + })).toEqual({auto_commit: false, open_in_new_tab: true}); + }); + + it('copes with missing saved options', () => { + expect(getRestorableToolbarOptions(undefined)).toEqual({}); + expect(getRestorableToolbarOptions(null)).toEqual({}); + }); +}); + +describe('getChangedExecutionOptions', () => { + it('returns only the execute options that differ', () => { + expect(getChangedExecutionOptions( + {auto_commit: false, auto_rollback: false, server_cursor: true, open_in_new_tab: true}, + {auto_commit: true, auto_rollback: false, server_cursor: false}, + )).toEqual({auto_commit: false, server_cursor: true}); + }); +}); + +describe('MainToolBar toolbar options restore', () => { + let eventBus, queryToolCtx; + const ThemedMainToolBar = withTheme(MainToolBar); + + const renderToolBar = (connected, isQueryTool=true) => { + const containerRef = React.createRef(); + const ui = (conn) => ( + + + +
+ {/* noop */}} + onManageMacros={()=>{/* noop */}} onAddToMacros={()=>{/* noop */}} /> +
+
+
+
+ ); + queryToolCtx.params.is_query_tool = isQueryTool; + const result = render(ui(connected)); + return {...result, setConnected: (conn) => result.rerender(ui(conn))}; + }; + + beforeEach(() => { + eventBus = new EventBus(); + queryToolCtx = { + params: {trans_id: 1234, is_query_tool: true, macros: []}, + preferences: {sqleditor: {...sqleditorPrefs}, editor: {}}, + api: {post: jest.fn(() => Promise.resolve({data: {}}))}, + modal: {}, + toolbarOptionsRef: {current: null}, + updateServerCursor: jest.fn(), + }; + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('publishes the current options for saving with the tab state', async () => { + renderToolBar(true); + await waitFor(() => { + expect(queryToolCtx.toolbarOptionsRef.current).toEqual(expect.objectContaining({ + auto_commit: true, open_in_new_tab: false, + })); + }); + }); + + it('restores saved options and applies changed execute options once connected', async () => { + const {setConnected} = renderToolBar(false); + await waitFor(() => expect(queryToolCtx.toolbarOptionsRef.current).not.toBeNull()); + + act(() => { + eventBus.fireEvent(QUERY_TOOL_EVENTS.RESTORE_TOOLBAR_OPTIONS, { + auto_commit: false, + auto_rollback: false, + server_cursor: true, + open_in_new_tab: true, + explain_verbose: true, + }); + }); + + await waitFor(() => { + expect(queryToolCtx.toolbarOptionsRef.current).toEqual(expect.objectContaining({ + auto_commit: false, server_cursor: true, open_in_new_tab: true, explain_verbose: true, + })); + }); + expect(queryToolCtx.updateServerCursor).toHaveBeenCalledWith({server_cursor: true}); + // Nothing can be sent to the server until the transaction exists. + expect(queryToolCtx.api.post).not.toHaveBeenCalled(); + + setConnected(true); + await waitFor(() => expect(queryToolCtx.api.post).toHaveBeenCalledTimes(2)); + expect(queryToolCtx.api.post).toHaveBeenCalledWith('/mock/sqleditor.auto_commit/1234', 'false'); + expect(queryToolCtx.api.post).toHaveBeenCalledWith('/mock/sqleditor.server_cursor/1234', 'true'); + }); + + it('reverts and saves a restored execute option the server rejects', async () => { + queryToolCtx.api.post = jest.fn(() => Promise.reject(new Error('failed'))); + const fireEvent = jest.spyOn(eventBus, 'fireEvent'); + renderToolBar(true); + await waitFor(() => expect(queryToolCtx.toolbarOptionsRef.current).not.toBeNull()); + + act(() => { + eventBus.fireEvent(QUERY_TOOL_EVENTS.RESTORE_TOOLBAR_OPTIONS, {auto_commit: false}); + }); + + await waitFor(() => { + expect(fireEvent).toHaveBeenCalledWith(QUERY_TOOL_EVENTS.TRIGGER_SAVE_QUERY_TOOL_DATA); + }); + await waitFor(() => { + expect(queryToolCtx.toolbarOptionsRef.current.auto_commit).toBe(true); + }); + fireEvent.mockRestore(); + }); + + it('does not restore execute options for View/Edit Data', async () => { + renderToolBar(true, false); + await waitFor(() => expect(queryToolCtx.toolbarOptionsRef.current).not.toBeNull()); + + act(() => { + eventBus.fireEvent(QUERY_TOOL_EVENTS.RESTORE_TOOLBAR_OPTIONS, { + auto_commit: false, explain_costs: true, + }); + }); + + await waitFor(() => { + expect(queryToolCtx.toolbarOptionsRef.current).toEqual(expect.objectContaining({ + auto_commit: true, explain_costs: true, + })); + }); + expect(queryToolCtx.api.post).not.toHaveBeenCalled(); + }); +});