Skip to content

Commit 0b8c4fb

Browse files
Added the ability to search for tables and automatically bring them into view in the ERD tool. #4306
1 parent 8d55e9a commit 0b8c4fb

File tree

18 files changed

+199
-20
lines changed

18 files changed

+199
-20
lines changed

docs/en_US/erd_tool.rst

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,9 @@ Editing Options
8686
+----------------------+---------------------------------------------------------------------------------------------------+----------------+
8787
| Icon | Behavior | Shortcut |
8888
+======================+===================================================================================================+================+
89+
| *Search table* | Click to search for a table in the diagram. Selecting a table from the search results will bring | Option/Alt + |
90+
| | it into view and highlight it. | Ctrl + F |
91+
+----------------------+---------------------------------------------------------------------------------------------------+----------------+
8992
| *Add table* | Click this button to add a new table to the diagram. On clicking, this will open a table dialog | Option/Alt + |
9093
| | where you can put the table details. | Ctrl + A |
9194
+----------------------+---------------------------------------------------------------------------------------------------+----------------+
@@ -109,11 +112,14 @@ Table Relationship Options
109112
+----------------------+---------------------------------------------------------------------------------------------------+----------------+
110113
| Icon | Behavior | Shortcut |
111114
+======================+===================================================================================================+================+
112-
| *1M* | Click this button to open a one-to-many relationship dialog to add a relationship between the | Option/Alt + |
115+
| *1-1* | Click this button to open a one-to-one relationship dialog to add a relationship between the | Option/Alt + |
116+
| | two tables. The selected table becomes the referencing table. | Ctrl + B |
117+
+----------------------+---------------------------------------------------------------------------------------------------+----------------+
118+
| *1-M* | Click this button to open a one-to-many relationship dialog to add a relationship between the | Option/Alt + |
113119
| | two tables. The selected table becomes the referencing table and will have the *many* endpoint of | Ctrl + O |
114120
| | the link. | |
115121
+----------------------+---------------------------------------------------------------------------------------------------+----------------+
116-
| *MM* | Click this button to open a many-to-many relationship dialog to add a relationship between the | Option/Alt + |
122+
| *M-M* | Click this button to open a many-to-many relationship dialog to add a relationship between the | Option/Alt + |
117123
| | two tables. This option will create a new table based on the selected columns for the two relating| Ctrl + M |
118124
| | tables and link them. | |
119125
+----------------------+---------------------------------------------------------------------------------------------------+----------------+

docs/en_US/images/erd_tool.png

339 KB
Loading
-163 Bytes
Loading

web/pgadmin/static/js/helpers/ModalProvider.jsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -295,7 +295,7 @@ const StyleDialog = styled(Dialog)(({theme}) => ({
295295
},
296296
}));
297297

298-
function ModalContainer({ id, title, content, dialogHeight, dialogWidth, onClose, fullScreen = false, isFullWidth = false, showFullScreen = false, isResizeable = false, minHeight = MIN_HEIGHT, minWidth = MIN_WIDTH, showTitle=true }) {
298+
function ModalContainer({ id, title, content, dialogHeight, dialogWidth, onClose, fullScreen = false, isFullWidth = false, showFullScreen = false, isResizeable = false, minHeight = MIN_HEIGHT, minWidth = MIN_WIDTH, showTitle=true, ...props }) {
299299
let useModalRef = useModal();
300300
let closeModal = (_e, reason) => {
301301
if(reason == 'backdropClick' && showTitle) {
@@ -321,6 +321,7 @@ function ModalContainer({ id, title, content, dialogHeight, dialogWidth, onClose
321321
fullScreen={isFullScreen}
322322
fullWidth={isFullWidth}
323323
disablePortal
324+
{...props}
324325
>
325326
{ showTitle &&
326327
<DialogTitle className='modal-drag-area'>

web/pgadmin/tools/erd/__init__.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -158,6 +158,24 @@ def register_preferences(self):
158158
fields=shortcut_fields
159159
)
160160

161+
self.preference.register(
162+
'keyboard_shortcuts',
163+
'search_table',
164+
gettext('Search table'),
165+
'keyboardshortcut',
166+
{
167+
'alt': True,
168+
'shift': False,
169+
'control': True,
170+
'key': {
171+
'key_code': 70,
172+
'char': 'f'
173+
}
174+
},
175+
category_label=PREF_LABEL_KEYBOARD_SHORTCUTS,
176+
fields=shortcut_fields
177+
)
178+
161179
self.preference.register(
162180
'keyboard_shortcuts',
163181
'add_table',

web/pgadmin/tools/erd/static/js/erd_tool/ERDConstants.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,13 @@ export const ERD_EVENTS = {
44
TRIGGER_SHOW_SQL: 'TRIGGER_SHOW_SQL',
55
SHOW_SQL: 'SHOW_SQL',
66
DOWNLOAD_IMAGE: 'DOWNLOAD_IMAGE',
7+
8+
SEARCH_NODE: 'SEARCH_NODE',
79
ADD_NODE: 'ADD_NODE',
810
EDIT_NODE: 'EDIT_NODE',
911
CLONE_NODE: 'CLONE_NODE',
1012
DELETE_NODE: 'DELETE_NODE',
13+
1114
SHOW_NOTE: 'SHOW_NOTE',
1215
ONE_TO_ONE: 'ONE_TO_ONE',
1316
ONE_TO_MANY: 'ONE_TO_MANY',

web/pgadmin/tools/erd/static/js/erd_tool/components/ERDTool.jsx

Lines changed: 72 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ import { useApplicationState } from '../../../../../../settings/static/Applicati
4242
import { connectServerModal, connectServer } from '../../../../../sqleditor/static/js/components/connectServer';
4343
import { useEffect } from 'react';
4444
import { FileManagerUtils } from '../../../../../../misc/file_manager/static/js/components/FileManager';
45+
import SearchNode from './SearchNode';
4546

4647
/* Custom react-diagram action for keyboard events */
4748
export class KeyboardShortcutAction extends Action {
@@ -169,9 +170,9 @@ export default class ERDTool extends React.Component {
169170
this.eventBus = new EventBus();
170171

171172
_.bindAll(this, ['onLoadDiagram', 'onSaveDiagram', 'onSQLClick',
172-
'onImageClick', 'onAddNewNode', 'onEditTable', 'onCloneNode', 'onDeleteNode', 'onNoteClick',
173+
'onImageClick', 'onSearchNode', 'onAddNewNode', 'onEditTable', 'onCloneNode', 'onDeleteNode', 'onNoteClick',
173174
'onNoteClose', 'onOneToOneClick', 'onOneToManyClick', 'onManyToManyClick', 'onAutoDistribute', 'onDetailsToggle',
174-
'onChangeColors', 'onDropNode', 'onNotationChange', 'closePanel'
175+
'onChangeColors', 'onDropNode', 'onNotationChange', 'closePanel', 'scrollToNode'
175176
]);
176177

177178
this.diagram.zoomToFit = this.diagram.zoomToFit.bind(this.diagram);
@@ -240,6 +241,7 @@ export default class ERDTool extends React.Component {
240241
this.eventBus.registerListener(ERD_EVENTS.SAVE_DIAGRAM, this.onSaveDiagram);
241242
this.eventBus.registerListener(ERD_EVENTS.SHOW_SQL, this.onSQLClick);
242243
this.eventBus.registerListener(ERD_EVENTS.DOWNLOAD_IMAGE, this.onImageClick);
244+
this.eventBus.registerListener(ERD_EVENTS.SEARCH_NODE, this.onSearchNode);
243245
this.eventBus.registerListener(ERD_EVENTS.ADD_NODE, this.onAddNewNode);
244246
this.eventBus.registerListener(ERD_EVENTS.EDIT_NODE, this.onEditTable);
245247
this.eventBus.registerListener(ERD_EVENTS.CLONE_NODE, this.onCloneNode);
@@ -276,6 +278,9 @@ export default class ERDTool extends React.Component {
276278
[this.state.preferences.download_image, ()=>{
277279
this.eventBus.fireEvent(ERD_EVENTS.DOWNLOAD_IMAGE);
278280
}],
281+
[this.state.preferences.search_table, ()=>{
282+
this.eventBus.fireEvent(ERD_EVENTS.SEARCH_NODE);
283+
}],
279284
[this.state.preferences.add_table, ()=>{
280285
this.eventBus.fireEvent(ERD_EVENTS.ADD_NODE);
281286
}],
@@ -471,12 +476,69 @@ export default class ERDTool extends React.Component {
471476
}
472477
}
473478

479+
scrollToNode(node) {
480+
const engine = this.diagram.getEngine();
481+
const model = engine.getModel();
482+
const container = this.canvasEle;
483+
if (!node || !container) return;
484+
485+
const { x, y } = node.getPosition();
486+
const zoom = model.getZoomLevel() / 100;
487+
const offsetX = model.getOffsetX();
488+
const offsetY = model.getOffsetY();
489+
490+
const viewportWidth = container.clientWidth;
491+
const viewportHeight = container.clientHeight;
492+
493+
const nodeWidth = node.width; // Approximate width of a table node
494+
const nodeHeight = node.height; // Approximate height of a table node
495+
496+
// Node screen bounds
497+
const nodeLeft = x * zoom + offsetX;
498+
const nodeRight = nodeLeft + nodeWidth * zoom;
499+
const nodeTop = y * zoom + offsetY;
500+
const nodeBottom = nodeTop + nodeHeight * zoom;
501+
502+
let newOffsetX = offsetX;
503+
let newOffsetY = offsetY;
504+
505+
// Check horizontal visibility
506+
if (nodeLeft < 0) {
507+
newOffsetX += -nodeLeft + 20; // 20px padding
508+
} else if (nodeRight > viewportWidth) {
509+
newOffsetX -= nodeRight - viewportWidth + 20;
510+
}
511+
512+
// Check vertical visibility
513+
if (nodeHeight * zoom >= viewportHeight) {
514+
// Node taller than viewport: snap top of node to top of viewport
515+
newOffsetY = offsetY + viewportHeight / 2 - (nodeHeight * zoom) / 2;
516+
newOffsetY = offsetY - (nodeTop - 20); // aligns top
517+
} else {
518+
// Node fits in viewport: ensure fully visible
519+
if (nodeTop < 0) {
520+
newOffsetY += -nodeTop + 20;
521+
} else if (nodeBottom > viewportHeight) {
522+
newOffsetY -= nodeBottom - viewportHeight + 20;
523+
}
524+
}
525+
526+
// Update offset only if needed
527+
if (newOffsetX !== offsetX || newOffsetY !== offsetY) {
528+
model.setOffset(newOffsetX, newOffsetY);
529+
}
530+
531+
this.diagram.repaint();
532+
node.setSelected(true);
533+
node.fireEvent({}, 'highlightFlash');
534+
};
535+
536+
474537
addEditTable(node) {
475538
let dialog = this.getDialog('table_dialog');
476539
if(node) {
477-
let [schema, table] = node.getSchemaTableName();
478540
let oldData = node.getData();
479-
dialog(gettext('Table: %s (%s)', _.escape(table),_.escape(schema)), oldData, false, (newData)=>{
541+
dialog(gettext('Table: %s', node.getDisplayName()), oldData, false, (newData)=>{
480542
if(this.diagram.anyDuplicateNodeName(newData, oldData)) {
481543
return gettext('Table name already exists');
482544
}
@@ -543,6 +605,12 @@ export default class ERDTool extends React.Component {
543605
}
544606
}
545607

608+
onSearchNode() {
609+
this.context.showModal(gettext('Search'), (closeModal)=>(
610+
<SearchNode tableNodes={this.diagram.getModel().getNodesDict()} onClose={closeModal} scrollToNode={this.scrollToNode} />
611+
), {id: 'id-erd-search-node', showTitle: false, disableRestoreFocus: true});
612+
}
613+
546614
onAddNewNode() {
547615
this.addEditTable();
548616
}

web/pgadmin/tools/erd/static/js/erd_tool/components/FloatingNote.jsx

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -62,8 +62,7 @@ export default function FloatingNote({open, onClose, anchorEl, rows, noteNode})
6262

6363
const header = useMemo(()=>{
6464
if(noteNode) {
65-
let [schema, name] = noteNode.getSchemaTableName();
66-
return `${name} (${schema})`;
65+
return noteNode.getDisplayName();
6766
}
6867
return '';
6968
}, [open]);

web/pgadmin/tools/erd/static/js/erd_tool/components/MainToolBar.jsx

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import ImageRoundedIcon from '@mui/icons-material/ImageRounded';
2828
import FormatColorFillRoundedIcon from '@mui/icons-material/FormatColorFillRounded';
2929
import FormatColorTextRoundedIcon from '@mui/icons-material/FormatColorTextRounded';
3030
import AccountTreeOutlinedIcon from '@mui/icons-material/AccountTreeOutlined';
31+
import SearchOutlinedIcon from '@mui/icons-material/SearchOutlined';
3132

3233
import { PgMenu, PgMenuItem, usePgMenuGroup } from '../../../../../../static/js/components/Menu';
3334
import gettext from 'sources/gettext';
@@ -201,6 +202,11 @@ export function MainToolBar({preferences, eventBus, fillColor, textColor, notati
201202
}} />
202203
</PgButtonGroup>
203204
<PgButtonGroup size="small">
205+
<PgIconButton title={gettext('Search Table')} icon={<SearchOutlinedIcon />}
206+
shortcut={preferences.search_table}
207+
onClick={()=>{
208+
eventBus.fireEvent(ERD_EVENTS.SEARCH_NODE);
209+
}} />
204210
<PgIconButton title={gettext('Add Table')} icon={<AddBoxIcon />}
205211
shortcut={preferences.add_table}
206212
onClick={()=>{
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
/////////////////////////////////////////////////////////////
2+
//
3+
// pgAdmin 4 - PostgreSQL Tools
4+
//
5+
// Copyright (C) 2013 - 2025, The pgAdmin Development Team
6+
// This software is released under the PostgreSQL Licence
7+
//
8+
//////////////////////////////////////////////////////////////
9+
10+
import PropTypes from 'prop-types';
11+
import { InputSelect } from '../../../../../../static/js/components/FormComponents';
12+
13+
14+
export default function SearchNode({tableNodes, onClose, scrollToNode}) {
15+
const onSelectChange = (val) => {
16+
let node = tableNodes[val];
17+
if(node) {
18+
scrollToNode(node);
19+
}
20+
onClose();
21+
};
22+
23+
return (
24+
<InputSelect
25+
options={Object.values(tableNodes).map(node => ({
26+
value: node.getID(),
27+
label: node.getDisplayName(),
28+
}))}
29+
onChange={onSelectChange}
30+
autoFocus
31+
placeholder="Select a table"
32+
openMenuOnFocus
33+
/>
34+
);
35+
}
36+
37+
SearchNode.propTypes = {
38+
tableNodes: PropTypes.object.isRequired,
39+
onClose: PropTypes.func.isRequired,
40+
};

0 commit comments

Comments
 (0)