Added support to allow tables to be dragged to ERD Tool. Fixes #6241

This commit is contained in:
Aditya Toshniwal 2021-10-16 12:43:39 +05:30 committed by Akshay Joshi
parent 476d7c5fc9
commit 7f3c3fa6f9
15 changed files with 152 additions and 50 deletions

View File

@ -12,6 +12,7 @@ The Entity-Relationship Diagram (ERD) tool is a database design tool that provid
* Save the diagram and open it later to continue working on it. * Save the diagram and open it later to continue working on it.
* Generate ready to run SQL from the database design. * Generate ready to run SQL from the database design.
* Generate the database diagram for an existing database. * Generate the database diagram for an existing database.
* Drag and drop tables from browser tree to the diagram.
.. image:: images/erd_tool.png .. image:: images/erd_tool.png
:alt: ERD tool window :alt: ERD tool window

Binary file not shown.

Before

Width:  |  Height:  |  Size: 36 KiB

After

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 41 KiB

After

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 37 KiB

After

Width:  |  Height:  |  Size: 47 KiB

View File

@ -11,6 +11,7 @@ New features
| `Issue #4596 <https://redmine.postgresql.org/issues/4596>`_ - Added support for indent guides in the browser tree. | `Issue #4596 <https://redmine.postgresql.org/issues/4596>`_ - Added support for indent guides in the browser tree.
| `Issue #6081 <https://redmine.postgresql.org/issues/6081>`_ - Added support for advanced table fields like the foreign key, primary key in the ERD tool. | `Issue #6081 <https://redmine.postgresql.org/issues/6081>`_ - Added support for advanced table fields like the foreign key, primary key in the ERD tool.
| `Issue #6241 <https://redmine.postgresql.org/issues/6241>`_ - Added support to allow tables to be dragged to ERD Tool.
| `Issue #6529 <https://redmine.postgresql.org/issues/6529>`_ - Added index creation when generating SQL in the ERD tool. | `Issue #6529 <https://redmine.postgresql.org/issues/6529>`_ - Added index creation when generating SQL in the ERD tool.
| `Issue #6657 <https://redmine.postgresql.org/issues/6657>`_ - Added support for authentication via the webserver (REMOTE_USER). | `Issue #6657 <https://redmine.postgresql.org/issues/6657>`_ - Added support for authentication via the webserver (REMOTE_USER).
| `Issue #6794 <https://redmine.postgresql.org/issues/6794>`_ - Added support to enable/disable rules. | `Issue #6794 <https://redmine.postgresql.org/issues/6794>`_ - Added support to enable/disable rules.

View File

@ -7,6 +7,8 @@
// //
////////////////////////////////////////////////////////////// //////////////////////////////////////////////////////////////
import { generateNodeUrl } from './node_ajax';
define('pgadmin.browser', [ define('pgadmin.browser', [
'sources/gettext', 'sources/url_for', 'require', 'jquery', 'underscore', 'sources/gettext', 'sources/url_for', 'require', 'jquery', 'underscore',
'bootstrap', 'sources/pgadmin', 'pgadmin.alertifyjs', 'bundled_codemirror', 'bootstrap', 'sources/pgadmin', 'pgadmin.alertifyjs', 'bundled_codemirror',
@ -61,8 +63,16 @@ define('pgadmin.browser', [
function(b) { function(b) {
InitTree.initBrowserTree(b).then(() => { InitTree.initBrowserTree(b).then(() => {
b.tree.registerDraggableType({ b.tree.registerDraggableType({
'collation domain domain_constraints fts_configuration fts_dictionary fts_parser fts_template synonym table partition type sequence package view mview foreign_table edbvar' : (data, item)=>{ 'collation domain domain_constraints fts_configuration fts_dictionary fts_parser fts_template synonym table partition type sequence package view mview foreign_table edbvar' : (data, item, treeNodeInfo)=>{
return pgadminUtils.fully_qualify(b, data, item); let text = pgadminUtils.fully_qualify(b, data, item);
return {
text: text,
objUrl: generateNodeUrl.call(pgBrowser.Nodes[data._type], treeNodeInfo, 'properties', data, true),
cur: {
from: text.length,
to: text.length,
},
};
}, },
'schema column database cast event_trigger extension language foreign_data_wrapper foreign_server user_mapping compound_trigger index index_constraint primary_key unique_constraint check_constraint exclusion_constraint foreign_key rule' : (data)=>{ 'schema column database cast event_trigger extension language foreign_data_wrapper foreign_server user_mapping compound_trigger index index_constraint primary_key unique_constraint check_constraint exclusion_constraint foreign_key rule' : (data)=>{
return pgadminUtils.quote_ident(data._label); return pgadminUtils.quote_ident(data._label);

View File

@ -467,7 +467,7 @@ export class Tree {
* overrides the dragstart event set using element.on('dragstart') * overrides the dragstart event set using element.on('dragstart')
* This will avoid conflict. * This will avoid conflict.
*/ */
let dropDetails = dropDetailsFunc(data, item); let dropDetails = dropDetailsFunc(data, item, this.getTreeNodeHierarchy(item));
if(typeof dropDetails == 'string') { if(typeof dropDetails == 'string') {
dropDetails = { dropDetails = {

View File

@ -175,11 +175,12 @@ export default class ERDCore {
getModel() {return this.getEngine().getModel();} getModel() {return this.getEngine().getModel();}
getNewNode(initData) { getNewNode(initData, dataUrl=null) {
return this.getEngine().getNodeFactories().getFactory('table').generateModel({ return this.getEngine().getNodeFactories().getFactory('table').generateModel({
initialConfig: { initialConfig: {
otherInfo: { otherInfo: {
data:initData, data:initData,
dataUrl: dataUrl,
}, },
}, },
}); });
@ -404,6 +405,21 @@ export default class ERDCore {
this.repaint(); this.repaint();
} }
cloneTableData(tableData, name) {
const SKIP_CLONE_KEYS = ['foreign_key'];
if(!tableData) {
return tableData;
}
let newData = {
..._.pickBy(tableData, (_v, k)=>(SKIP_CLONE_KEYS.indexOf(k) == -1)),
};
if(name) {
newData['name'] = name;
}
return newData;
}
serialize(version) { serialize(version) {
return { return {
version: version||0, version: version||0,
@ -422,7 +438,10 @@ export default class ERDCore {
let nodesDict = this.getModel().getNodesDict(); let nodesDict = this.getModel().getNodesDict();
Object.keys(nodesDict).forEach((id)=>{ Object.keys(nodesDict).forEach((id)=>{
nodes[id] = nodesDict[id].serializeData(); let nodeData = nodesDict[id].serializeData();
if(nodeData) {
nodes[id] = nodeData;
}
}); });
return { return {

View File

@ -18,6 +18,7 @@ import PrimaryKeyIcon from 'top/browser/server_groups/servers/databases/schemas/
import ForeignKeyIcon from 'top/browser/server_groups/servers/databases/schemas/tables/constraints/foreign_key/static/img/foreign_key.svg'; import ForeignKeyIcon from 'top/browser/server_groups/servers/databases/schemas/tables/constraints/foreign_key/static/img/foreign_key.svg';
import ColumnIcon from 'top/browser/server_groups/servers/databases/schemas/tables/columns/static/img/column.svg'; import ColumnIcon from 'top/browser/server_groups/servers/databases/schemas/tables/columns/static/img/column.svg';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import gettext from 'sources/gettext';
const TYPE = 'table'; const TYPE = 'table';
@ -29,11 +30,35 @@ export class TableNodeModel extends DefaultNodeModel {
}); });
this._note = otherInfo.note || ''; this._note = otherInfo.note || '';
this._metadata = {
this._data = { data_failed: false,
columns: [], ...otherInfo.metadata,
...otherInfo.data, is_promise: Boolean(otherInfo.data?.then || (otherInfo.metadata?.data_failed && !otherInfo.data)),
}; };
this._data = null;
if(otherInfo.data?.then) {
otherInfo.data.then((data)=>{
/* Once the data is available, it is no more a promise */
this._data = data;
this._metadata = {
data_failed: false,
is_promise: false,
};
this.fireEvent(this._metadata, 'dataAvaiable');
this.fireEvent({}, 'nodeUpdated');
}).catch(()=>{
this._metadata = {
data_failed: true,
is_promise: true,
};
this.fireEvent(this._metadata, 'dataAvaiable');
});
} else {
this._data = {
columns: [],
...otherInfo.data,
};
}
} }
getPortName(attnum) { getPortName(attnum) {
@ -48,6 +73,10 @@ export class TableNodeModel extends DefaultNodeModel {
return this._note; return this._note;
} }
getMetadata() {
return this._metadata;
}
addColumn(col) { addColumn(col) {
this._data.columns.push(col); this._data.columns.push(col);
} }
@ -64,17 +93,6 @@ export class TableNodeModel extends DefaultNodeModel {
this._data['name'] = name; this._data['name'] = name;
} }
cloneData(name) {
const SKIP_CLONE_KEYS = ['foreign_key'];
let newData = {
..._.pickBy(this.getData(), (_v, k)=>(SKIP_CLONE_KEYS.indexOf(k) == -1)),
};
if(name) {
newData['name'] = name;
}
return newData;
}
setData(data) { setData(data) {
let self = this; let self = this;
/* Remove the links if column dropped or primary key removed */ /* Remove the links if column dropped or primary key removed */
@ -116,6 +134,7 @@ export class TableNodeModel extends DefaultNodeModel {
otherInfo: { otherInfo: {
data: this.getData(), data: this.getData(),
note: this.getNote(), note: this.getNote(),
metadata: this.getMetadata(),
}, },
}; };
} }
@ -146,6 +165,10 @@ export class TableNodeWidget extends React.Component {
toggleDetails: (event) => { toggleDetails: (event) => {
this.setState({show_details: event.show_details}); this.setState({show_details: event.show_details});
}, },
dataAvaiable: ()=>{
/* Just re-render */
this.setState({});
}
}); });
} }
@ -192,26 +215,37 @@ export class TableNodeWidget extends React.Component {
render() { render() {
let tableData = this.props.node.getData(); let tableData = this.props.node.getData();
let tableMetaData = this.props.node.getMetadata();
return ( return (
<div className={'table-node ' + (this.props.node.isSelected() ? 'selected': '') } onDoubleClick={()=>{this.props.node.fireEvent({}, 'editTable');}}> <div className={'table-node ' + (this.props.node.isSelected() ? 'selected': '') } onDoubleClick={()=>{this.props.node.fireEvent({}, 'editTable');}}>
<div className="table-toolbar"> <div className="table-toolbar">
<DetailsToggleButton className='btn-xs' showDetails={this.state.show_details} onClick={this.toggleShowDetails} onDoubleClick={(e)=>{e.stopPropagation();}} /> <DetailsToggleButton className='btn-xs' showDetails={this.state.show_details}
onClick={this.toggleShowDetails} onDoubleClick={(e)=>{e.stopPropagation();}}
disabled={tableMetaData.is_promise} />
{this.props.node.getNote() && {this.props.node.getNote() &&
<IconButton icon="far fa-sticky-note" className="btn-xs btn-warning ml-auto" onClick={()=>{ <IconButton icon="far fa-sticky-note" className="btn-xs btn-warning ml-auto" onClick={()=>{
this.props.node.fireEvent({}, 'showNote'); this.props.node.fireEvent({}, 'showNote');
}} title="Check note" />} }} title="Check note"/>}
</div>
<div className="d-flex table-schema-data">
<RowIcon icon={SchemaIcon}/>
<div className="table-schema my-auto">{tableData.schema}</div>
</div>
<div className="d-flex table-name-data">
<RowIcon icon={TableIcon} />
<div className="table-name my-auto">{tableData.name}</div>
</div>
<div className="table-cols">
{_.map(tableData.columns, (col)=>this.generateColumn(col, tableData))}
</div> </div>
{tableMetaData.is_promise && <>
<div className="d-flex table-name-data">
{!tableMetaData.data_failed && <div className="table-name my-auto">{gettext('Fetching...')}</div>}
{tableMetaData.data_failed && <div className="table-name my-auto fetch-error">{gettext('Failed to get data. Please delete this table.')}</div>}
</div>
</>}
{!tableMetaData.is_promise && <>
<div className="d-flex table-schema-data">
<RowIcon icon={SchemaIcon}/>
<div className="table-schema my-auto">{tableData.schema}</div>
</div>
<div className="d-flex table-name-data">
<RowIcon icon={TableIcon} />
<div className="table-name my-auto">{tableData.name}</div>
</div>
<div className="table-cols">
{_.map(tableData.columns, (col)=>this.generateColumn(col, tableData))}
</div>
</>}
</div> </div>
); );
} }

View File

@ -26,6 +26,7 @@ import url_for from 'sources/url_for';
import {showERDSqlTool} from 'tools/datagrid/static/js/show_query_tool'; import {showERDSqlTool} from 'tools/datagrid/static/js/show_query_tool';
import 'wcdocker'; import 'wcdocker';
import Theme from '../../../../../../static/js/Theme'; import Theme from '../../../../../../static/js/Theme';
import TableSchema from '../../../../../../browser/server_groups/servers/databases/schemas/tables/static/js/table.ui';
/* Custom react-diagram action for keyboard events */ /* Custom react-diagram action for keyboard events */
export class KeyboardShortcutAction extends Action { export class KeyboardShortcutAction extends Action {
@ -94,7 +95,7 @@ export default class BodyWidget extends React.Component {
_.bindAll(this, ['onLoadDiagram', 'onSaveDiagram', 'onSaveAsDiagram', 'onSQLClick', _.bindAll(this, ['onLoadDiagram', 'onSaveDiagram', 'onSaveAsDiagram', 'onSQLClick',
'onImageClick', 'onAddNewNode', 'onEditTable', 'onCloneNode', 'onDeleteNode', 'onNoteClick', 'onImageClick', 'onAddNewNode', 'onEditTable', 'onCloneNode', 'onDeleteNode', 'onNoteClick',
'onNoteClose', 'onOneToManyClick', 'onManyToManyClick', 'onAutoDistribute', 'onDetailsToggle', 'onNoteClose', 'onOneToManyClick', 'onManyToManyClick', 'onAutoDistribute', 'onDetailsToggle',
'onDetailsToggle', 'onHelpClick' 'onDetailsToggle', 'onHelpClick', 'onDropNode',
]); ]);
this.diagram.zoomToFit = this.diagram.zoomToFit.bind(this.diagram); this.diagram.zoomToFit = this.diagram.zoomToFit.bind(this.diagram);
@ -114,8 +115,15 @@ export default class BodyWidget extends React.Component {
this.realignGrid({backgroundSize: `${bgSize*3}px ${bgSize*3}px`}); this.realignGrid({backgroundSize: `${bgSize*3}px ${bgSize*3}px`});
}, },
'nodesSelectionChanged': ()=>{ 'nodesSelectionChanged': ()=>{
let singleNodeSelected = false;
if(this.diagram.getSelectedNodes().length == 1) {
let metadata = this.diagram.getSelectedNodes()[0].getMetadata();
if(!metadata.is_promise) {
singleNodeSelected = true;
}
}
this.setState({ this.setState({
single_node_selected: this.diagram.getSelectedNodes().length == 1, single_node_selected: singleNodeSelected,
any_item_selected: this.diagram.getSelectedNodes().length > 0 || this.diagram.getSelectedLinks().length > 0, any_item_selected: this.diagram.getSelectedNodes().length > 0 || this.diagram.getSelectedLinks().length > 0,
}); });
}, },
@ -361,6 +369,29 @@ export default class BodyWidget extends React.Component {
} }
} }
onDropNode(e) {
let nodeDropData = JSON.parse(e.dataTransfer.getData('text'));
if(nodeDropData.objUrl) {
let matchUrl = `/${this.props.params.sgid}/${this.props.params.sid}/${this.props.params.did}/`;
if(nodeDropData.objUrl.indexOf(matchUrl) == -1) {
this.props.alertify.error(gettext('Cannot drop table from outside of the current database.'));
} else {
let dataPromise = new Promise((resolve, reject)=>{
axios.get(nodeDropData.objUrl)
.then((res)=>{
resolve(this.diagram.cloneTableData(TableSchema.getErdSupportedData(res.data)));
})
.catch((err)=>{
console.error(err);
reject();
});
});
const {x, y} = this.diagram.getEngine().getRelativeMousePoint(e);
this.diagram.addNode(dataPromise, [x, y]).setSelected(true);
}
}
}
onEditTable() { onEditTable() {
const selected = this.diagram.getSelectedNodes(); const selected = this.diagram.getSelectedNodes();
if(selected.length == 1) { if(selected.length == 1) {
@ -375,10 +406,12 @@ export default class BodyWidget extends React.Component {
onCloneNode() { onCloneNode() {
const selected = this.diagram.getSelectedNodes(); const selected = this.diagram.getSelectedNodes();
if(selected.length == 1) { if(selected.length == 1) {
let newData = selected[0].cloneData(this.diagram.getNextTableName()); let newData = this.diagram.cloneTableData(selected[0].getData(), this.diagram.getNextTableName());
let {x, y} = selected[0].getPosition(); if(newData) {
let newNode = this.diagram.addNode(newData, [x+20, y+20]); let {x, y} = selected[0].getPosition();
newNode.setSelected(true); let newNode = this.diagram.addNode(newData, [x+20, y+20]);
newNode.setSelected(true);
}
} }
} }
@ -825,7 +858,7 @@ export default class BodyWidget extends React.Component {
fgcolor={this.props.params.fgcolor} title={this.props.params.title}/> fgcolor={this.props.params.fgcolor} title={this.props.params.title}/>
<FloatingNote open={this.state.note_open} onClose={this.onNoteClose} <FloatingNote open={this.state.note_open} onClose={this.onNoteClose}
reference={this.noteRefEle} noteNode={this.state.note_node} appendTo={this.diagramContainerRef.current} rows={8}/> reference={this.noteRefEle} noteNode={this.state.note_node} appendTo={this.diagramContainerRef.current} rows={8}/>
<div className="diagram-container" ref={this.diagramContainerRef}> <div className="diagram-container" ref={this.diagramContainerRef} onDrop={this.onDropNode} onDragOver={e => {e.preventDefault();}}>
<Loader message={this.state.loading_msg} autoEllipsis={true}/> <Loader message={this.state.loading_msg} autoEllipsis={true}/>
<CanvasWidget className="diagram-canvas flex-grow-1" ref={(ele)=>{this.canvasEle = ele?.ref?.current;}} engine={this.diagram.getEngine()} /> <CanvasWidget className="diagram-canvas flex-grow-1" ref={(ele)=>{this.canvasEle = ele?.ref?.current;}} engine={this.diagram.getEngine()} />
</div> </div>

View File

@ -135,6 +135,10 @@
font-weight: bold; font-weight: bold;
word-break: break-all; word-break: break-all;
} }
& .fetch-error {
color: $color-danger;
}
} }
.table-cols { .table-cols {

View File

@ -101,6 +101,7 @@ describe('ERDCore', ()=>{
initialConfig: { initialConfig: {
otherInfo: { otherInfo: {
data:data, data:data,
dataUrl: null,
}, },
}, },
}); });

View File

@ -24,6 +24,11 @@ export class FakeNode {
retVal.name = tabName; retVal.name = tabName;
return retVal; return retVal;
} }
getMetadata() {
return {
is_promise: false,
};
}
} }
export class FakeLink { export class FakeLink {

View File

@ -63,15 +63,6 @@ describe('ERD TableNodeModel', ()=>{
expect(modelObj.getData().name).toBe('changedName'); expect(modelObj.getData().name).toBe('changedName');
}); });
it('cloneData', ()=>{
modelObj.addColumn({name: 'col1', not_null:false, attnum: 0});
expect(modelObj.cloneData('clonedNode')).toEqual({
name: 'clonedNode',
schema: 'erd',
columns: [{name: 'col1', not_null:false, attnum: 0}],
});
});
describe('setData', ()=>{ describe('setData', ()=>{
let existPort = jasmine.createSpyObj('port', { let existPort = jasmine.createSpyObj('port', {
'removeAllLinks': jasmine.createSpy('removeAllLinks'), 'removeAllLinks': jasmine.createSpy('removeAllLinks'),
@ -196,6 +187,9 @@ describe('ERD TableNodeModel', ()=>{
schema: 'erd', schema: 'erd',
}, },
note: 'some note', note: 'some note',
metadata: {
data_failed: false, is_promise: false
}
}, },
}); });
}); });

View File

@ -178,7 +178,7 @@ describe('ERD BodyWidget', ()=>{
}); });
it('event nodesSelectionChanged', (done)=>{ it('event nodesSelectionChanged', (done)=>{
spyOn(bodyInstance.diagram, 'getSelectedNodes').and.returnValue([{key:'value'}]); spyOn(bodyInstance.diagram, 'getSelectedNodes').and.returnValue([new FakeNode({key:'value'})]);
bodyInstance.diagram.fireEvent({}, 'nodesSelectionChanged', true); bodyInstance.diagram.fireEvent({}, 'nodesSelectionChanged', true);
setTimeout(()=>{ setTimeout(()=>{
expect(body.state().single_node_selected).toBe(true); expect(body.state().single_node_selected).toBe(true);