diff --git a/dev/codeserver/pom.xml b/dev/codeserver/pom.xml
index 5de3e7781..88b246b0b 100644
--- a/dev/codeserver/pom.xml
+++ b/dev/codeserver/pom.xml
@@ -155,6 +155,10 @@
org.apache.solr
solr-solrj
+
+ org.apache.solr
+ solr-solrj-jetty
+
ch.qos.logback
logback-classic
diff --git a/src/main/java/com/databasepreservation/common/client/common/visualization/browse/information/DatabaseInformationPanel.java b/src/main/java/com/databasepreservation/common/client/common/visualization/browse/information/DatabaseInformationPanel.java
index f2e8c65ea..f6af30d32 100644
--- a/src/main/java/com/databasepreservation/common/client/common/visualization/browse/information/DatabaseInformationPanel.java
+++ b/src/main/java/com/databasepreservation/common/client/common/visualization/browse/information/DatabaseInformationPanel.java
@@ -161,14 +161,20 @@ private void loadOnAccess() {
private void initDataContent() {
if (database.getMetadata().getSchemas().size() == 1) {
+ final DataPanel instance = DataPanel.getInstance(database, database.getMetadata().getSchemas().get(0).getUuid(),
+ status);
+ instance.reload(advancedMode);
+
if (dataContentCard.getWidgetCount() == 1) {
FlowPanel cardTitlePanel = CommonClientUtils.getCardTitle(messages.schema());
cardTitlePanel.addStyleName("card-header");
+ ViewerSchema schema = database.getMetadata().getSchemas().get(0);
+ ErDiagram erDiagram = ErDiagram.getInstance(database, schema,
+ HistoryManager.getCurrentHistoryPath().get(0), status);
+ cardTitlePanel.add(erDiagram.getLayoutConfigButton());
dataContentCard.insert(cardTitlePanel, 0);
}
- final DataPanel instance = DataPanel.getInstance(database, database.getMetadata().getSchemas().get(0).getUuid(),
- status);
- instance.reload(advancedMode);
+
dataContent.add(instance);
} else {
dataContentCard.addStyleName("card-diagram");
diff --git a/src/main/java/com/databasepreservation/common/client/common/visualization/browse/information/ErDiagram.java b/src/main/java/com/databasepreservation/common/client/common/visualization/browse/information/ErDiagram.java
index ace4a461c..466c92cb0 100644
--- a/src/main/java/com/databasepreservation/common/client/common/visualization/browse/information/ErDiagram.java
+++ b/src/main/java/com/databasepreservation/common/client/common/visualization/browse/information/ErDiagram.java
@@ -27,13 +27,16 @@
import com.databasepreservation.common.client.widgets.wcag.AccessibleFocusPanel;
import com.github.nmorel.gwtjackson.client.ObjectMapper;
import com.google.gwt.core.client.GWT;
+import com.google.gwt.event.dom.client.ClickEvent;
import com.google.gwt.event.logical.shared.SelectionEvent;
import com.google.gwt.event.logical.shared.SelectionHandler;
import com.google.gwt.uibinder.client.UiBinder;
import com.google.gwt.uibinder.client.UiField;
import com.google.gwt.user.client.ui.Composite;
import com.google.gwt.user.client.ui.FlowPanel;
+import com.google.gwt.user.client.ui.HTML;
import com.google.gwt.user.client.ui.MultiWordSuggestOracle;
+import com.google.gwt.user.client.ui.PopupPanel;
import com.google.gwt.user.client.ui.SimplePanel;
import com.google.gwt.user.client.ui.SuggestBox;
import com.google.gwt.user.client.ui.SuggestOracle;
@@ -55,6 +58,8 @@ public class ErDiagram extends Composite implements ICollectionStatusObserver {
AccessibleFocusPanel searchButton;
@UiField
AccessibleFocusPanel clearCrossButton;
+ private AccessibleFocusPanel layoutConfigButton;
+ private PopupPanel layoutDropdown;
@UiField
FlowPanel contentItems;
@@ -164,6 +169,55 @@ public void onSelection(SelectionEvent event) {
resetDiagramFocus(schema.getUuid());
searchBox.setFocus(true);
});
+
+ layoutConfigButton = new AccessibleFocusPanel();
+ layoutConfigButton.addStyleName("btn btn-link btn-pull-right");
+ layoutConfigButton.add(new HTML("Configuration "));
+ layoutDropdown = buildLayoutDropdown();
+ layoutConfigButton.addClickHandler(event -> {
+ if (layoutDropdown.isShowing()) {
+ layoutDropdown.hide();
+ } else {
+ layoutDropdown.showRelativeTo(layoutConfigButton);
+ }
+ });
+ }
+
+ private PopupPanel buildLayoutDropdown() {
+ PopupPanel popup = new PopupPanel(true);
+ popup.addStyleName("layoutDropdownPopup");
+
+ FlowPanel menu = new FlowPanel();
+ menu.addStyleName("layoutDropdownMenu");
+ menu.add(dropdownItem("fa-upload", "Upload layout",
+ () -> uploadPositions(databaseUUID, schema.getUuid())));
+ menu.add(dropdownItem("fa-download", "Download layout",
+ () -> downloadPositions(schema.getUuid())));
+ menu.add(dropdownItem("fa-undo", "Reset layout", this::resetLayout));
+
+ popup.setWidget(menu);
+ return popup;
+ }
+
+ private FlowPanel dropdownItem(String iconClass, String label, Runnable action) {
+ FlowPanel item = new FlowPanel();
+ item.addStyleName("layoutDropdownItem");
+ item.add(new HTML(" " + label));
+ item.addDomHandler(event -> {
+ layoutDropdown.hide();
+ action.run();
+ }, ClickEvent.getType());
+ return item;
+ }
+
+ public AccessibleFocusPanel getLayoutConfigButton() {
+ return layoutConfigButton;
+ }
+
+ private void resetLayout() {
+ clearSavedPositions(databaseUUID, schema.getUuid());
+ isInitialized = false;
+ renderDiagram();
}
/**
@@ -729,27 +783,64 @@ public static native void loadDiagram(String dbuuid, String schemaUUID, String n
(function erdiagramload(){
// network container
var container = $wnd.document.getElementById('erdiagram-' + schemaUUID);
-
+
// Destroy the previous graph instance to avoid overlap
if (container && container.network) {
container.network.destroy();
container.network = null;
}
-
+
// create an array with nodes
var rawNodes = eval(nodesJson);
-
+
var rawNodesLen = rawNodes.length;
for(var i=0; i 1){
options.physics.enabled = false;
network.setOptions(options);
+
+ // When some nodes had saved positions and others did not, physics settled
+ // the new nodes around the fixed ones — now unfix all and persist the full layout.
+ if (savedPositions && !allPositioned) {
+ var nodeIds = network.body.data.nodes.getIds();
+ var updates = [];
+ for (var ui = 0; ui < nodeIds.length; ui++) {
+ updates.push({id: nodeIds[ui], fixed: {x: false, y: false}});
+ }
+ network.body.data.nodes.update(updates);
+ savePositions();
+ }
}
- // network.fit();
});
-
+
network.on("dragEnd", function (params) {
+ // Save positions only after physics is off and the user actually moved a node
+ if (!options.physics.enabled && params.nodes && params.nodes.length > 0) {
+ savePositions();
+ }
network.unselectAll();
});
})();
@@ -935,4 +1045,69 @@ private native void resetDiagramFocus(String schemaUUID) /*-{
});
}
}-*/;
+
+ private static native void uploadPositions(String dbuuid, String schemaUUID) /*-{
+ var storageKey = 'erdiagram-positions-' + dbuuid + '-' + schemaUUID;
+ var container = $wnd.document.getElementById('erdiagram-' + schemaUUID);
+
+ var input = $wnd.document.createElement('input');
+ input.type = 'file';
+ input.accept = '.json,application/json';
+ input.style.display = 'none';
+
+ input.onchange = function(event) {
+ var file = event.target.files[0];
+ if (!file) return;
+ var reader = new $wnd.FileReader();
+ reader.onload = function(e) {
+ try {
+ var positions = JSON.parse(e.target.result);
+ $wnd.localStorage.setItem(storageKey, JSON.stringify(positions));
+
+ if (container && container.network) {
+ var network = container.network;
+ var updates = [];
+ for (var nodeId in positions) {
+ if (positions.hasOwnProperty(nodeId)) {
+ updates.push({id: nodeId, x: positions[nodeId].x, y: positions[nodeId].y, fixed: {x: false, y: false}});
+ }
+ }
+ network.body.data.nodes.update(updates);
+ network.setOptions({physics: {enabled: false}});
+ network.fit({animation: {duration: 500, easingFunction: "easeInOutQuad"}});
+ }
+ } catch(err) {
+ $wnd.alert('Invalid layout file: ' + err.message);
+ }
+ if (input.parentNode) input.parentNode.removeChild(input);
+ };
+ reader.readAsText(file);
+ };
+
+ $wnd.document.body.appendChild(input);
+ input.click();
+ }-*/;
+
+ private static native void downloadPositions(String schemaUUID) /*-{
+ var container = $wnd.document.getElementById('erdiagram-' + schemaUUID);
+ if (!container || !container.network) return;
+ var positions = container.network.getPositions();
+ var json = JSON.stringify(positions, null, 2);
+ var blob = new Blob([json], {type: 'application/json'});
+ var url = $wnd.URL.createObjectURL(blob);
+ var a = $wnd.document.createElement('a');
+ a.href = url;
+ a.download = 'erdiagram-layout.json';
+ $wnd.document.body.appendChild(a);
+ a.click();
+ $wnd.document.body.removeChild(a);
+ $wnd.URL.revokeObjectURL(url);
+ }-*/;
+
+ private static native void clearSavedPositions(String dbuuid, String schemaUUID) /*-{
+ var storageKey = 'erdiagram-positions-' + dbuuid + '-' + schemaUUID;
+ try {
+ $wnd.localStorage.removeItem(storageKey);
+ } catch(e) {}
+ }-*/;
}
diff --git a/src/main/java/com/databasepreservation/server/Server.gwt.xml b/src/main/java/com/databasepreservation/server/Server.gwt.xml
index 2f05d2c76..c4e2d77b3 100644
--- a/src/main/java/com/databasepreservation/server/Server.gwt.xml
+++ b/src/main/java/com/databasepreservation/server/Server.gwt.xml
@@ -13,5 +13,5 @@
-
+
diff --git a/src/main/resources/com/databasepreservation/common/public/main.css b/src/main/resources/com/databasepreservation/common/public/main.css
index ae5123856..9b42dd40d 100644
--- a/src/main/resources/com/databasepreservation/common/public/main.css
+++ b/src/main/resources/com/databasepreservation/common/public/main.css
@@ -911,6 +911,32 @@ p[data-value]:after {
margin-top: 10%;
}
+.layoutDropdownPopup {
+ background-color: #fff;
+ border: 1px solid #ccc;
+ border-radius: 4px;
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
+ padding: 4px 0;
+}
+
+.layoutDropdownMenu {
+ min-width: 160px;
+}
+
+.layoutDropdownItem {
+ padding: 4px 16px;
+ line-height: 2.5rem;
+ height: 2.5rem;
+ cursor: pointer;
+ font-size: 1.3rem;
+ white-space: nowrap;
+ color: #222;
+}
+
+.layoutDropdownItem:hover {
+ background-color: #f5f5f5;
+}
+
/**********************************************
DATABASE SEARCH PAGE
************************************************/
@@ -2844,10 +2870,20 @@ html, body {
font-weight: 500;
font-size: 1.1em;
margin-bottom: 10px;
- display: inline-block;
+ display: block;
+ width: 100%;
+ overflow: hidden;
line-height: 1em;
}
+.card-header > div:first-child {
+ float: left;
+}
+
+.card-header .btn-pull-right {
+ float: right;
+}
+
.card-diagram {
padding: 0;
}