Added ERD Diagram support with basic table fields, primary key, foreign key, and DDL SQL generation. Fixes #1802

This commit is contained in:
Aditya Toshniwal
2021-01-16 17:06:50 +05:30
committed by Akshay Joshi
parent 065bda37b4
commit 0c8226ff39
78 changed files with 9289 additions and 1472 deletions

View File

@@ -0,0 +1,215 @@
/////////////////////////////////////////////////////////////
//
// pgAdmin 4 - PostgreSQL Tools
//
// Copyright (C) 2013 - 2020, The pgAdmin Development Team
// This software is released under the PostgreSQL Licence
//
//////////////////////////////////////////////////////////////
import Alertify from 'pgadmin.alertifyjs';
import {getTreeNodeHierarchyFromIdentifier} from 'sources/tree/pgadmin_tree_node';
import {getPanelTitle} from 'tools/datagrid/static/js/datagrid_panel_title';
import {getRandomInt} from 'sources/utils';
export function setPanelTitle(erdToolPanel, panelTitle) {
erdToolPanel.title('<span title="'+panelTitle+'">'+panelTitle+'</span>');
}
export function initialize(gettext, url_for, $, _, pgAdmin, csrfToken, pgBrowser, wcDocker) {
/* Return back, this has been called more than once */
if (pgBrowser.erd)
return pgBrowser.erd;
pgBrowser.erd = {
init: function() {
if (this.initialized)
return;
this.initialized = true;
csrfToken.setPGCSRFToken(pgAdmin.csrf_token_header, pgAdmin.csrf_token);
// Define the nodes on which the menus to be appear
var menus = [{
name: 'erd',
module: this,
applies: ['tools'],
callback: 'showErdTool',
priority: 1,
label: gettext('New ERD project(Beta)'),
enable: this.erdToolEnabled,
}];
pgBrowser.add_menus(menus);
// Creating a new pgBrowser frame to show the data.
var erdFrameType = new pgBrowser.Frame({
name: 'frm_erdtool',
showTitle: true,
isCloseable: true,
isPrivate: true,
url: 'about:blank',
});
let self = this;
/* Cache may take time to load for the first time
* Keep trying till available
*/
let cacheIntervalId = setInterval(function() {
if(pgBrowser.preference_version() > 0) {
self.preferences = pgBrowser.get_preferences_for_module('erd');
clearInterval(cacheIntervalId);
}
},0);
pgBrowser.onPreferencesChange('erd', function() {
self.preferences = pgBrowser.get_preferences_for_module('erd');
});
// Load the newly created frame
erdFrameType.load(pgBrowser.docker);
return this;
},
erdToolEnabled: function(obj) {
/* Same as query tool */
var isEnabled = (() => {
if (!_.isUndefined(obj) && !_.isNull(obj)) {
if (_.indexOf(pgAdmin.unsupported_nodes, obj._type) == -1) {
if (obj._type == 'database' && obj.allowConn) {
return true;
} else if (obj._type != 'database') {
return true;
} else {
return false;
}
} else {
return false;
}
} else {
return false;
}
})();
return isEnabled;
},
// Callback to draw schema diff for objects
showErdTool: function(data, aciTreeIdentifier, gen=false) {
const node = pgBrowser.treeMenu.findNodeByDomElement(aciTreeIdentifier);
if (node === undefined || !node.getData()) {
Alertify.alert(
gettext('ERD Error'),
gettext('No object selected.')
);
return;
}
const parentData = getTreeNodeHierarchyFromIdentifier.call(
pgBrowser,
aciTreeIdentifier
);
if(_.isUndefined(parentData.database)) {
Alertify.alert(
gettext('ERD Error'),
gettext('Please select a database/database object.')
);
return;
}
const transId = getRandomInt(1, 9999999);
const panelTitle = getPanelTitle(pgBrowser, aciTreeIdentifier);
const [panelUrl, panelCloseUrl] = this.getPanelUrls(transId, panelTitle, parentData, gen);
let erdToolForm = `
<form id="erdToolForm" action="${panelUrl}" method="post">
<input id="title" name="title" hidden />
<input name="close_url" value="${panelCloseUrl}" hidden />
</form>
<script>
document.getElementById("title").value = "${_.escape(panelTitle)}";
document.getElementById("erdToolForm").submit();
</script>
`;
var open_new_tab = pgBrowser.get_preferences_for_module('browser').new_browser_tab_open;
if (open_new_tab && open_new_tab.includes('erd_tool')) {
var newWin = window.open('', '_blank');
newWin.document.write(erdToolForm);
newWin.document.title = panelTitle;
} else {
/* On successfully initialization find the dashboard panel,
* create new panel and add it to the dashboard panel.
*/
var propertiesPanel = pgBrowser.docker.findPanels('properties');
var erdToolPanel = pgBrowser.docker.addPanel('frm_erdtool', wcDocker.DOCK.STACKED, propertiesPanel[0]);
// Set panel title and icon
setPanelTitle(erdToolPanel, 'Untitled');
erdToolPanel.icon('fa fa-sitemap');
erdToolPanel.focus();
// Listen on the panel closed event.
erdToolPanel.on(wcDocker.EVENT.CLOSED, function() {
$.ajax({
url: panelCloseUrl,
method: 'DELETE',
});
});
var openErdToolURL = function(j) {
// add spinner element
let $spinner_el =
$(`<div class="pg-sp-container">
<div class="pg-sp-content">
<div class="row">
<div class="col-12 pg-sp-icon"></div>
</div>
</div>
</div>`).appendTo($(j).data('embeddedFrame').$container);
let init_poller_id = setInterval(function() {
var frameInitialized = $(j).data('frameInitialized');
if (frameInitialized) {
clearInterval(init_poller_id);
var frame = $(j).data('embeddedFrame');
if (frame) {
frame.onLoaded(()=>{
$spinner_el.remove();
});
frame.openHTML(erdToolForm);
}
}
}, 100);
};
openErdToolURL(erdToolPanel);
}
},
getPanelUrls: function(transId, panelTitle, parentData, gen) {
let openUrl = url_for('erd.panel', {
trans_id: transId,
});
openUrl += `?sgid=${parentData.server_group._id}`
+`&sid=${parentData.server._id}`
+`&server_type=${parentData.server.server_type}`
+`&did=${parentData.database._id}`
+`&gen=${gen}`;
let closeUrl = url_for('erd.close', {
trans_id: transId,
sgid: parentData.server_group._id,
sid: parentData.server._id,
did: parentData.database._id,
});
return [openUrl, closeUrl];
},
};
return pgBrowser.erd;
}

View File

@@ -0,0 +1,395 @@
/////////////////////////////////////////////////////////////
//
// pgAdmin 4 - PostgreSQL Tools
//
// Copyright (C) 2013 - 2020, The pgAdmin Development Team
// This software is released under the PostgreSQL Licence
//
//////////////////////////////////////////////////////////////
/*
* The ERDCore is the middleware between the canvas engine and the UI DOM.
*/
import createEngine from '@projectstorm/react-diagrams';
import {DagreEngine, PathFindingLinkFactory, PortModelAlignment} from '@projectstorm/react-diagrams';
import { ZoomCanvasAction } from '@projectstorm/react-canvas-core';
import {TableNodeFactory, TableNodeModel } from './nodes/TableNode';
import {OneToManyLinkFactory, OneToManyLinkModel } from './links/OneToManyLink';
import { OneToManyPortFactory } from './ports/OneToManyPort';
import ERDModel from './ERDModel';
export default class ERDCore {
constructor() {
this._cache = {};
this.table_counter = 1;
this.node_position_updating = false;
this.link_position_updating = false;
this.initializeEngine();
this.initializeModel();
this.computeTableCounter();
}
initializeEngine() {
this.engine = createEngine({
registerDefaultDeleteItemsAction: false,
registerDefaultZoomCanvasAction: false,
});
this.dagre_engine = new DagreEngine({
graph: {
marginx: 5,
marginy: 5,
},
includeLinks: true,
});
this.engine.getNodeFactories().registerFactory(new TableNodeFactory());
this.engine.getLinkFactories().registerFactory(new OneToManyLinkFactory());
this.engine.getPortFactories().registerFactory(new OneToManyPortFactory());
this.registerKeyAction(new ZoomCanvasAction({inverseZoom: true}));
}
initializeModel(data, callback=()=>{}) {
let model = new ERDModel();
if(data) {
model.deserializeModel(data, this.engine);
}
const registerNodeEvents = (node) => {
node.registerListener({
eventDidFire: (e) => {
if(e.function === 'selectionChanged') {
this.fireEvent({}, 'nodesSelectionChanged', true);
}
else if(e.function === 'showNote') {
this.fireEvent({node: e.entity}, 'showNote', true);
}
else if(e.function === 'editNode') {
this.fireEvent({node: e.entity}, 'editNode', true);
}
else if(e.function === 'nodeUpdated') {
this.fireEvent({}, 'nodesUpdated', true);
}
else if(e.function === 'positionChanged') {
/* Eat up the excessive positionChanged events if node is dragged continuosly */
if(!this.node_position_updating) {
this.node_position_updating = true;
this.fireEvent({}, 'nodesUpdated', true);
setTimeout(()=>{
this.node_position_updating = false;
}, 500);
}
}
},
});
};
const registerLinkEvents = (link) => {
link.registerListener({
eventDidFire: (e) => {
if(e.function === 'selectionChanged') {
this.fireEvent({}, 'linksSelectionChanged', true);
}
else if(e.function === 'positionChanged') {
/* positionChanged is triggered manually in Link */
/* Eat up the excessive positionChanged events if link is dragged continuosly */
if(!this.link_position_updating) {
this.link_position_updating = true;
this.fireEvent({}, 'linksUpdated', true);
setTimeout(()=>{
this.link_position_updating = false;
}, 500);
}
}
},
});
};
/* Register events for deserialized data */
model.getNodes().forEach(node => {
registerNodeEvents(node);
});
model.getLinks().forEach(link => {
registerLinkEvents(link);
});
/* Listen and register events for new data */
model.registerListener({
'nodesUpdated': (e)=>{
if(e.isCreated) {
registerNodeEvents(e.node);
}
},
'linksUpdated': (e)=>{
if(e.isCreated) {
registerLinkEvents(e.link);
}
},
});
model.setGridSize(15);
this.engine.setModel(model);
callback();
}
computeTableCounter() {
/* Some inteligence can be added to set the counter */
this.table_counter = 1;
}
setCache(data, value) {
if(typeof(data) == 'string') {
this._cache[data] = value;
} else {
this._cache = {
...this._cache,
...data,
};
}
}
getCache(key) {
return key ? this._cache[key]: this._cache;
}
registerModelEvent(eventName, callback) {
this.getModel().registerListener({
[eventName]: callback,
});
}
getNextTableName() {
let newTableName = `newtable${this.table_counter}`;
this.table_counter++;
return newTableName;
}
getEngine() {return this.engine;}
getModel() {return this.getEngine().getModel();}
getNewNode(initData) {
return this.getEngine().getNodeFactories().getFactory('table').generateModel({
initialConfig: {
otherInfo: {
data:initData,
},
},
});
}
getNewLink(type, initData) {
return this.getEngine().getLinkFactories().getFactory(type).generateModel({
initialConfig: {
data:initData,
},
});
}
getNewPort(type, initData, initOptions) {
return this.getEngine().getPortFactories().getFactory(type).generateModel({
initialConfig: {
data:initData,
options:initOptions,
},
});
}
addNode(data, position=[50, 50]) {
let newNode = this.getNewNode(data);
this.clearSelection();
newNode.setPosition(position[0], position[1]);
this.getModel().addNode(newNode);
return newNode;
}
addLink(data, type) {
let tableNodesDict = this.getModel().getNodesDict();
let sourceNode = tableNodesDict[data.referenced_table_uid];
let targetNode = tableNodesDict[data.local_table_uid];
let portName = sourceNode.getPortName(data.referenced_column_attnum);
let sourcePort = sourceNode.getPort(portName);
/* Create the port if not there */
if(!sourcePort) {
sourcePort = sourceNode.addPort(this.getNewPort(type, null, {name:portName, alignment:PortModelAlignment.RIGHT}));
}
portName = targetNode.getPortName(data.local_column_attnum);
let targetPort = targetNode.getPort(portName);
/* Create the port if not there */
if(!targetPort) {
targetPort = targetNode.addPort(this.getNewPort(type, null, {name:portName, alignment:PortModelAlignment.RIGHT}));
}
/* Link the ports */
let newLink = this.getNewLink(type, data);
newLink.setSourcePort(sourcePort);
newLink.setTargetPort(targetPort);
this.getModel().addLink(newLink);
return newLink;
}
serialize(version) {
return {
version: version||0,
data: this.getModel().serialize(),
};
}
deserialize(json_data) {
if(json_data.version) {
this.initializeModel(json_data.data);
}
}
serializeData() {
let nodes = {}, links = {};
let nodesDict = this.getModel().getNodesDict();
Object.keys(nodesDict).forEach((id)=>{
nodes[id] = nodesDict[id].serializeData();
});
this.getModel().getLinks().map((link)=>{
links[link.getID()] = link.serializeData(nodesDict);
});
/* Separate the links from nodes so that we don't have any dependancy issues */
return {
'nodes': nodes,
'links': links,
};
}
deserializeData(data){
let oidUidMap = {};
let uidFks = [];
data.forEach((node)=>{
let newData = {
name: node.name,
schema: node.schema,
description: node.description,
columns: node.columns,
primary_key: node.primary_key,
};
let newNode = this.addNode(newData);
oidUidMap[node.oid] = newNode.getID();
if(node.foreign_key) {
node.foreign_key.forEach((a_fk)=>{
uidFks.push({
uid: newNode.getID(),
data: a_fk.columns[0],
});
});
}
});
/* Lets use the oidUidMap for creating the links */
uidFks.forEach((fkData)=>{
let tableNodesDict = this.getModel().getNodesDict();
let newData = {
local_table_uid: fkData.uid,
local_column_attnum: undefined,
referenced_table_uid: oidUidMap[fkData.data.references],
referenced_column_attnum: undefined,
};
let sourceNode = tableNodesDict[newData.referenced_table_uid];
let targetNode = tableNodesDict[newData.local_table_uid];
newData.local_column_attnum = _.find(targetNode.getColumns(), (col)=>col.name==fkData.data.local_column).attnum;
newData.referenced_column_attnum = _.find(sourceNode.getColumns(), (col)=>col.name==fkData.data.referenced).attnum;
this.addLink(newData, 'onetomany');
});
setTimeout(this.dagreDistributeNodes.bind(this), 0);
}
repaint() {
this.getEngine().repaintCanvas();
}
clearSelection() {
this.getEngine()
.getModel()
.clearSelection();
}
getNodesData() {
return this.getEngine().getModel().getNodes().map((node)=>{
return node.getData();
});
}
getSelectedNodes() {
return this.getEngine()
.getModel()
.getSelectedEntities()
.filter(entity => entity instanceof TableNodeModel);
}
getSelectedLinks() {
return this.getEngine()
.getModel()
.getSelectedEntities()
.filter(entity => entity instanceof OneToManyLinkModel);
}
dagreDistributeNodes() {
this.dagre_engine.redistribute(this.getModel());
this.getEngine()
.getLinkFactories()
.getFactory(PathFindingLinkFactory.NAME)
.calculateRoutingMatrix();
this.repaint();
}
zoomIn() {
let model = this.getEngine().getModel();
if(model){
model.setZoomLevel(model.getZoomLevel() + 25);
this.repaint();
}
}
zoomOut() {
let model = this.getEngine().getModel();
if(model) {
model.setZoomLevel(model.getZoomLevel() - 25);
this.repaint();
}
}
zoomToFit() {
this.getEngine().zoomToFit();
}
// Sample call: this.fireAction({ type: 'keydown', ctrlKey: true, code: 'KeyN' });
fireAction(event) {
this.getEngine().getActionEventBus().fireAction({
event: {
...event,
key: '',
preventDefault: () => {},
stopPropagation: () => {},
},
});
}
fireEvent(data, eventName, model=false) {
if(model) {
this.getEngine().getModel().fireEvent(data, eventName);
} else {
this.getEngine().fireEvent(data, eventName);
}
}
registerKeyAction(action) {
this.getEngine().getActionEventBus().registerAction(action);
}
deregisterKeyAction(action) {
this.getEngine().getActionEventBus().deregisterAction(action);
}
}

View File

@@ -0,0 +1,21 @@
/////////////////////////////////////////////////////////////
//
// pgAdmin 4 - PostgreSQL Tools
//
// Copyright (C) 2013 - 2020, The pgAdmin Development Team
// This software is released under the PostgreSQL Licence
//
//////////////////////////////////////////////////////////////
import { DiagramModel } from '@projectstorm/react-diagrams';
import _ from 'lodash';
export default class ERDModel extends DiagramModel {
constructor(options) {
super(options);
}
getNodesDict() {
return _.fromPairs(this.getNodes().map(node => [node.getID(), node]));
}
}

View File

@@ -0,0 +1,158 @@
/////////////////////////////////////////////////////////////
//
// pgAdmin 4 - PostgreSQL Tools
//
// Copyright (C) 2013 - 2020, The pgAdmin Development Team
// This software is released under the PostgreSQL Licence
//
//////////////////////////////////////////////////////////////
import gettext from 'sources/gettext';
import * as commonUtils from 'sources/utils';
export default class DialogWrapper {
constructor(dialogContainerSelector, dialogTitle, typeOfDialog,
jquery, pgBrowser, alertify, backform, backgrid) {
this.dialogContainerSelector = dialogContainerSelector;
this.dialogTitle = dialogTitle;
this.jquery = jquery;
this.pgBrowser = pgBrowser;
this.alertify = alertify;
this.backform = backform;
this.backgrid = backgrid;
this.typeOfDialog = typeOfDialog;
}
main(title, dialogModel, okCallback) {
this.set('title', title);
this.dialogModel = dialogModel;
this.okCallback = okCallback;
}
build() {
this.alertify.pgDialogBuild.apply(this);
}
disableOKButton() {
this.__internal.buttons[1].element.disabled = true;
}
enableOKButton() {
this.__internal.buttons[1].element.disabled = false;
}
focusOnDialog(alertifyDialog) {
let backform_tab = this.jquery(alertifyDialog.elements.body).find('.backform-tab');
backform_tab.attr('tabindex', -1);
this.pgBrowser.keyboardNavigation.getDialogTabNavigator(this.jquery(alertifyDialog.elements.dialog));
let container = backform_tab.find('.tab-content:first > .tab-pane.active:first');
if(container.length === 0 && alertifyDialog.elements.content.innerHTML) {
container = this.jquery(alertifyDialog.elements.content);
}
commonUtils.findAndSetFocus(container);
}
setup() {
return {
buttons: [{
text: gettext('Cancel'),
key: 27,
className: 'btn btn-secondary fa fa-lg fa-times pg-alertify-button',
'data-btn-name': 'cancel',
}, {
text: gettext('OK'),
key: 13,
className: 'btn btn-primary fa fa-lg fa-save pg-alertify-button',
'data-btn-name': 'ok',
}],
// Set options for dialog
options: {
title: this.dialogTitle,
//disable both padding and overflow control.
padding: !1,
overflow: !1,
model: 0,
resizable: true,
maximizable: true,
pinnable: false,
closableByDimmer: false,
modal: false,
},
};
}
prepare() {
const $container = this.jquery(this.dialogContainerSelector);
const dialog = this.createDialog($container);
dialog.render();
this.elements.content.innerHTML = '';
this.elements.content.appendChild($container.get(0));
this.jquery(this.elements.body.childNodes[0]).addClass(
'alertify_tools_dialog_properties obj_properties'
);
const statusBar = this.jquery(
'<div class=\'pg-prop-status-bar pg-prop-status-bar-absolute pg-el-xs-12 d-none\'>' +
' <div class="error-in-footer"> ' +
' <div class="d-flex px-2 py-1"> ' +
' <div class="pr-2"> ' +
' <i class="fa fa-exclamation-triangle text-danger" aria-hidden="true"></i> ' +
' </div> ' +
' <div class="alert-text" role="alert"></div> ' +
' <div class="ml-auto close-error-bar"> ' +
' <a aria-label="' + gettext('Close error bar') + '" class="close-error fa fa-times text-danger"></a> ' +
' </div> ' +
' </div> ' +
' </div> ' +
'</div>').appendTo($container);
statusBar.find('.close-error').on('click', ()=>{
statusBar.addClass('d-none');
});
var onSessionInvalid = (msg) => {
statusBar.find('.alert-text').text(msg);
statusBar.removeClass('d-none');
this.disableOKButton();
return true;
};
var onSessionValidated = () => {
statusBar.find('.alert-text').text('');
statusBar.addClass('d-none');
this.enableOKButton();
return true;
};
this.dialogModel.on('pgadmin-session:valid', onSessionValidated);
this.dialogModel.on('pgadmin-session:invalid', onSessionInvalid);
this.dialogModel.startNewSession();
this.disableOKButton();
this.focusOnDialog(this);
}
callback(event) {
if (this.wasOkButtonPressed(event)) {
this.okCallback(this.view.model.toJSON(true));
}
}
createDialog($container) {
let fields = this.backform.generateViewSchema(
null, this.dialogModel, 'create', null, null, true, null
);
this.view = new this.backform.Dialog({
el: $container,
model: this.dialogModel,
schema: fields,
});
return this.view;
}
wasOkButtonPressed(event) {
return event.button['data-btn-name'] === 'ok';
}
}

View File

@@ -0,0 +1,140 @@
/////////////////////////////////////////////////////////////
//
// pgAdmin 4 - PostgreSQL Tools
//
// Copyright (C) 2013 - 2020, The pgAdmin Development Team
// This software is released under the PostgreSQL Licence
//
//////////////////////////////////////////////////////////////
import gettext from 'sources/gettext';
import Backform from 'sources/backform.pgadmin';
import Alertify from 'pgadmin.alertifyjs';
import $ from 'jquery';
import DialogWrapper from './DialogWrapper';
import _ from 'lodash';
export default class ManyToManyDialog {
constructor(pgBrowser) {
this.pgBrowser = pgBrowser;
}
dialogName() {
return 'manytomany_dialog';
}
getDataModel(attributes, tableNodesDict) {
const parseColumns = (columns)=>{
return columns.map((col)=>{
return {
value: col.attnum, label: col.name,
};
});
};
let dialogModel = this.pgBrowser.DataModel.extend({
defaults: {
left_table_uid: undefined,
left_table_column_attnum: undefined,
right_table_uid: undefined,
right_table_column_attnum: undefined,
},
schema: [{
id: 'left_table_uid', label: gettext('Left Table'),
type: 'select2', readonly: true,
options: ()=>{
let retOpts = [];
_.forEach(tableNodesDict, (node, uid)=>{
let [schema, name] = node.getSchemaTableName();
retOpts.push({value: uid, label: `(${schema}) ${name}`});
});
return retOpts;
},
}, {
id: 'left_table_column_attnum', label: gettext('Left table Column'),
type: 'select2', disabled: false, first_empty: false,
editable: true, options: (view)=>{
return parseColumns(tableNodesDict[view.model.get('left_table_uid')].getColumns());
},
},{
id: 'right_table_uid', label: gettext('Right Table'),
type: 'select2', disabled: false,
editable: true, options: (view)=>{
let retOpts = [];
_.forEach(tableNodesDict, (node, uid)=>{
if(uid === view.model.get('left_table_uid')) {
return;
}
let [schema, name] = node.getSchemaTableName();
retOpts.push({value: uid, label: `(${schema}) ${name}`});
});
return retOpts;
},
},{
id: 'right_table_column_attnum', label: gettext('Right table Column'),
type: 'select2', disabled: false, deps: ['right_table_uid'],
editable: true, options: (view)=>{
if(view.model.get('right_table_uid')) {
return parseColumns(tableNodesDict[view.model.get('right_table_uid')].getColumns());
}
return [];
},
}],
validate: function(keys) {
var msg = undefined;
// Nothing to validate
if (keys && keys.length == 0) {
this.errorModel.clear();
return null;
} else {
this.errorModel.clear();
}
if (_.isUndefined(this.get('left_table_column_attnum')) || this.get('left_table_column_attnum') == '') {
msg = gettext('Select the left table column.');
this.errorModel.set('left_table_column_attnum', msg);
return msg;
}
if (_.isUndefined(this.get('right_table_uid')) || this.get('right_table_uid') == '') {
msg = gettext('Select the right table.');
this.errorModel.set('right_table_uid', msg);
return msg;
}
if (_.isUndefined(this.get('right_table_column_attnum')) || this.get('right_table_column_attnum') == '') {
msg = gettext('Select the right table column.');
this.errorModel.set('right_table_column_attnum', msg);
return msg;
}
},
});
return new dialogModel(attributes);
}
createOrGetDialog(title) {
const dialogName = this.dialogName();
if (!Alertify[dialogName]) {
Alertify.dialog(dialogName, () => {
return new DialogWrapper(
`<div class="${dialogName}"></div>`,
title,
null,
$,
this.pgBrowser,
Alertify,
Backform
);
});
}
return Alertify[dialogName];
}
show(title, attributes, tablesData, sVersion, callback) {
let dialogTitle = title || gettext('Unknown');
const dialog = this.createOrGetDialog('manytomany_dialog');
dialog(dialogTitle, this.getDataModel(attributes, tablesData), callback).resizeTo(this.pgBrowser.stdW.sm, this.pgBrowser.stdH.md);
}
}

View File

@@ -0,0 +1,140 @@
/////////////////////////////////////////////////////////////
//
// pgAdmin 4 - PostgreSQL Tools
//
// Copyright (C) 2013 - 2020, The pgAdmin Development Team
// This software is released under the PostgreSQL Licence
//
//////////////////////////////////////////////////////////////
import gettext from 'sources/gettext';
import Backform from 'sources/backform.pgadmin';
import Alertify from 'pgadmin.alertifyjs';
import $ from 'jquery';
import DialogWrapper from './DialogWrapper';
import _ from 'lodash';
export default class OneToManyDialog {
constructor(pgBrowser) {
this.pgBrowser = pgBrowser;
}
dialogName() {
return 'onetomany_dialog';
}
getDataModel(attributes, tableNodesDict) {
const parseColumns = (columns)=>{
return columns.map((col)=>{
return {
value: col.attnum, label: col.name,
};
});
};
let dialogModel = this.pgBrowser.DataModel.extend({
defaults: {
local_table_uid: undefined,
local_column_attnum: undefined,
referenced_table_uid: undefined,
referenced_column_attnum: undefined,
},
schema: [{
id: 'local_table_uid', label: gettext('Local Table'),
type: 'select2', readonly: true,
options: ()=>{
let retOpts = [];
_.forEach(tableNodesDict, (node, uid)=>{
let [schema, name] = node.getSchemaTableName();
retOpts.push({value: uid, label: `(${schema}) ${name}`});
});
return retOpts;
},
}, {
id: 'local_column_attnum', label: gettext('Local Column'),
type: 'select2', disabled: false, first_empty: false,
editable: true, options: (view)=>{
return parseColumns(tableNodesDict[view.model.get('local_table_uid')].getColumns());
},
},{
id: 'referenced_table_uid', label: gettext('Referenced Table'),
type: 'select2', disabled: false,
editable: true, options: (view)=>{
let retOpts = [];
_.forEach(tableNodesDict, (node, uid)=>{
if(uid === view.model.get('local_table_uid')) {
return;
}
let [schema, name] = node.getSchemaTableName();
retOpts.push({value: uid, label: `(${schema}) ${name}`});
});
return retOpts;
},
},{
id: 'referenced_column_attnum', label: gettext('Referenced Column'),
type: 'select2', disabled: false, deps: ['referenced_table_uid'],
editable: true, options: (view)=>{
if(view.model.get('referenced_table_uid')) {
return parseColumns(tableNodesDict[view.model.get('referenced_table_uid')].getColumns());
}
return [];
},
}],
validate: function(keys) {
var msg = undefined;
// Nothing to validate
if (keys && keys.length == 0) {
this.errorModel.clear();
return null;
} else {
this.errorModel.clear();
}
if (_.isUndefined(this.get('local_column_attnum')) || this.get('local_column_attnum') == '') {
msg = gettext('Select the local column.');
this.errorModel.set('local_column_attnum', msg);
return msg;
}
if (_.isUndefined(this.get('referenced_table_uid')) || this.get('referenced_table_uid') == '') {
msg = gettext('Select the referenced table.');
this.errorModel.set('referenced_table_uid', msg);
return msg;
}
if (_.isUndefined(this.get('referenced_column_attnum')) || this.get('referenced_column_attnum') == '') {
msg = gettext('Select the referenced table column.');
this.errorModel.set('referenced_column_attnum', msg);
return msg;
}
},
});
return new dialogModel(attributes);
}
createOrGetDialog(title) {
const dialogName = this.dialogName();
if (!Alertify[dialogName]) {
Alertify.dialog(dialogName, () => {
return new DialogWrapper(
`<div class="${dialogName}"></div>`,
title,
null,
$,
this.pgBrowser,
Alertify,
Backform
);
});
}
return Alertify[dialogName];
}
show(title, attributes, tablesData, sVersion, callback) {
let dialogTitle = title || gettext('Unknown');
const dialog = this.createOrGetDialog('onetomany_dialog');
dialog(dialogTitle, this.getDataModel(attributes, tablesData), callback).resizeTo(this.pgBrowser.stdW.sm, this.pgBrowser.stdH.md);
}
}

View File

@@ -0,0 +1,739 @@
/////////////////////////////////////////////////////////////
//
// pgAdmin 4 - PostgreSQL Tools
//
// Copyright (C) 2013 - 2020, The pgAdmin Development Team
// This software is released under the PostgreSQL Licence
//
//////////////////////////////////////////////////////////////
import gettext from 'sources/gettext';
import Backgrid from 'sources/backgrid.pgadmin';
import Backform from 'sources/backform.pgadmin';
import Alertify from 'pgadmin.alertifyjs';
import $ from 'jquery';
import _ from 'lodash';
import DialogWrapper from './DialogWrapper';
export function transformToSupported(data) {
/* Table fields */
data = _.pick(data, ['oid', 'name', 'schema', 'description', 'columns', 'primary_key', 'foreign_key']);
/* Columns */
data['columns'] = data['columns'].map((column)=>{
return _.pick(column,[
'name','description','attowner','attnum','cltype','min_val_attlen','min_val_attprecision','max_val_attlen',
'max_val_attprecision', 'is_primary_key','attnotnull','attlen','attprecision','attidentity','colconstype',
'seqincrement','seqstart','seqmin','seqmax','seqcache','seqcycle',
]);
});
/* Primary key */
data['primary_key'] = data['primary_key'].map((primary_key)=>{
primary_key = _.pick(primary_key, ['columns']);
primary_key['columns'] = primary_key['columns'].map((column)=>{
return _.pick(column, ['column']);
});
return primary_key;
});
return data;
}
export default class TableDialog {
constructor(pgBrowser) {
this.pgBrowser = pgBrowser;
}
dialogName() {
return 'entity_dialog';
}
getDataModel(attributes, colTypes, schemas, sVersion) {
let dialogObj = this;
let columnsModel = this.pgBrowser.DataModel.extend({
idAttribute: 'attnum',
defaults: {
name: undefined,
description: undefined,
attowner: undefined,
attnum: undefined,
cltype: undefined,
min_val_attlen: undefined,
min_val_attprecision: undefined,
max_val_attlen: undefined,
max_val_attprecision: undefined,
is_primary_key: false,
attnotnull: false,
attlen: null,
attprecision: null,
attidentity: 'a',
colconstype: 'n',
seqincrement: undefined,
seqstart: undefined,
seqmin: undefined,
seqmax: undefined,
seqcache: undefined,
seqcycle: undefined,
},
initialize: function(attrs) {
if (_.size(attrs) !== 0) {
this.set({
'old_attidentity': this.get('attidentity'),
}, {silent: true});
}
dialogObj.pgBrowser.DataModel.prototype.initialize.apply(this, arguments);
if(!this.get('cltype') && colTypes.length > 0) {
this.set({
'cltype': colTypes[0]['value'],
}, {silent: true});
}
},
schema: [{
id: 'name', label: gettext('Name'), cell: 'string',
type: 'text', disabled: false,
cellHeaderClasses: 'width_percent_30',
editable: true,
}, {
// Need to show this field only when creating new table
// [in SubNode control]
id: 'is_primary_key', label: gettext('Primary key?'),
cell: Backgrid.Extension.TableChildSwitchCell, type: 'switch',
deps: ['name'], cellHeaderClasses: 'width_percent_5',
options: {
onText: gettext('Yes'), offText: gettext('No'),
onColor: 'success', offColor: 'ternary',
},
visible: function () {
return true;
},
disabled: false,
editable: true,
}, {
id: 'description', label: gettext('Comment'), cell: 'string', type: 'multiline',
}, {
id: 'cltype', label: gettext('Data type'),
cell: 'select2',
type: 'select2', disabled: false,
control: 'select2',
cellHeaderClasses: 'width_percent_30',
select2: { allowClear: false, first_empty: false }, group: gettext('Definition'),
options: function () {
return colTypes;
},
}, {
id: 'attlen', label: gettext('Length/Precision'), cell: Backgrid.Extension.IntegerDepCell,
deps: ['cltype'], type: 'int', group: gettext('Definition'), cellHeaderClasses: 'width_percent_20',
disabled: function (m) {
var of_type = m.get('cltype'),
flag = true;
_.each(colTypes, function (o) {
if (of_type == o.value) {
if (o.length) {
m.set('min_val_attlen', o.min_val, { silent: true });
m.set('max_val_attlen', o.max_val, { silent: true });
flag = false;
}
}
});
flag && setTimeout(function () {
if (m.get('attlen')) {
m.set('attlen', null);
}
}, 10);
return flag;
},
editable: function (m) {
var of_type = m.get('cltype'),
flag = false;
_.each(colTypes, function (o) {
if (of_type == o.value) {
if (o.length) {
m.set('min_val_attlen', o.min_val, { silent: true });
m.set('max_val_attlen', o.max_val, { silent: true });
flag = true;
}
}
});
!flag && setTimeout(function () {
if (m.get('attlen')) {
m.set('attlen', null);
}
}, 10);
return flag;
},
}, {
id: 'attprecision', label: gettext('Scale'), cell: Backgrid.Extension.IntegerDepCell,
deps: ['cltype'], type: 'int', group: gettext('Definition'), cellHeaderClasses: 'width_percent_20',
disabled: function (m) {
var of_type = m.get('cltype'),
flag = true;
_.each(colTypes, function (o) {
if (of_type == o.value) {
if (o.precision) {
m.set('min_val_attprecision', 0, { silent: true });
m.set('max_val_attprecision', o.max_val, { silent: true });
flag = false;
}
}
});
flag && setTimeout(function () {
if (m.get('attprecision')) {
m.set('attprecision', null);
}
}, 10);
return flag;
},
editable: function (m) {
if (!colTypes) {
// datatypes not loaded yet, may be this call is from CallByNeed from backgrid cell initialize.
return true;
}
var of_type = m.get('cltype'),
flag = false;
_.each(colTypes, function (o) {
if (of_type == o.value) {
if (o.precision) {
m.set('min_val_attprecision', 0, { silent: true });
m.set('max_val_attprecision', o.max_val, { silent: true });
flag = true;
}
}
});
!flag && setTimeout(function () {
if (m.get('attprecision')) {
m.set('attprecision', null);
}
}, 10);
return flag;
},
}, {
id: 'attnotnull', label: gettext('Not NULL?'), cell: 'switch',
type: 'switch', cellHeaderClasses: 'width_percent_20',
group: gettext('Constraints'),
options: { onText: gettext('Yes'), offText: gettext('No'), onColor: 'success', offColor: 'ternary' },
disabled: function(m) {
if (m.get('colconstype') == 'i') {
setTimeout(function () {
m.set('attnotnull', true);
}, 10);
}
return false;
},
}, {
id: 'colconstype',
label: gettext('Type'),
cell: 'string',
type: 'radioModern',
controlsClassName: 'pgadmin-controls col-12 col-sm-9',
controlLabelClassName: 'control-label col-sm-3 col-12',
group: gettext('Constraints'),
options: function() {
var opt_array = [
{'label': gettext('NONE'), 'value': 'n'},
{'label': gettext('IDENTITY'), 'value': 'i'},
];
if (sVersion >= 120000) {
opt_array.push({
'label': gettext('GENERATED'),
'value': 'g',
});
}
return opt_array;
},
disabled: false,
visible: function() {
if (sVersion >= 100000) {
return true;
}
return false;
},
}, {
id: 'attidentity', label: gettext('Identity'), control: 'select2',
cell: 'select2',
select2: {placeholder: 'Select identity', allowClear: false, width: '100%'},
group: gettext('Constraints'),
'options': [
{label: gettext('ALWAYS'), value: 'a'},
{label: gettext('BY DEFAULT'), value: 'd'},
],
deps: ['colconstype'],
visible: function(m) {
if (sVersion >= 100000 && m.isTypeIdentity(m)) {
return true;
}
return false;
},
disabled: function() {
return false;
},
}, {
id: 'seqincrement', label: gettext('Increment'), type: 'int',
mode: ['properties', 'create', 'edit'], group: gettext('Constraints'),
min: 1, deps: ['attidentity', 'colconstype'], disabled: 'isIdentityColumn',
visible: 'isTypeIdentity',
},{
id: 'seqstart', label: gettext('Start'), type: 'int',
mode: ['properties', 'create', 'edit'], group: gettext('Constraints'),
disabled: function(m) {
let isIdentity = m.get('attidentity');
if(!_.isUndefined(isIdentity) && !_.isNull(isIdentity) && !_.isEmpty(isIdentity))
return false;
return true;
}, deps: ['attidentity', 'colconstype'],
visible: 'isTypeIdentity',
},{
id: 'seqmin', label: gettext('Minimum'), type: 'int',
mode: ['properties', 'create', 'edit'], group: gettext('Constraints'),
deps: ['attidentity', 'colconstype'], disabled: 'isIdentityColumn',
visible: 'isTypeIdentity',
},{
id: 'seqmax', label: gettext('Maximum'), type: 'int',
mode: ['properties', 'create', 'edit'], group: gettext('Constraints'),
deps: ['attidentity', 'colconstype'], disabled: 'isIdentityColumn',
visible: 'isTypeIdentity',
},{
id: 'seqcache', label: gettext('Cache'), type: 'int',
mode: ['properties', 'create', 'edit'], group: gettext('Constraints'),
min: 1, deps: ['attidentity', 'colconstype'], disabled: 'isIdentityColumn',
visible: 'isTypeIdentity',
},{
id: 'seqcycle', label: gettext('Cycled'), type: 'switch',
mode: ['properties', 'create', 'edit'], group: gettext('Constraints'),
deps: ['attidentity', 'colconstype'], disabled: 'isIdentityColumn',
visible: 'isTypeIdentity',
}],
validate: function(keys) {
var msg = undefined;
// Nothing to validate
if (keys && keys.length == 0) {
this.errorModel.clear();
return null;
} else {
this.errorModel.clear();
}
if (_.isUndefined(this.get('name'))
|| String(this.get('name')).replace(/^\s+|\s+$/g, '') == '') {
msg = gettext('Column name cannot be empty.');
this.errorModel.set('name', msg);
return msg;
}
if (_.isUndefined(this.get('cltype'))
|| String(this.get('cltype')).replace(/^\s+|\s+$/g, '') == '') {
msg = gettext('Column type cannot be empty.');
this.errorModel.set('cltype', msg);
return msg;
}
if (!_.isUndefined(this.get('cltype'))
&& !_.isUndefined(this.get('attlen'))
&& !_.isNull(this.get('attlen'))
&& this.get('attlen') !== '') {
// Validation for Length field
if (this.get('attlen') < this.get('min_val_attlen'))
msg = gettext('Length/Precision should not be less than: ') + this.get('min_val_attlen');
if (this.get('attlen') > this.get('max_val_attlen'))
msg = gettext('Length/Precision should not be greater than: ') + this.get('max_val_attlen');
// If we have any error set then throw it to user
if(msg) {
this.errorModel.set('attlen', msg);
return msg;
}
}
if (!_.isUndefined(this.get('cltype'))
&& !_.isUndefined(this.get('attprecision'))
&& !_.isNull(this.get('attprecision'))
&& this.get('attprecision') !== '') {
// Validation for precision field
if (this.get('attprecision') < this.get('min_val_attprecision'))
msg = gettext('Scale should not be less than: ') + this.get('min_val_attprecision');
if (this.get('attprecision') > this.get('max_val_attprecision'))
msg = gettext('Scale should not be greater than: ') + this.get('max_val_attprecision');
// If we have any error set then throw it to user
if(msg) {
this.errorModel.set('attprecision', msg);
return msg;
}
}
var minimum = this.get('seqmin'),
maximum = this.get('seqmax'),
start = this.get('seqstart');
if (!this.isNew() && this.get('colconstype') == 'i' &&
(this.get('old_attidentity') == 'a' || this.get('old_attidentity') == 'd') &&
(this.get('attidentity') == 'a' || this.get('attidentity') == 'd')) {
if (_.isUndefined(this.get('seqincrement'))
|| String(this.get('seqincrement')).replace(/^\s+|\s+$/g, '') == '') {
msg = gettext('Increment value cannot be empty.');
this.errorModel.set('seqincrement', msg);
return msg;
} else {
this.errorModel.unset('seqincrement');
}
if (_.isUndefined(this.get('seqmin'))
|| String(this.get('seqmin')).replace(/^\s+|\s+$/g, '') == '') {
msg = gettext('Minimum value cannot be empty.');
this.errorModel.set('seqmin', msg);
return msg;
} else {
this.errorModel.unset('seqmin');
}
if (_.isUndefined(this.get('seqmax'))
|| String(this.get('seqmax')).replace(/^\s+|\s+$/g, '') == '') {
msg = gettext('Maximum value cannot be empty.');
this.errorModel.set('seqmax', msg);
return msg;
} else {
this.errorModel.unset('seqmax');
}
if (_.isUndefined(this.get('seqcache'))
|| String(this.get('seqcache')).replace(/^\s+|\s+$/g, '') == '') {
msg = gettext('Cache value cannot be empty.');
this.errorModel.set('seqcache', msg);
return msg;
} else {
this.errorModel.unset('seqcache');
}
}
var min_lt = gettext('Minimum value must be less than maximum value.'),
start_lt = gettext('Start value cannot be less than minimum value.'),
start_gt = gettext('Start value cannot be greater than maximum value.');
if (_.isEmpty(minimum) || _.isEmpty(maximum))
return null;
if ((minimum == 0 && maximum == 0) ||
(parseInt(minimum, 10) >= parseInt(maximum, 10))) {
this.errorModel.set('seqmin', min_lt);
return min_lt;
} else {
this.errorModel.unset('seqmin');
}
if (start && minimum && parseInt(start) < parseInt(minimum)) {
this.errorModel.set('seqstart', start_lt);
return start_lt;
} else {
this.errorModel.unset('seqstart');
}
if (start && maximum && parseInt(start) > parseInt(maximum)) {
this.errorModel.set('seqstart', start_gt);
return start_gt;
} else {
this.errorModel.unset('seqstart');
}
return null;
},
// Check whether the column is identity column or not
isIdentityColumn: function(m) {
let isIdentity = m.get('attidentity');
if(!_.isUndefined(isIdentity) && !_.isNull(isIdentity) && !_.isEmpty(isIdentity))
return false;
return true;
},
// Check whether the column is a identity column
isTypeIdentity: function(m) {
let colconstype = m.get('colconstype');
if (!_.isUndefined(colconstype) && !_.isNull(colconstype) && colconstype == 'i') {
return true;
}
return false;
},
// Check whether the column is a generated column
isTypeGenerated: function(m) {
let colconstype = m.get('colconstype');
if (!_.isUndefined(colconstype) && !_.isNull(colconstype) && colconstype == 'g') {
return true;
}
return false;
},
});
const formatSchemaItem = function(opt) {
if (!opt.id) {
return opt.text;
}
var optimage = $(opt.element).data('image');
if (!optimage) {
return opt.text;
} else {
return $('<span></span>').append(
$('<span></span>', {
class: 'wcTabIcon ' + optimage,
})
).append($('<span></span>').text(opt.text));
}
};
let dialogModel = this.pgBrowser.DataModel.extend({
defaults: {
name: undefined,
schema: undefined,
description: undefined,
columns: [],
primary_key: [],
},
initialize: function() {
dialogObj.pgBrowser.DataModel.prototype.initialize.apply(this, arguments);
if(!this.get('schema') && schemas.length > 0) {
this.set({
'schema': schemas[0]['name'],
}, {silent: true});
}
},
schema: [{
id: 'name', label: gettext('Name'), type: 'text', disabled: false,
},{
id: 'schema', label: gettext('Schema'), type: 'text',
control: 'select2', select2: {
allowClear: false, first_empty: false,
templateResult: formatSchemaItem,
templateSelection: formatSchemaItem,
},
options: function () {
return schemas.map((schema)=>{
return {
'value': schema['name'],
'image': 'icon-schema',
'label': schema['name'],
};
});
},
filter: function(d) {
// If schema name start with pg_* then we need to exclude them
if(d && d.label.match(/^pg_/))
{
return false;
}
return true;
},
},{
id: 'description', label: gettext('Comment'), type: 'multiline',
},{
id: 'columns', label: gettext('Columns'), type: 'collection', mode: ['create'],
group: gettext('Columns'),
model: columnsModel,
subnode: columnsModel,
disabled: false,
uniqueCol : ['name'],
columns : ['name' , 'cltype', 'attlen', 'attprecision', 'attnotnull', 'is_primary_key'],
control: Backform.UniqueColCollectionControl.extend({
initialize: function() {
Backform.UniqueColCollectionControl.prototype.initialize.apply(this, arguments);
var self = this,
collection = self.model.get(self.field.get('name'));
if(collection.isEmpty()) {
self.last_attnum = -1;
} else {
var lastCol = collection.max(function(col) {
return col.get('attnum');
});
self.last_attnum = lastCol.get('attnum');
}
collection.on('change:is_primary_key', function(m) {
var primary_key_coll = self.model.get('primary_key'),
column_name = m.get('name'),
primary_key, primary_key_column_coll;
if(m.get('is_primary_key')) {
// Add column to primary key.
if (primary_key_coll.length < 1) {
primary_key = new (primary_key_coll.model)({}, {
top: self.model,
collection: primary_key_coll,
handler: primary_key_coll,
});
primary_key_coll.add(primary_key);
} else {
primary_key = primary_key_coll.first();
}
primary_key_column_coll = primary_key.get('columns');
var primary_key_column_exist = primary_key_column_coll.where({column:column_name});
if (primary_key_column_exist.length == 0) {
var primary_key_column = new (
primary_key_column_coll.model
)({column: column_name}, {
silent: true,
top: self.model,
collection: primary_key_coll,
handler: primary_key_coll,
});
primary_key_column_coll.add(primary_key_column);
}
primary_key_column_coll.trigger(
'pgadmin:multicolumn:updated', primary_key_column_coll
);
} else {
// remove column from primary key.
if (primary_key_coll.length > 0) {
primary_key = primary_key_coll.first();
// Do not alter existing primary key columns.
if (!_.isUndefined(primary_key.get('oid'))) {
return;
}
primary_key_column_coll = primary_key.get('columns');
var removedCols = primary_key_column_coll.where({column:column_name});
if (removedCols.length > 0) {
primary_key_column_coll.remove(removedCols);
_.each(removedCols, function(local_model) {
local_model.destroy();
});
if (primary_key_column_coll.length == 0) {
/* Ideally above line of code should be "primary_key_coll.reset()".
* But our custom DataCollection (extended from Backbone collection in datamodel.js)
* does not respond to reset event, it only supports add, remove, change events.
* And hence no custom event listeners/validators get called for reset event.
*/
primary_key_coll.remove(primary_key_coll.first());
}
}
primary_key_column_coll.trigger('pgadmin:multicolumn:updated', primary_key_column_coll);
}
}
});
collection.on('change:name', function(m) {
let primary_key = self.model.get('primary_key').first();
if(primary_key) {
let updatedCols = primary_key.get('columns').where(
{column: m.previous('name')}
);
if (updatedCols.length > 0) {
/*
* Table column name has changed so update
* column name in primary key as well.
*/
updatedCols[0].set(
{'column': m.get('name')},
{silent: true});
}
}
});
collection.on('remove', function(m) {
let primary_key = self.model.get('primary_key').first();
if(primary_key) {
let removedCols = primary_key.get('columns').where(
{column: m.get('name')}
);
primary_key.get('columns').remove(removedCols);
}
});
},
}),
canAdd: true,
canEdit: true, canDelete: true,
// For each row edit/delete button enable/disable
canEditRow: true,
canDeleteRow: true,
allowMultipleEmptyRow: false,
beforeAdd: function(newModel) {
this.last_attnum++;
newModel.set('attnum', this.last_attnum);
return newModel;
},
},{
// Here we will create tab control for constraints
// We will hide the tab for ERD
type: 'nested', control: 'tab', group: gettext('Constraints'), mode: ['properties'],
schema: [{
id: 'primary_key', label: '',
model: this.pgBrowser.Nodes['primary_key'].model.extend({
validate: ()=>{},
}),
subnode: this.pgBrowser.Nodes['primary_key'].model.extend({
validate: ()=>{},
}),
editable: false, type: 'collection',
},
],
}],
validate: function() {
var msg,
name = this.get('name'),
schema = this.get('schema');
if (
_.isUndefined(name) || _.isNull(name) ||
String(name).replace(/^\s+|\s+$/g, '') == ''
) {
msg = gettext('Table name cannot be empty.');
this.errorModel.set('name', msg);
return msg;
}
this.errorModel.unset('name');
if (
_.isUndefined(schema) || _.isNull(schema) ||
String(schema).replace(/^\s+|\s+$/g, '') == ''
) {
msg = gettext('Table schema cannot be empty.');
this.errorModel.set('schema', msg);
return msg;
}
this.errorModel.unset('schema');
return null;
},
});
return new dialogModel(attributes);
}
createOrGetDialog(type) {
const dialogName = this.dialogName();
if (!Alertify[dialogName]) {
Alertify.dialog(dialogName, () => {
return new DialogWrapper(
`<div class="${dialogName}"></div>`,
null,
type,
$,
this.pgBrowser,
Alertify,
Backform
);
});
}
return Alertify[dialogName];
}
show(title, attributes, colTypes, schemas, sVersion, callback) {
let dialogTitle = title || gettext('Unknown');
const dialog = this.createOrGetDialog('table_dialog');
dialog(dialogTitle, this.getDataModel(attributes, colTypes, schemas, sVersion), callback).resizeTo(this.pgBrowser.stdW.md, this.pgBrowser.stdH.md);
}
}

View File

@@ -0,0 +1,32 @@
/////////////////////////////////////////////////////////////
//
// pgAdmin 4 - PostgreSQL Tools
//
// Copyright (C) 2013 - 2020, The pgAdmin Development Team
// This software is released under the PostgreSQL Licence
//
//////////////////////////////////////////////////////////////
import TableDialog, {transformToSupported as transformToSupportedTable} from './TableDialog';
import OneToManyDialog from './OneToManyDialog';
import ManyToManyDialog from './ManyToManyDialog';
import pgBrowser from 'top/browser/static/js/browser';
import 'sources/backgrid.pgadmin';
import 'sources/backform.pgadmin';
export default function getDialog(dialogName) {
if(dialogName === 'entity_dialog') {
return new TableDialog(pgBrowser);
} else if(dialogName === 'onetomany_dialog') {
return new OneToManyDialog(pgBrowser);
} else if(dialogName === 'manytomany_dialog') {
return new ManyToManyDialog(pgBrowser);
}
}
export function transformToSupported(type, data) {
if(type == 'table') {
return transformToSupportedTable(data);
}
return data;
}

View File

@@ -0,0 +1,30 @@
/////////////////////////////////////////////////////////////
//
// pgAdmin 4 - PostgreSQL Tools
//
// Copyright (C) 2013 - 2020, The pgAdmin Development Team
// This software is released under the PostgreSQL Licence
//
//////////////////////////////////////////////////////////////
import React from 'react';
import ReactDOM from 'react-dom';
import BodyWidget from './ui_components/BodyWidget';
import getDialog, {transformToSupported} from './dialogs';
import Alertify from 'pgadmin.alertifyjs';
import pgWindow from 'sources/window';
export default class ERDTool {
constructor(container, params) {
this.container = document.querySelector(container);
this.params = params;
}
render() {
/* Mount the React ERD tool to the container */
ReactDOM.render(
<BodyWidget params={this.params} getDialog={getDialog} transformToSupported={transformToSupported} pgAdmin={pgWindow.pgAdmin} alertify={Alertify} />,
this.container
);
}
}

View File

@@ -0,0 +1,288 @@
/////////////////////////////////////////////////////////////
//
// pgAdmin 4 - PostgreSQL Tools
//
// Copyright (C) 2013 - 2020, The pgAdmin Development Team
// This software is released under the PostgreSQL Licence
//
//////////////////////////////////////////////////////////////
import React from 'react';
import {
RightAngleLinkModel,
RightAngleLinkWidget,
DefaultLinkFactory,
PortModelAlignment,
LinkWidget,
PointModel
} from '@projectstorm/react-diagrams';
import {Point} from '@projectstorm/geometry';
import _ from 'lodash';
export const OneToManyModel = {
local_table_uid: undefined,
local_column_attnum: undefined,
referenced_table_uid: undefined,
referenced_column_attnum: undefined,
}
export class OneToManyLinkModel extends RightAngleLinkModel {
constructor({data, ...options}) {
super({
type: 'onetomany',
width: 1,
class: 'link-onetomany',
locked: true,
...options
});
this._data = {
...data,
};
}
getData() {
return this._data;
}
setData(data) {
this._data = data;
}
serializeData(nodesDict) {
let data = this.getData();
let target = nodesDict[data['local_table_uid']].getData();
let source = nodesDict[data['referenced_table_uid']].getData();
return {
'schema': target.schema,
'table': target.name,
'remote_schema': source.schema,
'remote_table': source.name,
'columns': [{
'local_column': _.find(target.columns, (col)=>data.local_column_attnum == col.attnum).name,
'referenced': _.find(source.columns, (col)=>data.referenced_column_attnum == col.attnum).name,
}],
}
}
serialize() {
return {
...super.serialize(),
data: this.getData()
};
}
}
const CustomLinkEndWidget = props => {
const { point, rotation, tx, ty, type } = props;
const svgForType = (itype) => {
if(itype == 'many') {
return (
<>
<circle className="svg-link-ele svg-otom-circle" cx="0" cy="16" r={props.width*1.75} strokeWidth={props.width} />
<polyline className="svg-link-ele" points="-8,0 0,15 0,0 0,30 0,15 8,0" fill="none" strokeWidth={props.width} />
</>
)
} else if (type == 'one') {
return (
<polyline className="svg-link-ele" points="-8,15 0,15 0,0 0,30 0,15 8,15" fill="none" strokeWidth={props.width} />
)
}
}
return (
<g transform={'translate(' + point.getPosition().x + ', ' + point.getPosition().y + ')'}>
<g transform={'translate('+tx+','+ty+')'}>
<g style={{ transform: 'rotate(' + rotation + 'deg)' }}>
{svgForType(type)}
</g>
</g>
</g>
);
};
export class OneToManyLinkWidget extends RightAngleLinkWidget {
constructor(props) {
super(props);
}
endPointTranslation(alignment, offset) {
let degree = 0;
let tx = 0, ty = 0;
switch(alignment) {
case PortModelAlignment.BOTTOM:
ty = -offset;
break;
case PortModelAlignment.LEFT:
degree = 90;
tx = offset
break;
case PortModelAlignment.TOP:
degree = 180;
ty = offset;
break;
case PortModelAlignment.RIGHT:
degree = -90;
tx = -offset;
break;
}
return [degree, tx, ty];
}
addCustomWidgetPoint(type, endpoint, point) {
let offset = 30;
const [rotation, tx, ty] = this.endPointTranslation(endpoint.options.alignment, offset);
if(!point) {
point = this.props.link.point(
endpoint.getX()-tx, endpoint.getY()-ty, {'one': 1, 'many': 2}[type]
);
} else {
point.setPosition(endpoint.getX()-tx, endpoint.getY()-ty);
}
return {
type: type,
point: point,
rotation: rotation,
tx: tx,
ty: ty
}
}
generateCustomEndWidget({type, point, rotation, tx, ty}) {
return (
<CustomLinkEndWidget
key={point.getID()}
point={point}
rotation={rotation}
tx={tx}
ty={ty}
type={type}
colorSelected={this.props.link.getOptions().selectedColor}
color={this.props.link.getOptions().color}
width={this.props.width}
/>
);
}
draggingEvent(event, index) {
let points = this.props.link.getPoints();
// get moving difference. Index + 1 will work because links indexes has
// length = points.lenght - 1
let dx = Math.abs(points[index].getX() - points[index + 1].getX());
let dy = Math.abs(points[index].getY() - points[index + 1].getY());
// moving with y direction
if (dx === 0) {
this.calculatePositions(points, event, index, 'x');
} else if (dy === 0) {
this.calculatePositions(points, event, index, 'y');
}
this.props.link.setFirstAndLastPathsDirection();
}
handleMove = function(event) {
this.props.link.getTargetPort()
this.draggingEvent(event, this.dragging_index);
this.props.link.fireEvent({}, 'positionChanged');
}.bind(this);
render() {
//ensure id is present for all points on the path
let points = this.props.link.getPoints();
let paths = [];
let onePoint = this.addCustomWidgetPoint('one', this.props.link.getSourcePort(), points[0]);
let manyPoint = this.addCustomWidgetPoint('many', this.props.link.getTargetPort(), points[points.length-1]);
if (!this.state.canDrag && points.length > 2) {
// Those points and its position only will be moved
for (let i = 1; i < points.length; i += points.length - 2) {
if (i - 1 === 0) {
if (this.props.link.getFirstPathXdirection()) {
points[i].setPosition(points[i].getX(), points[i - 1].getY());
} else {
points[i].setPosition(points[i - 1].getX(), points[i].getY());
}
} else {
if (this.props.link.getLastPathXdirection()) {
points[i - 1].setPosition(points[i - 1].getX(), points[i].getY());
} else {
points[i - 1].setPosition(points[i].getX(), points[i - 1].getY());
}
}
}
}
// If there is existing link which has two points add one
if (points.length === 2 && !this.state.canDrag) {
this.props.link.addPoint(
new PointModel({
link: this.props.link,
position: new Point(onePoint.point.getX(), manyPoint.point.getY())
})
);
}
paths.push(this.generateCustomEndWidget(onePoint));
for (let j = 0; j < points.length - 1; j++) {
paths.push(
this.generateLink(
LinkWidget.generateLinePath(points[j], points[j + 1]),
{
'data-linkid': this.props.link.getID(),
'data-point': j,
onMouseDown: (event) => {
if (event.button === 0) {
this.setState({ canDrag: true });
this.dragging_index = j;
// Register mouse move event to track mouse position
// On mouse up these events are unregistered check "this.handleUp"
window.addEventListener('mousemove', this.handleMove);
window.addEventListener('mouseup', this.handleUp);
}
},
onMouseEnter: (event) => {
this.setState({ selected: true });
this.props.link.lastHoverIndexOfPath = j;
}
},
j
)
);
}
paths.push(this.generateCustomEndWidget(manyPoint));
this.refPaths = [];
return <g data-default-link-test={this.props.link.getOptions().testName}>{paths}</g>;
}
}
export class OneToManyLinkFactory extends DefaultLinkFactory {
constructor() {
super('onetomany');
}
generateModel(event) {
return new OneToManyLinkModel(event.initialConfig);
}
generateReactWidget(event) {
return <OneToManyLinkWidget color='#fff' width={1} smooth={true} link={event.model} diagramEngine={this.engine} factory={this} />;
}
generateLinkSegment(model, selected, path) {
return (
<path
className={'svg-link-ele path ' + (selected ? 'selected' : '')}
stroke={model.getOptions().color}
selected={selected}
strokeWidth={model.getOptions().width}
d={path}
>
</path>
);
}
}

View File

@@ -0,0 +1,202 @@
/////////////////////////////////////////////////////////////
//
// pgAdmin 4 - PostgreSQL Tools
//
// Copyright (C) 2013 - 2020, The pgAdmin Development Team
// This software is released under the PostgreSQL Licence
//
//////////////////////////////////////////////////////////////
import React from 'react';
import { DefaultNodeModel, PortWidget } from '@projectstorm/react-diagrams';
import { AbstractReactFactory } from '@projectstorm/react-canvas-core';
import _ from 'lodash';
import { IconButton, DetailsToggleButton } from '../ui_components/ToolBar';
const TYPE = 'table';
export class TableNodeModel extends DefaultNodeModel {
constructor({otherInfo, ...options}) {
super({
...options,
type: TYPE
});
this._note = otherInfo.note || '';
this._data = {
columns: [],
...otherInfo.data,
};
}
getPortName(attnum) {
return `coll-port-${attnum}`;
}
setNote(note) {
this._note = note;
}
getNote() {
return this._note;
}
addColumn(col) {
this._data.columns.push(col);
}
getColumnAt(attnum) {
return _.find(this.getColumns(), (col)=>col.attnum==attnum);
}
getColumns() {
return this._data.columns;
}
setName(name) {
this._data['name'] = name;
}
cloneData(name) {
let newData = {
...this.getData()
};
if(name) {
newData['name'] = name
}
return newData;
}
setData(data) {
let self = this;
/* Remove the links if column dropped */
_.differenceWith(this._data.columns, data.columns, function(existing, incoming) {
return existing.attnum == incoming.attnum;
}).forEach((col)=>{
let existPort = self.getPort(self.getPortName(col.attnum));
if(existPort) {
existPort.removeAllLinks();
self.removePort(existPort);
}
});
this._data = data;
this.fireEvent({}, 'nodeUpdated');
}
getData() {
return this._data;
}
getSchemaTableName() {
return [this._data.schema, this._data.name];
}
remove() {
Object.values(this.getPorts()).forEach((port)=>{
port.removeAllLinks();
});
super.remove();
}
serializeData() {
return this.getData();
}
serialize() {
return {
...super.serialize(),
otherInfo: {
data: this.getData(),
note: this.getNote(),
}
};
}
}
export class TableNodeWidget extends React.Component {
constructor(props) {
super(props);
this.state = {
show_details: true
}
this.props.node.registerListener({
toggleDetails: (event) => {
this.setState({show_details: event.show_details});
},
});
}
generateColumn(col) {
let port = this.props.node.getPort(this.props.node.getPortName(col.attnum));
return (
<div className='d-flex col-row' key={col.attnum}>
<div className='d-flex col-row-data'>
<div><span className={'wcTabIcon ' + (col.is_primary_key?'icon-primary_key':'icon-column')}></span></div>
<div>
<span className='col-name'>{col.name}</span>&nbsp;
{this.state.show_details &&
<span className='col-datatype'>{col.cltype}{col.attlen ? ('('+ col.attlen + (col.attprecision ? ','+col.attprecision : '') +')') : ''}</span>}
</div>
</div>
<div className="ml-auto col-row-port">{this.generatePort(port)}</div>
</div>
)
}
generatePort = port => {
if(port) {
return (
<PortWidget engine={this.props.engine} port={port} key={port.getID()} className={"port-" + port.options.alignment} />
);
}
return <></>;
};
toggleShowDetails = (e) => {
e.preventDefault();
this.setState((prevState)=>({show_details: !prevState.show_details}));
}
render() {
let node_data = this.props.node.getData();
return (
<div className={"table-node " + (this.props.node.isSelected() ? 'selected': '') } onDoubleClick={()=>{this.props.node.fireEvent({}, 'editNode')}}>
<div className="table-toolbar">
<DetailsToggleButton className='btn-xs' showDetails={this.state.show_details} onClick={this.toggleShowDetails} onDoubleClick={(e)=>{e.stopPropagation();}} />
{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="table-schema">
<span className="wcTabIcon icon-schema"></span>
{node_data.schema}
</div>
<div className="table-name">
<span className="wcTabIcon icon-table"></span>
{node_data.name}
</div>
<div className="table-cols">
{_.map(node_data.columns, (col)=>this.generateColumn(col))}
</div>
</div>
);
}
}
export class TableNodeFactory extends AbstractReactFactory {
constructor() {
super(TYPE);
}
generateModel(event) {
return new TableNodeModel(event.initialConfig);
}
generateReactWidget(event) {
return <TableNodeWidget engine={this.engine} node={event.model} />;
}
}

View File

@@ -0,0 +1,43 @@
/////////////////////////////////////////////////////////////
//
// pgAdmin 4 - PostgreSQL Tools
//
// Copyright (C) 2013 - 2020, The pgAdmin Development Team
// This software is released under the PostgreSQL Licence
//
//////////////////////////////////////////////////////////////
import { PortModel } from '@projectstorm/react-diagrams-core';
import {OneToManyLinkModel} from '../links/OneToManyLink';
import { AbstractModelFactory } from '@projectstorm/react-canvas-core';
const TYPE = 'onetomany';
export default class OneToManyPortModel extends PortModel {
constructor({options}) {
super({
...options,
type: TYPE,
});
}
removeAllLinks() {
Object.values(this.getLinks()).forEach((link)=>{
link.remove();
});
}
createLinkModel() {
return new OneToManyLinkModel({});
}
}
export class OneToManyPortFactory extends AbstractModelFactory {
constructor() {
super(TYPE);
}
generateModel(event) {
return new OneToManyPortModel(event.initialConfig||{});
}
}

View File

@@ -0,0 +1,681 @@
/////////////////////////////////////////////////////////////
//
// pgAdmin 4 - PostgreSQL Tools
//
// Copyright (C) 2013 - 2020, The pgAdmin Development Team
// This software is released under the PostgreSQL Licence
//
//////////////////////////////////////////////////////////////
import * as React from 'react';
import { CanvasWidget } from '@projectstorm/react-canvas-core';
import axios from 'axios';
import { Action, InputType } from '@projectstorm/react-canvas-core';
import PropTypes from 'prop-types';
import ERDCore from '../ERDCore';
import ToolBar, {IconButton, DetailsToggleButton, ButtonGroup} from './ToolBar';
import ConnectionBar, { STATUS as CONNECT_STATUS } from './ConnectionBar';
import Loader from './Loader';
import FloatingNote from './FloatingNote';
import {setPanelTitle} from '../../erd_module';
import gettext from 'sources/gettext';
import url_for from 'sources/url_for';
import {showERDSqlTool} from 'tools/datagrid/static/js/show_query_tool';
/* Custom react-diagram action for keyboard events */
export class KeyboardShortcutAction extends Action {
constructor(shortcut_handlers=[]) {
super({
type: InputType.KEY_DOWN,
fire: ({ event })=>{
this.callHandler(event);
}
});
this.shortcuts = {};
for(let i=0; i<shortcut_handlers.length; i++){
let [key, handler] = shortcut_handlers[i];
this.shortcuts[this.shortcutKey(key.alt, key.control, key.shift, false, key.key.key_code)] = handler;
}
}
shortcutKey(altKey, ctrlKey, shiftKey, metaKey, keyCode) {
return `${altKey}:${ctrlKey}:${shiftKey}:${metaKey}:${keyCode}`;
}
callHandler(event) {
let handler = this.shortcuts[this.shortcutKey(event.altKey, event.ctrlKey, event.shiftKey, event.metaKey, event.keyCode)];
if(handler) {
handler();
}
}
}
/* The main body container for the ERD */
export default class BodyWidget extends React.Component {
constructor() {
super();
this.state = {
conn_status: CONNECT_STATUS.DISCONNECTED,
server_version: null,
any_item_selected: false,
single_node_selected: false,
single_link_selected: false,
coll_types: [],
loading_msg: null,
note_open: false,
note_node: null,
current_file: null,
dirty: false,
show_details: true,
preferences: {},
}
this.diagram = new ERDCore();
this.fileInputRef = React.createRef();
this.diagramContainerRef = React.createRef();
this.canvasEle = null;
this.noteRefEle = null;
this.noteNode = null;
this.keyboardActionObj = null;
this.onLoadDiagram = this.onLoadDiagram.bind(this);
this.onSaveDiagram = this.onSaveDiagram.bind(this);
this.onSaveAsDiagram = this.onSaveAsDiagram.bind(this);
this.onSQLClick = this.onSQLClick.bind(this);
this.onAddNewNode = this.onAddNewNode.bind(this);
this.onEditNode = this.onEditNode.bind(this);
this.onCloneNode = this.onCloneNode.bind(this);
this.onDeleteNode = this.onDeleteNode.bind(this);
this.onNoteClick = this.onNoteClick.bind(this);
this.onNoteClose = this.onNoteClose.bind(this);
this.onOneToManyClick = this.onOneToManyClick.bind(this);
this.onManyToManyClick = this.onManyToManyClick.bind(this);
this.onAutoDistribute = this.onAutoDistribute.bind(this);
this.onDetailsToggle = this.onDetailsToggle.bind(this);
this.onHelpClick = this.onHelpClick.bind(this);
this.diagram.zoomToFit = this.diagram.zoomToFit.bind(this.diagram);
this.diagram.zoomIn = this.diagram.zoomIn.bind(this.diagram);
this.diagram.zoomOut = this.diagram.zoomOut.bind(this.diagram);
}
registerModelEvents() {
let diagramEvents = {
'offsetUpdated': (event)=>{
this.realignGrid({backgroundPosition: `${event.offsetX}px ${event.offsetY}px`});
event.stopPropagation();
},
'zoomUpdated': (event)=>{
let { gridSize } = this.diagram.getModel().getOptions();
let bgSize = gridSize*event.zoom/100;
this.realignGrid({backgroundSize: `${bgSize*3}px ${bgSize*3}px`});
},
'nodesSelectionChanged': ()=>{
this.setState({
single_node_selected: this.diagram.getSelectedNodes().length == 1,
any_item_selected: this.diagram.getSelectedNodes().length > 0 || this.diagram.getSelectedLinks().length > 0,
});
},
'linksSelectionChanged': ()=>{
this.setState({
single_link_selected: this.diagram.getSelectedLinks().length == 1,
any_item_selected: this.diagram.getSelectedNodes().length > 0 || this.diagram.getSelectedLinks().length > 0,
});
},
'linksUpdated': () => {
this.setState({dirty: true});
},
'nodesUpdated': ()=>{
this.setState({dirty: true});
},
'showNote': (event)=>{
this.showNote(event.node);
},
'editNode': (event) => {
this.addEditNode(event.node);
}
};
Object.keys(diagramEvents).forEach(eventName => {
this.diagram.registerModelEvent(eventName, diagramEvents[eventName]);
});
}
registerKeyboardShortcuts() {
/* First deregister to avoid double events */
this.keyboardActionObj && this.diagram.deregisterKeyAction(this.keyboardActionObj);
this.keyboardActionObj = new KeyboardShortcutAction([
[this.state.preferences.open_project, this.onLoadDiagram],
[this.state.preferences.save_project, this.onSaveDiagram],
[this.state.preferences.save_project_as, this.onSaveAsDiagram],
[this.state.preferences.generate_sql, this.onSQLClick],
[this.state.preferences.add_table, this.onAddNewNode],
[this.state.preferences.edit_table, this.onEditNode],
[this.state.preferences.clone_table, this.onCloneNode],
[this.state.preferences.drop_table, this.onDeleteNode],
[this.state.preferences.add_edit_note, this.onNoteClick],
[this.state.preferences.one_to_many, this.onOneToManyClick],
[this.state.preferences.many_to_many, this.onManyToManyClick],
[this.state.preferences.auto_align, this.onAutoDistribute],
[this.state.preferences.zoom_to_fit, this.diagram.zoomToFit],
[this.state.preferences.zoom_in, this.diagram.zoomIn],
[this.state.preferences.zoom_out, this.diagram.zoomOut]
]);
this.diagram.registerKeyAction(this.keyboardActionObj);
}
handleAxiosCatch(err) {
let alert = this.props.alertify.alert().set('title', gettext('Error'));
if (err.response) {
// client received an error response (5xx, 4xx)
alert.set('message', `${err.response.statusText} - ${err.response.data.errormsg}`).show();
console.error('response error', err.response);
} else if (err.request) {
// client never received a response, or request never left
alert.set('message', gettext('Client error') + ':' + err).show();
console.error('client eror', err);
} else {
alert.set('message', err.message).show();
console.error('other error', err);
}
}
async componentDidMount() {
this.setLoading('Preparing');
this.setTitle(this.state.current_file);
this.setState({
preferences: this.props.pgAdmin.Browser.get_preferences_for_module('erd')
}, this.registerKeyboardShortcuts);
this.registerModelEvents();
this.realignGrid({
backgroundSize: '45px 45px',
backgroundPosition: '0px 0px',
});
this.props.pgAdmin.Browser.Events.on('pgadmin-storage:finish_btn:select_file', this.openFile, this);
this.props.pgAdmin.Browser.Events.on('pgadmin-storage:finish_btn:create_file', this.saveFile, this);
this.props.pgAdmin.Browser.onPreferencesChange('erd', () => {
this.setState({
preferences: this.props.pgAdmin.Browser.get_preferences_for_module('erd')
}, ()=>this.registerKeyboardShortcuts());
});
let done = await this.initConnection();
if(!done) return;
done = await this.loadPrequisiteData();
if(!done) return;
if(this.props.params.gen) {
await this.loadTablesData();
}
}
componentDidUpdate() {
if(this.state.dirty) {
this.setTitle(this.state.current_file, true);
}
}
getDialog(dialogName) {
if(dialogName === 'entity_dialog') {
return (title, attributes, callback)=>{
this.props.getDialog(dialogName).show(
title, attributes, this.diagram.getCache('colTypes'), this.diagram.getCache('schemas'), this.state.server_version, callback
);
};
} else if(dialogName === 'onetomany_dialog' || dialogName === 'manytomany_dialog') {
return (title, attributes, callback)=>{
this.props.getDialog(dialogName).show(
title, attributes, this.diagram.getModel().getNodesDict(), this.state.server_version, callback
);
};
}
}
setLoading(message) {
this.setState({loading_msg: message});
}
realignGrid({backgroundSize, backgroundPosition}) {
if(backgroundSize) {
this.canvasEle.style.backgroundSize = backgroundSize;
}
if(backgroundPosition) {
this.canvasEle.style.backgroundPosition = backgroundPosition;
}
}
addEditNode(node) {
let dialog = this.getDialog('entity_dialog');
if(node) {
let [schema, table] = node.getSchemaTableName();
dialog(_.escape(`Table: ${table} (${schema})`), node.getData(), (newData)=>{
node.setData(newData);
this.diagram.repaint();
});
} else {
dialog('New table', {name: this.diagram.getNextTableName()}, (newData)=>{
let newNode = this.diagram.addNode(newData);
newNode.setSelected(true);
});
}
}
onEditNode() {
const selected = this.diagram.getSelectedNodes();
if(selected.length == 1) {
this.addEditNode(selected[0]);
}
}
onAddNewNode() {
this.addEditNode();
}
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);
}
}
onDeleteNode() {
this.props.alertify.confirm(
gettext('Delete ?'),
gettext('You have selected %s tables and %s links.', this.diagram.getSelectedNodes().length, this.diagram.getSelectedLinks().length)
+ '<br />' + gettext('Are you sure you want to delete ?'),
() => {
this.diagram.getSelectedNodes().forEach((node)=>{
node.setSelected(false);
node.remove();
});
this.diagram.getSelectedLinks().forEach((link)=>{
link.getTargetPort().remove();
link.getSourcePort().remove();
link.setSelected(false);
link.remove();
});
this.diagram.repaint();
},
() => {}
);
}
onAutoDistribute() {
this.diagram.dagreDistributeNodes();
}
onDetailsToggle() {
this.setState((prevState)=>({
show_details: !prevState.show_details
}), ()=>{
this.diagram.getModel().getNodes().forEach((node)=>{
node.fireEvent({show_details: this.state.show_details}, 'toggleDetails');
})
});
}
onHelpClick() {
let url = url_for('help.static', {'filename': 'erd.html'});
window.open(url, 'pgadmin_help');
}
onLoadDiagram() {
var params = {
'supported_types': ['pgerd'], // file types allowed
'dialog_type': 'select_file', // open select file dialog
};
this.props.pgAdmin.FileManager.init();
this.props.pgAdmin.FileManager.show_dialog(params);
}
openFile(fileName) {
axios.post(url_for('sqleditor.load_file'), {
'file_name': decodeURI(fileName)
}).then((res)=>{
this.setState({
current_file: fileName,
dirty: false,
});
this.setTitle(fileName);
this.diagram.deserialize(res.data);
this.registerModelEvents();
}).catch((err)=>{
this.handleAxiosCatch(err);
});
}
onSaveDiagram(isSaveAs=false) {
if(this.state.current_file && !isSaveAs) {
this.saveFile(this.state.current_file);
} else {
var params = {
'supported_types': ['pgerd'],
'dialog_type': 'create_file',
'dialog_title': 'Save File',
'btn_primary': 'Save',
};
this.props.pgAdmin.FileManager.init();
this.props.pgAdmin.FileManager.show_dialog(params);
}
}
onSaveAsDiagram() {
this.onSaveDiagram(true);
}
saveFile(fileName) {
axios.post(url_for('sqleditor.save_file'), {
'file_name': decodeURI(fileName),
'file_content': JSON.stringify(this.diagram.serialize(this.props.pgAdmin.Browser.utils.app_version_int))
}).then(()=>{
this.props.alertify.success(gettext('Project saved successfully.'));
this.setState({
current_file: fileName,
dirty: false,
});
this.setTitle(fileName);
}).catch((err)=>{
this.handleAxiosCatch(err);
});
}
getCurrentProjectName(path) {
let currPath = path || this.state.current_file || 'Untitled';
return currPath.split('\\').pop().split('/').pop();
}
setTitle(title, dirty=false) {
if(title === null || title === '') {
title = 'Untitled';
}
title = this.getCurrentProjectName(title) + (dirty ? '*': '');
if (this.new_browser_tab) {
window.document.title = title;
} else {
_.each(this.props.pgAdmin.Browser.docker.findPanels('frm_erdtool'), function(p) {
if (p.isVisible()) {
setPanelTitle(p, title);
}
});
}
}
onSQLClick() {
let scriptHeader = gettext('-- This script was generated by a beta version of the ERD tool in pgAdmin 4.\n');
scriptHeader += gettext('-- Please log an issue at https://redmine.postgresql.org/projects/pgadmin4/issues/new if you find any bugs, including reproduction steps.\n');
let url = url_for('erd.sql', {
trans_id: this.props.params.trans_id,
sgid: this.props.params.sgid,
sid: this.props.params.sid,
did: this.props.params.did
});
this.setLoading(gettext('Preparing the SQL...'));
axios.post(url, this.diagram.serializeData())
.then((resp)=>{
let sqlScript = resp.data.data;
sqlScript = scriptHeader + 'BEGIN;\n' + sqlScript + '\nEND;';
let parentData = {
sgid: this.props.params.sgid,
sid: this.props.params.sid,
did: this.props.params.did,
stype: this.props.params.server_type,
}
let sqlId = `erd${this.props.params.trans_id}`;
localStorage.setItem(sqlId, sqlScript);
showERDSqlTool(parentData, sqlId, this.props.params.title, this.props.pgAdmin.DataGrid, this.props.alertify);
})
.catch((error)=>{
this.handleAxiosCatch(error);
})
.then(()=>{
this.setLoading(null);
})
}
onOneToManyClick() {
let dialog = this.getDialog('onetomany_dialog');
let initData = {local_table_uid: this.diagram.getSelectedNodes()[0].getID()};
dialog('One to many relation', initData, (newData)=>{
let newLink = this.diagram.addLink(newData, 'onetomany');
this.diagram.clearSelection();
newLink.setSelected(true);
this.diagram.repaint();
});
}
onManyToManyClick() {
let dialog = this.getDialog('manytomany_dialog');
let initData = {left_table_uid: this.diagram.getSelectedNodes()[0].getID()};
dialog('Many to many relation', initData, (newData)=>{
let nodes = this.diagram.getModel().getNodesDict();
let left_table = nodes[newData.left_table_uid];
let right_table = nodes[newData.right_table_uid];
let tableData = {
name: `${left_table.getData().name}_${right_table.getData().name}`,
schema: left_table.getData().schema,
columns: [{
...left_table.getColumnAt(newData.left_table_column_attnum),
'name': `${left_table.getData().name}_${left_table.getColumnAt(newData.left_table_column_attnum).name}`,
'is_primary_key': false,
'attnum': 0,
},{
...right_table.getColumnAt(newData.right_table_column_attnum),
'name': `${right_table.getData().name}_${right_table.getColumnAt(newData.right_table_column_attnum).name}`,
'is_primary_key': false,
'attnum': 1,
}]
}
let newNode = this.diagram.addNode(tableData);
this.diagram.clearSelection();
newNode.setSelected(true);
let linkData = {
local_table_uid: newNode.getID(),
local_column_attnum: newNode.getColumns()[0].attnum,
referenced_table_uid: newData.left_table_uid,
referenced_column_attnum : newData.left_table_column_attnum,
}
this.diagram.addLink(linkData, 'onetomany');
linkData = {
local_table_uid: newNode.getID(),
local_column_attnum: newNode.getColumns()[1].attnum,
referenced_table_uid: newData.right_table_uid,
referenced_column_attnum : newData.right_table_column_attnum,
}
this.diagram.addLink(linkData, 'onetomany');
this.diagram.repaint();
});
}
showNote(noteNode) {
if(noteNode) {
this.noteRefEle = this.diagram.getEngine().getNodeElement(noteNode);
this.setState({
note_node: noteNode,
note_open: true
});
}
}
onNoteClick(e) {
let noteNode = this.diagram.getSelectedNodes()[0];
this.showNote(noteNode);
}
onNoteClose(updated) {
this.setState({note_open: false});
updated && this.diagram.fireEvent({}, 'nodesUpdated', true);
}
async initConnection() {
this.setLoading(gettext('Initializing connection...'));
this.setState({conn_status: CONNECT_STATUS.CONNECTING});
let initUrl = url_for('erd.initialize', {
trans_id: this.props.params.trans_id,
sgid: this.props.params.sgid,
sid: this.props.params.sid,
did: this.props.params.did
});
try {
let response = await axios.post(initUrl);
this.setState({
conn_status: CONNECT_STATUS.CONNECTED,
server_version: response.data.data.serverVersion
});
return true;
} catch (error) {
this.setState({conn_status: CONNECT_STATUS.FAILED});
this.handleAxiosCatch(error);
return false;
} finally {
this.setLoading(null);
}
}
/* Get all prequisite in one conn since
* we have only one connection
*/
async loadPrequisiteData() {
this.setLoading(gettext('Fetching required data...'));
let url = url_for('erd.prequisite', {
trans_id: this.props.params.trans_id,
sgid: this.props.params.sgid,
sid: this.props.params.sid,
did: this.props.params.did
});
try {
let response = await axios.get(url);
let data = response.data.data;
this.diagram.setCache('colTypes', data['col_types']);
this.diagram.setCache('schemas', data['schemas']);
return true;
} catch (error) {
this.handleAxiosCatch(error);
return false;
} finally {
this.setLoading(null);
}
}
async loadTablesData() {
this.setLoading(gettext('Fetching schema data...'));
let url = url_for('erd.tables', {
trans_id: this.props.params.trans_id,
sgid: this.props.params.sgid,
sid: this.props.params.sid,
did: this.props.params.did
});
try {
let response = await axios.get(url);
let tables = response.data.data.map((table)=>{
return this.props.transformToSupported('table', table);
});
this.diagram.deserializeData(tables);
return true;
} catch (error) {
this.handleAxiosCatch(error);
return false;
} finally {
this.setLoading(null);
}
}
render() {
return (
<>
<ToolBar id="btn-toolbar">
<ButtonGroup>
<IconButton id="open-file" icon="fa fa-folder-open" onClick={this.onLoadDiagram} title={gettext('Load from file')}
shortcut={this.state.preferences.open_project}/>
<IconButton id="save-erd" icon="fa fa-save" onClick={()=>{this.onSaveDiagram()}} title={gettext('Save project')}
shortcut={this.state.preferences.save_project} disabled={!this.state.dirty}/>
<IconButton id="save-as-erd" icon="fa fa-share-square" onClick={this.onSaveAsDiagram} title={gettext('Save as')}
shortcut={this.state.preferences.save_project_as}/>
</ButtonGroup>
<ButtonGroup>
<IconButton id="save-sql" icon="fa fa-file-code" onClick={this.onSQLClick} title={gettext('Generate SQL')}
shortcut={this.state.preferences.generate_sql}/>
</ButtonGroup>
<ButtonGroup>
<IconButton id="add-node" icon="fa fa-plus-square" onClick={this.onAddNewNode} title={gettext('Add table')}
shortcut={this.state.preferences.add_table}/>
<IconButton id="edit-node" icon="fa fa-pencil-alt" onClick={this.onEditNode} title={gettext('Edit table')}
shortcut={this.state.preferences.edit_table} disabled={!this.state.single_node_selected || this.state.single_link_selected}/>
<IconButton id="clone-node" icon="fa fa-clone" onClick={this.onCloneNode} title={gettext('Clone table')}
shortcut={this.state.preferences.clone_table} disabled={!this.state.single_node_selected || this.state.single_link_selected}/>
<IconButton id="delete-node" icon="fa fa-trash-alt" onClick={this.onDeleteNode} title={gettext('Drop table/link')}
shortcut={this.state.preferences.drop_table} disabled={!this.state.any_item_selected}/>
</ButtonGroup>
<ButtonGroup>
<IconButton id="add-note" icon="fa fa-sticky-note" onClick={this.onNoteClick} title={gettext('Add/Edit note')}
shortcut={this.state.preferences.add_edit_note} disabled={!this.state.single_node_selected || this.state.single_link_selected}/>
<IconButton id="add-onetomany" text="1M" onClick={this.onOneToManyClick} title={gettext('One-to-Many link')}
shortcut={this.state.preferences.one_to_many} disabled={!this.state.single_node_selected || this.state.single_link_selected}/>
<IconButton id="add-manytomany" text="MM" onClick={this.onManyToManyClick} title={gettext('Many-to-Many link')}
shortcut={this.state.preferences.many_to_many} disabled={!this.state.single_node_selected || this.state.single_link_selected}/>
</ButtonGroup>
<ButtonGroup>
<IconButton id="auto-align" icon="fa fa-magic" onClick={this.onAutoDistribute} title={gettext('Auto align')}
shortcut={this.state.preferences.auto_align} />
<DetailsToggleButton id="more-details" onClick={this.onDetailsToggle} showDetails={this.state.show_details} />
</ButtonGroup>
<ButtonGroup>
<IconButton id="zoom-to-fit" icon="fa fa-compress" onClick={this.diagram.zoomToFit} title={gettext('Zoom to fit')}
shortcut={this.state.preferences.zoom_to_fit}/>
<IconButton id="zoom-in" icon="fa fa-search-plus" onClick={this.diagram.zoomIn} title={gettext('Zoom in')}
shortcut={this.state.preferences.zoom_in}/>
<IconButton id="zoom-out" icon="fa fa-search-minus" onClick={this.diagram.zoomOut} title={gettext('Zoom out')}
shortcut={this.state.preferences.zoom_out}/>
</ButtonGroup>
<ButtonGroup>
<IconButton id="help" icon="fa fa-question" onClick={this.onHelpClick} title={gettext('Help')} />
</ButtonGroup>
</ToolBar>
<ConnectionBar statusId="btn-conn-status" status={this.state.conn_status} bgcolor={this.props.params.bgcolor}
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}>
<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>
</>
);
}
}
BodyWidget.propTypes = {
params:PropTypes.shape({
trans_id: PropTypes.number.isRequired,
sgid: PropTypes.oneOfType([PropTypes.number, PropTypes.string]).isRequired,
sid: PropTypes.oneOfType([PropTypes.number, PropTypes.string]).isRequired,
did: PropTypes.oneOfType([PropTypes.number, PropTypes.string]).isRequired,
server_type: PropTypes.string.isRequired,
title: PropTypes.string.isRequired,
bgcolor: PropTypes.string,
fgcolor: PropTypes.string,
gen: PropTypes.bool.isRequired
}),
getDialog: PropTypes.func.isRequired,
transformToSupported: PropTypes.func.isRequired,
pgAdmin: PropTypes.object.isRequired,
alertify: PropTypes.object.isRequired
};

View File

@@ -0,0 +1,57 @@
/////////////////////////////////////////////////////////////
//
// pgAdmin 4 - PostgreSQL Tools
//
// Copyright (C) 2013 - 2020, The pgAdmin Development Team
// This software is released under the PostgreSQL Licence
//
//////////////////////////////////////////////////////////////
import React from 'react';
import gettext from 'sources/gettext';
import PropTypes from 'prop-types';
export const STATUS = {
CONNECTED: 1,
DISCONNECTED: 2,
CONNECTING: 3,
FAILED: 4,
}
/* The connection bar component */
export default function ConnectionBar({statusId, status, bgcolor, fgcolor, title}) {
return (
<div className="connection_status_wrapper d-flex">
<div id={statusId}
role="status"
className="connection_status d-flex justify-content-center align-items-center" data-container="body"
data-toggle="popover" data-placement="bottom"
data-content=""
data-panel-visible="visible"
tabIndex="0">
<span className={'pg-font-icon d-flex m-auto '
+ (status == STATUS.CONNECTED ? 'icon-query-tool-connected' : '')
+ (status == (STATUS.DISCONNECTED || STATUS.FAILED) ? 'icon-query-tool-disconnected ' : '')
+ (status == STATUS.CONNECTING ? 'obtaining-conn' : '')}
aria-hidden="true" title="" role="img">
</span>
</div>
<div className="connection-info btn-group" role="group" aria-label="">
<div className="editor-title"
style={{backgroundColor: bgcolor, color: fgcolor}}>
{status == STATUS.CONNECTING ? '(' + gettext('Obtaining connection...') + ') ' : ''}
{status == STATUS.FAILED ? '(' + gettext('Connection failed') + ') ' : ''}
{title}
</div>
</div>
</div>
)
}
ConnectionBar.propTypes = {
statusId: PropTypes.string.isRequired,
status: PropTypes.oneOf(Object.values(STATUS)).isRequired,
bgcolor: PropTypes.string,
fgcolor: PropTypes.string,
title: PropTypes.string.isRequired,
};

View File

@@ -0,0 +1,71 @@
/////////////////////////////////////////////////////////////
//
// pgAdmin 4 - PostgreSQL Tools
//
// Copyright (C) 2013 - 2020, The pgAdmin Development Team
// This software is released under the PostgreSQL Licence
//
//////////////////////////////////////////////////////////////
import React, { useEffect, useState } from 'react';
import Tippy from '@tippyjs/react';
import gettext from 'sources/gettext';
import PropTypes from 'prop-types';
import { TableNodeModel } from '../nodes/TableNode';
import CustomPropTypes from 'sources/custom_prop_types';
/* The note component of ERD. It uses tippy to create the floating note */
export default function FloatingNote({open, onClose, reference, rows, noteNode, ...tippyProps}) {
const textRef = React.useRef(null);
const [text, setText] = useState('');
const [header, setHeader] = useState('');
useEffect(()=>{
if(noteNode) {
setText(noteNode.getNote());
let [schema, name] = noteNode.getSchemaTableName();
setHeader(`${name} (${schema})`);
}
if(open) {
textRef?.current.focus();
textRef?.current.dispatchEvent(new KeyboardEvent('keypress'));
}
}, [noteNode, open]);
return (
<Tippy render={(attrs)=>(
<div className="floating-note" {...attrs}>
<div className="note-header">{gettext('Note')}:</div>
<div className="note-body">
<div className="p-1">{header}</div>
<textarea ref={textRef} className="pg-textarea" value={text} rows={rows} onChange={(e)=>setText(e.target.value)}></textarea>
<div className="pg_buttons">
<button className="btn btn-primary long_text_editor pg-alertify-button" data-label="OK"
onClick={()=>{
let updated = (noteNode.getNote() != text);
noteNode.setNote(text);
if(onClose) onClose(updated);
}}>
<span className="fa fa-check pg-alertify-button"></span>&nbsp;{gettext('OK')}
</button>
</div>
</div>
</div>
)}
visible={open}
interactive={true}
animation={false}
reference={reference}
placement='auto-end'
{...tippyProps}
/>
);
}
FloatingNote.propTypes = {
open: PropTypes.bool.isRequired,
onClose: PropTypes.func.isRequired,
reference: CustomPropTypes.ref,
rows: PropTypes.number,
noteNode: PropTypes.object,
};

View File

@@ -0,0 +1,34 @@
/////////////////////////////////////////////////////////////
//
// pgAdmin 4 - PostgreSQL Tools
//
// Copyright (C) 2013 - 2020, The pgAdmin Development Team
// This software is released under the PostgreSQL Licence
//
//////////////////////////////////////////////////////////////
import React from 'react';
import PropTypes from 'prop-types';
/* The loader/spinner component */
export default function Loader({message, autoEllipsis=false}) {
if(message || message == '') {
return (
<div className="pg-sp-container">
<div className="pg-sp-content">
<div className="row">
<div className="col-12 pg-sp-icon"></div>
</div>
<div className="row"><div className="col-12 pg-sp-text">{message}{autoEllipsis ? '...':''}</div></div>
</div>
</div>
);
} else {
return null;
}
}
Loader.propTypes = {
message: PropTypes.string,
autoEllipsis: PropTypes.bool,
};

View File

@@ -0,0 +1,136 @@
/////////////////////////////////////////////////////////////
//
// pgAdmin 4 - PostgreSQL Tools
//
// Copyright (C) 2013 - 2020, The pgAdmin Development Team
// This software is released under the PostgreSQL Licence
//
//////////////////////////////////////////////////////////////
import React, { forwardRef } from 'react';
import Tippy from '@tippyjs/react';
import {isMac} from 'sources/keyboard_shortcuts';
import gettext from 'sources/gettext';
import PropTypes from 'prop-types';
import CustomPropTypes from 'sources/custom_prop_types';
/* The base icon button.
React does not pass ref prop to child component hierarchy.
Use forwardRef for the same
*/
const BaseIconButton = forwardRef((props, ref)=>{
const {icon, text, className, ...otherProps} = props;
return(
<button ref={ref} className={className} {...otherProps}>
{icon && <span className={`${icon} sql-icon-lg`} aria-hidden="true" role="img"></span>}
{text && <span className="text-icon">{text}</span>}
</button>
);
});
BaseIconButton.propTypes = {
icon: PropTypes.string,
text: PropTypes.string,
className: PropTypes.string,
ref: CustomPropTypes.ref,
}
/* The tooltip content to show shortcut details */
export function Shortcut({shortcut}) {
let keys = [];
shortcut.alt && keys.push((isMac() ? 'Option' : 'Alt'));
shortcut.control && keys.push('Ctrl');
shortcut.shift && keys.push('Shift');
keys.push(shortcut.key.char.toUpperCase());
return (
<div style={{justifyContent: 'center', marginTop: '0.125rem'}} className="d-flex">
{keys.map((key, i)=>{
return <div key={i} className="shortcut-key">{key}</div>
})}
</div>
)
}
const shortcutPropType = PropTypes.shape({
alt: PropTypes.bool,
control: PropTypes.bool,
shift: PropTypes.bool,
key: PropTypes.shape({
char: PropTypes.string,
}),
});
Shortcut.propTypes = {
shortcut: shortcutPropType,
};
/* The icon button component which can have a tooltip based on props.
React does not pass ref prop to child component hierarchy.
Use forwardRef for the same
*/
export const IconButton = forwardRef((props, ref) => {
const {title, shortcut, className, ...otherProps} = props;
if (title) {
return (
<Tippy content={
<>
{<div style={{textAlign: 'center'}}>{title}</div>}
{shortcut && <Shortcut shortcut={shortcut} />}
</>
}>
<BaseIconButton ref={ref} className={'btn btn-sm btn-primary-icon ' + (className || '')} {...otherProps}/>
</Tippy>
);
} else {
return <BaseIconButton ref={ref} className='btn btn-sm btn-primary-icon' {...otherProps}/>
}
});
IconButton.propTypes = {
title: PropTypes.string,
shortcut: shortcutPropType,
className: PropTypes.string,
}
/* Toggle button, icon changes based on value */
export function DetailsToggleButton({showDetails, ...props}) {
return (
<IconButton
icon={showDetails ? 'far fa-eye' : 'fas fa-low-vision'}
title={showDetails ? gettext('Show fewer details') : gettext("Show more details") }
{...props} />
);
}
DetailsToggleButton.propTypes = {
showDetails: PropTypes.bool,
}
/* Button group container */
export function ButtonGroup({className, children}) {
return (
<div className={'btn-group mr-1 ' + (className ? className : '')} role="group" aria-label="save group">
{children}
</div>
)
}
ButtonGroup.propTypes = {
className: PropTypes.string,
}
/* Toolbar container */
export default function ToolBar({id, children}) {
return (
<div id={id} className="editor-toolbar d-flex" role="toolbar" aria-label="">
{children}
</div>
)
}
ButtonGroup.propTypes = {
id: PropTypes.string,
}

View File

@@ -0,0 +1,35 @@
/////////////////////////////////////////////////////////////
//
// pgAdmin 4 - PostgreSQL Tools
//
// Copyright (C) 2013 - 2020, The pgAdmin Development Team
// This software is released under the PostgreSQL Licence
//
//////////////////////////////////////////////////////////////
define([
'sources/pgadmin', 'pgadmin.tools.erd/erd_tool', 'pgadmin.browser',
'pgadmin.browser.server.privilege', 'pgadmin.node.database', 'pgadmin.node.primary_key',
'pgadmin.node.foreign_key', 'pgadmin.browser.datamodel', 'pgadmin.file_manager',
], function(
pgAdmin, ERDToolModule
) {
var pgTools = pgAdmin.Tools = pgAdmin.Tools || {};
var ERDTool = ERDToolModule.default;
/* Return back, this has been called more than once */
if (pgTools.ERDToolHook)
return pgTools.ERDToolHook;
pgTools.ERDToolHook = {
load: function(params) {
/* Create the ERD Tool object and render it */
let erdObj = new ERDTool('#erd-tool-container', params);
erdObj.render();
},
};
return pgTools.ERDToolHook;
});

View File

@@ -0,0 +1,23 @@
/////////////////////////////////////////////////////////////
//
// pgAdmin 4 - PostgreSQL Tools
//
// Copyright (C) 2013 - 2020, The pgAdmin Development Team
// This software is released under the PostgreSQL Licence
//
//////////////////////////////////////////////////////////////
import gettext from 'sources/gettext';
import url_for from 'sources/url_for';
import $ from 'jquery';
import _ from 'underscore';
import pgAdmin from 'sources/pgadmin';
import pgBrowser from 'top/browser/static/js/browser';
import * as csrfToken from 'sources/csrf';
import {initialize} from './erd_module';
var wcDocker = window.wcDocker;
let pgBrowserOut = initialize(gettext, url_for, $, _, pgAdmin, csrfToken, pgBrowser, wcDocker);
module.exports = {
pgBrowser: pgBrowserOut,
};

View File

@@ -0,0 +1,189 @@
.shortcut-key {
padding: 0 0.25rem;
border: 1px solid $border-color;
margin-right: 0.125rem;
border-radius: $btn-border-radius;
}
#erd-tool-container {
width: 100%;
height: 100%;
.file-input-hidden {
height: 0;
width: 0;
visibility: hidden;
}
.text-icon {
font-weight: bold;
}
.erd-hint-bar {
background: $sql-gutters-bg;
padding: 0.25rem 0.5rem;
}
.diagram-container {
position: relative;
width: 100%;
height: 100%;
}
.floating-note {
width: 250px;
border: $panel-border;
border-radius: $panel-border-radius;
box-shadow: $dialog-box-shadow;
background-color: $alert-dialog-body-bg !important;
color: $color-fg !important;
.note-header {
padding: 0.25rem 0.5rem;
background-color: $alert-header-bg;
font-size: $font-size-base;
font-weight: bold;
color: $alert-header-fg;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
border-radius: 0rem;
border-top-left-radius: $panel-border-radius;
border-top-right-radius: $panel-border-radius;
border-bottom: none;
margin: -$alertify-borderremove-margin; //-24px is default by alertify
margin-bottom: 0px;
}
.note-body {
& textarea {
width: 100%;
border: none;
border-bottom: $border-width solid $erd-node-border-color;
border-top: $border-width solid $erd-node-border-color;
}
& .pg_buttons {
padding: 0.25rem;
}
}
}
.diagram-canvas{
width: 100%;
height: 100%;
color: $color-fg;
font-family: sans-serif;
background-image: $erd-bg-grid;
cursor: unset;
.table-node {
background-color: $input-bg;
border: $border-width solid $erd-node-border-color;
border-radius: $input-border-radius;
position: relative;
width: 175px;
font-size: 0.8em;
&.selected {
border-color: $input-focus-border-color;
box-shadow: $input-btn-focus-box-shadow;
}
.table-toolbar {
background: $editor-toolbar-bg;
border-bottom: $border-width solid $erd-node-border-color;
padding: 0.125rem;
border-top-left-radius: inherit;
border-top-right-radius: inherit;
display: flex;
.btn {
&:not(:first-of-type) {
margin-left: 0.125rem;
}
}
}
.table-schema {
border-bottom: $border-width solid $erd-node-border-color;
padding: $erd-row-padding;
font-weight: bold;
}
.table-name {
border-bottom: $border-width*2 solid $erd-node-border-color;
padding: $erd-row-padding;
font-weight: bold;
}
.table-cols {
.col-row {
border-bottom: $border-width solid $erd-node-border-color;
.col-row-data {
padding: $erd-row-padding;
width: 100%;
.col-name {
word-break: break-all;
}
}
.col-row-port {
padding: 0;
min-height: 0;
}
}
}
}
.svg-link-ele {
stroke: $erd-link-color;
}
.svg-link-ele.path {
pointer-events: all;
}
@keyframes svg-link-ele-selected {
from { stroke-dashoffset: 24; } to { stroke-dashoffset: 0; }
}
.svg-link-ele.selected {
stroke: $erd-link-selected-color;
stroke-dasharray: 10, 2;
animation: svg-link-ele-selected 1s linear infinite;
}
.svg-link-ele.svg-otom-circle {
fill: $erd-link-color;
}
.custom-node-color{
position: absolute;
top: 50%;
left: 50%;
width: 20px;
height: 20px;
transform: translate(-50%, -50%);
border-radius: 10px;
}
.circle-port{
width: 12px;
height: 12px;
margin: 2px;
border-radius: 4px;
background: darkgray;
cursor: pointer;
}
.circle-port:hover{
background: mediumpurple;
}
.port {
display: inline-block;
margin: auto;
}
}
}