mirror of
https://github.com/pgadmin-org/pgadmin4.git
synced 2024-11-26 02:30:21 -06:00
Added support to allow tables to be dragged to ERD Tool. Fixes #6241
This commit is contained in:
parent
476d7c5fc9
commit
7f3c3fa6f9
@ -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 |
@ -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.
|
||||
|
@ -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);
|
||||
|
@ -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 = {
|
||||
|
@ -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 {
|
||||
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
@ -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>
|
||||
|
@ -135,6 +135,10 @@
|
||||
font-weight: bold;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
& .fetch-error {
|
||||
color: $color-danger;
|
||||
}
|
||||
}
|
||||
|
||||
.table-cols {
|
||||
|
@ -101,6 +101,7 @@ describe('ERDCore', ()=>{
|
||||
initialConfig: {
|
||||
otherInfo: {
|
||||
data:data,
|
||||
dataUrl: null,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
@ -24,6 +24,11 @@ export class FakeNode {
|
||||
retVal.name = tabName;
|
||||
return retVal;
|
||||
}
|
||||
getMetadata() {
|
||||
return {
|
||||
is_promise: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export class FakeLink {
|
||||
|
@ -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
|
||||
}
|
||||
},
|
||||
});
|
||||
});
|
||||
|
@ -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);
|
||||
|
Loading…
Reference in New Issue
Block a user