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.
* Generate ready to run SQL from the database design.
* Generate the database diagram for an existing database.
* Drag and drop tables from browser tree to the diagram.
.. image:: images/erd_tool.png
: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 #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 #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.

View File

@ -7,6 +7,8 @@
//
//////////////////////////////////////////////////////////////
import { generateNodeUrl } from './node_ajax';
define('pgadmin.browser', [
'sources/gettext', 'sources/url_for', 'require', 'jquery', 'underscore',
'bootstrap', 'sources/pgadmin', 'pgadmin.alertifyjs', 'bundled_codemirror',
@ -61,8 +63,16 @@ define('pgadmin.browser', [
function(b) {
InitTree.initBrowserTree(b).then(() => {
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)=>{
return pgadminUtils.fully_qualify(b, 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)=>{
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)=>{
return pgadminUtils.quote_ident(data._label);

View File

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

View File

@ -175,11 +175,12 @@ export default class ERDCore {
getModel() {return this.getEngine().getModel();}
getNewNode(initData) {
getNewNode(initData, dataUrl=null) {
return this.getEngine().getNodeFactories().getFactory('table').generateModel({
initialConfig: {
otherInfo: {
data:initData,
dataUrl: dataUrl,
},
},
});
@ -404,6 +405,21 @@ export default class ERDCore {
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) {
return {
version: version||0,
@ -422,7 +438,10 @@ export default class ERDCore {
let nodesDict = this.getModel().getNodesDict();
Object.keys(nodesDict).forEach((id)=>{
nodes[id] = nodesDict[id].serializeData();
let nodeData = nodesDict[id].serializeData();
if(nodeData) {
nodes[id] = nodeData;
}
});
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 ColumnIcon from 'top/browser/server_groups/servers/databases/schemas/tables/columns/static/img/column.svg';
import PropTypes from 'prop-types';
import gettext from 'sources/gettext';
const TYPE = 'table';
@ -29,11 +30,35 @@ export class TableNodeModel extends DefaultNodeModel {
});
this._note = otherInfo.note || '';
this._data = {
columns: [],
...otherInfo.data,
this._metadata = {
data_failed: false,
...otherInfo.metadata,
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) {
@ -48,6 +73,10 @@ export class TableNodeModel extends DefaultNodeModel {
return this._note;
}
getMetadata() {
return this._metadata;
}
addColumn(col) {
this._data.columns.push(col);
}
@ -64,17 +93,6 @@ export class TableNodeModel extends DefaultNodeModel {
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) {
let self = this;
/* Remove the links if column dropped or primary key removed */
@ -116,6 +134,7 @@ export class TableNodeModel extends DefaultNodeModel {
otherInfo: {
data: this.getData(),
note: this.getNote(),
metadata: this.getMetadata(),
},
};
}
@ -146,6 +165,10 @@ export class TableNodeWidget extends React.Component {
toggleDetails: (event) => {
this.setState({show_details: event.show_details});
},
dataAvaiable: ()=>{
/* Just re-render */
this.setState({});
}
});
}
@ -192,26 +215,37 @@ export class TableNodeWidget extends React.Component {
render() {
let tableData = this.props.node.getData();
let tableMetaData = this.props.node.getMetadata();
return (
<div className={'table-node ' + (this.props.node.isSelected() ? 'selected': '') } onDoubleClick={()=>{this.props.node.fireEvent({}, 'editTable');}}>
<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() &&
<IconButton icon="far fa-sticky-note" className="btn-xs btn-warning ml-auto" onClick={()=>{
this.props.node.fireEvent({}, 'showNote');
}} 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))}
}} title="Check note"/>}
</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>
);
}

View File

@ -26,6 +26,7 @@ import url_for from 'sources/url_for';
import {showERDSqlTool} from 'tools/datagrid/static/js/show_query_tool';
import 'wcdocker';
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 */
export class KeyboardShortcutAction extends Action {
@ -94,7 +95,7 @@ export default class BodyWidget extends React.Component {
_.bindAll(this, ['onLoadDiagram', 'onSaveDiagram', 'onSaveAsDiagram', 'onSQLClick',
'onImageClick', 'onAddNewNode', 'onEditTable', 'onCloneNode', 'onDeleteNode', 'onNoteClick',
'onNoteClose', 'onOneToManyClick', 'onManyToManyClick', 'onAutoDistribute', 'onDetailsToggle',
'onDetailsToggle', 'onHelpClick'
'onDetailsToggle', 'onHelpClick', 'onDropNode',
]);
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`});
},
'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({
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,
});
},
@ -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() {
const selected = this.diagram.getSelectedNodes();
if(selected.length == 1) {
@ -375,10 +406,12 @@ export default class BodyWidget extends React.Component {
onCloneNode() {
const selected = this.diagram.getSelectedNodes();
if(selected.length == 1) {
let newData = selected[0].cloneData(this.diagram.getNextTableName());
let {x, y} = selected[0].getPosition();
let newNode = this.diagram.addNode(newData, [x+20, y+20]);
newNode.setSelected(true);
let newData = this.diagram.cloneTableData(selected[0].getData(), this.diagram.getNextTableName());
if(newData) {
let {x, y} = selected[0].getPosition();
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}/>
<FloatingNote open={this.state.note_open} onClose={this.onNoteClose}
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}/>
<CanvasWidget className="diagram-canvas flex-grow-1" ref={(ele)=>{this.canvasEle = ele?.ref?.current;}} engine={this.diagram.getEngine()} />
</div>

View File

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

View File

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

View File

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

View File

@ -63,15 +63,6 @@ describe('ERD TableNodeModel', ()=>{
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', ()=>{
let existPort = jasmine.createSpyObj('port', {
'removeAllLinks': jasmine.createSpy('removeAllLinks'),
@ -196,6 +187,9 @@ describe('ERD TableNodeModel', ()=>{
schema: 'erd',
},
note: 'some note',
metadata: {
data_failed: false, is_promise: false
}
},
});
});

View File

@ -178,7 +178,7 @@ describe('ERD BodyWidget', ()=>{
});
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);
setTimeout(()=>{
expect(body.state().single_node_selected).toBe(true);