mirror of
https://github.com/pgadmin-org/pgadmin4.git
synced 2025-02-25 18:55:31 -06:00
Added ERD Diagram support with basic table fields, primary key, foreign key, and DDL SQL generation. Fixes #1802
This commit is contained in:
committed by
Akshay Joshi
parent
065bda37b4
commit
0c8226ff39
215
web/pgadmin/tools/erd/static/js/erd_module.js
Normal file
215
web/pgadmin/tools/erd/static/js/erd_module.js
Normal 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;
|
||||
}
|
||||
395
web/pgadmin/tools/erd/static/js/erd_tool/ERDCore.js
Normal file
395
web/pgadmin/tools/erd/static/js/erd_tool/ERDCore.js
Normal 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);
|
||||
}
|
||||
}
|
||||
21
web/pgadmin/tools/erd/static/js/erd_tool/ERDModel.js
Normal file
21
web/pgadmin/tools/erd/static/js/erd_tool/ERDModel.js
Normal 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]));
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
739
web/pgadmin/tools/erd/static/js/erd_tool/dialogs/TableDialog.js
Normal file
739
web/pgadmin/tools/erd/static/js/erd_tool/dialogs/TableDialog.js
Normal 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);
|
||||
}
|
||||
}
|
||||
32
web/pgadmin/tools/erd/static/js/erd_tool/dialogs/index.js
Normal file
32
web/pgadmin/tools/erd/static/js/erd_tool/dialogs/index.js
Normal 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;
|
||||
}
|
||||
30
web/pgadmin/tools/erd/static/js/erd_tool/index.js
Normal file
30
web/pgadmin/tools/erd/static/js/erd_tool/index.js
Normal 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
|
||||
);
|
||||
}
|
||||
}
|
||||
288
web/pgadmin/tools/erd/static/js/erd_tool/links/OneToManyLink.jsx
Normal file
288
web/pgadmin/tools/erd/static/js/erd_tool/links/OneToManyLink.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
}
|
||||
202
web/pgadmin/tools/erd/static/js/erd_tool/nodes/TableNode.jsx
Normal file
202
web/pgadmin/tools/erd/static/js/erd_tool/nodes/TableNode.jsx
Normal 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>
|
||||
{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} />;
|
||||
}
|
||||
}
|
||||
@@ -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||{});
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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> {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,
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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,
|
||||
}
|
||||
35
web/pgadmin/tools/erd/static/js/erd_tool_hook.js
Normal file
35
web/pgadmin/tools/erd/static/js/erd_tool_hook.js
Normal 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;
|
||||
});
|
||||
|
||||
|
||||
23
web/pgadmin/tools/erd/static/js/index.js
Normal file
23
web/pgadmin/tools/erd/static/js/index.js
Normal 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,
|
||||
};
|
||||
189
web/pgadmin/tools/erd/static/scss/_erd.scss
Normal file
189
web/pgadmin/tools/erd/static/scss/_erd.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user