From 35892d51085734abcb098734445011b67b72ee35 Mon Sep 17 00:00:00 2001 From: plucik Date: Tue, 29 Apr 2025 16:13:26 +0300 Subject: [PATCH 1/4] adds save and restore state in erd tab, code refactoring --- pgmanage/app/file_manager/file_manager.py | 22 +- .../src/components/ERDTab.vue | 218 +++++++++++------- pgmanage/app/urls.py | 1 + pgmanage/app/views/workspace.py | 53 ++++- 4 files changed, 201 insertions(+), 93 deletions(-) diff --git a/pgmanage/app/file_manager/file_manager.py b/pgmanage/app/file_manager/file_manager.py index 3d0cd8e40..57490bb7d 100644 --- a/pgmanage/app/file_manager/file_manager.py +++ b/pgmanage/app/file_manager/file_manager.py @@ -11,22 +11,22 @@ def __init__(self, current_user): self.user = current_user self.storage = self._get_storage_directory() - def _get_storage_directory(self) -> Optional[str]: + def _get_storage_directory(self) -> str: """ Get the storage directory for the current user, creating it if it does not exist. Returns: - Optional[str]: The absolute path to the user's storage directory if not in desktop mode, - otherwise None. + str: The absolute path to the user's storage directory. """ - if not DESKTOP_MODE: - storage_dir = os.path.join(HOME_DIR, "storage", self.user.username) + storage_dir = os.path.join(HOME_DIR, "storage", self.user.username) - if not os.path.exists(storage_dir): - os.makedirs(storage_dir) + if not os.path.exists(storage_dir): + os.makedirs(storage_dir) - return storage_dir - return None + if not os.path.exists(os.path.join(storage_dir, ".erd_layouts")): + os.makedirs(os.path.join(storage_dir, ".erd_layouts")) + + return storage_dir def _create_file(self, path: str) -> None: """Create an empty file at the specified path.""" @@ -74,7 +74,7 @@ def create(self, path: str, name: str, file_type: str) -> None: name: The name of the file or directory to create. file_type: The type of entity to create ("file" or "dir"). """ - normalized_path = "." if path == "/" else os.path.normpath(path.lstrip('/')) + normalized_path = "." if path == "/" else os.path.normpath(path.lstrip("/")) abs_path = self.resolve_path(normalized_path) full_path = os.path.abspath(os.path.join(abs_path, name)) @@ -100,7 +100,7 @@ def get_directory_content(self, path: Optional[str] = None) -> Dict[str, Any]: if path is None: abs_path = self.storage else: - normalized_path = "." if path == "/" else os.path.normpath(path.lstrip('/')) + normalized_path = "." if path == "/" else os.path.normpath(path.lstrip("/")) abs_path = os.path.join(self.storage, normalized_path) rel_path = os.path.relpath(abs_path, self.storage) diff --git a/pgmanage/app/static/pgmanage_frontend/src/components/ERDTab.vue b/pgmanage/app/static/pgmanage_frontend/src/components/ERDTab.vue index 4fa5da22b..17d6b4ffe 100644 --- a/pgmanage/app/static/pgmanage_frontend/src/components/ERDTab.vue +++ b/pgmanage/app/static/pgmanage_frontend/src/components/ERDTab.vue @@ -1,4 +1,10 @@ @@ -9,6 +15,7 @@ import cytoscape from 'cytoscape'; import nodeHtmlLabel from 'cytoscape-node-html-label' import { tabsStore } from '../stores/stores_initializer'; import { handleError } from '../logging/utils'; +import isEmpty from 'lodash/isEmpty'; export default { @@ -30,7 +37,42 @@ export default { edges: [], cy: {}, layout: {}, - instance_uid: '' + instance_uid: '', + options: { + boxSelectionEnabled: false, + wheelSensitivity: 0.4, + style: [ + { + selector: 'node', + style: { + "shape": "round-rectangle", + "background-color": "#F8FAFC", + "background-opacity": 1, + "height": 40, + "width": 140, + shape: "round-rectangle", + } + }, + { + selector: 'edge', + style: { + 'curve-style': 'straight', + 'target-arrow-shape': 'triangle', + 'width': 2, + 'line-style': 'solid' + } + }, + { + selector: 'edge:selected', + style: { + 'width': 4, + 'line-color': '#F76707', + 'target-arrow-color': '#F76707', + 'source-arrow-color': '#F76707', + } + }, + ], + } }; }, mounted() { @@ -41,6 +83,18 @@ export default { this.$refs.cyContainer.style.visibility = 'visible'; }, methods: { + resetToDefault() { + this.layout = this.cy.layout({ + name: "grid", + padding: 50, + spacingFactor: 0.85, + }) + + setTimeout(() => { + this.adjustSizes() + this.saveGraphState(); + }, 100) + }, loadSchemaGraph() { axios.post('/draw_graph/', { database_index: this.databaseIndex, @@ -48,6 +102,7 @@ export default { schema: this.schema, }) .then((response) => { + this.isDefault = !response.data.nodes[0]?.position this.nodes = response.data.nodes.map((node) => ( { data: { @@ -61,12 +116,12 @@ export default { cgid: column.cgid, is_pk: column.is_pk, is_fk: column.is_fk, - is_highlighed: false + is_highlighted: false } )), type: 'table' }, - position: {}, + position: node?.position ?? {}, classes: 'group' + node.group } )) @@ -107,81 +162,44 @@ export default { classes.push('pk-column') if(column.is_fk) classes.push('fk-column') - if(column.is_highlighed) + if(column.is_highlighted) classes.push('highlighted') return classes.join(' ') }, initGraph() { - this.cy = cytoscape({ - container: this.$refs.cyContainer, - boxSelectionEnabled: false, - wheelSensitivity: 0.4, - style: [ - { - selector: 'node', - style: { - "shape": "round-rectangle", - "background-color": "#F8FAFC", - "background-opacity": 0, - "height": 40, - "width": 140, - shape: "round-rectangle", - } - }, - { - selector: 'edge', - style: { - 'curve-style': 'straight', - 'target-arrow-shape': 'triangle', - 'width': 2, - 'line-style': 'solid' - } - }, - { - selector: 'edge:selected', - style: { - 'width': 4, - 'line-color': '#F76707', - 'target-arrow-color': '#F76707', - 'source-arrow-color': '#F76707', - } - }, - ], - elements: { - selectable: true, - grabbable: false, - nodes: this.nodes, - edges: this.edges - } - }) - - this.cy.on('select unselect', 'edge', function(evt) { - let should_highlight = evt.type == 'select' - let {source_col, target_col} = evt.target.data() - let edge = evt.target - let srccols = edge.source().data('columns') - srccols.find((c) => c.name === source_col).is_highlighed = should_highlight - edge.source().data('columns', srccols) - let dstcols = edge.target().data('columns') - dstcols.find((c) => c.name === target_col).is_highlighed = should_highlight - edge.target().data('columns', dstcols) - }) - - this.layout = this.cy.layout({ - name: 'grid', - padding: 50, - spacingFactor: 0.85, - }) + if (this.isDefault) { + this.cy = cytoscape({ + container: this.$refs.cyContainer, + ...this.options, + elements: { + selectable: true, + grabbable: false, + nodes: [...this.nodes], + edges: [...this.edges] + } + }) + this.layout = this.cy.layout({ + name: "grid", + padding: 50, + spacingFactor: 0.85, + }) - this.cy.on('click', 'node', function (evt) { - if (evt.originalEvent) { - const element = document.elementFromPoint(evt.originalEvent.clientX, evt.originalEvent.clientY); - if(element.dataset.cgid) { - let edge = this.cy().edges().filter(( ele ) => ele.data('cgid') === element.dataset.cgid) - setTimeout(() => {edge.select()}, 1) + } else { + this.cy = cytoscape({ + container: this.$refs.cyContainer, + ...this.options, + elements: { + selectable: true, + grabbable: false, + nodes: [...this.nodes], + edges: [...this.edges] + }, + layout: { + name: 'preset' } - } - }) + }) + } + this.setupEvents(); this.cy.nodeHtmlLabel( [{ @@ -209,12 +227,6 @@ export default { }], ) - this.cy.on("resize", () => { - if(!(tabsStore.selectedPrimaryTab.metaData.selectedTab.id === this.tabId)) return; - this.cy.fit() - }) - - setTimeout(() => { this.adjustSizes() }, 100) @@ -228,10 +240,58 @@ export default { node.style('height', el.parentElement.clientHeight + padding) } }) - this.layout.run() + if (!isEmpty(this.layout)) this.layout.run() this.cy.fit() this.$refs.cyContainer.style.visibility = 'visible' }, + saveGraphState() { + const state = this.cy.nodes().map(node => ({ + id: node.id(), // table name + position: node.position(), + })); + + axios.post('/save_graph_state/', { + workspace_id: this.workspaceId, + schema: this.schema, + database_name: this.databaseName, + database_index: this.databaseIndex, + node_positions: state, + }).catch((error) => { + handleError(error); + }); + }, + setupEvents() { + this.cy.on('select unselect', 'edge', function(evt) { + let should_highlight = evt.type == 'select' + let {source_col, target_col} = evt.target.data() + let edge = evt.target + let srccols = edge.source().data('columns') + srccols.find((c) => c.name === source_col).is_highlighted = should_highlight + edge.source().data('columns', srccols) + let dstcols = edge.target().data('columns') + dstcols.find((c) => c.name === target_col).is_highlighted = should_highlight + edge.target().data('columns', dstcols) + }); + + this.cy.on('click', 'node', function (evt) { + if (evt.originalEvent) { + const element = document.elementFromPoint(evt.originalEvent.clientX, evt.originalEvent.clientY); + if(element.dataset.cgid) { + let edge = this.cy().edges().filter(( ele ) => ele.data('cgid') === element.dataset.cgid) + setTimeout(() => {edge.select()}, 1) + } + } + }); + + this.cy.on("resize", () => { + if(!(tabsStore.selectedPrimaryTab.metaData.selectedTab.id === this.tabId)) return; + this.cy.fit() + }); + + this.cy.on("dragfree", () => { + this.saveGraphState(); + }); + }, }, }; diff --git a/pgmanage/app/urls.py b/pgmanage/app/urls.py index c4a386dda..1c04128b7 100644 --- a/pgmanage/app/urls.py +++ b/pgmanage/app/urls.py @@ -39,6 +39,7 @@ path('master_password/', views.workspace.master_password, name='master_password'), path('reset_master_password/', views.workspace.reset_master_password, name='reset_master_password'), path('draw_graph/', views.workspace.draw_graph, name='draw_graph'), + path('save_graph_state/', views.workspace.save_graph_state, name='save_graph_state'), path('get_table_columns/', views.workspace.get_table_columns, name='get_table_columns'), path('refresh_monitoring/', views.workspace.refresh_monitoring, name='refresh_monitoring'), # path('delete_plugin/', views.plugins.delete_plugin, name='delete_plugin'), diff --git a/pgmanage/app/views/workspace.py b/pgmanage/app/views/workspace.py index fbfc37a4f..9b5ff220d 100644 --- a/pgmanage/app/views/workspace.py +++ b/pgmanage/app/views/workspace.py @@ -5,6 +5,7 @@ from datetime import datetime, timezone from app.client_manager import client_manager +from app.file_manager.file_manager import FileManager from app.models.main import Connection, Shortcut, Tab, UserDetails from app.utils.crypto import make_hash from app.utils.decorators import database_required, user_authenticated @@ -225,7 +226,8 @@ def renew_password(request, session): @user_authenticated @database_required(check_timeout=True, open_connection=True) def draw_graph(request, database): - schema = request.data.get("schema", '') + data = request.data + schema = data.get("schema", '') edge_dict = {} node_dict = {} @@ -293,14 +295,59 @@ def draw_graph(request, database): col['is_pk'] = True col['cgid'] = f"{fkcol['r_table_name']}-{fkcol['r_column_name']}" - response_data = {"nodes": list(node_dict.values()), "edges": list(edge_dict.values())} + + file_manager = FileManager(request.user) + database_name = ( + "sqlite3" if database.v_db_type == "sqlite" else database.v_service + ) + path = os.path.join( + file_manager.storage, + ".erd_layouts", + f'{data.get("database_index")}-{database_name}-{data.get("schema")}', + ) + + if os.path.exists(path): + with open(path, "r", encoding="utf-8") as f: + json_data = json.load(f) + + json_data_saved = {obj["id"] for obj in json_data} + data_loaded = set(list(node_dict.keys())) + + if json_data_saved == data_loaded: + for node in json_data: + node_dict[node["id"]]["position"] = node["position"] + + response_data = { + "nodes": list(node_dict.values()), + "edges": list(edge_dict.values()), + } except Exception as exc: return JsonResponse(data={'data': str(exc)}, status=400) - return JsonResponse(response_data) +@user_authenticated +def save_graph_state(request): + file_manager = FileManager(request.user) + data = request.data + layout = data.get("node_positions") + database_name = ( + "sqlite3" + if os.path.isfile(data.get("database_name")) + else data.get("database_name") + ) + path = os.path.join( + file_manager.storage, + ".erd_layouts", + f'{data.get("database_index")}-{database_name}-{data.get("schema", "-noschema-")}', + ) + + with open(path, "w", encoding="utf-8") as f: + json.dump(layout, f, indent=2) + + return JsonResponse({"status": "saved"}) + @user_authenticated @database_required(check_timeout=True, open_connection=True) From 40220bffb820deda5035d982d8a5186dd6eec54a Mon Sep 17 00:00:00 2001 From: plucik Date: Wed, 30 Apr 2025 10:30:53 +0300 Subject: [PATCH 2/4] adds zoom in and zoom out buttons in ERD tab --- .../src/components/ERDTab.vue | 41 +++++++++++++++++-- 1 file changed, 37 insertions(+), 4 deletions(-) diff --git a/pgmanage/app/static/pgmanage_frontend/src/components/ERDTab.vue b/pgmanage/app/static/pgmanage_frontend/src/components/ERDTab.vue index 17d6b4ffe..41a11f016 100644 --- a/pgmanage/app/static/pgmanage_frontend/src/components/ERDTab.vue +++ b/pgmanage/app/static/pgmanage_frontend/src/components/ERDTab.vue @@ -1,11 +1,26 @@ From 19c3887b2e2ca7c82c37f11bb066b910fd282a3e Mon Sep 17 00:00:00 2001 From: plucik Date: Wed, 30 Apr 2025 11:19:36 +0300 Subject: [PATCH 3/4] prevents from creating same erd tab --- .../static/pgmanage_frontend/src/stores/tabs.js | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/pgmanage/app/static/pgmanage_frontend/src/stores/tabs.js b/pgmanage/app/static/pgmanage_frontend/src/stores/tabs.js index e34bd0f01..09db7ff3c 100644 --- a/pgmanage/app/static/pgmanage_frontend/src/stores/tabs.js +++ b/pgmanage/app/static/pgmanage_frontend/src/stores/tabs.js @@ -593,7 +593,21 @@ const useTabsStore = defineStore("tabs", { this.selectTab(tab); }, createERDTab(schema = "") { - let tabName = schema ? `ERD: ${schema}` : "ERD"; + let secondaryTabs = this.selectedPrimaryTab.metaData.secondaryTabs; + let existingTabs = secondaryTabs.filter((t) => { + return t.component === "ERDTab"; + }); + + let tabName = schema + ? `ERD: ${this.selectedPrimaryTab?.metaData?.selectedDatabase}@${schema}` + : "ERD"; + if (existingTabs) { + let existingSameTab = existingTabs.find((t) => t.name == tabName); + if (!!existingSameTab) { + this.selectTab(existingSameTab); + return; + } + } const tab = this.addTab({ parentId: this.selectedPrimaryTab.id, From 66c932dc2359dc84ae77ec28d16caa711ff670c5 Mon Sep 17 00:00:00 2001 From: plucik Date: Fri, 2 May 2025 15:28:50 +0300 Subject: [PATCH 4/4] rewrites erd tab saving functionality --- pgmanage/app/file_manager/file_manager.py | 22 +-- pgmanage/app/migrations/0026_erdlayout.py | 26 +++ pgmanage/app/models/main.py | 9 + .../src/components/ERDTab.vue | 184 +++++++++++------- pgmanage/app/views/workspace.py | 85 +++++--- 5 files changed, 208 insertions(+), 118 deletions(-) create mode 100644 pgmanage/app/migrations/0026_erdlayout.py diff --git a/pgmanage/app/file_manager/file_manager.py b/pgmanage/app/file_manager/file_manager.py index 57490bb7d..3d0cd8e40 100644 --- a/pgmanage/app/file_manager/file_manager.py +++ b/pgmanage/app/file_manager/file_manager.py @@ -11,22 +11,22 @@ def __init__(self, current_user): self.user = current_user self.storage = self._get_storage_directory() - def _get_storage_directory(self) -> str: + def _get_storage_directory(self) -> Optional[str]: """ Get the storage directory for the current user, creating it if it does not exist. Returns: - str: The absolute path to the user's storage directory. + Optional[str]: The absolute path to the user's storage directory if not in desktop mode, + otherwise None. """ - storage_dir = os.path.join(HOME_DIR, "storage", self.user.username) + if not DESKTOP_MODE: + storage_dir = os.path.join(HOME_DIR, "storage", self.user.username) - if not os.path.exists(storage_dir): - os.makedirs(storage_dir) + if not os.path.exists(storage_dir): + os.makedirs(storage_dir) - if not os.path.exists(os.path.join(storage_dir, ".erd_layouts")): - os.makedirs(os.path.join(storage_dir, ".erd_layouts")) - - return storage_dir + return storage_dir + return None def _create_file(self, path: str) -> None: """Create an empty file at the specified path.""" @@ -74,7 +74,7 @@ def create(self, path: str, name: str, file_type: str) -> None: name: The name of the file or directory to create. file_type: The type of entity to create ("file" or "dir"). """ - normalized_path = "." if path == "/" else os.path.normpath(path.lstrip("/")) + normalized_path = "." if path == "/" else os.path.normpath(path.lstrip('/')) abs_path = self.resolve_path(normalized_path) full_path = os.path.abspath(os.path.join(abs_path, name)) @@ -100,7 +100,7 @@ def get_directory_content(self, path: Optional[str] = None) -> Dict[str, Any]: if path is None: abs_path = self.storage else: - normalized_path = "." if path == "/" else os.path.normpath(path.lstrip("/")) + normalized_path = "." if path == "/" else os.path.normpath(path.lstrip('/')) abs_path = os.path.join(self.storage, normalized_path) rel_path = os.path.relpath(abs_path, self.storage) diff --git a/pgmanage/app/migrations/0026_erdlayout.py b/pgmanage/app/migrations/0026_erdlayout.py new file mode 100644 index 000000000..1f8c74d21 --- /dev/null +++ b/pgmanage/app/migrations/0026_erdlayout.py @@ -0,0 +1,26 @@ +# Generated by Django 4.2.19 on 2025-04-30 13:59 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('app', '0025_monwidgetsconnections_position_and_more'), + ] + + operations = [ + migrations.CreateModel( + name='ERDLayout', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=200)), + ('layout', models.JSONField()), + ('connection', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='app.connection')), + ], + options={ + 'unique_together': {('connection', 'name')}, + }, + ), + ] diff --git a/pgmanage/app/models/main.py b/pgmanage/app/models/main.py index 32f1d7620..3ab63d8dd 100644 --- a/pgmanage/app/models/main.py +++ b/pgmanage/app/models/main.py @@ -238,3 +238,12 @@ class Job(models.Model): utility_pid = models.IntegerField(null=True) process_state = models.IntegerField(null=True) connection = models.ForeignKey(Connection, on_delete=models.CASCADE, null=True) + + +class ERDLayout(models.Model): + connection = models.ForeignKey(Connection, on_delete=models.CASCADE) + name = models.CharField(max_length=200) + layout = models.JSONField() + + class Meta: + unique_together = ["connection", "name"] diff --git a/pgmanage/app/static/pgmanage_frontend/src/components/ERDTab.vue b/pgmanage/app/static/pgmanage_frontend/src/components/ERDTab.vue index 41a11f016..91a93bb02 100644 --- a/pgmanage/app/static/pgmanage_frontend/src/components/ERDTab.vue +++ b/pgmanage/app/static/pgmanage_frontend/src/components/ERDTab.vue @@ -28,9 +28,8 @@ import axios from 'axios' import ShortUniqueId from 'short-unique-id' import cytoscape from 'cytoscape'; import nodeHtmlLabel from 'cytoscape-node-html-label' -import { tabsStore } from '../stores/stores_initializer'; import { handleError } from '../logging/utils'; -import isEmpty from 'lodash/isEmpty'; +import debounce from 'lodash/debounce' export default { @@ -117,42 +116,47 @@ export default { schema: this.schema, }) .then((response) => { - this.isDefault = !response.data.nodes[0]?.position - this.nodes = response.data.nodes.map((node) => ( - { - data: { - id: node.id, - html_id: node.id.replace(/[^a-zA-Z_.-:]+/, '_'), - label: node.label, - columns: node.columns.map((column) => ( - { - name: column.name, - type: this.shortDataType(column.type), - cgid: column.cgid, - is_pk: column.is_pk, - is_fk: column.is_fk, - is_highlighted: false - } - )), - type: 'table' - }, - position: node?.position ?? {}, - classes: 'group' + node.group - } - )) - - this.edges = response.data.edges.map((edge) => ( - { - data: { - source: edge.from, - target: edge.to, - source_col: edge.from_col, - target_col: edge.to_col, - label: edge.label, - cgid: edge.cgid + if (response.data.layout) { + this.jsonLayout = response.data.layout + this.new_nodes = response.data.new_nodes + this.new_edges = response.data.new_edges + } else { + this.nodes = response.data.nodes.map((node) => ( + { + data: { + id: node.id, + html_id: node.id.replace(/[^a-zA-Z_.-:]+/, '_'), + label: node.label, + columns: node.columns.map((column) => ( + { + name: column.name, + type: this.shortDataType(column.type), + cgid: column.cgid, + is_pk: column.is_pk, + is_fk: column.is_fk, + is_highlighted: false + } + )), + type: 'table' + }, + position: node?.position ?? {}, + classes: 'group' + node.group } - } - )) + )) + + this.edges = response.data.edges.map((edge) => ( + { + data: { + source: edge.from, + target: edge.to, + source_col: edge.from_col, + target_col: edge.to_col, + label: edge.label, + cgid: edge.cgid + } + } + )) + } }) .then(() => { this.initGraph() }) .catch((error) => { @@ -182,23 +186,53 @@ export default { return classes.join(' ') }, initGraph() { - if (this.isDefault) { + if (this.jsonLayout) { this.cy = cytoscape({ container: this.$refs.cyContainer, ...this.options, - elements: { - selectable: true, - grabbable: false, - nodes: [...this.nodes], - edges: [...this.edges] - } - }) - this.layout = this.cy.layout({ - name: "grid", - padding: 50, - spacingFactor: 0.85, + elements: [], }) + this.cy.json(this.jsonLayout) + + if (this.new_nodes && this.new_nodes.length > 0) { + const formattedNodes = this.new_nodes.map(node => ({ + group: 'nodes', + data: { + id: node.id, + html_id: node.id.replace(/[^a-zA-Z_.-:]+/, '_'), + label: node.label, + columns: node.columns.map((column) => ( + { + name: column.name, + type: this.shortDataType(column.type), + cgid: column.cgid, + is_pk: column.is_pk, + is_fk: column.is_fk, + is_highlighted: false + } + )), + type: 'table' + }, + classes: 'group' + node.group + })); + this.cy.add(formattedNodes); + } + if (this.new_edges && this.new_edges.length > 0) { + const formattedEdges = this.new_edges.map(edge => ({ + group: 'edges', + data: { + source: edge.from, + target: edge.to, + source_col: edge.from_col, + target_col: edge.to_col, + label: edge.label, + cgid: edge.cgid + } + })); + this.cy.add(formattedEdges); + } + } else { this.cy = cytoscape({ container: this.$refs.cyContainer, @@ -208,12 +242,19 @@ export default { grabbable: false, nodes: [...this.nodes], edges: [...this.edges] - }, - layout: { - name: 'preset' } }) + this.layout = this.cy.layout({ + name: "grid", + padding: 50, + spacingFactor: 0.85, + }) + + setTimeout(() => { + this.adjustSizes() + }, 100) } + this.setupEvents(); this.cy.nodeHtmlLabel( @@ -242,35 +283,29 @@ export default { }], ) - setTimeout(() => { - this.adjustSizes() - }, 100) + this.$refs.cyContainer.style.visibility = 'visible'; }, adjustSizes() { const padding = 2; - this.cy.nodes().forEach((node) => { - let el = document.querySelector(`#${this.instance_uid}-${node.data().html_id}`) - if (el) { - node.style('width', el.parentElement.clientWidth + padding) - node.style('height', el.parentElement.clientHeight + padding) - } - }) - if (!isEmpty(this.layout)) this.layout.run() - this.cy.fit() - this.$refs.cyContainer.style.visibility = 'visible' + this.cy.nodes().forEach((node) => { + let el = document.querySelector(`#${this.instance_uid}-${node.data().html_id}`) + if (el) { + node.style('width', el.parentElement.clientWidth + padding) + node.style('height', el.parentElement.clientHeight + padding) + } + }) + this.layout.run() + this.cy.fit() }, saveGraphState() { - const state = this.cy.nodes().map(node => ({ - id: node.id(), // table name - position: node.position(), - })); + const layoutData = this.cy.json(); axios.post('/save_graph_state/', { workspace_id: this.workspaceId, schema: this.schema, database_name: this.databaseName, database_index: this.databaseIndex, - node_positions: state, + layout: layoutData, }).catch((error) => { handleError(error); }); @@ -298,14 +333,13 @@ export default { } }); - this.cy.on("resize", () => { - if(!(tabsStore.selectedPrimaryTab.metaData.selectedTab.id === this.tabId)) return; - this.cy.fit() - }); - this.cy.on("dragfree", () => { this.saveGraphState(); }); + + this.cy.on('viewport', debounce(() => { + this.saveGraphState(); + }, 500)); }, zoomIn() { if (this.cy) { diff --git a/pgmanage/app/views/workspace.py b/pgmanage/app/views/workspace.py index 9b5ff220d..25fbfea91 100644 --- a/pgmanage/app/views/workspace.py +++ b/pgmanage/app/views/workspace.py @@ -5,8 +5,7 @@ from datetime import datetime, timezone from app.client_manager import client_manager -from app.file_manager.file_manager import FileManager -from app.models.main import Connection, Shortcut, Tab, UserDetails +from app.models.main import Connection, Shortcut, Tab, UserDetails, ERDLayout from app.utils.crypto import make_hash from app.utils.decorators import database_required, user_authenticated from app.utils.key_manager import key_manager @@ -296,26 +295,45 @@ def draw_graph(request, database): col['cgid'] = f"{fkcol['r_table_name']}-{fkcol['r_column_name']}" - file_manager = FileManager(request.user) database_name = ( "sqlite3" if database.v_db_type == "sqlite" else database.v_service ) - path = os.path.join( - file_manager.storage, - ".erd_layouts", - f'{data.get("database_index")}-{database_name}-{data.get("schema")}', - ) + layout_name = f"{database_name}@{data.get('schema')}" + + layout_obj = ERDLayout.objects.filter(name=layout_name, connection=Connection.objects.get(id=data.get("database_index"))).first() + + if layout_obj: + layout_data = layout_obj.layout + + layout_nodes = {node["data"]["id"]: node for node in layout_data.get('elements', {}).get('nodes', [])} + layout_edges = {edge["data"]["cgid"]: edge for edge in layout_data.get("elements", {}).get("edges", []) if edge["data"]["cgid"] is not None} + + + current_node_ids = set(node_dict.keys()) + current_edge_ids = set(edge_dict.keys()) + + filtered_nodes = [node for id_, node in layout_nodes.items() if id_ in current_node_ids] + + + new_nodes = [v for k, v in node_dict.items() if k not in layout_nodes.keys()] - if os.path.exists(path): - with open(path, "r", encoding="utf-8") as f: - json_data = json.load(f) + filtered_edges = [edge for id_, edge in layout_edges.items() if id_ in current_edge_ids] - json_data_saved = {obj["id"] for obj in json_data} - data_loaded = set(list(node_dict.keys())) + new_edges = [ + edge for edge in edge_dict.values() + if edge['cgid'] not in layout_edges.keys() + ] - if json_data_saved == data_loaded: - for node in json_data: - node_dict[node["id"]]["position"] = node["position"] + layout_data["elements"] = { + "nodes": filtered_nodes, + "edges": filtered_edges + } + + return JsonResponse(data={ + "layout": layout_data, + "new_nodes": new_nodes, + "new_edges": new_edges, + }) response_data = { "nodes": list(node_dict.values()), @@ -329,22 +347,25 @@ def draw_graph(request, database): @user_authenticated def save_graph_state(request): - file_manager = FileManager(request.user) - data = request.data - layout = data.get("node_positions") - database_name = ( - "sqlite3" - if os.path.isfile(data.get("database_name")) - else data.get("database_name") - ) - path = os.path.join( - file_manager.storage, - ".erd_layouts", - f'{data.get("database_index")}-{database_name}-{data.get("schema", "-noschema-")}', - ) - - with open(path, "w", encoding="utf-8") as f: - json.dump(layout, f, indent=2) + try: + data = request.data + database_index = data.get("database_index") + layout = data.get("layout") + database_name = ( + "sqlite3" + if os.path.isfile(data.get("database_name")) + else data.get("database_name") + ) + layout_name = f"{database_name}@{data.get('schema')}" + conn = Connection.objects.get(id=database_index) + + layout_obj, _ = ERDLayout.objects.update_or_create( + connection=conn, + name=layout_name, + defaults={"layout": layout}, + ) + except Exception as exc: + return JsonResponse(data={'data': str(exc)}, status=400) return JsonResponse({"status": "saved"})