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 '' + + '
' + + '
' + + '
' + + '
Main Object Bar
' + + '
' + + '
' + + '
Screen Section
' + + '
' + + ' ' + + ' ' + + '
' + + '
' + + '
' + + '
' + + '
System-Object Section
' + + '
' + + '
' + + '
' + + '
' + + '
' + + '
' + + '
' + + '
System Object Properties
' + + '
' + + '
' + + '
' + + '
' + + '
' + + '
' + + '
' + + ' ' + + ' ' + + '
' + + '
' + + '
' + + '
' + + '
' + + '
' + + '
' + + '
Workspace / Screen Canvas
' + + '
' + + '
' + + '
' + + '
' + + '
' + + '
'; +}; + +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 '' + + '
' + + '
' + + '
' + + '
Main Object Bar
' + + '
' + + '
' + + '
Screen Section
' + + '
' + + ' ' + + ' ' + + '
' + + '
' + + '
' + + '
' + + '
System-Object Section
' + + '
' + + '
' + + '
' + + '
' + + '
' + + '
' + + '
' + + '
System Object Properties
' + + '
' + + '
' + + '
' + + '
' + + '
' + + '
' + + '
' + + ' ' + + ' ' + + '
' + + '
' + + '
' + + '
' + + '
' + + '
' + + '
' + + '
Workspace / Screen Canvas
' + + '
' + + '
' + + '
' + + '
' + + '
' + + '
'; +}; + +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; } };