From 533bc135081b3edd01bde338c87c9508ed1cc753 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Thu, 30 Jul 2026 10:58:59 +0000
Subject: [PATCH 1/2] Add object editor example and runtime editor object
---
README.md | 4 +-
example/README.md | 1 +
example/object_editor/README.md | 26 +
example/object_editor/sql/sys-config.sql | 11 +
example/object_editor/static/menu.json | 1 +
example/object_editor/static/object.json | 8 +
example/object_editor/static/skeleton.json | 9 +
.../static/sysObjObjectEditor.js | 678 ++++++++++++++++++
.../config/editor_builder/sql/sys-config.sql | 11 +
.../config/editor_builder/static/menu.json | 1 +
.../config/editor_builder/static/object.json | 8 +
.../editor_builder/static/skeleton.json | 9 +
.../static/sysObjObjectEditor.js | 678 ++++++++++++++++++
test/integration/test_editor_builder.py | 135 ++++
14 files changed, 1578 insertions(+), 2 deletions(-)
create mode 100644 example/object_editor/README.md
create mode 100644 example/object_editor/sql/sys-config.sql
create mode 100644 example/object_editor/static/menu.json
create mode 100644 example/object_editor/static/object.json
create mode 100644 example/object_editor/static/skeleton.json
create mode 100644 example/object_editor/static/sysObjObjectEditor.js
create mode 100644 test/integration/config/editor_builder/sql/sys-config.sql
create mode 100644 test/integration/config/editor_builder/static/menu.json
create mode 100644 test/integration/config/editor_builder/static/object.json
create mode 100644 test/integration/config/editor_builder/static/skeleton.json
create mode 100644 test/integration/config/editor_builder/static/sysObjObjectEditor.js
create mode 100644 test/integration/test_editor_builder.py
diff --git a/README.md b/README.md
index e9e17335..0ae2ce73 100644
--- a/README.md
+++ b/README.md
@@ -201,12 +201,12 @@ Full details in [WEB/codeX PKI Manager](https://www.webcodex.de/index.html?menu=
## :bulb: Examples
-Try 15 live examples (after local docker containers have been started):
+Try 16 live examples (after local docker containers have been started):
```
http://x0-app.x0.localnet/python/Index.py?appid=example1
...
-http://x0-app.x0.localnet/python/Index.py?appid=example15
+http://x0-app.x0.localnet/python/Index.py?appid=example16
```
Most examples now include a descriptive video: [./example/README.md](./example/README.md).
diff --git a/example/README.md b/example/README.md
index 6c957c19..48a9a8f2 100644
--- a/example/README.md
+++ b/example/README.md
@@ -24,6 +24,7 @@ get started quickly.
- [net_messages](https://github.com/WEBcodeX1/x0/tree/main/example/net_messages) - Network messaging between sessions
- [object_instances](https://github.com/WEBcodeX1/x0/tree/main/example/object_instances) - Dynamic object instancing (experimental)
- [open_close_container](https://github.com/WEBcodeX1/x0/tree/main/example/open_close_container) - Collapsible container sections
+- [object_editor](https://github.com/WEBcodeX1/x0/tree/main/example/object_editor) - In-browser object/screen editor with runtime drag & drop
- [screen_overlay](https://github.com/WEBcodeX1/x0/tree/main/example/screen_overlay) - Modal overlay functionality
- [tree_simple](https://github.com/WEBcodeX1/x0/tree/main/example/tree_simple) - Hierarchical tree structures with navigation
diff --git a/example/object_editor/README.md b/example/object_editor/README.md
new file mode 100644
index 00000000..5a99d586
--- /dev/null
+++ b/example/object_editor/README.md
@@ -0,0 +1,26 @@
+# Example #16 (Object Editor)
+
+This example provides an in-browser x0 editor application with runtime object and screen editing.
+
+## URL
+
+Open URL: `http://x0-app.x0.localnet/python/Index.py?appid=example16`
+
+## Features
+
+- Main object bar with:
+ - **screen section** (add, remove, switch screen)
+ - **system-object section** (all registered x0 system objects)
+- Object properties form with editable JSON attributes.
+- Workspace/canvas for drag and drop object placement.
+- Drag and drop for:
+ - adding new objects from system-object section
+ - reparenting existing connected objects
+- Runtime updates of:
+ - `sysFactory.DataObject.XMLRPCResultData` (object.json structure)
+ - `sysFactory.DataSkeleton.XMLRPCResultData` (skeleton.json structure)
+
+## Notes
+
+- Changes are runtime/in-memory only.
+- No backend persistence endpoint is included in this example.
diff --git a/example/object_editor/sql/sys-config.sql b/example/object_editor/sql/sys-config.sql
new file mode 100644
index 00000000..ba733adb
--- /dev/null
+++ b/example/object_editor/sql/sys-config.sql
@@ -0,0 +1,11 @@
+INSERT INTO system.config (app_id, config_group, "value") VALUES ('example16', 'index_title', 'x0 Example Object Editor');
+INSERT INTO system.config (app_id, config_group, "value") VALUES ('example16', 'debug_level', '10');
+INSERT INTO system.config (app_id, config_group, "value") VALUES ('example16', 'display_language', 'en');
+INSERT INTO system.config (app_id, config_group, "value") VALUES ('example16', 'default_screen', 'Screen1');
+INSERT INTO system.config (app_id, config_group, "value") VALUES ('example16', 'parent_window_url', 'null');
+INSERT INTO system.config (app_id, config_group, "value") VALUES ('example16', 'subdir', '/examples/object_editor');
+INSERT INTO system.config (app_id, config_group, "value") VALUES ('example16', 'config_file_menu', 'menu.json');
+INSERT INTO system.config (app_id, config_group, "value") VALUES ('example16', 'config_file_object', 'object.json');
+INSERT INTO system.config (app_id, config_group, "value") VALUES ('example16', 'config_file_skeleton', 'skeleton.json');
+INSERT INTO system.config (app_id, config_group, "value") VALUES ('example16', 'template_file', 'examples/object_editor/sysObjObjectEditor.js');
+INSERT INTO system.config (app_id, config_group, "value") VALUES ('example16', 'setup_class', "['ObjectEditor']=sysObjObjectEditor");
diff --git a/example/object_editor/static/menu.json b/example/object_editor/static/menu.json
new file mode 100644
index 00000000..fe51488c
--- /dev/null
+++ b/example/object_editor/static/menu.json
@@ -0,0 +1 @@
+[]
diff --git a/example/object_editor/static/object.json b/example/object_editor/static/object.json
new file mode 100644
index 00000000..47a9356d
--- /dev/null
+++ b/example/object_editor/static/object.json
@@ -0,0 +1,8 @@
+{
+ "ObjectEditor1": {
+ "Type": "ObjectEditor",
+ "Attributes": {
+ "Style": "container-fluid py-3"
+ }
+ }
+}
diff --git a/example/object_editor/static/skeleton.json b/example/object_editor/static/skeleton.json
new file mode 100644
index 00000000..ac3c7946
--- /dev/null
+++ b/example/object_editor/static/skeleton.json
@@ -0,0 +1,9 @@
+{
+ "Screen1": [
+ {
+ "ObjectEditor1": {
+ "RefID": "Screen1"
+ }
+ }
+ ]
+}
diff --git a/example/object_editor/static/sysObjObjectEditor.js b/example/object_editor/static/sysObjObjectEditor.js
new file mode 100644
index 00000000..36f63c8d
--- /dev/null
+++ b/example/object_editor/static/sysObjObjectEditor.js
@@ -0,0 +1,678 @@
+//-------1---------2---------3---------4---------5---------6---------7--------//
+//- Copyright WEB/codeX, clickIT 2011 - 2025 -//
+//-------1---------2---------3---------4---------5---------6---------7--------//
+//- SYSTEM OBJECT "ObjectEditor" -//
+//-------1---------2---------3---------4---------5---------6---------7--------//
+
+function sysObjObjectEditor() {
+ this.EventListeners = new Object();
+ this.ChildObjects = new Array();
+
+ this.SelectedSystemType = 'Div';
+ this.SelectedObjectID = null;
+ this.DraftObjectID = null;
+
+ this.EventListeners['EditorClick'] = {
+ 'Type': 'click',
+ 'Element': this.EventListenerClick.bind(this)
+ };
+
+ this.EventListeners['EditorInput'] = {
+ 'Type': 'input',
+ 'Element': this.EventListenerInput.bind(this)
+ };
+
+ this.EventListeners['EditorChange'] = {
+ 'Type': 'change',
+ 'Element': this.EventListenerInput.bind(this)
+ };
+
+ this.EventListeners['EditorDragStart'] = {
+ 'Type': 'dragstart',
+ 'Element': this.EventListenerDragStart.bind(this)
+ };
+
+ this.EventListeners['EditorDragOver'] = {
+ 'Type': 'dragover',
+ 'Element': this.EventListenerDragOver.bind(this)
+ };
+
+ this.EventListeners['EditorDrop'] = {
+ 'Type': 'drop',
+ 'Element': this.EventListenerDrop.bind(this)
+ };
+}
+
+sysObjObjectEditor.prototype = new sysBaseObject();
+
+sysObjObjectEditor.prototype.init = function() {
+ const Attributes = (this.JSONConfig !== undefined && this.JSONConfig.Attributes !== undefined)
+ ? this.JSONConfig.Attributes : {};
+
+ this.DOMType = 'div';
+ this.DOMStyle = (Attributes.Style !== undefined)
+ ? Attributes.Style
+ : 'container-fluid py-3';
+
+ this.DOMValue = this.getEditorDOMTemplate();
+};
+
+sysObjObjectEditor.prototype.getEditorDOMTemplate = function() {
+ return '' +
+ '
' +
+ '
' +
+ '
' +
+ ' ' +
+ '
' +
+ '
' +
+ '
Screen Section
' +
+ '
' +
+ ' ' +
+ ' ' +
+ '
' +
+ '
' +
+ '
' +
+ '
' +
+ '
System-Object Section
' +
+ '
' +
+ '
' +
+ '
' +
+ '
' +
+ '
' +
+ '
' +
+ '
' +
+ ' ' +
+ '
' +
+ '
' +
+ '
' +
+ '
' +
+ '
' +
+ '
' +
+ '
' +
+ ' ' +
+ ' ' +
+ '
' +
+ '
' +
+ '
' +
+ '
' +
+ '
' +
+ '
' +
+ '
';
+};
+
+sysObjObjectEditor.prototype.processEventListener = function() {
+ sysBaseObject.prototype.processEventListener.call(this);
+ this.refreshUI();
+};
+
+sysObjObjectEditor.prototype.refreshUI = function() {
+ this.renderScreenList();
+ this.renderSystemObjectList();
+ this.renderCanvas();
+ this.loadDraftToForm();
+};
+
+sysObjObjectEditor.prototype.getSkeletonStore = function() {
+ return sysFactory.DataSkeleton.XMLRPCResultData;
+};
+
+sysObjObjectEditor.prototype.getObjectStore = function() {
+ return sysFactory.DataObject.XMLRPCResultData;
+};
+
+sysObjObjectEditor.prototype.getCurrentScreenID = function() {
+ var screenID = sysFactory.CurrentScreenID;
+ if (screenID === undefined || screenID == null) {
+ const all = Object.keys(this.getSkeletonStore());
+ screenID = all.length > 0 ? all[0] : null;
+ }
+ return screenID;
+};
+
+sysObjObjectEditor.prototype.setStatus = function(Text) {
+ const El = document.getElementById(this.DOMObjectID + '__status');
+ if (El !== null) {
+ El.innerHTML = Text;
+ }
+};
+
+sysObjObjectEditor.prototype.renderScreenList = function() {
+ const Container = document.getElementById(this.DOMObjectID + '__screen-list');
+ if (Container === null) {
+ return;
+ }
+
+ const current = this.getCurrentScreenID();
+ const ScreenIDs = Object.keys(this.getSkeletonStore());
+
+ var html = '';
+ for (const ScreenID of ScreenIDs) {
+ const active = (ScreenID === current) ? ' active' : '';
+ html += '';
+ }
+
+ Container.innerHTML = html;
+};
+
+sysObjObjectEditor.prototype.renderSystemObjectList = function() {
+ const Container = document.getElementById(this.DOMObjectID + '__system-list');
+ if (Container === null) {
+ return;
+ }
+
+ const TypeIDs = Object.keys(sysFactory.SetupClasses).sort();
+ var html = '';
+
+ for (const TypeID of TypeIDs) {
+ const active = (TypeID === this.SelectedSystemType) ? ' active' : '';
+ html += '' + TypeID + '
';
+ }
+
+ Container.innerHTML = html;
+};
+
+sysObjObjectEditor.prototype.renderCanvas = function() {
+ const Canvas = document.getElementById(this.DOMObjectID + '__canvas');
+ if (Canvas === null) {
+ return;
+ }
+
+ const current = this.getCurrentScreenID();
+ if (current == null) {
+ Canvas.innerHTML = 'No screen available.
';
+ return;
+ }
+
+ const roots = this.getChildrenByParentRef(current, current);
+ var html = '';
+ html += '';
+ html += '
' + current + ' (root)
';
+
+ if (roots.length === 0) {
+ html += '
Drop a system object here.
';
+ }
+ else {
+ html += this.renderObjectNodes(current, roots, 0);
+ }
+
+ html += '
';
+
+ Canvas.innerHTML = html;
+};
+
+sysObjObjectEditor.prototype.renderObjectNodes = function(ScreenID, nodes, level) {
+ var html = '';
+
+ for (const NodeID of nodes) {
+ const selected = (NodeID === this.SelectedObjectID) ? ' border-primary' : ' border-secondary';
+
+ html += '';
+ html += '
';
+ html += ' ' + this.getObjectType(NodeID) + '' + NodeID;
+ html += '
';
+
+ const children = this.getChildrenByParentRef(ScreenID, NodeID);
+ if (children.length > 0) {
+ html += this.renderObjectNodes(ScreenID, children, level + 1);
+ }
+
+ html += '
';
+ }
+
+ return html;
+};
+
+sysObjObjectEditor.prototype.getChildrenByParentRef = function(ScreenID, ParentID) {
+ const SkeletonScreen = this.getSkeletonStore()[ScreenID] || [];
+ const Children = [];
+
+ for (const Item of SkeletonScreen) {
+ const ObjectID = Object.keys(Item)[0];
+ const Meta = Item[ObjectID];
+ if (Meta.RefID === ParentID) {
+ Children.push(ObjectID);
+ }
+ }
+
+ return Children;
+};
+
+sysObjObjectEditor.prototype.getObjectType = function(ObjectID) {
+ try {
+ return this.getObjectStore()[ObjectID].Type;
+ }
+ catch (err) {
+ return 'Unknown';
+ }
+};
+
+sysObjObjectEditor.prototype.loadDraftToForm = function() {
+ const TypeEl = document.getElementById(this.DOMObjectID + '__prop-type');
+ const ObjectIDEl = document.getElementById(this.DOMObjectID + '__prop-object-id');
+ const ScreenEl = document.getElementById(this.DOMObjectID + '__prop-screen-id');
+ const ParentEl = document.getElementById(this.DOMObjectID + '__prop-parent-id');
+ const AttributesEl = document.getElementById(this.DOMObjectID + '__prop-attributes');
+
+ if (TypeEl === null || ObjectIDEl === null || ScreenEl === null || ParentEl === null || AttributesEl === null) {
+ return;
+ }
+
+ const current = this.getCurrentScreenID();
+
+ if (this.SelectedObjectID !== null) {
+ const SkeletonMeta = this.getSkeletonMetaByObjectID(current, this.SelectedObjectID);
+ const Config = this.getObjectStore()[this.SelectedObjectID];
+
+ TypeEl.value = Config.Type;
+ ObjectIDEl.value = this.SelectedObjectID;
+ ScreenEl.value = current;
+ ParentEl.value = SkeletonMeta.RefID;
+ AttributesEl.value = JSON.stringify(Config.Attributes || {}, null, 2);
+ return;
+ }
+
+ if (this.DraftObjectID === null) {
+ this.DraftObjectID = this.generateUniqueObjectID(this.SelectedSystemType);
+ }
+
+ TypeEl.value = this.SelectedSystemType;
+ ObjectIDEl.value = this.DraftObjectID;
+ ScreenEl.value = current;
+ ParentEl.value = current;
+ AttributesEl.value = JSON.stringify(this.getDefaultAttributes(this.SelectedSystemType), null, 2);
+};
+
+sysObjObjectEditor.prototype.getDefaultAttributes = function(TypeID) {
+ const Defaults = {
+ 'Div': { 'Style': 'border p-2 mb-2', 'Value': 'New Div' },
+ 'SQLText': { 'Style': 'fw-semibold', 'TextID': 'TXT.SQLTEXT.TEST' },
+ 'ButtonInternal': { 'Style': 'btn btn-sm btn-outline-primary', 'DOMValue': 'Action' },
+ 'Button': { 'Style': 'btn btn-sm btn-primary', 'DOMValue': 'Submit', 'FormValidate': false },
+ 'Link': { 'Style': 'btn btn-sm btn-outline-secondary', 'TextID': 'TXT.MENU.LISTDETAILVIEW.SCREEN1', 'ScreenID': 'Screen1' },
+ 'FormfieldList': { 'Sections': [] },
+ 'List': { 'Style': 'border rounded', 'HeaderRowStyle': 'row fw-bold', 'RowCount': 3, 'Columns': [] },
+ 'TabContainer': { 'Tabs': [{ 'ID': 'Tab1', 'Default': true, 'TextID': 'TXT.BASIC-TABCONTAINER.TAB1', 'Style': 'col-md-12' }] }
+ };
+
+ return (Defaults[TypeID] !== undefined) ? Defaults[TypeID] : {};
+};
+
+sysObjObjectEditor.prototype.getSkeletonMetaByObjectID = function(ScreenID, ObjectID) {
+ const SkeletonScreen = this.getSkeletonStore()[ScreenID] || [];
+
+ for (const Item of SkeletonScreen) {
+ const Key = Object.keys(Item)[0];
+ if (Key === ObjectID) {
+ return Item[Key];
+ }
+ }
+
+ return { 'RefID': ScreenID };
+};
+
+sysObjObjectEditor.prototype.EventListenerClick = function(Event) {
+ const ActionNode = Event.target.closest('[data-editor-action]');
+ if (ActionNode === null) {
+ return;
+ }
+
+ const Action = ActionNode.getAttribute('data-editor-action');
+
+ if (Action === 'add-screen') {
+ this.addScreen();
+ }
+ else if (Action === 'remove-screen') {
+ this.removeCurrentScreen();
+ }
+ else if (Action === 'switch-screen') {
+ this.switchScreen(ActionNode.getAttribute('data-screen-id'));
+ }
+ else if (Action === 'select-system-type') {
+ this.SelectedObjectID = null;
+ this.SelectedSystemType = ActionNode.getAttribute('data-system-type');
+ this.DraftObjectID = this.generateUniqueObjectID(this.SelectedSystemType);
+ this.refreshUI();
+ }
+ else if (Action === 'select-existing-object') {
+ this.SelectedObjectID = ActionNode.getAttribute('data-existing-object-id');
+ this.SelectedSystemType = this.getObjectType(this.SelectedObjectID);
+ this.refreshUI();
+ }
+ else if (Action === 'save-object') {
+ this.saveObjectFromForm();
+ }
+ else if (Action === 'reset-draft') {
+ this.SelectedObjectID = null;
+ this.DraftObjectID = this.generateUniqueObjectID(this.SelectedSystemType);
+ this.refreshUI();
+ }
+};
+
+sysObjObjectEditor.prototype.EventListenerInput = function(Event) {
+ if (this.SelectedObjectID === null) {
+ return;
+ }
+
+ const TargetID = Event.target.id;
+ if (TargetID === this.DOMObjectID + '__prop-attributes') {
+ return;
+ }
+};
+
+sysObjObjectEditor.prototype.saveObjectFromForm = function() {
+ const TypeID = document.getElementById(this.DOMObjectID + '__prop-type').value;
+ var ObjectID = document.getElementById(this.DOMObjectID + '__prop-object-id').value.trim();
+ const ScreenID = document.getElementById(this.DOMObjectID + '__prop-screen-id').value.trim();
+ var ParentID = document.getElementById(this.DOMObjectID + '__prop-parent-id').value.trim();
+ const AttrText = document.getElementById(this.DOMObjectID + '__prop-attributes').value;
+
+ var Attributes;
+
+ try {
+ Attributes = (AttrText.trim() === '') ? {} : JSON.parse(AttrText);
+ }
+ catch (err) {
+ this.setStatus('Invalid Attributes JSON');
+ return;
+ }
+
+ if (ObjectID === '') {
+ ObjectID = this.generateUniqueObjectID(TypeID);
+ }
+
+ if (ParentID === '') {
+ ParentID = ScreenID;
+ }
+
+ if (this.SelectedObjectID !== null && this.SelectedObjectID === ObjectID) {
+ this.updateExistingObject(ObjectID, ScreenID, ParentID, Attributes);
+ return;
+ }
+
+ if (this.getObjectStore()[ObjectID] !== undefined) {
+ this.setStatus('ObjectID already exists');
+ return;
+ }
+
+ this.createObjectOnScreen(TypeID, ObjectID, ScreenID, ParentID, Attributes);
+};
+
+sysObjObjectEditor.prototype.updateExistingObject = function(ObjectID, ScreenID, ParentID, Attributes) {
+ const Config = this.getObjectStore()[ObjectID];
+ Config.Attributes = Attributes;
+
+ const Meta = this.getSkeletonMetaByObjectID(ScreenID, ObjectID);
+ Meta.RefID = ParentID;
+ if (Meta.ElementID !== undefined) {
+ delete Meta.ElementID;
+ }
+
+ const RuntimeObj = sysFactory.getObjectByID(ObjectID);
+ if (RuntimeObj !== undefined && RuntimeObj !== null) {
+ RuntimeObj.JSONConfig.Attributes = Attributes;
+
+ if (Attributes.Style !== undefined) {
+ RuntimeObj.DOMStyle = Attributes.Style;
+ RuntimeObj.setDOMElementStyle();
+ }
+
+ if (Attributes.Value !== undefined || Attributes.DOMValue !== undefined) {
+ RuntimeObj.DOMValue = (Attributes.Value !== undefined) ? Attributes.Value : Attributes.DOMValue;
+ RuntimeObj.setDOMElementValue();
+ }
+ }
+
+ this.rebuildScreen(ScreenID);
+ this.setStatus('Updated object ' + ObjectID);
+ this.refreshUI();
+};
+
+sysObjObjectEditor.prototype.createObjectOnScreen = function(TypeID, ObjectID, ScreenID, ParentID, Attributes) {
+ if (this.getSkeletonStore()[ScreenID] === undefined) {
+ this.setStatus('Unknown ScreenID');
+ return;
+ }
+
+ this.getObjectStore()[ObjectID] = {
+ 'Type': TypeID,
+ 'Attributes': Attributes
+ };
+
+ const NewEntry = {};
+ NewEntry[ObjectID] = { 'RefID': ParentID };
+ this.getSkeletonStore()[ScreenID].push(NewEntry);
+
+ this.rebuildScreen(ScreenID);
+
+ this.SelectedObjectID = ObjectID;
+ this.DraftObjectID = this.generateUniqueObjectID(TypeID);
+
+ this.setStatus('Created object ' + ObjectID);
+ this.refreshUI();
+};
+
+sysObjObjectEditor.prototype.addScreen = function() {
+ const Skeleton = this.getSkeletonStore();
+ var Index = 1;
+ var NewID = 'Screen' + Index;
+
+ while (Skeleton[NewID] !== undefined) {
+ Index += 1;
+ NewID = 'Screen' + Index;
+ }
+
+ Skeleton[NewID] = [];
+
+ const NewScreen = sysFactory.addScreen(NewID, Skeleton[NewID]);
+ NewScreen.setup();
+ sysFactory.switchScreen(NewID);
+
+ this.SelectedObjectID = null;
+ this.setStatus('Added screen ' + NewID);
+ this.refreshUI();
+};
+
+sysObjObjectEditor.prototype.removeCurrentScreen = function() {
+ const Skeleton = this.getSkeletonStore();
+ const ScreenIDs = Object.keys(Skeleton);
+
+ if (ScreenIDs.length <= 1) {
+ this.setStatus('At least one screen must remain');
+ return;
+ }
+
+ const Current = this.getCurrentScreenID();
+ const ScreenObj = sysFactory.getScreenByID(Current);
+
+ if (ScreenObj !== undefined && ScreenObj !== null) {
+ ScreenObj.HierarchyRootObject.removeDOMElement();
+ }
+
+ delete Skeleton[Current];
+ delete sysFactory.Screens[Current];
+
+ const Fallback = Object.keys(Skeleton)[0];
+ sysFactory.switchScreen(Fallback);
+
+ this.SelectedObjectID = null;
+ this.setStatus('Removed screen ' + Current);
+ this.refreshUI();
+};
+
+sysObjObjectEditor.prototype.switchScreen = function(ScreenID) {
+ sysFactory.switchScreen(ScreenID);
+ this.SelectedObjectID = null;
+ this.setStatus('Switched to ' + ScreenID);
+ this.refreshUI();
+};
+
+sysObjObjectEditor.prototype.generateUniqueObjectID = function(TypeID) {
+ const Prefix = (TypeID || 'Object').replace(/[^a-zA-Z0-9]/g, '');
+ const Store = this.getObjectStore();
+
+ var Index = 1;
+ var Candidate = Prefix + Index;
+
+ while (Store[Candidate] !== undefined) {
+ Index += 1;
+ Candidate = Prefix + Index;
+ }
+
+ return Candidate;
+};
+
+sysObjObjectEditor.prototype.rebuildScreen = function(ScreenID) {
+ const ScreenObj = sysFactory.getScreenByID(ScreenID);
+
+ if (ScreenObj === undefined || ScreenObj === null) {
+ return;
+ }
+
+ try {
+ ScreenObj.HierarchyRootObject.removeDOMElement();
+ }
+ catch (err) {
+ }
+
+ ScreenObj.HierarchyRootObject = new sysObjDiv();
+ ScreenObj.HierarchyRootObject.ObjectID = ScreenObj.ScreenID;
+ ScreenObj.HierarchyRootObject.DOMStyle = ScreenObj.CSSStyle;
+
+ ScreenObj.setupObject(ScreenObj.ScreenID, ScreenObj.HierarchyRootObject);
+ ScreenObj.HierarchyRootObject.connectServiceConnectorObjects();
+ ScreenObj.HierarchyRootObject.renderObject();
+ ScreenObj.HierarchyRootObject.processReset();
+ ScreenObj.HierarchyRootObject.processEventListener();
+
+ if (sysFactory.CurrentScreenID !== ScreenID) {
+ ScreenObj.HierarchyRootObject.VisibleState = 'hidden';
+ ScreenObj.HierarchyRootObject.setDOMVisibleState();
+ }
+};
+
+sysObjObjectEditor.prototype.EventListenerDragStart = function(Event) {
+ const SystemTypeNode = Event.target.closest('[data-system-type]');
+ if (SystemTypeNode !== null) {
+ const TypeID = SystemTypeNode.getAttribute('data-system-type');
+ Event.dataTransfer.setData('x0-editor-kind', 'system-type');
+ Event.dataTransfer.setData('x0-editor-system-type', TypeID);
+ return;
+ }
+
+ const ExistingNode = Event.target.closest('[data-existing-object-id]');
+ if (ExistingNode !== null) {
+ const ObjectID = ExistingNode.getAttribute('data-existing-object-id');
+ Event.dataTransfer.setData('x0-editor-kind', 'existing-object');
+ Event.dataTransfer.setData('x0-editor-object-id', ObjectID);
+ return;
+ }
+};
+
+sysObjObjectEditor.prototype.EventListenerDragOver = function(Event) {
+ const DropNode = Event.target.closest('[data-drop-target="1"]');
+ if (DropNode !== null) {
+ Event.preventDefault();
+ }
+};
+
+sysObjObjectEditor.prototype.EventListenerDrop = function(Event) {
+ const DropNode = Event.target.closest('[data-drop-target="1"]');
+ if (DropNode === null) {
+ return;
+ }
+
+ Event.preventDefault();
+
+ const Kind = Event.dataTransfer.getData('x0-editor-kind');
+ const ScreenID = DropNode.getAttribute('data-screen-id');
+ const ParentID = DropNode.getAttribute('data-parent-id');
+
+ if (Kind === 'system-type') {
+ const TypeID = Event.dataTransfer.getData('x0-editor-system-type');
+ const FormType = document.getElementById(this.DOMObjectID + '__prop-type').value;
+ const FormObjectID = document.getElementById(this.DOMObjectID + '__prop-object-id').value.trim();
+ const AttrText = document.getElementById(this.DOMObjectID + '__prop-attributes').value;
+
+ var Attributes = this.getDefaultAttributes(TypeID);
+ try {
+ if (FormType === TypeID && AttrText.trim() !== '') {
+ Attributes = JSON.parse(AttrText);
+ }
+ }
+ catch (err) {
+ }
+
+ var ObjectID = FormObjectID;
+ if (FormType !== TypeID || ObjectID === '' || this.getObjectStore()[ObjectID] !== undefined) {
+ ObjectID = this.generateUniqueObjectID(TypeID);
+ }
+
+ this.createObjectOnScreen(TypeID, ObjectID, ScreenID, ParentID, Attributes);
+ }
+
+ if (Kind === 'existing-object') {
+ const ObjectID = Event.dataTransfer.getData('x0-editor-object-id');
+ this.reparentObject(ScreenID, ObjectID, ParentID);
+ }
+};
+
+sysObjObjectEditor.prototype.reparentObject = function(ScreenID, ObjectID, NewParentID) {
+ if (ObjectID === NewParentID) {
+ this.setStatus('Cannot set object as its own parent');
+ return;
+ }
+
+ if (this.isDescendant(ScreenID, NewParentID, ObjectID) === true) {
+ this.setStatus('Invalid move: target parent is child of object');
+ return;
+ }
+
+ const Meta = this.getSkeletonMetaByObjectID(ScreenID, ObjectID);
+ if (Meta.RefID === NewParentID) {
+ this.setStatus('Object is already connected to this parent');
+ return;
+ }
+
+ Meta.RefID = NewParentID;
+ if (Meta.ElementID !== undefined) {
+ delete Meta.ElementID;
+ }
+
+ this.rebuildScreen(ScreenID);
+ this.SelectedObjectID = ObjectID;
+ this.setStatus('Reparented object ' + ObjectID + ' to ' + NewParentID);
+ this.refreshUI();
+};
+
+sysObjObjectEditor.prototype.isDescendant = function(ScreenID, CandidateParentID, ObjectID) {
+ const ParentMap = {};
+ const SkeletonScreen = this.getSkeletonStore()[ScreenID] || [];
+
+ for (const Item of SkeletonScreen) {
+ const Key = Object.keys(Item)[0];
+ ParentMap[Key] = Item[Key].RefID;
+ }
+
+ var Current = CandidateParentID;
+
+ while (Current !== undefined && Current !== null) {
+ if (Current === ObjectID) {
+ return true;
+ }
+
+ if (Current === ScreenID) {
+ return false;
+ }
+
+ Current = ParentMap[Current];
+ }
+
+ return false;
+};
diff --git a/test/integration/config/editor_builder/sql/sys-config.sql b/test/integration/config/editor_builder/sql/sys-config.sql
new file mode 100644
index 00000000..7b49bf0f
--- /dev/null
+++ b/test/integration/config/editor_builder/sql/sys-config.sql
@@ -0,0 +1,11 @@
+INSERT INTO system.config (app_id, config_group, "value") VALUES ('test_editor_builder', 'index_title', 'x0 Integration Test Object Editor');
+INSERT INTO system.config (app_id, config_group, "value") VALUES ('test_editor_builder', 'debug_level', '10');
+INSERT INTO system.config (app_id, config_group, "value") VALUES ('test_editor_builder', 'display_language', 'en');
+INSERT INTO system.config (app_id, config_group, "value") VALUES ('test_editor_builder', 'default_screen', 'Screen1');
+INSERT INTO system.config (app_id, config_group, "value") VALUES ('test_editor_builder', 'parent_window_url', 'null');
+INSERT INTO system.config (app_id, config_group, "value") VALUES ('test_editor_builder', 'subdir', '/test/editor_builder');
+INSERT INTO system.config (app_id, config_group, "value") VALUES ('test_editor_builder', 'config_file_menu', 'menu.json');
+INSERT INTO system.config (app_id, config_group, "value") VALUES ('test_editor_builder', 'config_file_object', 'object.json');
+INSERT INTO system.config (app_id, config_group, "value") VALUES ('test_editor_builder', 'config_file_skeleton', 'skeleton.json');
+INSERT INTO system.config (app_id, config_group, "value") VALUES ('test_editor_builder', 'template_file', 'test/editor_builder/sysObjObjectEditor.js');
+INSERT INTO system.config (app_id, config_group, "value") VALUES ('test_editor_builder', 'setup_class', "['ObjectEditor']=sysObjObjectEditor");
diff --git a/test/integration/config/editor_builder/static/menu.json b/test/integration/config/editor_builder/static/menu.json
new file mode 100644
index 00000000..fe51488c
--- /dev/null
+++ b/test/integration/config/editor_builder/static/menu.json
@@ -0,0 +1 @@
+[]
diff --git a/test/integration/config/editor_builder/static/object.json b/test/integration/config/editor_builder/static/object.json
new file mode 100644
index 00000000..47a9356d
--- /dev/null
+++ b/test/integration/config/editor_builder/static/object.json
@@ -0,0 +1,8 @@
+{
+ "ObjectEditor1": {
+ "Type": "ObjectEditor",
+ "Attributes": {
+ "Style": "container-fluid py-3"
+ }
+ }
+}
diff --git a/test/integration/config/editor_builder/static/skeleton.json b/test/integration/config/editor_builder/static/skeleton.json
new file mode 100644
index 00000000..ac3c7946
--- /dev/null
+++ b/test/integration/config/editor_builder/static/skeleton.json
@@ -0,0 +1,9 @@
+{
+ "Screen1": [
+ {
+ "ObjectEditor1": {
+ "RefID": "Screen1"
+ }
+ }
+ ]
+}
diff --git a/test/integration/config/editor_builder/static/sysObjObjectEditor.js b/test/integration/config/editor_builder/static/sysObjObjectEditor.js
new file mode 100644
index 00000000..36f63c8d
--- /dev/null
+++ b/test/integration/config/editor_builder/static/sysObjObjectEditor.js
@@ -0,0 +1,678 @@
+//-------1---------2---------3---------4---------5---------6---------7--------//
+//- Copyright WEB/codeX, clickIT 2011 - 2025 -//
+//-------1---------2---------3---------4---------5---------6---------7--------//
+//- SYSTEM OBJECT "ObjectEditor" -//
+//-------1---------2---------3---------4---------5---------6---------7--------//
+
+function sysObjObjectEditor() {
+ this.EventListeners = new Object();
+ this.ChildObjects = new Array();
+
+ this.SelectedSystemType = 'Div';
+ this.SelectedObjectID = null;
+ this.DraftObjectID = null;
+
+ this.EventListeners['EditorClick'] = {
+ 'Type': 'click',
+ 'Element': this.EventListenerClick.bind(this)
+ };
+
+ this.EventListeners['EditorInput'] = {
+ 'Type': 'input',
+ 'Element': this.EventListenerInput.bind(this)
+ };
+
+ this.EventListeners['EditorChange'] = {
+ 'Type': 'change',
+ 'Element': this.EventListenerInput.bind(this)
+ };
+
+ this.EventListeners['EditorDragStart'] = {
+ 'Type': 'dragstart',
+ 'Element': this.EventListenerDragStart.bind(this)
+ };
+
+ this.EventListeners['EditorDragOver'] = {
+ 'Type': 'dragover',
+ 'Element': this.EventListenerDragOver.bind(this)
+ };
+
+ this.EventListeners['EditorDrop'] = {
+ 'Type': 'drop',
+ 'Element': this.EventListenerDrop.bind(this)
+ };
+}
+
+sysObjObjectEditor.prototype = new sysBaseObject();
+
+sysObjObjectEditor.prototype.init = function() {
+ const Attributes = (this.JSONConfig !== undefined && this.JSONConfig.Attributes !== undefined)
+ ? this.JSONConfig.Attributes : {};
+
+ this.DOMType = 'div';
+ this.DOMStyle = (Attributes.Style !== undefined)
+ ? Attributes.Style
+ : 'container-fluid py-3';
+
+ this.DOMValue = this.getEditorDOMTemplate();
+};
+
+sysObjObjectEditor.prototype.getEditorDOMTemplate = function() {
+ return '' +
+ '' +
+ '
' +
+ '
' +
+ ' ' +
+ '
' +
+ '
' +
+ '
Screen Section
' +
+ '
' +
+ ' ' +
+ ' ' +
+ '
' +
+ '
' +
+ '
' +
+ '
' +
+ '
System-Object Section
' +
+ '
' +
+ '
' +
+ '
' +
+ '
' +
+ '
' +
+ '
' +
+ '
' +
+ ' ' +
+ '
' +
+ '
' +
+ '
' +
+ '
' +
+ '
' +
+ '
' +
+ '
' +
+ ' ' +
+ ' ' +
+ '
' +
+ '
' +
+ '
' +
+ '
' +
+ '
' +
+ '
' +
+ '
';
+};
+
+sysObjObjectEditor.prototype.processEventListener = function() {
+ sysBaseObject.prototype.processEventListener.call(this);
+ this.refreshUI();
+};
+
+sysObjObjectEditor.prototype.refreshUI = function() {
+ this.renderScreenList();
+ this.renderSystemObjectList();
+ this.renderCanvas();
+ this.loadDraftToForm();
+};
+
+sysObjObjectEditor.prototype.getSkeletonStore = function() {
+ return sysFactory.DataSkeleton.XMLRPCResultData;
+};
+
+sysObjObjectEditor.prototype.getObjectStore = function() {
+ return sysFactory.DataObject.XMLRPCResultData;
+};
+
+sysObjObjectEditor.prototype.getCurrentScreenID = function() {
+ var screenID = sysFactory.CurrentScreenID;
+ if (screenID === undefined || screenID == null) {
+ const all = Object.keys(this.getSkeletonStore());
+ screenID = all.length > 0 ? all[0] : null;
+ }
+ return screenID;
+};
+
+sysObjObjectEditor.prototype.setStatus = function(Text) {
+ const El = document.getElementById(this.DOMObjectID + '__status');
+ if (El !== null) {
+ El.innerHTML = Text;
+ }
+};
+
+sysObjObjectEditor.prototype.renderScreenList = function() {
+ const Container = document.getElementById(this.DOMObjectID + '__screen-list');
+ if (Container === null) {
+ return;
+ }
+
+ const current = this.getCurrentScreenID();
+ const ScreenIDs = Object.keys(this.getSkeletonStore());
+
+ var html = '';
+ for (const ScreenID of ScreenIDs) {
+ const active = (ScreenID === current) ? ' active' : '';
+ html += '';
+ }
+
+ Container.innerHTML = html;
+};
+
+sysObjObjectEditor.prototype.renderSystemObjectList = function() {
+ const Container = document.getElementById(this.DOMObjectID + '__system-list');
+ if (Container === null) {
+ return;
+ }
+
+ const TypeIDs = Object.keys(sysFactory.SetupClasses).sort();
+ var html = '';
+
+ for (const TypeID of TypeIDs) {
+ const active = (TypeID === this.SelectedSystemType) ? ' active' : '';
+ html += '' + TypeID + '
';
+ }
+
+ Container.innerHTML = html;
+};
+
+sysObjObjectEditor.prototype.renderCanvas = function() {
+ const Canvas = document.getElementById(this.DOMObjectID + '__canvas');
+ if (Canvas === null) {
+ return;
+ }
+
+ const current = this.getCurrentScreenID();
+ if (current == null) {
+ Canvas.innerHTML = 'No screen available.
';
+ return;
+ }
+
+ const roots = this.getChildrenByParentRef(current, current);
+ var html = '';
+ html += '';
+ html += '
' + current + ' (root)
';
+
+ if (roots.length === 0) {
+ html += '
Drop a system object here.
';
+ }
+ else {
+ html += this.renderObjectNodes(current, roots, 0);
+ }
+
+ html += '
';
+
+ Canvas.innerHTML = html;
+};
+
+sysObjObjectEditor.prototype.renderObjectNodes = function(ScreenID, nodes, level) {
+ var html = '';
+
+ for (const NodeID of nodes) {
+ const selected = (NodeID === this.SelectedObjectID) ? ' border-primary' : ' border-secondary';
+
+ html += '';
+ html += '
';
+ html += ' ' + this.getObjectType(NodeID) + '' + NodeID;
+ html += '
';
+
+ const children = this.getChildrenByParentRef(ScreenID, NodeID);
+ if (children.length > 0) {
+ html += this.renderObjectNodes(ScreenID, children, level + 1);
+ }
+
+ html += '
';
+ }
+
+ return html;
+};
+
+sysObjObjectEditor.prototype.getChildrenByParentRef = function(ScreenID, ParentID) {
+ const SkeletonScreen = this.getSkeletonStore()[ScreenID] || [];
+ const Children = [];
+
+ for (const Item of SkeletonScreen) {
+ const ObjectID = Object.keys(Item)[0];
+ const Meta = Item[ObjectID];
+ if (Meta.RefID === ParentID) {
+ Children.push(ObjectID);
+ }
+ }
+
+ return Children;
+};
+
+sysObjObjectEditor.prototype.getObjectType = function(ObjectID) {
+ try {
+ return this.getObjectStore()[ObjectID].Type;
+ }
+ catch (err) {
+ return 'Unknown';
+ }
+};
+
+sysObjObjectEditor.prototype.loadDraftToForm = function() {
+ const TypeEl = document.getElementById(this.DOMObjectID + '__prop-type');
+ const ObjectIDEl = document.getElementById(this.DOMObjectID + '__prop-object-id');
+ const ScreenEl = document.getElementById(this.DOMObjectID + '__prop-screen-id');
+ const ParentEl = document.getElementById(this.DOMObjectID + '__prop-parent-id');
+ const AttributesEl = document.getElementById(this.DOMObjectID + '__prop-attributes');
+
+ if (TypeEl === null || ObjectIDEl === null || ScreenEl === null || ParentEl === null || AttributesEl === null) {
+ return;
+ }
+
+ const current = this.getCurrentScreenID();
+
+ if (this.SelectedObjectID !== null) {
+ const SkeletonMeta = this.getSkeletonMetaByObjectID(current, this.SelectedObjectID);
+ const Config = this.getObjectStore()[this.SelectedObjectID];
+
+ TypeEl.value = Config.Type;
+ ObjectIDEl.value = this.SelectedObjectID;
+ ScreenEl.value = current;
+ ParentEl.value = SkeletonMeta.RefID;
+ AttributesEl.value = JSON.stringify(Config.Attributes || {}, null, 2);
+ return;
+ }
+
+ if (this.DraftObjectID === null) {
+ this.DraftObjectID = this.generateUniqueObjectID(this.SelectedSystemType);
+ }
+
+ TypeEl.value = this.SelectedSystemType;
+ ObjectIDEl.value = this.DraftObjectID;
+ ScreenEl.value = current;
+ ParentEl.value = current;
+ AttributesEl.value = JSON.stringify(this.getDefaultAttributes(this.SelectedSystemType), null, 2);
+};
+
+sysObjObjectEditor.prototype.getDefaultAttributes = function(TypeID) {
+ const Defaults = {
+ 'Div': { 'Style': 'border p-2 mb-2', 'Value': 'New Div' },
+ 'SQLText': { 'Style': 'fw-semibold', 'TextID': 'TXT.SQLTEXT.TEST' },
+ 'ButtonInternal': { 'Style': 'btn btn-sm btn-outline-primary', 'DOMValue': 'Action' },
+ 'Button': { 'Style': 'btn btn-sm btn-primary', 'DOMValue': 'Submit', 'FormValidate': false },
+ 'Link': { 'Style': 'btn btn-sm btn-outline-secondary', 'TextID': 'TXT.MENU.LISTDETAILVIEW.SCREEN1', 'ScreenID': 'Screen1' },
+ 'FormfieldList': { 'Sections': [] },
+ 'List': { 'Style': 'border rounded', 'HeaderRowStyle': 'row fw-bold', 'RowCount': 3, 'Columns': [] },
+ 'TabContainer': { 'Tabs': [{ 'ID': 'Tab1', 'Default': true, 'TextID': 'TXT.BASIC-TABCONTAINER.TAB1', 'Style': 'col-md-12' }] }
+ };
+
+ return (Defaults[TypeID] !== undefined) ? Defaults[TypeID] : {};
+};
+
+sysObjObjectEditor.prototype.getSkeletonMetaByObjectID = function(ScreenID, ObjectID) {
+ const SkeletonScreen = this.getSkeletonStore()[ScreenID] || [];
+
+ for (const Item of SkeletonScreen) {
+ const Key = Object.keys(Item)[0];
+ if (Key === ObjectID) {
+ return Item[Key];
+ }
+ }
+
+ return { 'RefID': ScreenID };
+};
+
+sysObjObjectEditor.prototype.EventListenerClick = function(Event) {
+ const ActionNode = Event.target.closest('[data-editor-action]');
+ if (ActionNode === null) {
+ return;
+ }
+
+ const Action = ActionNode.getAttribute('data-editor-action');
+
+ if (Action === 'add-screen') {
+ this.addScreen();
+ }
+ else if (Action === 'remove-screen') {
+ this.removeCurrentScreen();
+ }
+ else if (Action === 'switch-screen') {
+ this.switchScreen(ActionNode.getAttribute('data-screen-id'));
+ }
+ else if (Action === 'select-system-type') {
+ this.SelectedObjectID = null;
+ this.SelectedSystemType = ActionNode.getAttribute('data-system-type');
+ this.DraftObjectID = this.generateUniqueObjectID(this.SelectedSystemType);
+ this.refreshUI();
+ }
+ else if (Action === 'select-existing-object') {
+ this.SelectedObjectID = ActionNode.getAttribute('data-existing-object-id');
+ this.SelectedSystemType = this.getObjectType(this.SelectedObjectID);
+ this.refreshUI();
+ }
+ else if (Action === 'save-object') {
+ this.saveObjectFromForm();
+ }
+ else if (Action === 'reset-draft') {
+ this.SelectedObjectID = null;
+ this.DraftObjectID = this.generateUniqueObjectID(this.SelectedSystemType);
+ this.refreshUI();
+ }
+};
+
+sysObjObjectEditor.prototype.EventListenerInput = function(Event) {
+ if (this.SelectedObjectID === null) {
+ return;
+ }
+
+ const TargetID = Event.target.id;
+ if (TargetID === this.DOMObjectID + '__prop-attributes') {
+ return;
+ }
+};
+
+sysObjObjectEditor.prototype.saveObjectFromForm = function() {
+ const TypeID = document.getElementById(this.DOMObjectID + '__prop-type').value;
+ var ObjectID = document.getElementById(this.DOMObjectID + '__prop-object-id').value.trim();
+ const ScreenID = document.getElementById(this.DOMObjectID + '__prop-screen-id').value.trim();
+ var ParentID = document.getElementById(this.DOMObjectID + '__prop-parent-id').value.trim();
+ const AttrText = document.getElementById(this.DOMObjectID + '__prop-attributes').value;
+
+ var Attributes;
+
+ try {
+ Attributes = (AttrText.trim() === '') ? {} : JSON.parse(AttrText);
+ }
+ catch (err) {
+ this.setStatus('Invalid Attributes JSON');
+ return;
+ }
+
+ if (ObjectID === '') {
+ ObjectID = this.generateUniqueObjectID(TypeID);
+ }
+
+ if (ParentID === '') {
+ ParentID = ScreenID;
+ }
+
+ if (this.SelectedObjectID !== null && this.SelectedObjectID === ObjectID) {
+ this.updateExistingObject(ObjectID, ScreenID, ParentID, Attributes);
+ return;
+ }
+
+ if (this.getObjectStore()[ObjectID] !== undefined) {
+ this.setStatus('ObjectID already exists');
+ return;
+ }
+
+ this.createObjectOnScreen(TypeID, ObjectID, ScreenID, ParentID, Attributes);
+};
+
+sysObjObjectEditor.prototype.updateExistingObject = function(ObjectID, ScreenID, ParentID, Attributes) {
+ const Config = this.getObjectStore()[ObjectID];
+ Config.Attributes = Attributes;
+
+ const Meta = this.getSkeletonMetaByObjectID(ScreenID, ObjectID);
+ Meta.RefID = ParentID;
+ if (Meta.ElementID !== undefined) {
+ delete Meta.ElementID;
+ }
+
+ const RuntimeObj = sysFactory.getObjectByID(ObjectID);
+ if (RuntimeObj !== undefined && RuntimeObj !== null) {
+ RuntimeObj.JSONConfig.Attributes = Attributes;
+
+ if (Attributes.Style !== undefined) {
+ RuntimeObj.DOMStyle = Attributes.Style;
+ RuntimeObj.setDOMElementStyle();
+ }
+
+ if (Attributes.Value !== undefined || Attributes.DOMValue !== undefined) {
+ RuntimeObj.DOMValue = (Attributes.Value !== undefined) ? Attributes.Value : Attributes.DOMValue;
+ RuntimeObj.setDOMElementValue();
+ }
+ }
+
+ this.rebuildScreen(ScreenID);
+ this.setStatus('Updated object ' + ObjectID);
+ this.refreshUI();
+};
+
+sysObjObjectEditor.prototype.createObjectOnScreen = function(TypeID, ObjectID, ScreenID, ParentID, Attributes) {
+ if (this.getSkeletonStore()[ScreenID] === undefined) {
+ this.setStatus('Unknown ScreenID');
+ return;
+ }
+
+ this.getObjectStore()[ObjectID] = {
+ 'Type': TypeID,
+ 'Attributes': Attributes
+ };
+
+ const NewEntry = {};
+ NewEntry[ObjectID] = { 'RefID': ParentID };
+ this.getSkeletonStore()[ScreenID].push(NewEntry);
+
+ this.rebuildScreen(ScreenID);
+
+ this.SelectedObjectID = ObjectID;
+ this.DraftObjectID = this.generateUniqueObjectID(TypeID);
+
+ this.setStatus('Created object ' + ObjectID);
+ this.refreshUI();
+};
+
+sysObjObjectEditor.prototype.addScreen = function() {
+ const Skeleton = this.getSkeletonStore();
+ var Index = 1;
+ var NewID = 'Screen' + Index;
+
+ while (Skeleton[NewID] !== undefined) {
+ Index += 1;
+ NewID = 'Screen' + Index;
+ }
+
+ Skeleton[NewID] = [];
+
+ const NewScreen = sysFactory.addScreen(NewID, Skeleton[NewID]);
+ NewScreen.setup();
+ sysFactory.switchScreen(NewID);
+
+ this.SelectedObjectID = null;
+ this.setStatus('Added screen ' + NewID);
+ this.refreshUI();
+};
+
+sysObjObjectEditor.prototype.removeCurrentScreen = function() {
+ const Skeleton = this.getSkeletonStore();
+ const ScreenIDs = Object.keys(Skeleton);
+
+ if (ScreenIDs.length <= 1) {
+ this.setStatus('At least one screen must remain');
+ return;
+ }
+
+ const Current = this.getCurrentScreenID();
+ const ScreenObj = sysFactory.getScreenByID(Current);
+
+ if (ScreenObj !== undefined && ScreenObj !== null) {
+ ScreenObj.HierarchyRootObject.removeDOMElement();
+ }
+
+ delete Skeleton[Current];
+ delete sysFactory.Screens[Current];
+
+ const Fallback = Object.keys(Skeleton)[0];
+ sysFactory.switchScreen(Fallback);
+
+ this.SelectedObjectID = null;
+ this.setStatus('Removed screen ' + Current);
+ this.refreshUI();
+};
+
+sysObjObjectEditor.prototype.switchScreen = function(ScreenID) {
+ sysFactory.switchScreen(ScreenID);
+ this.SelectedObjectID = null;
+ this.setStatus('Switched to ' + ScreenID);
+ this.refreshUI();
+};
+
+sysObjObjectEditor.prototype.generateUniqueObjectID = function(TypeID) {
+ const Prefix = (TypeID || 'Object').replace(/[^a-zA-Z0-9]/g, '');
+ const Store = this.getObjectStore();
+
+ var Index = 1;
+ var Candidate = Prefix + Index;
+
+ while (Store[Candidate] !== undefined) {
+ Index += 1;
+ Candidate = Prefix + Index;
+ }
+
+ return Candidate;
+};
+
+sysObjObjectEditor.prototype.rebuildScreen = function(ScreenID) {
+ const ScreenObj = sysFactory.getScreenByID(ScreenID);
+
+ if (ScreenObj === undefined || ScreenObj === null) {
+ return;
+ }
+
+ try {
+ ScreenObj.HierarchyRootObject.removeDOMElement();
+ }
+ catch (err) {
+ }
+
+ ScreenObj.HierarchyRootObject = new sysObjDiv();
+ ScreenObj.HierarchyRootObject.ObjectID = ScreenObj.ScreenID;
+ ScreenObj.HierarchyRootObject.DOMStyle = ScreenObj.CSSStyle;
+
+ ScreenObj.setupObject(ScreenObj.ScreenID, ScreenObj.HierarchyRootObject);
+ ScreenObj.HierarchyRootObject.connectServiceConnectorObjects();
+ ScreenObj.HierarchyRootObject.renderObject();
+ ScreenObj.HierarchyRootObject.processReset();
+ ScreenObj.HierarchyRootObject.processEventListener();
+
+ if (sysFactory.CurrentScreenID !== ScreenID) {
+ ScreenObj.HierarchyRootObject.VisibleState = 'hidden';
+ ScreenObj.HierarchyRootObject.setDOMVisibleState();
+ }
+};
+
+sysObjObjectEditor.prototype.EventListenerDragStart = function(Event) {
+ const SystemTypeNode = Event.target.closest('[data-system-type]');
+ if (SystemTypeNode !== null) {
+ const TypeID = SystemTypeNode.getAttribute('data-system-type');
+ Event.dataTransfer.setData('x0-editor-kind', 'system-type');
+ Event.dataTransfer.setData('x0-editor-system-type', TypeID);
+ return;
+ }
+
+ const ExistingNode = Event.target.closest('[data-existing-object-id]');
+ if (ExistingNode !== null) {
+ const ObjectID = ExistingNode.getAttribute('data-existing-object-id');
+ Event.dataTransfer.setData('x0-editor-kind', 'existing-object');
+ Event.dataTransfer.setData('x0-editor-object-id', ObjectID);
+ return;
+ }
+};
+
+sysObjObjectEditor.prototype.EventListenerDragOver = function(Event) {
+ const DropNode = Event.target.closest('[data-drop-target="1"]');
+ if (DropNode !== null) {
+ Event.preventDefault();
+ }
+};
+
+sysObjObjectEditor.prototype.EventListenerDrop = function(Event) {
+ const DropNode = Event.target.closest('[data-drop-target="1"]');
+ if (DropNode === null) {
+ return;
+ }
+
+ Event.preventDefault();
+
+ const Kind = Event.dataTransfer.getData('x0-editor-kind');
+ const ScreenID = DropNode.getAttribute('data-screen-id');
+ const ParentID = DropNode.getAttribute('data-parent-id');
+
+ if (Kind === 'system-type') {
+ const TypeID = Event.dataTransfer.getData('x0-editor-system-type');
+ const FormType = document.getElementById(this.DOMObjectID + '__prop-type').value;
+ const FormObjectID = document.getElementById(this.DOMObjectID + '__prop-object-id').value.trim();
+ const AttrText = document.getElementById(this.DOMObjectID + '__prop-attributes').value;
+
+ var Attributes = this.getDefaultAttributes(TypeID);
+ try {
+ if (FormType === TypeID && AttrText.trim() !== '') {
+ Attributes = JSON.parse(AttrText);
+ }
+ }
+ catch (err) {
+ }
+
+ var ObjectID = FormObjectID;
+ if (FormType !== TypeID || ObjectID === '' || this.getObjectStore()[ObjectID] !== undefined) {
+ ObjectID = this.generateUniqueObjectID(TypeID);
+ }
+
+ this.createObjectOnScreen(TypeID, ObjectID, ScreenID, ParentID, Attributes);
+ }
+
+ if (Kind === 'existing-object') {
+ const ObjectID = Event.dataTransfer.getData('x0-editor-object-id');
+ this.reparentObject(ScreenID, ObjectID, ParentID);
+ }
+};
+
+sysObjObjectEditor.prototype.reparentObject = function(ScreenID, ObjectID, NewParentID) {
+ if (ObjectID === NewParentID) {
+ this.setStatus('Cannot set object as its own parent');
+ return;
+ }
+
+ if (this.isDescendant(ScreenID, NewParentID, ObjectID) === true) {
+ this.setStatus('Invalid move: target parent is child of object');
+ return;
+ }
+
+ const Meta = this.getSkeletonMetaByObjectID(ScreenID, ObjectID);
+ if (Meta.RefID === NewParentID) {
+ this.setStatus('Object is already connected to this parent');
+ return;
+ }
+
+ Meta.RefID = NewParentID;
+ if (Meta.ElementID !== undefined) {
+ delete Meta.ElementID;
+ }
+
+ this.rebuildScreen(ScreenID);
+ this.SelectedObjectID = ObjectID;
+ this.setStatus('Reparented object ' + ObjectID + ' to ' + NewParentID);
+ this.refreshUI();
+};
+
+sysObjObjectEditor.prototype.isDescendant = function(ScreenID, CandidateParentID, ObjectID) {
+ const ParentMap = {};
+ const SkeletonScreen = this.getSkeletonStore()[ScreenID] || [];
+
+ for (const Item of SkeletonScreen) {
+ const Key = Object.keys(Item)[0];
+ ParentMap[Key] = Item[Key].RefID;
+ }
+
+ var Current = CandidateParentID;
+
+ while (Current !== undefined && Current !== null) {
+ if (Current === ObjectID) {
+ return true;
+ }
+
+ if (Current === ScreenID) {
+ return false;
+ }
+
+ Current = ParentMap[Current];
+ }
+
+ return false;
+};
diff --git a/test/integration/test_editor_builder.py b/test/integration/test_editor_builder.py
new file mode 100644
index 00000000..d1e9e4b1
--- /dev/null
+++ b/test/integration/test_editor_builder.py
@@ -0,0 +1,135 @@
+import os
+import pytest
+import logging
+
+import globalconf
+
+from selenium import webdriver
+from selenium.webdriver.common.by import By
+from selenium.webdriver.support.ui import WebDriverWait
+from selenium.webdriver.support import expected_conditions as EC
+
+logging.basicConfig(level=logging.DEBUG)
+logger = logging.getLogger()
+
+wd_options = webdriver.ChromeOptions()
+wd_options.add_argument('ignore-certificate-errors')
+wd_options.add_argument('headless')
+
+
+@pytest.fixture(scope='module')
+def config():
+ try:
+ run_namespace = os.environ['RUN_NAMESPACE']
+ except Exception:
+ run_namespace = None
+
+ try:
+ run_kube_env = os.environ['KUBERNETES_SERVICE_HOST']
+ except Exception:
+ run_kube_env = None
+
+ try:
+ domain_suffix = '.' + run_namespace
+ except Exception:
+ domain_suffix = ''
+
+ if run_kube_env is not None:
+ domain_suffix += '.svc.cluster.local'
+
+ vhost_test_urls = globalconf.setup()
+
+ selenium_server_url = 'http://selenium-server-0{}:4444'.format(domain_suffix)
+
+ wd = webdriver.Remote(
+ command_executor=selenium_server_url,
+ options=wd_options
+ )
+
+ cfg = {}
+ cfg['timeout'] = 10
+ cfg['driver'] = wd
+ cfg['base_url'] = '{}/python/Index.py'.format(vhost_test_urls['x0-app'])
+
+ return cfg
+
+
+class TestEditorBuilder:
+
+ def test_editor_builder_add_switch_and_dnd_reparent(self, config):
+ d, w = config['driver'], config['timeout']
+ get_url = '{}?appid=test_editor_builder'.format(config['base_url'])
+
+ logger.info('testing editor builder at url: {}'.format(get_url))
+ d.get(get_url)
+
+ wait = WebDriverWait(d, w)
+
+ wait.until(EC.presence_of_element_located((By.ID, 'Screen1_ObjectEditor1__canvas')))
+
+ add_screen = wait.until(
+ EC.presence_of_element_located(
+ (By.CSS_SELECTOR, '#Screen1_ObjectEditor1 [data-editor-action="add-screen"]')
+ )
+ )
+ add_screen.click()
+
+ wait.until(
+ EC.presence_of_element_located(
+ (By.CSS_SELECTOR, '#Screen1_ObjectEditor1 [data-editor-action="switch-screen"][data-screen-id="Screen2"]')
+ )
+ )
+
+ d.execute_script("""
+ var editor = sysFactory.getObjectByID('ObjectEditor1');
+ var root = document.querySelector('#Screen1_ObjectEditor1 [data-drop-target="1"][data-parent-id="Screen2"]');
+
+ editor.EventListenerDrop({
+ preventDefault: function(){},
+ target: root,
+ dataTransfer: {
+ getData: function(key) {
+ if (key === 'x0-editor-kind') return 'system-type';
+ if (key === 'x0-editor-system-type') return 'Div';
+ return '';
+ }
+ }
+ });
+
+ editor.createObjectOnScreen('Div', 'DivEditorA', 'Screen2', 'Screen2', {'Style':'border p-2', 'Value':'A'});
+ editor.createObjectOnScreen('Div', 'DivEditorB', 'Screen2', 'DivEditorA', {'Style':'border p-2', 'Value':'B'});
+
+ var target = document.querySelector('#Screen1_ObjectEditor1 [data-drop-target="1"][data-parent-id="Screen2"]');
+ editor.EventListenerDrop({
+ preventDefault: function(){},
+ target: target,
+ dataTransfer: {
+ getData: function(key) {
+ if (key === 'x0-editor-kind') return 'existing-object';
+ if (key === 'x0-editor-object-id') return 'DivEditorB';
+ return '';
+ }
+ }
+ });
+ return true;
+ """)
+
+ result = d.execute_script("""
+ var screen = sysFactory.DataSkeleton.XMLRPCResultData['Screen2'];
+ var parentMap = {};
+ for (var i=0; i
Date: Thu, 30 Jul 2026 11:01:15 +0000
Subject: [PATCH 2/2] Add integration-tested object editor example and fix XSS
sink
---
example/object_editor/static/sysObjObjectEditor.js | 2 +-
.../config/editor_builder/static/sysObjObjectEditor.js | 2 +-
2 files changed, 2 insertions(+), 2 deletions(-)
diff --git a/example/object_editor/static/sysObjObjectEditor.js b/example/object_editor/static/sysObjObjectEditor.js
index 36f63c8d..9b5426f0 100644
--- a/example/object_editor/static/sysObjObjectEditor.js
+++ b/example/object_editor/static/sysObjObjectEditor.js
@@ -139,7 +139,7 @@ sysObjObjectEditor.prototype.getCurrentScreenID = function() {
sysObjObjectEditor.prototype.setStatus = function(Text) {
const El = document.getElementById(this.DOMObjectID + '__status');
if (El !== null) {
- El.innerHTML = Text;
+ El.textContent = Text;
}
};
diff --git a/test/integration/config/editor_builder/static/sysObjObjectEditor.js b/test/integration/config/editor_builder/static/sysObjObjectEditor.js
index 36f63c8d..9b5426f0 100644
--- a/test/integration/config/editor_builder/static/sysObjObjectEditor.js
+++ b/test/integration/config/editor_builder/static/sysObjObjectEditor.js
@@ -139,7 +139,7 @@ sysObjObjectEditor.prototype.getCurrentScreenID = function() {
sysObjObjectEditor.prototype.setStatus = function(Text) {
const El = document.getElementById(this.DOMObjectID + '__status');
if (El !== null) {
- El.innerHTML = Text;
+ El.textContent = Text;
}
};