1
0
mirror of synced 2026-10-23 07:08:55 +03:00

Restore the per-tab toolbar options when Query Tool tabs are restored on relaunch. #9603 (#10593)

This commit is contained in:
Dave Page
2026-10-15 17:29:43 +01:00
parent 09afc34c0e
commit 7828c9b2a1
5 changed files with 296 additions and 1 deletions

View File

@@ -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) {

View File

@@ -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',

View File

@@ -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']));

View File

@@ -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);

View File

@@ -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) => (
<QueryToolContext.Provider value={queryToolCtx}>
<QueryToolConnectionContext.Provider value={{connected: conn, obtainingConn: false, connectionStatus: null}}>
<QueryToolEventsContext.Provider value={eventBus}>
<div ref={containerRef}>
<ThemedMainToolBar containerRef={containerRef} onFilterClick={()=>{/* noop */}}
onManageMacros={()=>{/* noop */}} onAddToMacros={()=>{/* noop */}} />
</div>
</QueryToolEventsContext.Provider>
</QueryToolConnectionContext.Provider>
</QueryToolContext.Provider>
);
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();
});
});