Added the ability to search for tables and automatically bring them into view in the ERD tool. #4306

pull/9307/head
Aditya Toshniwal 2025-10-31 12:19:27 +05:30 committed by GitHub
parent 08379d6ae0
commit 986ba41ba9
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
19 changed files with 218 additions and 24 deletions

View File

@ -86,6 +86,9 @@ Editing Options
+----------------------+---------------------------------------------------------------------------------------------------+----------------+
| Icon | Behavior | Shortcut |
+======================+===================================================================================================+================+
| *Search table* | Click to search for a table in the diagram. Selecting a table from the search results will bring | Option/Alt + |
| | it into view and highlight it. | Ctrl + F |
+----------------------+---------------------------------------------------------------------------------------------------+----------------+
| *Add table* | Click this button to add a new table to the diagram. On clicking, this will open a table dialog | Option/Alt + |
| | where you can put the table details. | Ctrl + A |
+----------------------+---------------------------------------------------------------------------------------------------+----------------+
@ -109,11 +112,14 @@ Table Relationship Options
+----------------------+---------------------------------------------------------------------------------------------------+----------------+
| Icon | Behavior | Shortcut |
+======================+===================================================================================================+================+
| *1M* | Click this button to open a one-to-many relationship dialog to add a relationship between the | Option/Alt + |
| *1-1* | Click this button to open a one-to-one relationship dialog to add a relationship between the | Option/Alt + |
| | two tables. The selected table becomes the referencing table. | Ctrl + B |
+----------------------+---------------------------------------------------------------------------------------------------+----------------+
| *1-M* | Click this button to open a one-to-many relationship dialog to add a relationship between the | Option/Alt + |
| | two tables. The selected table becomes the referencing table and will have the *many* endpoint of | Ctrl + O |
| | the link. | |
+----------------------+---------------------------------------------------------------------------------------------------+----------------+
| *MM* | Click this button to open a many-to-many relationship dialog to add a relationship between the | Option/Alt + |
| *M-M* | Click this button to open a many-to-many relationship dialog to add a relationship between the | Option/Alt + |
| | two tables. This option will create a new table based on the selected columns for the two relating| Ctrl + M |
| | tables and link them. | |
+----------------------+---------------------------------------------------------------------------------------------------+----------------+

Binary file not shown.

Before

Width:  |  Height:  |  Size: 340 KiB

After

Width:  |  Height:  |  Size: 679 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

After

Width:  |  Height:  |  Size: 22 KiB

View File

@ -20,6 +20,7 @@ Bundled PostgreSQL Utilities
New features
************
| `Issue #4306 <https://github.com/pgadmin-org/pgadmin4/issues/4306>`_ - Added the ability to search for tables and automatically bring them into view in the ERD tool.
| `Issue #6698 <https://github.com/pgadmin-org/pgadmin4/issues/6698>`_ - Add support for setting image download resolution in the ERD tool.
| `Issue #7885 <https://github.com/pgadmin-org/pgadmin4/issues/7885>`_ - Add support for displaying detailed Citus query plans instead of 'Custom Scan' placeholder.
| `Issue #8912 <https://github.com/pgadmin-org/pgadmin4/issues/8912>`_ - Add support for formatting .pgerd ERD project file.
@ -34,4 +35,6 @@ Bug fixes
| `Issue #8504 <https://github.com/pgadmin-org/pgadmin4/issues/8504>`_ - Fixed an issue where data output column resize is not sticking in Safari.
| `Issue #9117 <https://github.com/pgadmin-org/pgadmin4/issues/9117>`_ - Fixed an issue where Schema Diff does not ignore Tablespace for indexes.
| `Issue #9132 <https://github.com/pgadmin-org/pgadmin4/issues/9132>`_ - Fixed an issue where the 2FA window redirected to the login page after session expiration.
| `Issue #9240 <https://github.com/pgadmin-org/pgadmin4/issues/9240>`_ - Fixed an issue where the Debian build process failed with a "Sphinx module not found" error when using a Python virtual environment.
| `Issue #9304 <https://github.com/pgadmin-org/pgadmin4/issues/9304>`_ - Fixed an issue that prevented assigning multiple users to an RLS policy.

View File

@ -295,7 +295,7 @@ const StyleDialog = styled(Dialog)(({theme}) => ({
},
}));
function ModalContainer({ id, title, content, dialogHeight, dialogWidth, onClose, fullScreen = false, isFullWidth = false, showFullScreen = false, isResizeable = false, minHeight = MIN_HEIGHT, minWidth = MIN_WIDTH, showTitle=true }) {
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 }) {
let useModalRef = useModal();
let closeModal = (_e, reason) => {
if(reason == 'backdropClick' && showTitle) {
@ -321,6 +321,7 @@ function ModalContainer({ id, title, content, dialogHeight, dialogWidth, onClose
fullScreen={isFullScreen}
fullWidth={isFullWidth}
disablePortal
{...props}
>
{ showTitle &&
<DialogTitle className='modal-drag-area'>

View File

@ -158,6 +158,24 @@ class ERDModule(PgAdminModule):
fields=shortcut_fields
)
self.preference.register(
'keyboard_shortcuts',
'search_table',
gettext('Search table'),
'keyboardshortcut',
{
'alt': True,
'shift': False,
'control': True,
'key': {
'key_code': 70,
'char': 'f'
}
},
category_label=PREF_LABEL_KEYBOARD_SHORTCUTS,
fields=shortcut_fields
)
self.preference.register(
'keyboard_shortcuts',
'add_table',

View File

@ -4,10 +4,13 @@ export const ERD_EVENTS = {
TRIGGER_SHOW_SQL: 'TRIGGER_SHOW_SQL',
SHOW_SQL: 'SHOW_SQL',
DOWNLOAD_IMAGE: 'DOWNLOAD_IMAGE',
SEARCH_NODE: 'SEARCH_NODE',
ADD_NODE: 'ADD_NODE',
EDIT_NODE: 'EDIT_NODE',
CLONE_NODE: 'CLONE_NODE',
DELETE_NODE: 'DELETE_NODE',
SHOW_NOTE: 'SHOW_NOTE',
ONE_TO_ONE: 'ONE_TO_ONE',
ONE_TO_MANY: 'ONE_TO_MANY',

View File

@ -42,6 +42,7 @@ import { useApplicationState } from '../../../../../../settings/static/Applicati
import { connectServerModal, connectServer } from '../../../../../sqleditor/static/js/components/connectServer';
import { useEffect } from 'react';
import { FileManagerUtils } from '../../../../../../misc/file_manager/static/js/components/FileManager';
import SearchNode from './SearchNode';
/* Custom react-diagram action for keyboard events */
export class KeyboardShortcutAction extends Action {
@ -178,9 +179,9 @@ export default class ERDTool extends React.Component {
this.eventBus = new EventBus();
_.bindAll(this, ['onLoadDiagram', 'onSaveDiagram', 'onSQLClick',
'onImageClick', 'onAddNewNode', 'onEditTable', 'onCloneNode', 'onDeleteNode', 'onNoteClick',
'onImageClick', 'onSearchNode', 'onAddNewNode', 'onEditTable', 'onCloneNode', 'onDeleteNode', 'onNoteClick',
'onNoteClose', 'onOneToOneClick', 'onOneToManyClick', 'onManyToManyClick', 'onAutoDistribute', 'onDetailsToggle',
'onChangeColors', 'onDropNode', 'onNotationChange', 'closePanel'
'onChangeColors', 'onDropNode', 'onNotationChange', 'closePanel', 'scrollToNode'
]);
this.diagram.zoomToFit = this.diagram.zoomToFit.bind(this.diagram);
@ -249,6 +250,7 @@ export default class ERDTool extends React.Component {
this.eventBus.registerListener(ERD_EVENTS.SAVE_DIAGRAM, this.onSaveDiagram);
this.eventBus.registerListener(ERD_EVENTS.SHOW_SQL, this.onSQLClick);
this.eventBus.registerListener(ERD_EVENTS.DOWNLOAD_IMAGE, this.onImageClick);
this.eventBus.registerListener(ERD_EVENTS.SEARCH_NODE, this.onSearchNode);
this.eventBus.registerListener(ERD_EVENTS.ADD_NODE, this.onAddNewNode);
this.eventBus.registerListener(ERD_EVENTS.EDIT_NODE, this.onEditTable);
this.eventBus.registerListener(ERD_EVENTS.CLONE_NODE, this.onCloneNode);
@ -285,6 +287,9 @@ export default class ERDTool extends React.Component {
[this.state.preferences.download_image, ()=>{
this.eventBus.fireEvent(ERD_EVENTS.DOWNLOAD_IMAGE);
}],
[this.state.preferences.search_table, ()=>{
this.eventBus.fireEvent(ERD_EVENTS.SEARCH_NODE);
}],
[this.state.preferences.add_table, ()=>{
this.eventBus.fireEvent(ERD_EVENTS.ADD_NODE);
}],
@ -488,12 +493,69 @@ export default class ERDTool extends React.Component {
}
}
scrollToNode(node) {
const engine = this.diagram.getEngine();
const model = engine.getModel();
const container = this.canvasEle;
if (!node || !container) return;
const { x, y } = node.getPosition();
const zoom = model.getZoomLevel() / 100;
const offsetX = model.getOffsetX();
const offsetY = model.getOffsetY();
const viewportWidth = container.clientWidth;
const viewportHeight = container.clientHeight;
const nodeWidth = node.width; // Approximate width of a table node
const nodeHeight = node.height; // Approximate height of a table node
// Node screen bounds
const nodeLeft = x * zoom + offsetX;
const nodeRight = nodeLeft + nodeWidth * zoom;
const nodeTop = y * zoom + offsetY;
const nodeBottom = nodeTop + nodeHeight * zoom;
let newOffsetX = offsetX;
let newOffsetY = offsetY;
// Check horizontal visibility
if (nodeLeft < 0) {
newOffsetX += -nodeLeft + 20; // 20px padding
} else if (nodeRight > viewportWidth) {
newOffsetX -= nodeRight - viewportWidth + 20;
}
// Check vertical visibility
if (nodeHeight * zoom >= viewportHeight) {
// Node taller than viewport: snap top of node to top of viewport
newOffsetY = offsetY + viewportHeight / 2 - (nodeHeight * zoom) / 2;
newOffsetY = offsetY - (nodeTop - 20); // aligns top
} else {
// Node fits in viewport: ensure fully visible
if (nodeTop < 0) {
newOffsetY += -nodeTop + 20;
} else if (nodeBottom > viewportHeight) {
newOffsetY -= nodeBottom - viewportHeight + 20;
}
}
// Update offset only if needed
if (newOffsetX !== offsetX || newOffsetY !== offsetY) {
model.setOffset(newOffsetX, newOffsetY);
}
this.diagram.repaint();
node.setSelected(true);
node.fireEvent({}, 'highlightFlash');
};
addEditTable(node) {
let dialog = this.getDialog('table_dialog');
if(node) {
let [schema, table] = node.getSchemaTableName();
let oldData = node.getData();
dialog(gettext('Table: %s (%s)', _.escape(table),_.escape(schema)), oldData, false, (newData)=>{
dialog(gettext('Table: %s', node.getDisplayName()), oldData, false, (newData)=>{
if(this.diagram.anyDuplicateNodeName(newData, oldData)) {
return gettext('Table name already exists');
}
@ -560,6 +622,12 @@ export default class ERDTool extends React.Component {
}
}
onSearchNode() {
this.context.showModal(gettext('Search'), (closeModal)=>(
<SearchNode tableNodes={this.diagram.getModel().getNodesDict()} onClose={closeModal} scrollToNode={this.scrollToNode} />
), {id: 'id-erd-search-node', showTitle: false, disableRestoreFocus: true});
}
onAddNewNode() {
this.addEditTable();
}

View File

@ -62,8 +62,7 @@ export default function FloatingNote({open, onClose, anchorEl, rows, noteNode})
const header = useMemo(()=>{
if(noteNode) {
let [schema, name] = noteNode.getSchemaTableName();
return `${name} (${schema})`;
return noteNode.getDisplayName();
}
return '';
}, [open]);

View File

@ -28,6 +28,7 @@ import ImageRoundedIcon from '@mui/icons-material/ImageRounded';
import FormatColorFillRoundedIcon from '@mui/icons-material/FormatColorFillRounded';
import FormatColorTextRoundedIcon from '@mui/icons-material/FormatColorTextRounded';
import AccountTreeOutlinedIcon from '@mui/icons-material/AccountTreeOutlined';
import SearchOutlinedIcon from '@mui/icons-material/SearchOutlined';
import { PgMenu, PgMenuItem, usePgMenuGroup } from '../../../../../../static/js/components/Menu';
import gettext from 'sources/gettext';
@ -201,6 +202,11 @@ export function MainToolBar({preferences, eventBus, fillColor, textColor, notati
}} />
</PgButtonGroup>
<PgButtonGroup size="small">
<PgIconButton title={gettext('Search Table')} icon={<SearchOutlinedIcon />}
shortcut={preferences.search_table}
onClick={()=>{
eventBus.fireEvent(ERD_EVENTS.SEARCH_NODE);
}} />
<PgIconButton title={gettext('Add Table')} icon={<AddBoxIcon />}
shortcut={preferences.add_table}
onClick={()=>{

View File

@ -0,0 +1,40 @@
/////////////////////////////////////////////////////////////
//
// pgAdmin 4 - PostgreSQL Tools
//
// Copyright (C) 2013 - 2025, The pgAdmin Development Team
// This software is released under the PostgreSQL Licence
//
//////////////////////////////////////////////////////////////
import PropTypes from 'prop-types';
import { InputSelect } from '../../../../../../static/js/components/FormComponents';
export default function SearchNode({tableNodes, onClose, scrollToNode}) {
const onSelectChange = (val) => {
let node = tableNodes[val];
if(node) {
scrollToNode(node);
}
onClose();
};
return (
<InputSelect
options={Object.values(tableNodes).map(node => ({
value: node.getID(),
label: node.getDisplayName(),
}))}
onChange={onSelectChange}
autoFocus
placeholder="Select a table"
openMenuOnFocus
/>
);
}
SearchNode.propTypes = {
tableNodes: PropTypes.object.isRequired,
onClose: PropTypes.func.isRequired,
};

View File

@ -50,8 +50,7 @@ class ManyToManySchema extends BaseUISchema {
export function getManyToManyDialogSchema(attributes, tableNodesDict) {
let tablesData = [];
_.forEach(tableNodesDict, (node, uid)=>{
let [schema, name] = node.getSchemaTableName();
tablesData.push({value: uid, label: `(${schema}) ${name}`, image: 'icon-table'});
tablesData.push({value: uid, label: node.getDisplayName(), image: 'icon-table'});
});
return new ManyToManySchema({

View File

@ -50,8 +50,7 @@ class OneToManySchema extends BaseUISchema {
export function getOneToManyDialogSchema(attributes, tableNodesDict) {
let tablesData = [];
_.forEach(tableNodesDict, (node, uid)=>{
let [schema, name] = node.getSchemaTableName();
tablesData.push({value: uid, label: `(${schema}) ${name}`, image: 'icon-table'});
tablesData.push({value: uid, label: node.getDisplayName(), image: 'icon-table'});
});
return new OneToManySchema({

View File

@ -83,8 +83,7 @@ class OneToOneSchema extends BaseUISchema {
export function getOneToOneDialogSchema(attributes, tableNodesDict) {
let tablesData = [];
_.forEach(tableNodesDict, (node, uid)=>{
let [schema, name] = node.getSchemaTableName();
tablesData.push({value: uid, label: `(${schema}) ${name}`, image: 'icon-table'});
tablesData.push({value: uid, label: node.getDisplayName(), image: 'icon-table'});
});
return new OneToOneSchema({

View File

@ -61,8 +61,7 @@ export function getTableDialogSchema(attributes, isNew, tableNodesDict, colTypes
references: ()=>{
let retOpts = [];
_.forEach(tableNodesDict, (node, uid)=>{
let [schema, name] = node.getSchemaTableName();
retOpts.push({value: uid, label: `(${schema}) ${name}`});
retOpts.push({value: uid, label: node.getDisplayName()});
});
return retOpts;
}

View File

@ -147,6 +147,10 @@ export class TableNodeModel extends DefaultNodeModel {
return [this._data.schema, this._data.name];
}
getDisplayName() {
return `(${this._data.schema}) ${this._data.name}`;
}
remove() {
Object.values(this.getPorts()).forEach((port)=>{
port.removeAllLinks();
@ -218,6 +222,19 @@ const StyledDiv = styled('div')(({theme})=>({
position: 'relative',
width: `${TABLE_WIDTH}px`,
fontSize: '0.8em',
borderRadius: theme.shape.borderRadius,
'&.flash': {
animation: 'flash 2s ease-in-out',
},
'@keyframes flash': {
'0%': {
boxShadow: `0 0 10px 5px ${theme.palette.primary.main}`,
},
'100%': {
boxShadow: 'none',
},
},
'& .TableNode-tableContent': {
backgroundColor: theme.palette.background.default,
@ -263,10 +280,21 @@ const StyledDiv = styled('div')(({theme})=>({
padding: '0.125rem 0.25rem',
wordBreak: 'break-all',
},
'&:last-child': {
borderBottom: 'none',
},
},
},
'&.TableNode-tableNodeSelected': {
borderColor: theme.palette.primary.main,
'& .TableNode-tableToolbar': {
borderColor: theme.palette.primary.main,
},
'& .TableNode-tableContent': {
borderLeftColor: theme.palette.primary.main,
borderRightColor: theme.palette.primary.main,
borderBottomColor: theme.palette.primary.main,
},
},
}));
@ -276,6 +304,7 @@ export class TableNodeWidget extends React.Component {
this.state = {
show_details: true,
flash: false,
};
this.tableNodeEventListener = this.props.node.registerListener({
@ -291,6 +320,12 @@ export class TableNodeWidget extends React.Component {
dataAvaiable: ()=>{
/* Just re-render */
this.setState({});
},
highlightFlash: ()=>{
this.setState({flash: true});
setTimeout(()=>{
this.setState({flash: false});
}, 2000);
}
});
}
@ -368,13 +403,21 @@ export class TableNodeWidget extends React.Component {
(tableData.unique_constraint||[]).forEach((uk)=>{
localUkCols.push(...uk.columns.map((c)=>c.column));
});
const styles = {
const contentStyles = {
backgroundColor: tableMetaData.fillColor,
color: tableMetaData.textColor,
};
let classList = ['TableNode-tableNode'];
if(this.props.node.isSelected()) {
classList.push('TableNode-tableNodeSelected');
}
if(this.state.flash) {
classList.push('flash');
}
return (
<StyledDiv className={['TableNode-tableNode', (this.props.node.isSelected() ? 'TableNode-tableNodeSelected': '')].join(' ')}
onDoubleClick={()=>{this.props.node.fireEvent({}, 'editTable');}} style={styles}>
<StyledDiv className={classList.join(' ')}
onDoubleClick={()=>{this.props.node.fireEvent({}, 'editTable');}}>
<div className={'TableNode-tableToolbar'}>
<PgIconButton size="xs" title={gettext('Show Details')} icon={this.state.show_details ? <VisibilityRoundedIcon /> : <VisibilityOffRoundedIcon />}
onClick={this.toggleShowDetails} onDoubleClick={(e)=>{e.stopPropagation();}} />
@ -386,7 +429,7 @@ export class TableNodeWidget extends React.Component {
}}
/>}
</div>
<div className='TableNode-tableContent'>
<div className='TableNode-tableContent' style={contentStyles}>
{tableMetaData.is_promise &&
<div className='TableNode-tableSection'>
{!tableMetaData.data_failed && <div className='TableNode-tableNameText'>{gettext('Fetching...')}</div>}

View File

@ -65,18 +65,25 @@ describe('SchemaView', ()=>{
});
},
simulateValidData = async ()=>{
// Wait for focus
await act(async ()=>{
await new Promise(resolve => setTimeout(resolve, 500));
});
await user.type(ctrl.container.querySelector('[name="field1"]'), 'val1');
await user.type(ctrl.container.querySelector('[name="field2"]'), '2');
await user.type(ctrl.container.querySelector('[name="field5"]'), 'val5');
/* Add a row */
await user.click(ctrl.container.querySelector('button[data-test="add-row"]'));
await user.click(ctrl.container.querySelector('button[data-test="add-row"]'));
// Wait for focus
await act(async ()=>{
await new Promise(resolve => setTimeout(resolve, 500));
});
await user.type(ctrl.container.querySelectorAll('[name="field5"]')[0], 'rval51');
await user.type(ctrl.container.querySelectorAll('[name="field5"]')[1], 'rval52');
// Wait for validations to run
await act(async ()=>{
await new Promise(resolve => setTimeout(resolve));
await new Promise(resolve => setTimeout(resolve, 500));
});
};

View File

@ -20,6 +20,7 @@ export class FakeNode {
getColumnAt(pos) {return _.find(this.getColumns()||[], (c)=>c.attnum==pos);}
remove() {/*This is intentional (SonarQube)*/}
getSchemaTableName() {return [this.data.schema, this.data.name];}
getDisplayName() {return `(${this.data.schema}) ${this.data.name}`;}
cloneData(tabName) {
let retVal = {...this.data};
retVal.name = tabName;

View File

@ -26,6 +26,9 @@ describe('ERD FloatingNote', ()=>{
getSchemaTableName: function() {
return ['schema1', 'table1'];
},
getDisplayName: function() {
return '(schema1) table1';
}
};
const user = userEvent.setup();