Enhancements to the ERD when selecting a relationship. #4088
parent
0e6d8ed030
commit
4ab06b4a2a
|
@ -15,7 +15,7 @@ import { ZoomCanvasAction } from '@projectstorm/react-canvas-core';
|
|||
import _ from 'lodash';
|
||||
|
||||
import {TableNodeFactory, TableNodeModel } from './nodes/TableNode';
|
||||
import {OneToManyLinkFactory, OneToManyLinkModel } from './links/OneToManyLink';
|
||||
import {OneToManyLinkFactory, OneToManyLinkModel, POINTER_SIZE } from './links/OneToManyLink';
|
||||
import { OneToManyPortFactory } from './ports/OneToManyPort';
|
||||
import ERDModel from './ERDModel';
|
||||
import ForeignKeySchema from '../../../../../browser/server_groups/servers/databases/schemas/tables/constraints/foreign_key/static/js/foreign_key.ui';
|
||||
|
@ -81,6 +81,7 @@ export default class ERDCore {
|
|||
if(!this.node_position_updating) {
|
||||
this.node_position_updating = true;
|
||||
this.fireEvent({}, 'nodesUpdated', true);
|
||||
this.optimizePortsPosition(node);
|
||||
setTimeout(()=>{
|
||||
this.node_position_updating = false;
|
||||
}, 500);
|
||||
|
@ -193,15 +194,44 @@ export default class ERDCore {
|
|||
});
|
||||
}
|
||||
|
||||
getNewPort(type, initData, initOptions) {
|
||||
return this.getEngine().getPortFactories().getFactory(type).generateModel({
|
||||
getNewPort(portName, alignment) {
|
||||
return this.getEngine().getPortFactories().getFactory('onetomany').generateModel({
|
||||
initialConfig: {
|
||||
data:initData,
|
||||
options:initOptions,
|
||||
data: null,
|
||||
options: {
|
||||
name: portName,
|
||||
alignment: alignment
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
getLeftRightPorts(node, attnum) {
|
||||
const leftPort = node.getPort(node.getPortName(attnum, PortModelAlignment.LEFT))
|
||||
?? node.addPort(this.getNewPort(node.getPortName(attnum, PortModelAlignment.LEFT), PortModelAlignment.LEFT));
|
||||
const rightPort = node.getPort(node.getPortName(attnum, PortModelAlignment.RIGHT))
|
||||
?? node.addPort(this.getNewPort(node.getPortName(attnum, PortModelAlignment.RIGHT), PortModelAlignment.RIGHT));
|
||||
|
||||
return [leftPort, rightPort];
|
||||
}
|
||||
|
||||
optimizePortsPosition(node) {
|
||||
Object.values(node.getLinks()).forEach((link)=>{
|
||||
const sourcePort = link.getSourcePort();
|
||||
const targetPort = link.getTargetPort();
|
||||
|
||||
const [newSourcePort, newTargetPort] = this.getOptimumPorts(
|
||||
sourcePort.getNode(),
|
||||
sourcePort.getNode().getPortAttnum(sourcePort.getName()),
|
||||
targetPort.getNode(),
|
||||
targetPort.getNode().getPortAttnum(targetPort.getName())
|
||||
);
|
||||
|
||||
sourcePort != newSourcePort && link.setSourcePort(newSourcePort);
|
||||
targetPort != newTargetPort && link.setTargetPort(newTargetPort);
|
||||
});
|
||||
}
|
||||
|
||||
addNode(data, position=[50, 50], metadata={}) {
|
||||
let newNode = this.getNewNode(data);
|
||||
this.clearSelection();
|
||||
|
@ -231,24 +261,45 @@ export default class ERDCore {
|
|||
}).length > 0;
|
||||
}
|
||||
|
||||
getOptimumPorts(sourceNode, sourceAttnum, targetNode, targetAttnum) {
|
||||
const [sourceLeftPort, sourceRightPort] = this.getLeftRightPorts(sourceNode, sourceAttnum);
|
||||
const [targetLeftPort, targetRightPort] = this.getLeftRightPorts(targetNode, targetAttnum);
|
||||
|
||||
/* Lets use right as default */
|
||||
let sourcePort = sourceRightPort;
|
||||
let targetPort = targetRightPort;
|
||||
const sourceNodePos = sourceNode.getBoundingBox();
|
||||
const targetNodePos = targetNode.getBoundingBox();
|
||||
const sourceLeftX = sourceNodePos.getBottomLeft().x;
|
||||
const sourceRightX = sourceNodePos.getBottomRight().x;
|
||||
const targetLeftX = targetNodePos.getBottomLeft().x;
|
||||
const targetRightX = targetNodePos.getBottomRight().x;
|
||||
|
||||
const OFFSET = POINTER_SIZE*2+10;
|
||||
|
||||
if(targetLeftX - sourceRightX >= OFFSET) {
|
||||
sourcePort = sourceRightPort;
|
||||
targetPort = targetLeftPort;
|
||||
} else if(sourceLeftX - targetRightX >= OFFSET) {
|
||||
sourcePort = sourceLeftPort;
|
||||
targetPort = targetRightPort;
|
||||
} else if(targetLeftX - sourceRightX < OFFSET || sourceLeftX - targetRightX < OFFSET) {
|
||||
if(sourcePort.getAlignment() == PortModelAlignment.RIGHT) {
|
||||
targetPort = targetRightPort;
|
||||
} else {
|
||||
targetPort = targetLeftPort;
|
||||
}
|
||||
}
|
||||
return [sourcePort, targetPort];
|
||||
}
|
||||
|
||||
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, subtype: 'one', 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, subtype: 'many', alignment:PortModelAlignment.RIGHT}));
|
||||
}
|
||||
const [sourcePort, targetPort] = this.getOptimumPorts(
|
||||
sourceNode, data.referenced_column_attnum, targetNode, data.local_column_attnum);
|
||||
|
||||
/* Link the ports */
|
||||
let newLink = this.getNewLink(type, data);
|
||||
|
@ -297,30 +348,30 @@ export default class ERDCore {
|
|||
}
|
||||
let tableData = tableNode.getData();
|
||||
/* Sync the name changes in references FK */
|
||||
Object.values(tableNode.getPorts()).forEach((port)=>{
|
||||
if(port.getSubtype() != 'one') {
|
||||
Object.values(tableNode.getLinks()).forEach((link)=>{
|
||||
if(link.getSourcePort().getNode() != tableNode) {
|
||||
/* SourcePort is the referred table */
|
||||
/* If the link doesn't refer this table, skip it */
|
||||
return;
|
||||
}
|
||||
Object.values(port.getLinks()).forEach((link)=>{
|
||||
let linkData = link.getData();
|
||||
let fkTableNode = this.getModel().getNodesDict()[linkData.local_table_uid];
|
||||
let linkData = link.getData();
|
||||
let fkTableNode = this.getModel().getNodesDict()[linkData.local_table_uid];
|
||||
|
||||
let newForeingKeys = [];
|
||||
/* Update the FK table with new references */
|
||||
fkTableNode.getData().foreign_key?.forEach((theFkRow)=>{
|
||||
for(let fkColumn of theFkRow.columns) {
|
||||
if(fkColumn.references == tableNode.getID()) {
|
||||
let attnum = _.find(oldTableData.columns, (c)=>c.name==fkColumn.referenced).attnum;
|
||||
fkColumn.referenced = _.find(tableData.columns, (colm)=>colm.attnum==attnum).name;
|
||||
fkColumn.references_table_name = tableData.name;
|
||||
}
|
||||
let newForeingKeys = [];
|
||||
/* Update the FK table with new references */
|
||||
fkTableNode.getData().foreign_key?.forEach((theFkRow)=>{
|
||||
for(let fkColumn of theFkRow.columns) {
|
||||
if(fkColumn.references == tableNode.getID()) {
|
||||
let attnum = _.find(oldTableData.columns, (c)=>c.name==fkColumn.referenced).attnum;
|
||||
fkColumn.referenced = _.find(tableData.columns, (colm)=>colm.attnum==attnum).name;
|
||||
fkColumn.references_table_name = tableData.name;
|
||||
}
|
||||
newForeingKeys.push(theFkRow);
|
||||
});
|
||||
fkTableNode.setData({
|
||||
...fkTableNode.getData(),
|
||||
foreign_key: newForeingKeys,
|
||||
});
|
||||
}
|
||||
newForeingKeys.push(theFkRow);
|
||||
});
|
||||
fkTableNode.setData({
|
||||
...fkTableNode.getData(),
|
||||
foreign_key: newForeingKeys,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
@ -352,12 +403,20 @@ export default class ERDCore {
|
|||
|
||||
const removeLink = (theFk)=>{
|
||||
if(!theFk) return;
|
||||
let attnum = _.find(tableNode.getColumns(), (col)=>col.name==theFk.local_column).attnum;
|
||||
let existPort = tableNode.getPort(tableNode.getPortName(attnum));
|
||||
if(existPort && existPort.getSubtype() == 'many') {
|
||||
existPort.removeAllLinks();
|
||||
tableNode.removePort(existPort);
|
||||
}
|
||||
|
||||
let tableNodesDict = this.getModel().getNodesDict();
|
||||
let sourceNode = tableNodesDict[theFk.references];
|
||||
|
||||
let localAttnum = _.find(tableNode.getColumns(), (col)=>col.name==theFk.local_column).attnum;
|
||||
let refAttnum = _.find(sourceNode.getColumns(), (col)=>col.name==theFk.referenced).attnum;
|
||||
const fkLink = Object.values(tableNode.getLinks()).find((link)=>{
|
||||
const ldata = link.getData();
|
||||
return ldata.local_column_attnum == localAttnum
|
||||
&& ldata.local_table_uid == tableNode.getID()
|
||||
&& ldata.referenced_column_attnum == refAttnum
|
||||
&& ldata.referenced_table_uid == theFk.references;
|
||||
});
|
||||
fkLink?.remove();
|
||||
};
|
||||
|
||||
const changeDiff = diffArray(
|
||||
|
|
|
@ -22,6 +22,8 @@ import PropTypes from 'prop-types';
|
|||
import { makeStyles } from '@material-ui/core';
|
||||
import clsx from 'clsx';
|
||||
|
||||
export const POINTER_SIZE = 30;
|
||||
|
||||
export const OneToManyModel = {
|
||||
local_table_uid: undefined,
|
||||
local_column_attnum: undefined,
|
||||
|
@ -141,32 +143,31 @@ export class OneToManyLinkWidget extends RightAngleLinkWidget {
|
|||
super(props);
|
||||
}
|
||||
|
||||
endPointTranslation(alignment, offset) {
|
||||
endPointTranslation(alignment) {
|
||||
let degree = 0;
|
||||
let tx = 0, ty = 0;
|
||||
switch(alignment) {
|
||||
case PortModelAlignment.BOTTOM:
|
||||
ty = -offset;
|
||||
ty = -POINTER_SIZE;
|
||||
break;
|
||||
case PortModelAlignment.LEFT:
|
||||
degree = 90;
|
||||
tx = offset;
|
||||
tx = POINTER_SIZE;
|
||||
break;
|
||||
case PortModelAlignment.TOP:
|
||||
degree = 180;
|
||||
ty = offset;
|
||||
ty = POINTER_SIZE;
|
||||
break;
|
||||
case PortModelAlignment.RIGHT:
|
||||
degree = -90;
|
||||
tx = -offset;
|
||||
tx = -POINTER_SIZE;
|
||||
break;
|
||||
}
|
||||
return [degree, tx, ty];
|
||||
}
|
||||
|
||||
addCustomWidgetPoint(type, endpoint, point) {
|
||||
let offset = 30;
|
||||
const [rotation, tx, ty] = this.endPointTranslation(endpoint.options.alignment, offset);
|
||||
const [rotation, tx, ty] = this.endPointTranslation(endpoint.options.alignment);
|
||||
if(!point) {
|
||||
point = this.props.link.point(
|
||||
endpoint.getX()-tx, endpoint.getY()-ty, {'one': 1, 'many': 2}[type]
|
||||
|
|
|
@ -8,7 +8,7 @@
|
|||
//////////////////////////////////////////////////////////////
|
||||
|
||||
import React from 'react';
|
||||
import { DefaultNodeModel, DiagramEngine, PortWidget } from '@projectstorm/react-diagrams';
|
||||
import { DefaultNodeModel, DiagramEngine, PortModelAlignment, PortWidget } from '@projectstorm/react-diagrams';
|
||||
import { AbstractReactFactory } from '@projectstorm/react-canvas-core';
|
||||
import _ from 'lodash';
|
||||
import SchemaIcon from 'top/browser/server_groups/servers/databases/schemas/static/img/schema.svg';
|
||||
|
@ -29,6 +29,7 @@ import { Box } from '@material-ui/core';
|
|||
|
||||
|
||||
const TYPE = 'table';
|
||||
const TABLE_WIDTH = 175;
|
||||
|
||||
export class TableNodeModel extends DefaultNodeModel {
|
||||
constructor({otherInfo, ...options}) {
|
||||
|
@ -36,6 +37,7 @@ export class TableNodeModel extends DefaultNodeModel {
|
|||
...options,
|
||||
type: TYPE,
|
||||
});
|
||||
this.width = TABLE_WIDTH;
|
||||
|
||||
this._note = otherInfo.note || '';
|
||||
this._metadata = {
|
||||
|
@ -72,8 +74,15 @@ export class TableNodeModel extends DefaultNodeModel {
|
|||
}
|
||||
}
|
||||
|
||||
getPortName(attnum) {
|
||||
return `coll-port-${attnum}`;
|
||||
getPortName(attnum, alignment) {
|
||||
if(alignment) {
|
||||
return `coll-port-${attnum}-${alignment}`;
|
||||
}
|
||||
return `coll-port-${attnum}-right`;
|
||||
}
|
||||
|
||||
getPortAttnum(portName) {
|
||||
return portName.split('-')[2];
|
||||
}
|
||||
|
||||
setNote(note) {
|
||||
|
@ -95,6 +104,18 @@ export class TableNodeModel extends DefaultNodeModel {
|
|||
};
|
||||
}
|
||||
|
||||
getLinks() {
|
||||
let links = {};
|
||||
this.getPorts();
|
||||
Object.values(this.getPorts()).forEach((port)=>{
|
||||
links = {
|
||||
...links,
|
||||
...port.getLinks(),
|
||||
};
|
||||
});
|
||||
return links;
|
||||
}
|
||||
|
||||
addColumn(col) {
|
||||
this._data.columns.push(col);
|
||||
}
|
||||
|
@ -166,7 +187,7 @@ const styles = (theme)=>({
|
|||
...theme.mixins.panelBorder.all,
|
||||
borderRadius: theme.shape.borderRadius,
|
||||
position: 'relative',
|
||||
width: '175px',
|
||||
width: `${TABLE_WIDTH}px`,
|
||||
fontSize: '0.8em',
|
||||
'& div:last-child': {
|
||||
borderBottomLeftRadius: 'inherit',
|
||||
|
@ -227,7 +248,9 @@ class TableNodeWidgetRaw extends React.Component {
|
|||
}
|
||||
|
||||
generateColumn(col, localFkCols, localUkCols) {
|
||||
let port = this.props.node.getPort(this.props.node.getPortName(col.attnum));
|
||||
let leftPort = this.props.node.getPort(this.props.node.getPortName(col.attnum, PortModelAlignment.LEFT));
|
||||
let rightPort = this.props.node.getPort(this.props.node.getPortName(col.attnum, PortModelAlignment.RIGHT));
|
||||
|
||||
let icon = ColumnIcon;
|
||||
/* Less priority */
|
||||
if(localUkCols.indexOf(col.name) > -1) {
|
||||
|
@ -247,6 +270,9 @@ class TableNodeWidgetRaw extends React.Component {
|
|||
const {classes} = this.props;
|
||||
return (
|
||||
<div className={classes.tableSection} key={col.attnum} data-test="column-row">
|
||||
<Box marginRight="auto" padding="0" minHeight="0" display="flex" alignItems="center">
|
||||
{this.generatePort(leftPort)}
|
||||
</Box>
|
||||
<Box display="flex" width="100%" style={{wordBreak: 'break-all'}}>
|
||||
<RowIcon icon={icon} />
|
||||
<Box margin="auto 0">
|
||||
|
@ -256,13 +282,13 @@ class TableNodeWidgetRaw extends React.Component {
|
|||
</Box>
|
||||
</Box>
|
||||
<Box marginLeft="auto" padding="0" minHeight="0" display="flex" alignItems="center">
|
||||
{this.generatePort(port)}
|
||||
{this.generatePort(rightPort)}
|
||||
</Box>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
generatePort = port => {
|
||||
generatePort = (port) => {
|
||||
if(port) {
|
||||
return (
|
||||
<PortWidget engine={this.props.engine} port={port} key={port.getID()} className={'port-' + port.options.alignment} />
|
||||
|
|
|
@ -7,7 +7,7 @@
|
|||
//
|
||||
//////////////////////////////////////////////////////////////
|
||||
|
||||
import { PortModel } from '@projectstorm/react-diagrams-core';
|
||||
import { PortModel, PortModelAlignment } from '@projectstorm/react-diagrams-core';
|
||||
import {OneToManyLinkModel} from '../links/OneToManyLink';
|
||||
import { AbstractModelFactory } from '@projectstorm/react-canvas-core';
|
||||
|
||||
|
@ -16,7 +16,6 @@ const TYPE = 'onetomany';
|
|||
export default class OneToManyPortModel extends PortModel {
|
||||
constructor({options}) {
|
||||
super({
|
||||
subtype: 'notset',
|
||||
...options,
|
||||
type: TYPE,
|
||||
});
|
||||
|
@ -32,21 +31,24 @@ export default class OneToManyPortModel extends PortModel {
|
|||
return new OneToManyLinkModel({});
|
||||
}
|
||||
|
||||
getSubtype() {
|
||||
return this.options.subtype;
|
||||
}
|
||||
|
||||
deserialize(event) {
|
||||
/* Make it backward compatible */
|
||||
const alignment = event.data?.name?.split('-').slice(-1)[0];
|
||||
if(event.data?.name && ![PortModelAlignment.LEFT, PortModelAlignment.RIGHT].includes(alignment)) {
|
||||
event.data.name += '-' + PortModelAlignment.RIGHT;
|
||||
}
|
||||
super.deserialize(event);
|
||||
this.options.subtype = event.data.subtype || 'notset';
|
||||
}
|
||||
|
||||
serialize() {
|
||||
return {
|
||||
...super.serialize(),
|
||||
subtype: this.options.subtype,
|
||||
};
|
||||
}
|
||||
|
||||
getAlignment() {
|
||||
return this.options.alignment;
|
||||
}
|
||||
}
|
||||
|
||||
export class OneToManyPortFactory extends AbstractModelFactory {
|
||||
|
|
|
@ -10,6 +10,7 @@ import ERDCore from 'pgadmin.tools.erd/erd_tool/ERDCore';
|
|||
import * as createEngineLib from '@projectstorm/react-diagrams';
|
||||
import TEST_TABLES_DATA from './test_tables';
|
||||
import { FakeLink, FakeNode } from './fake_item';
|
||||
import { PortModelAlignment } from '@projectstorm/react-diagrams';
|
||||
|
||||
describe('ERDCore', ()=>{
|
||||
let eleFactory = jasmine.createSpyObj('nodeFactories', {
|
||||
|
@ -120,15 +121,16 @@ describe('ERDCore', ()=>{
|
|||
});
|
||||
|
||||
it('getNewPort', ()=>{
|
||||
let data = {name: 'link1'};
|
||||
let options = {opt1: 'val1'};
|
||||
erdCoreObj.getNewPort('porttype', data, options);
|
||||
|
||||
expect(erdEngine.getPortFactories().getFactory).toHaveBeenCalledWith('porttype');
|
||||
erdEngine.getPortFactories().getFactory().generateModel.calls.reset();
|
||||
erdCoreObj.getNewPort('port1', PortModelAlignment.LEFT);
|
||||
expect(erdEngine.getPortFactories().getFactory).toHaveBeenCalledWith('onetomany');
|
||||
expect(erdEngine.getPortFactories().getFactory().generateModel).toHaveBeenCalledWith({
|
||||
initialConfig: {
|
||||
data:data,
|
||||
options:options,
|
||||
data: null,
|
||||
options: {
|
||||
name: 'port1',
|
||||
alignment: PortModelAlignment.LEFT
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
@ -156,8 +158,7 @@ describe('ERDCore', ()=>{
|
|||
it('addLink', ()=>{
|
||||
let node1 = new FakeNode({'name': 'table1'}, 'id1');
|
||||
let node2 = new FakeNode({'name': 'table2'}, 'id2');
|
||||
spyOn(node1, 'addPort').and.callThrough();
|
||||
spyOn(node2, 'addPort').and.callThrough();
|
||||
spyOn(erdCoreObj, 'getOptimumPorts').and.returnValue([{name: 'port-1'}, {name: 'port-3'}]);
|
||||
let nodesDict = {
|
||||
'id1': node1,
|
||||
'id2': node2,
|
||||
|
@ -169,11 +170,6 @@ describe('ERDCore', ()=>{
|
|||
spyOn(erdCoreObj, 'getNewLink').and.callFake(function() {
|
||||
return link;
|
||||
});
|
||||
spyOn(erdCoreObj, 'getNewPort').and.callFake(function(type, initData, options) {
|
||||
return {
|
||||
name: options.name,
|
||||
};
|
||||
});
|
||||
|
||||
erdCoreObj.addLink({
|
||||
'referenced_column_attnum': 1,
|
||||
|
@ -182,8 +178,6 @@ describe('ERDCore', ()=>{
|
|||
'local_table_uid': 'id2',
|
||||
}, 'onetomany');
|
||||
|
||||
expect(nodesDict['id1'].addPort).toHaveBeenCalledWith({name: 'port-1'});
|
||||
expect(nodesDict['id2'].addPort).toHaveBeenCalledWith({name: 'port-3'});
|
||||
expect(link.setSourcePort).toHaveBeenCalledWith({name: 'port-1'});
|
||||
expect(link.setTargetPort).toHaveBeenCalledWith({name: 'port-3'});
|
||||
});
|
||||
|
|
|
@ -37,7 +37,7 @@ describe('ERD TableNodeModel', ()=>{
|
|||
});
|
||||
|
||||
it('getPortName', ()=>{
|
||||
expect(modelObj.getPortName(2)).toBe('coll-port-2');
|
||||
expect(modelObj.getPortName(2)).toBe('coll-port-2-right');
|
||||
});
|
||||
|
||||
it('setNote', ()=>{
|
||||
|
@ -66,7 +66,6 @@ describe('ERD TableNodeModel', ()=>{
|
|||
describe('setData', ()=>{
|
||||
let existPort = jasmine.createSpyObj('port', {
|
||||
'removeAllLinks': jasmine.createSpy('removeAllLinks'),
|
||||
'getSubtype': 'notset',
|
||||
});
|
||||
|
||||
beforeEach(()=>{
|
||||
|
@ -87,7 +86,6 @@ describe('ERD TableNodeModel', ()=>{
|
|||
});
|
||||
|
||||
it('add columns', ()=>{
|
||||
spyOn(existPort, 'getSubtype').and.returnValue('many');
|
||||
existPort.removeAllLinks.calls.reset();
|
||||
modelObj.setData({
|
||||
name: 'noname',
|
||||
|
@ -113,7 +111,6 @@ describe('ERD TableNodeModel', ()=>{
|
|||
});
|
||||
|
||||
it('update columns', ()=>{
|
||||
spyOn(existPort, 'getSubtype').and.returnValue('many');
|
||||
existPort.removeAllLinks.calls.reset();
|
||||
modelObj.setData({
|
||||
name: 'noname',
|
||||
|
|
Loading…
Reference in New Issue