Improve keyboard shortcuts for tab switching, line duplication in the SQL editor, and Enter/Escape in dialogs. #3834, #5691, #7167, #7232 (#10067)
Tab switching now works when focus is inside a tool and defaults to Ctrl+Alt+PageUp/PageDown, Ctrl/Cmd+Shift+D duplicates the current line or selection in the SQL editor, and dialogs handle Enter and Escape consistently.
This commit is contained in:
@@ -15,47 +15,47 @@ When using main browser window, the following keyboard shortcuts are available:
|
||||
:class: longtable
|
||||
:widths: 2 2 3
|
||||
|
||||
+----------------------------+--------------------+------------------------------------+
|
||||
| Shortcut (Windows/Linux) | Shortcut (Mac) | Function |
|
||||
+============================+====================+====================================+
|
||||
| Shift + Ctrl + a | Shift + Ctrl + a | Add grid row |
|
||||
+----------------------------+--------------------+------------------------------------+
|
||||
| Shift + Alt + b | Shift + Option + b | Object explorer |
|
||||
+----------------------------+--------------------+------------------------------------+
|
||||
| Ctrl + Alt + b | Ctrl + Option + b | Toggle Object Explorer |
|
||||
+----------------------------+--------------------+------------------------------------+
|
||||
| Shift + Alt + n | Shift + Option + n | Create object |
|
||||
+----------------------------+--------------------+------------------------------------+
|
||||
| Shift + Alt + d | Shift + Option + d | Delete object |
|
||||
+----------------------------+--------------------+------------------------------------+
|
||||
| Shift + Alt + g | Shift + Option + g | Direct debugging |
|
||||
+----------------------------+--------------------+------------------------------------+
|
||||
| Shift + Alt + e | Shift + Option + e | Edit object properties |
|
||||
+----------------------------+--------------------+------------------------------------+
|
||||
| Shift + Alt + f | Shift + Option + f | File main menu |
|
||||
+----------------------------+--------------------+------------------------------------+
|
||||
| Shift + Alt + h | Shift + Option + h | Help main menu |
|
||||
+----------------------------+--------------------+------------------------------------+
|
||||
| Shift + Alt + o | Shift + Option + o | Object main menu |
|
||||
+----------------------------+--------------------+------------------------------------+
|
||||
| Shift + Alt + q | Shift + Option + q | Open query tool |
|
||||
+----------------------------+--------------------+------------------------------------+
|
||||
| Shift + Ctrl + f | Shift + Ctrl + f | Quick Search |
|
||||
+----------------------------+--------------------+------------------------------------+
|
||||
| F5 | F5 | Refresh object explorer |
|
||||
+----------------------------+--------------------+------------------------------------+
|
||||
| Shift + Alt + s | Shift + Option + s | Search objects |
|
||||
+----------------------------+--------------------+------------------------------------+
|
||||
| Shift + Alt + [ | Shift + Option + [ | Tabbed panel backward |
|
||||
+----------------------------+--------------------+------------------------------------+
|
||||
| Shift + Alt + ] | Shift + Option + ] | Tabbed panel forward |
|
||||
+----------------------------+--------------------+------------------------------------+
|
||||
| Shift + Alt + w | Shift + Ctrl + w | Close tab panel |
|
||||
+----------------------------+--------------------+------------------------------------+
|
||||
| Shift + Alt + l | Shift + Option + l | Tools main menu |
|
||||
+----------------------------+--------------------+------------------------------------+
|
||||
| Shift + Alt + v | Shift + Option + v | View data |
|
||||
+----------------------------+--------------------+------------------------------------+
|
||||
+----------------------------+---------------------------+------------------------------------+
|
||||
| Shortcut (Windows/Linux) | Shortcut (Mac) | Function |
|
||||
+============================+===========================+====================================+
|
||||
| Shift + Ctrl + a | Shift + Ctrl + a | Add grid row |
|
||||
+----------------------------+---------------------------+------------------------------------+
|
||||
| Shift + Alt + b | Shift + Option + b | Object explorer |
|
||||
+----------------------------+---------------------------+------------------------------------+
|
||||
| Ctrl + Alt + b | Ctrl + Option + b | Toggle Object Explorer |
|
||||
+----------------------------+---------------------------+------------------------------------+
|
||||
| Shift + Alt + n | Shift + Option + n | Create object |
|
||||
+----------------------------+---------------------------+------------------------------------+
|
||||
| Shift + Alt + d | Shift + Option + d | Delete object |
|
||||
+----------------------------+---------------------------+------------------------------------+
|
||||
| Shift + Alt + g | Shift + Option + g | Direct debugging |
|
||||
+----------------------------+---------------------------+------------------------------------+
|
||||
| Shift + Alt + e | Shift + Option + e | Edit object properties |
|
||||
+----------------------------+---------------------------+------------------------------------+
|
||||
| Shift + Alt + f | Shift + Option + f | File main menu |
|
||||
+----------------------------+---------------------------+------------------------------------+
|
||||
| Shift + Alt + h | Shift + Option + h | Help main menu |
|
||||
+----------------------------+---------------------------+------------------------------------+
|
||||
| Shift + Alt + o | Shift + Option + o | Object main menu |
|
||||
+----------------------------+---------------------------+------------------------------------+
|
||||
| Shift + Alt + q | Shift + Option + q | Open query tool |
|
||||
+----------------------------+---------------------------+------------------------------------+
|
||||
| Shift + Ctrl + f | Shift + Ctrl + f | Quick Search |
|
||||
+----------------------------+---------------------------+------------------------------------+
|
||||
| F5 | F5 | Refresh object explorer |
|
||||
+----------------------------+---------------------------+------------------------------------+
|
||||
| Shift + Alt + s | Shift + Option + s | Search objects |
|
||||
+----------------------------+---------------------------+------------------------------------+
|
||||
| Ctrl + Alt + Page Up | Ctrl + Option + Page Up | Tabbed panel backward |
|
||||
+----------------------------+---------------------------+------------------------------------+
|
||||
| Ctrl + Alt + Page Down | Ctrl + Option + Page Down | Tabbed panel forward |
|
||||
+----------------------------+---------------------------+------------------------------------+
|
||||
| Shift + Alt + w | Shift + Ctrl + w | Close tab panel |
|
||||
+----------------------------+---------------------------+------------------------------------+
|
||||
| Shift + Alt + l | Shift + Option + l | Tools main menu |
|
||||
+----------------------------+---------------------------+------------------------------------+
|
||||
| Shift + Alt + v | Shift + Option + v | View data |
|
||||
+----------------------------+---------------------------+------------------------------------+
|
||||
|
||||
Property Grid Controls
|
||||
**********************
|
||||
@@ -100,6 +100,8 @@ When using the syntax-highlighting SQL editors, the following shortcuts are avai
|
||||
+--------------------------+----------------------+-------------------------------------+
|
||||
| Ctrl + / | Cmd + / | Comment/Uncomment code (Block) |
|
||||
+--------------------------+----------------------+-------------------------------------+
|
||||
| Ctrl + Shift + d | Cmd + Shift + d | Duplicate current line/selection |
|
||||
+--------------------------+----------------------+-------------------------------------+
|
||||
| Ctrl + a | Cmd + a | Select all |
|
||||
+--------------------------+----------------------+-------------------------------------+
|
||||
| Ctrl + c | Cmd + c | Copy selected text to the clipboard |
|
||||
|
||||
@@ -196,9 +196,9 @@ def register_browser_preferences(self):
|
||||
'keyboardshortcut',
|
||||
{
|
||||
'alt': True,
|
||||
'shift': True,
|
||||
'control': False,
|
||||
'key': {'key_code': 91, 'char': '['}
|
||||
'shift': False,
|
||||
'control': True,
|
||||
'key': {'key_code': 33, 'char': 'PageUp'}
|
||||
},
|
||||
category_label=PREF_LABEL_KEYBOARD_SHORTCUTS,
|
||||
fields=fields
|
||||
@@ -226,9 +226,9 @@ def register_browser_preferences(self):
|
||||
'keyboardshortcut',
|
||||
{
|
||||
'alt': True,
|
||||
'shift': True,
|
||||
'control': False,
|
||||
'key': {'key_code': 93, 'char': ']'}
|
||||
'shift': False,
|
||||
'control': True,
|
||||
'key': {'key_code': 34, 'char': 'PageDown'}
|
||||
},
|
||||
category_label=PREF_LABEL_KEYBOARD_SHORTCUTS,
|
||||
fields=fields
|
||||
|
||||
@@ -153,42 +153,49 @@ _.extend(pgBrowser.keyboardNavigation, {
|
||||
bindRightPanel: function(event, combo) {
|
||||
const self = this;
|
||||
const shortcutObj = this.keyboardShortcut;
|
||||
const activeElement = document.activeElement;
|
||||
const rootDock = document.getElementById('root');
|
||||
if (!rootDock) return;
|
||||
|
||||
if (activeElement.closest('.dock-tab-btn')) {
|
||||
const currDockTab = activeElement.closest('.dock-tab-btn');
|
||||
const dockLayout = currDockTab.closest('.dock-layout');
|
||||
const dockLayoutTabs = dockLayout ? Array.from(dockLayout.querySelectorAll('.dock-tab-btn')) : null;
|
||||
// Find the active workspace tab independently of where the keyboard focus
|
||||
// currently sits. Previously this relied on document.activeElement, which
|
||||
// breaks when focus is inside a tool's own (nested) dock layout - the SQL
|
||||
// editor, an ERD/Schema Diff canvas - or inside the PSQL iframe, because
|
||||
// the resolved tab id then belonged to the tool's inner tab rather than a
|
||||
// main workspace tab (issue #7232).
|
||||
//
|
||||
// rc-dock renders the object explorer and the workspace as separate
|
||||
// tab-sets, and each marks its own active tab with `dock-tab-active`, so
|
||||
// prefer the active tab that is not the object explorer.
|
||||
//
|
||||
// Only the outermost dock layout is considered. The SQL editor, ERD and
|
||||
// Schema Diff each render a DockLayout of their own inside a workspace
|
||||
// tab, and their active tabs (Data Output, Messages, Notifications and
|
||||
// so on) carry the same `dock-tab-active` class, so a search across the
|
||||
// whole tree can land on one of those and cycle a tool's inner tabs
|
||||
// instead of the workspace tabs it was asked to move between.
|
||||
const topDockLayout = rootDock.querySelector('.dock-layout');
|
||||
if (!topDockLayout) return;
|
||||
|
||||
if (dockLayoutTabs && dockLayoutTabs.length > 1) {
|
||||
const activeTabIndex = dockLayoutTabs.indexOf(currDockTab);
|
||||
self._focusTab(dockLayoutTabs, activeTabIndex, shortcutObj, combo);
|
||||
}
|
||||
}
|
||||
else if (activeElement.nodeName === 'IFRAME' || activeElement.closest('.dock-tabpane.dock-tabpane-active')) {
|
||||
let activeTabId = '';
|
||||
activeTabId = (activeElement.nodeName === 'IFRAME') ? activeElement.id : activeElement.closest('.dock-tabpane.dock-tabpane-active').id;
|
||||
const dockLayout = document.getElementById('root');
|
||||
const dockLayoutTabs = dockLayout ? Array.from(dockLayout.querySelectorAll('.dock-tab-btn')) : null;
|
||||
const activeTabBtns = Array.from(
|
||||
topDockLayout.querySelectorAll('.dock-tab.dock-tab-active .dock-tab-btn')
|
||||
).filter(tab => tab.closest('.dock-layout') === topDockLayout);
|
||||
const activeTabBtn =
|
||||
activeTabBtns.find(tab => !tab.id.includes('id-object-explorer')) ||
|
||||
activeTabBtns[0];
|
||||
if (!activeTabBtn) return;
|
||||
|
||||
if (dockLayoutTabs && dockLayoutTabs.length > 1 && activeTabId) {
|
||||
const activeTabIndex = dockLayoutTabs.findIndex(tab => tab.id.slice(14) === activeTabId);
|
||||
self._focusTab(dockLayoutTabs, activeTabIndex, shortcutObj, combo);
|
||||
}
|
||||
}
|
||||
else if (activeElement === document.body || document.querySelector('div[data-test="app-menu-bar"]')) {
|
||||
const activeTabs = document.getElementsByClassName('dock-tabpane dock-tabpane-active');
|
||||
// Restrict navigation to the tabs of the same tab-set (dock panel) as the
|
||||
// active tab, so cycling stays within the workspace tabs and does not
|
||||
// include the object explorer or a tool's nested tabs.
|
||||
const panel = activeTabBtn.closest('.dock-panel');
|
||||
const dockLayoutTabs = panel ? Array.from(
|
||||
panel.querySelectorAll('.dock-tab-btn'))
|
||||
.filter(tab => tab.closest('.dock-panel') === panel &&
|
||||
tab.closest('.dock-layout') === topDockLayout) : [];
|
||||
|
||||
if (activeTabs.length > 1) {
|
||||
const activeTabId = activeTabs[1].id;
|
||||
const dockLayout = document.getElementById('root');
|
||||
const dockLayoutTabs = dockLayout ? Array.from(dockLayout.querySelectorAll('.dock-tab-btn')) : null;
|
||||
|
||||
if (dockLayoutTabs && dockLayoutTabs.length > 1 && activeTabId) {
|
||||
const activeTabIndex = dockLayoutTabs.findIndex(tab => tab.id.slice(14) === activeTabId);
|
||||
self._focusTab(dockLayoutTabs, activeTabIndex, shortcutObj, combo);
|
||||
}
|
||||
}
|
||||
if (dockLayoutTabs.length > 1) {
|
||||
const activeTabIndex = dockLayoutTabs.indexOf(activeTabBtn);
|
||||
self._focusTab(dockLayoutTabs, activeTabIndex, shortcutObj, combo);
|
||||
}
|
||||
},
|
||||
_focusTab: function(dockLayoutTabs, activeTabIdx, shortcut_obj, combo){
|
||||
|
||||
@@ -97,7 +97,7 @@ export default function SchemaDialogView({
|
||||
);
|
||||
};
|
||||
|
||||
const save = (changeData) => {
|
||||
const save = (changeData, onSaved) => {
|
||||
props.onSave(schemaState.isNew, changeData)
|
||||
.then(()=>{
|
||||
if(schema.informText) {
|
||||
@@ -106,6 +106,7 @@ export default function SchemaDialogView({
|
||||
schema.informText,
|
||||
);
|
||||
}
|
||||
onSaved?.();
|
||||
}).catch((err)=>{
|
||||
schemaState.setError({
|
||||
name: 'apierror',
|
||||
@@ -119,8 +120,15 @@ export default function SchemaDialogView({
|
||||
});
|
||||
};
|
||||
|
||||
const onSaveClick = () => {
|
||||
// Do nothing when there is no change or there is an error
|
||||
// closeOnSave is only ever passed explicitly as true, by the Ctrl/Cmd+Enter
|
||||
// handler below. The Save button's onClick passes its click event instead,
|
||||
// which is never === true, so a plain Save click keeps its existing
|
||||
// behaviour (some dialogs, e.g. object properties, stay open after Save).
|
||||
const onSaveClick = (closeOnSave) => {
|
||||
// Do nothing when a save is already in flight, when there is no change,
|
||||
// or when there is an error. The Save button is disabled whilst saving,
|
||||
// but the Ctrl/Cmd+Enter shortcut calls this directly.
|
||||
if (schemaState.isSaving) return;
|
||||
if (
|
||||
!schemaState._changes || Object.keys(schemaState._changes).length === 0 ||
|
||||
schemaState.errors.name
|
||||
@@ -129,15 +137,17 @@ export default function SchemaDialogView({
|
||||
setSaving(true);
|
||||
setLoaderText(schemaState.customLoadingText || gettext('Saving...'));
|
||||
|
||||
const onSaved = closeOnSave === true ? () => props.onClose?.() : undefined;
|
||||
|
||||
if (!schema.warningText) {
|
||||
save(schemaState.changes(true));
|
||||
save(schemaState.changes(true), onSaved);
|
||||
return;
|
||||
}
|
||||
|
||||
Notifier.confirm(
|
||||
gettext('Warning'),
|
||||
schema.warningText,
|
||||
() => { save(schemaState.changes(true)); },
|
||||
() => { save(schemaState.changes(true), onSaved); },
|
||||
() => {
|
||||
setSaving(false);
|
||||
setLoaderText('');
|
||||
@@ -177,9 +187,35 @@ export default function SchemaDialogView({
|
||||
return <SaveIcon />;
|
||||
};
|
||||
|
||||
const onKeyDown = (e) => {
|
||||
// Ctrl/Cmd+Enter saves and closes the dialog from anywhere within it
|
||||
// (issue #7167). onSaveClick is a no-op when there is nothing to save or
|
||||
// there is a validation error, so this is safe to call unconditionally.
|
||||
if ((e.ctrlKey || e.metaKey) && !e.altKey && e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
onSaveClick(true);
|
||||
return;
|
||||
}
|
||||
|
||||
// Escape closes the dialog, mirroring the Close button (issue #5691).
|
||||
// This is needed for dialogs rendered as dockable panels (Properties,
|
||||
// Backup, and other utility dialogs); dialogs rendered inside a MUI modal
|
||||
// already close on Escape, so skip those to avoid a double close. The
|
||||
// !e.defaultPrevented guard lets an inner control that handles Escape
|
||||
// (e.g. an open dropdown) consume it first.
|
||||
if (e.key === 'Escape' && !e.defaultPrevented && props.onClose &&
|
||||
!e.currentTarget.closest('.MuiDialog-root')) {
|
||||
e.preventDefault();
|
||||
props.onClose();
|
||||
}
|
||||
};
|
||||
|
||||
/* I am Groot */
|
||||
return useMemo(() =>
|
||||
<StyledBox>
|
||||
// Only the children are memoized: the wrapper carries onKeyDown, which
|
||||
// closes over props.onClose and onSaveClick, and memoizing it would pin
|
||||
// whichever versions of those existed when the deps last changed.
|
||||
const dialogContent = useMemo(() =>
|
||||
<>
|
||||
<SchemaStateContext.Provider value={schemaState}>
|
||||
<Box className='Dialog-form'>
|
||||
<FormLoader/>
|
||||
@@ -234,8 +270,10 @@ export default function SchemaDialogView({
|
||||
</Box>
|
||||
}
|
||||
</SchemaStateContext.Provider>
|
||||
</StyledBox>, [schema._id, viewHelperProps.mode, resetKey]
|
||||
</>, [schema._id, viewHelperProps.mode, resetKey]
|
||||
);
|
||||
|
||||
return <StyledBox onKeyDown={onKeyDown}>{dialogContent}</StyledBox>;
|
||||
}
|
||||
|
||||
SchemaDialogView.propTypes = {
|
||||
|
||||
@@ -30,7 +30,7 @@ import {
|
||||
keymap,
|
||||
} from '@codemirror/view';
|
||||
import { EditorState, Compartment } from '@codemirror/state';
|
||||
import { history, defaultKeymap, historyKeymap, indentLess, indentMore, deleteCharBackwardStrict } from '@codemirror/commands';
|
||||
import { history, defaultKeymap, historyKeymap, indentLess, indentMore, deleteCharBackwardStrict, copyLineDown } from '@codemirror/commands';
|
||||
import { closeBrackets, autocompletion, closeBracketsKeymap, completionKeymap, acceptCompletion } from '@codemirror/autocomplete';
|
||||
import {
|
||||
foldGutter,
|
||||
@@ -140,6 +140,12 @@ const defaultExtensions = [
|
||||
key: 'Backspace',
|
||||
preventDefault: true,
|
||||
run: deleteCharBackwardStrict,
|
||||
},{
|
||||
// Duplicate the current line, or the selected lines if there is a
|
||||
// selection (issue #3834).
|
||||
key: 'Mod-Shift-d',
|
||||
preventDefault: true,
|
||||
run: copyLineDown,
|
||||
}]),
|
||||
PgSQL.language.data.of({
|
||||
autocomplete: false,
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
/////////////////////////////////////////////////////////////
|
||||
//
|
||||
// pgAdmin 4 - PostgreSQL Tools
|
||||
//
|
||||
// Copyright (C) 2013 - 2026, The pgAdmin Development Team
|
||||
// This software is released under the PostgreSQL Licence
|
||||
//
|
||||
//////////////////////////////////////////////////////////////
|
||||
|
||||
import { act, fireEvent, render, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
|
||||
import BaseUISchema from 'sources/SchemaView/base_schema.ui';
|
||||
import SchemaView from '../../../pgadmin/static/js/SchemaView';
|
||||
import { TestSchema } from './TestSchema.ui';
|
||||
import { withBrowser } from '../genericFunctions';
|
||||
|
||||
// A single required field is all the Ctrl/Cmd+Enter save-and-close tests
|
||||
// need; TestSchema's nested tab and row collection require a lot more
|
||||
// simulated input just to get to a savable state.
|
||||
class MinimalSchema extends BaseUISchema {
|
||||
constructor() {
|
||||
super({field1: null});
|
||||
}
|
||||
|
||||
get baseFields() {
|
||||
return [
|
||||
{
|
||||
id: 'field1', label: 'Field1', type: 'text', group: null,
|
||||
mode: ['properties', 'edit', 'create'], disabled: false, visible: true,
|
||||
},
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
// Escape closes a dialog rendered as a dockable panel (issue #5691). The
|
||||
// handler sits on the dialog wrapper, which must not be memoized along with
|
||||
// the dialog body: the body only changes with the schema, the mode or the
|
||||
// reset key, whereas onClose is a fresh function on most parent renders, and
|
||||
// a memoized wrapper would keep calling whichever one it captured first.
|
||||
describe('SchemaDialogView keyboard handling', () => {
|
||||
const SchemaViewWithBrowser = withBrowser(SchemaView);
|
||||
const user = userEvent.setup();
|
||||
|
||||
const dialog = (schema, onClose) => (
|
||||
<SchemaViewWithBrowser
|
||||
formType='dialog'
|
||||
schema={schema}
|
||||
viewHelperProps={{mode: 'create'}}
|
||||
onSave={jest.fn(() => Promise.resolve())}
|
||||
onClose={onClose}
|
||||
onHelp={jest.fn()}
|
||||
onEdit={jest.fn()}
|
||||
onDataChange={jest.fn()}
|
||||
hasSQL={false}
|
||||
disableSqlHelp={true}
|
||||
disableDialogHelp={true}
|
||||
/>
|
||||
);
|
||||
|
||||
const renderDialog = async (onClose, onSave = jest.fn(() => Promise.resolve()), schema = new TestSchema()) => {
|
||||
let ctrl;
|
||||
await act(async () => {
|
||||
ctrl = render(
|
||||
<SchemaViewWithBrowser
|
||||
formType='dialog'
|
||||
schema={schema}
|
||||
viewHelperProps={{mode: 'create'}}
|
||||
onSave={onSave}
|
||||
onClose={onClose}
|
||||
onHelp={jest.fn()}
|
||||
onEdit={jest.fn()}
|
||||
onDataChange={jest.fn()}
|
||||
hasSQL={false}
|
||||
disableSqlHelp={true}
|
||||
disableDialogHelp={true}
|
||||
/>
|
||||
);
|
||||
});
|
||||
return ctrl;
|
||||
};
|
||||
|
||||
const pressEscape = async (ctrl) => {
|
||||
await act(async () => {
|
||||
fireEvent.keyDown(ctrl.container.firstChild, {key: 'Escape'});
|
||||
});
|
||||
};
|
||||
|
||||
const pressCtrlEnter = async (ctrl) => {
|
||||
await act(async () => {
|
||||
fireEvent.keyDown(ctrl.container.firstChild, {key: 'Enter', ctrlKey: true});
|
||||
});
|
||||
};
|
||||
|
||||
it('closes the dialog on Escape', async () => {
|
||||
const onClose = jest.fn();
|
||||
const ctrl = await renderDialog(onClose);
|
||||
|
||||
await pressEscape(ctrl);
|
||||
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// An inner control that handles Escape itself (react-select closing an open
|
||||
// menu, for example) calls preventDefault, and the event still bubbles up
|
||||
// to the dialog, which must then leave the dialog open.
|
||||
it('does not close when an inner control consumes Escape', async () => {
|
||||
const onClose = jest.fn();
|
||||
const ctrl = await renderDialog(onClose, undefined, new MinimalSchema());
|
||||
const control = ctrl.container.querySelector('[name="field1"]');
|
||||
control.addEventListener(
|
||||
'keydown', (event) => event.preventDefault(), {once: true}
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.keyDown(control, {key: 'Escape'});
|
||||
});
|
||||
|
||||
expect(onClose).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('calls the current onClose, not the one from the first render',
|
||||
async () => {
|
||||
const firstOnClose = jest.fn();
|
||||
const secondOnClose = jest.fn();
|
||||
// The same schema throughout: the memo deps are the schema id, the mode
|
||||
// and the reset key, so this is the case where nothing invalidates the
|
||||
// memo and only the callback has changed.
|
||||
const schema = new TestSchema();
|
||||
|
||||
let ctrl;
|
||||
await act(async () => {
|
||||
ctrl = render(dialog(schema, firstOnClose));
|
||||
});
|
||||
|
||||
// The parent re-renders with a new callback, as it does whenever it
|
||||
// defines onClose inline.
|
||||
await act(async () => {
|
||||
ctrl.rerender(dialog(schema, secondOnClose));
|
||||
});
|
||||
|
||||
await pressEscape(ctrl);
|
||||
|
||||
expect(secondOnClose).toHaveBeenCalled();
|
||||
expect(firstOnClose).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Ctrl/Cmd+Enter is meant to save and close in one step (issue #7167).
|
||||
// onSaveClick alone is not enough: it only calls onSave, so this covers the
|
||||
// dialog actually closing once that save resolves.
|
||||
it('saves and closes the dialog on Ctrl/Cmd+Enter', async () => {
|
||||
const onClose = jest.fn();
|
||||
const onSave = jest.fn(() => Promise.resolve());
|
||||
const ctrl = await renderDialog(onClose, onSave, new MinimalSchema());
|
||||
|
||||
// Wait for the dialog's auto-focus to settle before typing, as the other
|
||||
// SchemaDialogView specs do.
|
||||
await act(async () => {
|
||||
await new Promise(resolve => setTimeout(resolve, 500));
|
||||
});
|
||||
await user.type(ctrl.container.querySelector('[name="field1"]'), 'val1');
|
||||
|
||||
await pressCtrlEnter(ctrl);
|
||||
|
||||
expect(onSave).toHaveBeenCalled();
|
||||
await waitFor(() => expect(onClose).toHaveBeenCalled());
|
||||
});
|
||||
|
||||
// A second Ctrl/Cmd+Enter whilst the first save is still in flight must not
|
||||
// start another save: the Save button is disabled during a save, but the
|
||||
// shortcut reaches onSaveClick directly.
|
||||
it('ignores Ctrl/Cmd+Enter whilst a save is in progress', async () => {
|
||||
const onClose = jest.fn();
|
||||
let resolveSave;
|
||||
const onSave = jest.fn(() => new Promise(resolve => { resolveSave = resolve; }));
|
||||
const ctrl = await renderDialog(onClose, onSave, new MinimalSchema());
|
||||
|
||||
await act(async () => {
|
||||
await new Promise(resolve => setTimeout(resolve, 500));
|
||||
});
|
||||
await user.type(ctrl.container.querySelector('[name="field1"]'), 'val1');
|
||||
|
||||
await pressCtrlEnter(ctrl);
|
||||
await pressCtrlEnter(ctrl);
|
||||
|
||||
expect(onSave).toHaveBeenCalledTimes(1);
|
||||
|
||||
await act(async () => { resolveSave(); });
|
||||
await waitFor(() => expect(onClose).toHaveBeenCalledTimes(1));
|
||||
});
|
||||
});
|
||||
176
web/regression/javascript/browser/keyboard_navigation_spec.js
Normal file
176
web/regression/javascript/browser/keyboard_navigation_spec.js
Normal file
@@ -0,0 +1,176 @@
|
||||
/////////////////////////////////////////////////////////////
|
||||
//
|
||||
// pgAdmin 4 - PostgreSQL Tools
|
||||
//
|
||||
// Copyright (C) 2013 - 2026, The pgAdmin Development Team
|
||||
// This software is released under the PostgreSQL Licence
|
||||
//
|
||||
//////////////////////////////////////////////////////////////
|
||||
|
||||
// keyboard.js reaches pgadmin.js by relative path, which skips the
|
||||
// sources/pgadmin alias that maps to the fake, so point it there explicitly.
|
||||
jest.mock('../../../pgadmin/static/js/pgadmin', () =>
|
||||
jest.requireActual('../fake_pgadmin'));
|
||||
|
||||
import pgAdmin from 'sources/pgadmin';
|
||||
import '../../../pgadmin/browser/static/js/keyboard';
|
||||
|
||||
// Cycling between workspace tabs must stay in the workspace: the object
|
||||
// explorer is a tab-set of its own, and the SQL editor, ERD and Schema Diff
|
||||
// each render a nested DockLayout whose own tabs carry the same
|
||||
// dock-tab-active class (issue #7232).
|
||||
describe('keyboardNavigation.bindRightPanel', () => {
|
||||
const shortcutObj = {
|
||||
tabbed_panel_forward: 'ctrl+alt+pagedown',
|
||||
tabbed_panel_backward: 'ctrl+alt+pageup',
|
||||
close_tab_panel: 'shift+alt+w',
|
||||
};
|
||||
|
||||
const tabBtn = (id, isActive) => {
|
||||
const tab = document.createElement('div');
|
||||
tab.className = isActive ? 'dock-tab dock-tab-active' : 'dock-tab';
|
||||
const btn = document.createElement('div');
|
||||
btn.className = 'dock-tab-btn';
|
||||
btn.id = `rc-dock-tab-btn-${id}`;
|
||||
tab.appendChild(btn);
|
||||
return tab;
|
||||
};
|
||||
|
||||
const panel = (tabs) => {
|
||||
const el = document.createElement('div');
|
||||
el.className = 'dock-panel';
|
||||
tabs.forEach((tab) => el.appendChild(tab));
|
||||
return el;
|
||||
};
|
||||
|
||||
/* A workspace holding the object explorer, three workspace tabs and, inside
|
||||
* the active workspace tab, a tool with its own dock layout. */
|
||||
const buildLayout = ({activeWorkspaceTab = 'id-dashboard'} = {}) => {
|
||||
const root = document.createElement('div');
|
||||
root.id = 'root';
|
||||
|
||||
const topLayout = document.createElement('div');
|
||||
topLayout.className = 'dock-layout';
|
||||
root.appendChild(topLayout);
|
||||
|
||||
topLayout.appendChild(panel([tabBtn('id-object-explorer', true)]));
|
||||
|
||||
const workspaceTabs = ['id-dashboard', 'id-properties', 'id-sql'].map(
|
||||
(id) => tabBtn(id, id === activeWorkspaceTab));
|
||||
const workspacePanel = panel(workspaceTabs);
|
||||
topLayout.appendChild(workspacePanel);
|
||||
|
||||
// The tool's own dock layout, nested inside the workspace panel exactly
|
||||
// as the SQL editor's is.
|
||||
const innerLayout = document.createElement('div');
|
||||
innerLayout.className = 'dock-layout';
|
||||
innerLayout.appendChild(panel([
|
||||
tabBtn('id-dataoutput', true),
|
||||
tabBtn('id-messages', false),
|
||||
]));
|
||||
workspacePanel.appendChild(innerLayout);
|
||||
|
||||
document.body.appendChild(root);
|
||||
return {root, workspacePanel};
|
||||
};
|
||||
|
||||
let focusTabSpy;
|
||||
|
||||
beforeEach(() => {
|
||||
document.body.innerHTML = '';
|
||||
pgAdmin.Browser.keyboardNavigation.keyboardShortcut = shortcutObj;
|
||||
focusTabSpy = jest.spyOn(
|
||||
pgAdmin.Browser.keyboardNavigation, '_focusTab'
|
||||
).mockImplementation(() => {});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
focusTabSpy.mockRestore();
|
||||
document.body.innerHTML = '';
|
||||
});
|
||||
|
||||
it('cycles the workspace tabs, not a tool\'s nested tabs', () => {
|
||||
buildLayout();
|
||||
|
||||
pgAdmin.Browser.keyboardNavigation.bindRightPanel(
|
||||
new Event('keydown'), {key: shortcutObj.tabbed_panel_forward});
|
||||
|
||||
expect(focusTabSpy).toHaveBeenCalled();
|
||||
const [tabs, activeIdx] = focusTabSpy.mock.calls[0];
|
||||
const ids = tabs.map((tab) => tab.id);
|
||||
|
||||
expect(ids).toEqual([
|
||||
'rc-dock-tab-btn-id-dashboard',
|
||||
'rc-dock-tab-btn-id-properties',
|
||||
'rc-dock-tab-btn-id-sql',
|
||||
]);
|
||||
// Neither the nested tool tabs nor the object explorer may take part.
|
||||
expect(ids).not.toContain('rc-dock-tab-btn-id-dataoutput');
|
||||
expect(ids).not.toContain('rc-dock-tab-btn-id-object-explorer');
|
||||
expect(tabs[activeIdx].id).toBe('rc-dock-tab-btn-id-dashboard');
|
||||
});
|
||||
|
||||
it('starts from whichever workspace tab is active', () => {
|
||||
buildLayout({activeWorkspaceTab: 'id-sql'});
|
||||
|
||||
pgAdmin.Browser.keyboardNavigation.bindRightPanel(
|
||||
new Event('keydown'), {key: shortcutObj.tabbed_panel_backward});
|
||||
|
||||
const [tabs, activeIdx] = focusTabSpy.mock.calls[0];
|
||||
expect(tabs[activeIdx].id).toBe('rc-dock-tab-btn-id-sql');
|
||||
});
|
||||
|
||||
/* The selection must not depend on where the nested layout happens to sit
|
||||
* in the DOM: rc-dock is free to order panels as it likes, and an inner
|
||||
* layout appearing first would otherwise win the search for the active
|
||||
* tab and cycle a tool's own tabs. */
|
||||
it('ignores nested tool tabs even when they come first in the DOM', () => {
|
||||
const root = document.createElement('div');
|
||||
root.id = 'root';
|
||||
const topLayout = document.createElement('div');
|
||||
topLayout.className = 'dock-layout';
|
||||
root.appendChild(topLayout);
|
||||
|
||||
const innerLayout = document.createElement('div');
|
||||
innerLayout.className = 'dock-layout';
|
||||
innerLayout.appendChild(panel([
|
||||
tabBtn('id-dataoutput', true),
|
||||
tabBtn('id-messages', false),
|
||||
]));
|
||||
topLayout.appendChild(innerLayout);
|
||||
|
||||
topLayout.appendChild(panel([tabBtn('id-object-explorer', true)]));
|
||||
topLayout.appendChild(panel([
|
||||
tabBtn('id-dashboard', false),
|
||||
tabBtn('id-sql', true),
|
||||
]));
|
||||
document.body.appendChild(root);
|
||||
|
||||
pgAdmin.Browser.keyboardNavigation.bindRightPanel(
|
||||
new Event('keydown'), {key: shortcutObj.tabbed_panel_forward});
|
||||
|
||||
expect(focusTabSpy).toHaveBeenCalled();
|
||||
const [tabs, activeIdx] = focusTabSpy.mock.calls[0];
|
||||
const ids = tabs.map((tab) => tab.id);
|
||||
expect(ids).toEqual([
|
||||
'rc-dock-tab-btn-id-dashboard',
|
||||
'rc-dock-tab-btn-id-sql',
|
||||
]);
|
||||
expect(tabs[activeIdx].id).toBe('rc-dock-tab-btn-id-sql');
|
||||
});
|
||||
|
||||
it('does nothing when only the object explorer is present', () => {
|
||||
const root = document.createElement('div');
|
||||
root.id = 'root';
|
||||
const topLayout = document.createElement('div');
|
||||
topLayout.className = 'dock-layout';
|
||||
topLayout.appendChild(panel([tabBtn('id-object-explorer', true)]));
|
||||
root.appendChild(topLayout);
|
||||
document.body.appendChild(root);
|
||||
|
||||
pgAdmin.Browser.keyboardNavigation.bindRightPanel(
|
||||
new Event('keydown'), {key: shortcutObj.tabbed_panel_forward});
|
||||
|
||||
expect(focusTabSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -11,6 +11,7 @@
|
||||
import { withTheme } from '../fake_theme';
|
||||
import CodeMirror from 'sources/components/ReactCodeMirror';
|
||||
import { syntaxTree } from '@codemirror/language';
|
||||
import { runScopeHandlers } from '@codemirror/view';
|
||||
|
||||
import { render } from '@testing-library/react';
|
||||
|
||||
@@ -320,4 +321,25 @@ describe('CodeMirrorCustomEditorView', ()=>{
|
||||
expect(result.to).toBe(stmts[1].to);
|
||||
});
|
||||
|
||||
|
||||
it('Mod-Shift-D duplicates the current line',()=>{
|
||||
cmRerender({value: 'select 1;\nselect 2;'});
|
||||
editor.dispatch({selection: {anchor: 0}});
|
||||
// jsdom does not report a Mac platform, so Mod is Ctrl here.
|
||||
const handled = runScopeHandlers(editor, new KeyboardEvent('keydown', {
|
||||
key: 'D', code: 'KeyD', keyCode: 68, ctrlKey: true, shiftKey: true,
|
||||
}), 'editor');
|
||||
expect(handled).toBe(true);
|
||||
expect(editor.state.doc.toString()).toEqual('select 1;\nselect 1;\nselect 2;');
|
||||
});
|
||||
|
||||
it('Mod-Shift-D duplicates every line in the selection',()=>{
|
||||
cmRerender({value: 'select 1;\nselect 2;\nselect 3;'});
|
||||
editor.dispatch({selection: {anchor: 0, head: 12}});
|
||||
runScopeHandlers(editor, new KeyboardEvent('keydown', {
|
||||
key: 'D', code: 'KeyD', keyCode: 68, ctrlKey: true, shiftKey: true,
|
||||
}), 'editor');
|
||||
expect(editor.state.doc.toString()).toEqual(
|
||||
'select 1;\nselect 2;\nselect 1;\nselect 2;\nselect 3;');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user