From a150c8f3242795a95249ace89076ad9be453bf67 Mon Sep 17 00:00:00 2001 From: Stefan Hagspiel Date: Fri, 6 Mar 2026 10:16:25 +0100 Subject: [PATCH 1/7] add support for site-specific custom settings in admin bundle (#60) --- public/js/opendxp/document/tree.js | 488 +++++++++++------- public/js/opendxp/settings/system.js | 479 +++++++++-------- .../Admin/Document/DocumentController.php | 34 +- src/Enum/SiteCustomConfigNodeType.php | 24 + src/Event/AdminEvents.php | 7 + src/Event/SiteCustomSettingsEvent.php | 55 ++ translations/admin_ext.ca.yaml | 1 + translations/admin_ext.cs.yaml | 1 + translations/admin_ext.de.yaml | 1 + translations/admin_ext.en.yaml | 1 + translations/admin_ext.es.yaml | 1 + translations/admin_ext.fr.yaml | 1 + translations/admin_ext.hu.yaml | 1 + translations/admin_ext.it.yaml | 1 + translations/admin_ext.nl.yaml | 1 + translations/admin_ext.pl.yaml | 1 + translations/admin_ext.pt_br.yaml | 1 + translations/admin_ext.ro.yaml | 1 + translations/admin_ext.sk.yaml | 1 + translations/admin_ext.sv.yaml | 1 + translations/admin_ext.th.yaml | 1 + translations/admin_ext.zh_Hans.yaml | 1 + 22 files changed, 669 insertions(+), 434 deletions(-) create mode 100644 src/Enum/SiteCustomConfigNodeType.php create mode 100644 src/Event/SiteCustomSettingsEvent.php diff --git a/public/js/opendxp/document/tree.js b/public/js/opendxp/document/tree.js index 61cda219..b582fc5b 100644 --- a/public/js/opendxp/document/tree.js +++ b/public/js/opendxp/document/tree.js @@ -11,7 +11,7 @@ * @license https://www.gnu.org/licenses/gpl-3.0.html GNU General Public License version 3 (GPLv3) */ - Ext.define('documentreemodel', { +Ext.define('documentreemodel', { extend: 'Ext.data.TreeModel', idProperty: 'id', fields: [{ @@ -31,7 +31,7 @@ opendxp.document.tree = Class.create({ treeDataUrl: null, - initialize: function(config, perspectiveCfg) { + initialize: function (config, perspectiveCfg) { this.treeDataUrl = Routing.generate('opendxp_admin_document_document_treegetchildrenbyid'); this.perspectiveCfg = perspectiveCfg; if (!perspectiveCfg) { @@ -53,8 +53,7 @@ opendxp.document.tree = Class.create({ treeTitle: t('documents'), parentPanel: Ext.getCmp("opendxp_panel_tree_" + this.position) }; - } - else { + } else { this.config = config; } @@ -70,8 +69,9 @@ opendxp.document.tree = Class.create({ }, success: function (response) { var res = Ext.decode(response.responseText); - var callback = function () {}; - if(res["id"]) { + var callback = function () { + }; + if (res["id"]) { callback = this.init.bind(this, res); } opendxp.layout.treepanelmanager.initPanel(this.config.treeId, callback); @@ -80,14 +80,14 @@ opendxp.document.tree = Class.create({ }, - init: function(rootNodeConfig) { + init: function (rootNodeConfig) { var itemsPerPage = opendxp.settings['document_tree_paging_limit']; let rootNodeConfigText = t('home'); let rootNodeConfigIconCls = "opendxp_icon_home"; - if(this.config.customViewId !== undefined && rootNodeConfig.id !== 1) { + if (this.config.customViewId !== undefined && rootNodeConfig.id !== 1) { rootNodeConfigText = rootNodeConfig.key; rootNodeConfigIconCls = rootNodeConfig.iconCls; } @@ -107,7 +107,7 @@ opendxp.document.tree = Class.create({ url: this.treeDataUrl, reader: { type: 'json', - totalProperty : 'total', + totalProperty: 'total', rootProperty: 'nodes' }, @@ -123,15 +123,15 @@ opendxp.document.tree = Class.create({ // documents this.tree = Ext.create('opendxp.tree.Panel', { - selModel : { - mode : 'MULTI' + selModel: { + mode: 'MULTI' }, region: "center", id: this.config.treeId, title: this.config.treeTitle, iconCls: this.config.treeIconCls, cls: this.config['rootVisible'] ? '' : 'opendxp_tree_no_root_node', - autoScroll:true, + autoScroll: true, autoLoad: false, animate: false, containerScroll: true, @@ -153,7 +153,7 @@ opendxp.document.tree = Class.create({ type: "right", handler: opendxp.layout.treepanelmanager.toRight.bind(this), hidden: this.position == "right" - },{ + }, { type: "left", handler: opendxp.layout.treepanelmanager.toLeft.bind(this), hidden: this.position == "left" @@ -208,7 +208,7 @@ opendxp.document.tree = Class.create({ return treeNodeListeners; }, - onTreeNodeClick: function (tree, record, item, index, event, eOpts ) { + onTreeNodeClick: function (tree, record, item, index, event, eOpts) { if (event.ctrlKey === false && event.shiftKey === false && event.altKey === false) { if (record.data.permissions && record.data.permissions.view) { opendxp.helpers.treeNodeThumbnailPreviewHide(); @@ -217,15 +217,14 @@ opendxp.document.tree = Class.create({ } }, - onTreeNodeOver: function (targetNode, position, dragData, e, eOpts ) { + onTreeNodeOver: function (targetNode, position, dragData, e, eOpts) { var node = dragData.records[0]; // check for permission try { if (node.data.permissions.settings) { return true; } - } - catch (e) { + } catch (e) { console.log(e); } @@ -233,7 +232,7 @@ opendxp.document.tree = Class.create({ }, - onTreeNodeMove: function (node, oldParent, newParent, index, eOpts ) { + onTreeNodeMove: function (node, oldParent, newParent, index, eOpts) { var tree = node.getOwnerTree(); if (newParent.pagingData) { @@ -241,7 +240,7 @@ opendxp.document.tree = Class.create({ } var moveCallback = function (newParent, oldParent, tree, response) { - try{ + try { var rdata = Ext.decode(response.responseText); if (rdata && rdata.success) { // set new paths @@ -265,11 +264,10 @@ opendxp.document.tree = Class.create({ } this.updateOpenDocumentPaths(node); - } - else { + } else { tree.loadMask.hide(); opendxp.helpers.showNotification(t("error"), t("cant_move_node_to_target"), - "error",t(rdata.message)); + "error", t(rdata.message)); // we have to delay refresh between two nodes, // as there could be parent child relationship leading to race condition window.setTimeout(function () { @@ -277,7 +275,7 @@ opendxp.document.tree = Class.create({ }, 500); opendxp.elementservice.refreshNode(newParent); } - } catch(e){ + } catch (e) { tree.loadMask.hide(); opendxp.helpers.showNotification(t("error"), t("cant_move_node_to_target"), "error"); // we have to delay refresh between two nodes, @@ -300,7 +298,7 @@ opendxp.document.tree = Class.create({ }, - onTreeNodeBeforeMove: function (node, oldParent, newParent, index, eOpts ) { + onTreeNodeBeforeMove: function (node, oldParent, newParent, index, eOpts) { var tree = node.getOwnerTree(); if (oldParent.getOwnerTree().getId() != newParent.getOwnerTree().getId()) { @@ -316,12 +314,12 @@ opendxp.document.tree = Class.create({ } // check new parent's permission - if(!newParent.data.permissions.create){ + if (!newParent.data.permissions.create) { Ext.MessageBox.alert(' ', t('element_cannot_be_moved')); return false; } - if(opendxp.elementservice.isDisallowedDocumentKey(newParent.id, node.data.text)) { + if (opendxp.elementservice.isDisallowedDocumentKey(newParent.id, node.data.text)) { return false; } @@ -333,17 +331,17 @@ opendxp.document.tree = Class.create({ return false; }, - onTreeNodeContextmenu: function (tree, record, item, index, e, eOpts ) { + onTreeNodeContextmenu: function (tree, record, item, index, e, eOpts) { e.stopEvent(); - if(opendxp.helpers.hasTreeNodeLoadingIndicator("document", record.data.id)) { + if (opendxp.helpers.hasTreeNodeLoadingIndicator("document", record.data.id)) { return; } var menu = new Ext.menu.Menu(); var perspectiveCfg = this.perspectiveCfg; - if(tree.getSelectionModel().getSelected().length > 1) { + if (tree.getSelectionModel().getSelected().length > 1) { var selectedIds = []; tree.getSelectionModel().getSelected().each(function (item) { selectedIds.push(item.id); @@ -359,7 +357,7 @@ opendxp.document.tree = Class.create({ } else { var pasteMenu = []; var pasteInheritanceMenu = []; - var childSupportedDocument = (record.data.type)?opendxp.helpers.documentTypeHasSpecificRole(record.data.type, "children_supported"):false; + var childSupportedDocument = (record.data.type) ? opendxp.helpers.documentTypeHasSpecificRole(record.data.type, "children_supported") : false; if (childSupportedDocument && record.data.permissions && record.data.permissions.create) { @@ -652,7 +650,7 @@ opendxp.document.tree = Class.create({ if (record.data.id != 1 && record.data.permissions && record.data.permissions.publish && !record.data.locked && perspectiveCfg.inTreeContextMenu("document.convert")) { let conversionTargets = []; - if(addDocuments) { + if (addDocuments) { conversionTargets.push({ text: t("page"), iconCls: "opendxp_icon_page", @@ -660,7 +658,7 @@ opendxp.document.tree = Class.create({ hidden: record.data.type == "page" }); } - if(addSnippet) { + if (addSnippet) { conversionTargets.push({ text: t("snippet"), iconCls: "opendxp_icon_snippet", @@ -668,7 +666,7 @@ opendxp.document.tree = Class.create({ hidden: record.data.type == "snippet" || !addSnippet }); } - if(addEmail) { + if (addEmail) { conversionTargets.push({ text: t("email"), iconCls: "opendxp_icon_email", @@ -676,7 +674,7 @@ opendxp.document.tree = Class.create({ hidden: record.data.type == "email" || !addEmail }); } - if(addLink) { + if (addLink) { conversionTargets.push({ text: t("link"), iconCls: "opendxp_icon_link", @@ -684,7 +682,7 @@ opendxp.document.tree = Class.create({ hidden: record.data.type == "link" || !addLink }); } - if(addHardlink) { + if (addHardlink) { conversionTargets.push({ text: t("hardlink"), iconCls: "opendxp_icon_hardlink", @@ -693,7 +691,7 @@ opendxp.document.tree = Class.create({ }); } - if(conversionTargets.length > 0) { + if (conversionTargets.length > 0) { advancedMenuItems.push(new Ext.menu.Item({ text: t('convert_to'), iconCls: "opendxp_icon_convert", @@ -869,7 +867,7 @@ opendxp.document.tree = Class.create({ document.dispatchEvent(prepareDocumentTreeContextMenu); - menu.showAt(e.pageX+1, e.pageY+1); + menu.showAt(e.pageX + 1, e.pageY + 1); }, pasteLanguageDocument: function (tree, record, type, enableInheritance) { @@ -890,8 +888,8 @@ opendxp.document.tree = Class.create({ var websiteLanguages = opendxp.settings.websiteLanguages; var selectContent = ""; - for (var i=0; i
' + t('wildcards_are_supported') + ' (*example.com)', + value: data.domains.join("\n") + }, + { + xtype: 'textfield', + name: 'errorDocument', + fieldCls: 'input_drop_target', + fieldLabel: t('error_page') + ' (' + t('default') + ')', + value: data['errorDocument'], + listeners: { + render: function (el) { + new Ext.dd.DropZone(el.getEl(), { + reference: this, + ddGroup: 'element', + getTargetFromEvent: function (e) { + return this.getEl(); + }.bind(el), + + onNodeOver: function (target, dd, e, data) { + if ( + data.records.length === 1 && + data.records[0].data.elementType === 'document' && + in_array(data.records[0].data.type, ['page', 'link', 'hardlink']) + ) { + return Ext.dd.DropZone.prototype.dropAllowed; + } + }, + + onNodeDrop: function (target, dd, e, data) { + + if (!opendxp.helpers.dragAndDropValidateSingleItem(data)) { + return false; + } + + data = data.records[0].data; + + if ( + data.elementType === 'document' && + in_array(data.type, ['page', 'link', 'hardlink']) + ) { + this.setValue(data.path); + return true; + } + + return false; + }.bind(el) + }); + } + } + }, + { + xtype: 'container', + style: 'margin-top: 20px;', + items: this.renderErrorDocuments(data['localizedErrorDocuments']), + }, + { + xtype: 'checkbox', + name: 'redirectToMainDomain', + fieldLabel: t('redirect_to_main_domain'), + checked: data['redirectToMainDomain'] + }, + { + xtype: 'form', + style: 'margin-top: 20px;', + title: t('site_custom_settings'), + hidden: true, + border: false, + items: [], + listeners: { + render: function (el) { + this.renderSiteCustomSettings(el, data) + }.bind(this) + } + } + ]; + + windowCfg = { width: 600, height: 600, - layout: "fit", - closeAction: "close", + layout: 'fit', + closeAction: 'close', items: [{ autoScroll: true, - xtype: "form", - bodyStyle: "padding: 10px;", + xtype: 'form', + bodyStyle: 'padding: 10px;', defaults: { labelWidth: 250, - width: 550 + width: 560 }, - itemId: "form", - items: [{ - xtype: "textfield", - name: "mainDomain", - fieldLabel: t("main_domain"), - value: data["mainDomain"] - }, { - xtype: "textarea", - name: "domains", - height: 150, - style: "word-wrap: normal;", - fieldLabel: t("additional_domains") + "

" + t("wildcards_are_supported") + " (*example.com)", - value: data.domains.join("\n") - }, { - xtype: "textfield", - name: "errorDocument", - fieldCls: "input_drop_target", - fieldLabel: t("error_page") + " (" + t("default") + ")", - value: data["errorDocument"], - listeners: { - "render": function (el) { - new Ext.dd.DropZone(el.getEl(), { - reference: this, - ddGroup: "element", - getTargetFromEvent: function(e) { - return this.getEl(); - }.bind(el), - - onNodeOver : function(target, dd, e, data) { - if (data.records.length === 1 && data.records[0].data.elementType === "document" && in_array(data.records[0].data.type, ["page", "link", "hardlink"])) { - return Ext.dd.DropZone.prototype.dropAllowed; - } - }, - - onNodeDrop : function (target, dd, e, data) { - - if(!opendxp.helpers.dragAndDropValidateSingleItem(data)) { - return false; - } - - data = data.records[0].data; - if (data.elementType === "document" && in_array(data.type, ["page", "link", "hardlink"])) { - this.setValue(data.path); - return true; - } - return false; - }.bind(el) - }); - } - } - }, { - xtype: "fieldset", - style: "margin-top: 20px;", - items: this.renderErrorDocuments(data["localizedErrorDocuments"]), - },{ - xtype: "checkbox", - name: "redirectToMainDomain", - fieldLabel: t("redirect_to_main_domain"), - checked: data["redirectToMainDomain"] - }] + itemId: 'form', + items: siteItems }], buttons: [{ - text: t("cancel"), - iconCls: "opendxp_icon_cancel", + text: t('cancel'), + iconCls: 'opendxp_icon_cancel', handler: function () { win.close(); } }, { - text: t("apply"), - iconCls: "opendxp_icon_apply", + text: t('apply'), + iconCls: 'opendxp_icon_apply', handler: function () { - var data = win.getComponent("form").getForm().getFieldValues(); - data["id"] = record.id; + + const form = win.getComponent('form').getForm(); + const data = form.getFieldValues(); + + if (!form.isValid()) { + return; + } + + data['id'] = record.id; Ext.Ajax.request({ url: Routing.generate('opendxp_admin_document_document_updatesite'), method: 'PUT', params: data, success: function (tree, record, response) { - var site = Ext.decode(response.responseText); - record.data.site = site; + record.data.site = Ext.decode(response.responseText); tree.getStore().load({ node: record.parentNode }); - opendxp.globalmanager.get("sites").reload(); + opendxp.globalmanager.get('sites').reload(); }.bind(this, tree, record) }); @@ -1304,16 +1339,16 @@ opendxp.document.tree = Class.create({ windowCfg.title = title; } - var win = new Ext.Window(windowCfg); + win = new Ext.Window(windowCfg); win.show(); }, - addDocument : function (tree, record, type, docTypeId) { + addDocument: function (tree, record, type, docTypeId) { var textKeyTitle; var textKeyMessage; - if(type == "page") { + if (type == "page") { textKeyTitle = t("add_page"); textKeyMessage = t("enter_the_name_of_the_new_item"); @@ -1341,13 +1376,13 @@ opendxp.document.tree = Class.create({ pageForm.getComponent("key").setValue(el.getValue()); }.bind(this) } - },{ + }, { xtype: "textfield", itemId: "name", fieldLabel: t('navigation'), name: 'name', width: "100%" - },{ + }, { xtype: "textfield", width: "100%", fieldLabel: t('key'), @@ -1356,10 +1391,10 @@ opendxp.document.tree = Class.create({ }] }); - var submitFunction = function() { + var submitFunction = function () { var params = pageForm.getForm().getFieldValues(); messageBox.close(); - if(params["key"].length >= 1) { + if (params["key"].length >= 1) { params["type"] = type; params["docTypeId"] = docTypeId; this.addDocumentCreate(tree, record, params); @@ -1377,9 +1412,9 @@ opendxp.document.tree = Class.create({ buttons: [{ text: t('OK'), handler: submitFunction.bind(this, tree, record) - },{ + }, { text: t('cancel'), - handler: function() { + handler: function () { messageBox.close(); } }] @@ -1389,7 +1424,7 @@ opendxp.document.tree = Class.create({ var map = new Ext.util.KeyMap({ target: messageBox.getEl(), - key: Ext.event.Event.ENTER, + key: Ext.event.Event.ENTER, fn: submitFunction.bind(this) }); @@ -1453,8 +1488,7 @@ opendxp.document.tree = Class.create({ opendxp.helpers.showNotification(t("success"), t("successful_" + task + "_document"), "success"); - } - else { + } else { opendxp.helpers.showNotification(t("error"), t("error_" + task + "_document"), "error", t(rdata.message)); } @@ -1467,15 +1501,15 @@ opendxp.document.tree = Class.create({ } }, - addDocumentCreate : function (tree, record, params) { + addDocumentCreate: function (tree, record, params) { - if(params["key"]) { + if (params["key"]) { // check for ident filename in current level - if(opendxp.elementservice.isKeyExistingInLevel(record, params["key"])) { + if (opendxp.elementservice.isKeyExistingInLevel(record, params["key"])) { return; } - if(opendxp.elementservice.isDisallowedDocumentKey(record.id, params["key"])) { + if (opendxp.elementservice.isDisallowedDocumentKey(record.id, params["key"])) { return; } @@ -1500,9 +1534,9 @@ opendxp.document.tree = Class.create({ opendxp.elementservice.editElementKey(options); }, - deleteDocument : function (ids) { + deleteDocument: function (ids) { var options = { - "elementType" : "document", + "elementType": "document", "id": ids }; opendxp.elementservice.deleteElement(options); @@ -1510,10 +1544,10 @@ opendxp.document.tree = Class.create({ convert: function (tree, record, type) { Ext.MessageBox.show({ - title:t('are_you_sure'), + title: t('are_you_sure'), msg: t("all_content_will_be_lost"), - buttons: Ext.Msg.OKCANCEL , - icon: Ext.MessageBox.INFO , + buttons: Ext.Msg.OKCANCEL, + icon: Ext.MessageBox.INFO, fn: function (type, button) { if (button == "ok") { @@ -1538,14 +1572,13 @@ opendxp.document.tree = Class.create({ }); }, - searchAndMove: function(tree, record) { + searchAndMove: function (tree, record) { var parentId = record.data.id; - opendxp.helpers.searchAndMove(parentId, function() { + opendxp.helpers.searchAndMove(parentId, function () { opendxp.elementservice.refreshNode(record); }.bind(this), "document"); }, - isKeyValid: function (key) { // key must be at least one character, an maximum 30 characters @@ -1554,13 +1587,13 @@ opendxp.document.tree = Class.create({ } }, - updateOpenDocumentPaths: function(node) { + updateOpenDocumentPaths: function (node) { try { var openTabs = opendxp.helpers.getOpenTab(); for (var i = 0; i < openTabs.length; i++) { - if(openTabs[i].indexOf("document_") == 0 && (openTabs[i].indexOf("_page") || openTabs[i].indexOf("_snippet") || openTabs[i].indexOf("_email"))) { - var documentElement = opendxp.globalmanager.get(openTabs[i].replace(/_page|_snippet|_email/gi,'')); - if(typeof documentElement.data != 'undefined' && documentElement.data.idPath.indexOf("/" + node.data.id) > 0) { + if (openTabs[i].indexOf("document_") == 0 && (openTabs[i].indexOf("_page") || openTabs[i].indexOf("_snippet") || openTabs[i].indexOf("_email"))) { + var documentElement = opendxp.globalmanager.get(openTabs[i].replace(/_page|_snippet|_email/gi, '')); + if (typeof documentElement.data != 'undefined' && documentElement.data.idPath.indexOf("/" + node.data.id) > 0) { documentElement.resetPath(); } } @@ -1570,11 +1603,12 @@ opendxp.document.tree = Class.create({ } }, - renderErrorDocuments: function(localizedErrorDocumentsData) { - var localizedErrorDocumentFields = [] - var availableLanguages = opendxp.available_languages + renderErrorDocuments: function (localizedErrorDocumentsData) { + + var localizedErrorDocumentFields = [], + availableLanguages = opendxp.available_languages, + websiteLanguages = opendxp.settings.websiteLanguages; - var websiteLanguages = opendxp.settings.websiteLanguages; if (websiteLanguages && websiteLanguages.length > 0) { Ext.each(websiteLanguages, function (language) { if (empty(language)) { @@ -1582,35 +1616,31 @@ opendxp.document.tree = Class.create({ } localizedErrorDocumentFields.push({ - fieldLabel: t("error_page") + " (" + availableLanguages[language] + ")", - name: "errorDocument.localized." + language, - fieldCls: "input_drop_target", + fieldLabel: t('error_page') + ' - ' + availableLanguages[language], + name: 'errorDocument.localized.' + language, + fieldCls: 'input_drop_target', value: (localizedErrorDocumentsData && localizedErrorDocumentsData[language]) ? localizedErrorDocumentsData[language] : '', - labelWidth: 200, - width: 500, - xtype: "textfield", + labelWidth: 250, + width: 560, + xtype: 'textfield', listeners: { - "render": function (el) { + render: function (el) { new Ext.dd.DropZone(el.getEl(), { reference: this, - ddGroup: "element", + ddGroup: 'element', getTargetFromEvent: function (e) { return this.getEl(); }.bind(el), - onNodeOver: function (target, dd, e, data) { - if (data.records.length == 1 && data.records[0].data.elementType == "document") { + if (data.records.length === 1 && data.records[0].data.elementType === 'document') { return Ext.dd.DropZone.prototype.dropAllowed; } }, - onNodeDrop: function (target, dd, e, data) { if (opendxp.helpers.dragAndDropValidateSingleItem(data)) { var record = data.records[0]; - var data = record.data; - - if (data.elementType == "document") { - this.setValue(data.path); + if (record.data.elementType === 'document') { + this.setValue(record.data.path); return true; } } @@ -1624,5 +1654,89 @@ opendxp.document.tree = Class.create({ } return localizedErrorDocumentFields; + }, + + renderSiteCustomSettings: function (container, site) { + + const additionalConfigFactory = { + input: (node, nodeValue) => ({ + value: nodeValue, + allowBlank: (node.config.required ?? false) !== true, + }), + text: (node, nodeValue) => ({ + xtype: 'textarea', + grow: true, + value: nodeValue, + allowBlank: (node.config.required ?? false) !== true, + }), + checkbox: (node, nodeValue) => ({ + checked: nodeValue ?? false, + inputValue: node.config.checkedValue ?? true, + uncheckedValue: node.config.uncheckedValue ?? false, + }), + combobox: (node, nodeValue) => ({ + store: node.config.store ?? [], + queryMode: 'local', + displayField: node.config.displayField ?? 'label', + valueField: node.config.valueField ?? 'value', + allowBlank: (node.config.required ?? false) !== true, + editable: false, + forceSelection: true, + value: nodeValue, + }) + }; + + Ext.Ajax.request({ + url: Routing.generate('opendxp_admin_document_document_get_site_custom_settings'), + method: 'POST', + params: { + id: site.id, + }, + success: function (response) { + + const r = Ext.decode(response.responseText); + + if (!Ext.isObject(r.data)) { + return; + } + + Ext.Object.each(r.data, function (scope, settingsBlock) { + + const fieldset = { + xtype: 'fieldset', + title: scope, + items: [] + }; + + Ext.Array.each(settingsBlock, function (configNode) { + + const nodeValue = site.customSettings?.[scope]?.[configNode.name] ?? null;; + + const baseConfig = { + xtype: configNode.type, + fieldLabel: configNode.label, + name: 'customSettings.' + scope + '.' + configNode.name, + labelWidth: 100, + anchor: '100%', + }; + + const additionalConfig = + additionalConfigFactory[configNode.type] + ? additionalConfigFactory[configNode.type](configNode, nodeValue) + : {}; + + const nodeConfig = Ext.apply({}, baseConfig, additionalConfig); + + fieldset.items.push(nodeConfig); + }); + + container.add(fieldset) + }); + + container.setHidden(false); + + }.bind(this) + }); + } }); diff --git a/public/js/opendxp/settings/system.js b/public/js/opendxp/settings/system.js index 31c389bb..36409b1f 100644 --- a/public/js/opendxp/settings/system.js +++ b/public/js/opendxp/settings/system.js @@ -11,14 +11,11 @@ * @license https://www.gnu.org/licenses/gpl-3.0.html GNU General Public License version 3 (GPLv3) */ -opendxp.registerNS("opendxp.settings.system"); -/** - * @private - */ +opendxp.registerNS('opendxp.settings.system'); + opendxp.settings.system = Class.create({ initialize: function () { - this.getData(); }, @@ -29,7 +26,6 @@ opendxp.settings.system = Class.create({ this.data = Ext.decode(response.responseText); - //valid languages try { this.languagesStore = new Ext.data.JsonStore({ autoDestroy: true, @@ -49,7 +45,6 @@ opendxp.settings.system = Class.create({ }); } - this.getTabPanel(); }.bind(this) @@ -58,11 +53,11 @@ opendxp.settings.system = Class.create({ getValue: function (key, ignoreCheck) { - var nk = key.split("\."); - var current = this.data.values; + var nk = key.split('\.'), + current = this.data.values; for (var i = 0; i < nk.length; i++) { - if (typeof current[nk[i]] != "undefined") { + if (typeof current[nk[i]] !== 'undefined') { current = current[nk[i]]; } else { current = null; @@ -70,31 +65,31 @@ opendxp.settings.system = Class.create({ } } - if (ignoreCheck || (typeof current != "object" && typeof current != "array" && typeof current != "function")) { + if (ignoreCheck || (typeof current !== 'object' && typeof current !== 'array' && typeof current !== 'function')) { return current; } - return ""; + return ''; }, getTabPanel: function () { - let urlToCustomImageField = {}; + + var tabPanel; if (!this.panel) { this.panel = Ext.create('Ext.panel.Panel', { - id: "opendxp_settings_system", - title: t("system_settings"), - iconCls: "opendxp_icon_system", + id: 'opendxp_settings_system', + title: t('system_settings'), + iconCls: 'opendxp_icon_system', border: false, - layout: "fit", + layout: 'fit', closable: true }); - this.panel.on("destroy", function () { - opendxp.globalmanager.remove("settings_system"); + this.panel.on('destroy', function () { + opendxp.globalmanager.remove('settings_system'); }.bind(this)); - this.layout = Ext.create('Ext.form.Panel', { bodyStyle: 'padding:20px 5px 20px 5px;', border: false, @@ -108,16 +103,16 @@ opendxp.settings.system = Class.create({ }, buttons: [ { - text: t("save"), + text: t('save'), handler: this.save.bind(this), - iconCls: "opendxp_icon_apply", - disabled: !this.getValue("writeable") + iconCls: 'opendxp_icon_apply', + disabled: !this.getValue('writeable') } ], items: [ { xtype: 'fieldset', - title: t('localization_and_internationalization') + " (i18n/l10n)", + title: t('localization_and_internationalization') + ' (i18n/l10n)', collapsible: true, collapsed: true, autoHeight: true, @@ -125,27 +120,27 @@ opendxp.settings.system = Class.create({ defaultType: 'textfield', defaults: {width: 300}, items: [{ - xtype: "container", + xtype: 'container', html: '' + t('frontend_languages') + '' }, { - xtype: "displayfield", + xtype: 'displayfield', hideLabel: true, width: 600, - value: t('valid_languages_frontend_description') + "

" + t('delete_language_note'), - cls: "opendxp_extra_label_bottom" + value: t('valid_languages_frontend_description') + '

' + t('delete_language_note'), + cls: 'opendxp_extra_label_bottom' }, { - xtype: "fieldset", - layout: "hbox", + xtype: 'fieldset', + layout: 'hbox', border: false, - style: "border-top: none !important", + style: 'border-top: none !important', padding: 0, width: 600, items: [{ labelWidth: 150, - fieldLabel: t("add_language"), - xtype: "combo", - id: "system_settings_general_languageSelection", + fieldLabel: t('add_language'), + xtype: 'combo', + id: 'system_settings_general_languageSelection', triggerAction: 'all', queryMode: 'local', store: this.languagesStore, @@ -156,38 +151,38 @@ opendxp.settings.system = Class.create({ anyMatch: true, width: 450 }, { - xtype: "button", - iconCls: "opendxp_icon_add", + xtype: 'button', + iconCls: 'opendxp_icon_add', handler: function () { - var combo = Ext.getCmp("system_settings_general_languageSelection"); + var combo = Ext.getCmp('system_settings_general_languageSelection'); this.addLanguage(combo.getValue()); }.bind(this) }] }, { - xtype: "hidden", - id: "system_settings_general_validLanguages", + xtype: 'hidden', + id: 'system_settings_general_validLanguages', name: 'general.validLanguages', - value: this.getValue("general.valid_languages", true) + value: this.getValue('general.valid_languages', true) }, { - xtype: "hidden", - id: "system_settings_general_requiredLanguages", + xtype: 'hidden', + id: 'system_settings_general_requiredLanguages', name: 'general.requiredLanguages', - value: this.getValue("general.required_languages", true) + value: this.getValue('general.required_languages', true) }, { - xtype: "hidden", - id: "system_settings_general_defaultLanguage", - name: "general.defaultLanguage", - value: this.getValue("general.default_language") + xtype: 'hidden', + id: 'system_settings_general_defaultLanguage', + name: 'general.defaultLanguage', + value: this.getValue('general.default_language') }, { - xtype: "container", + xtype: 'container', width: 450, - style: "margin-top: 20px;", - id: "system_settings_general_languageContainer", + style: 'margin-top: 20px;', + id: 'system_settings_general_languageContainer', items: [], listeners: { beforerender: function () { // add existing language entries - var locales = this.getValue("general.valid_languages", true); + var locales = this.getValue('general.valid_languages', true); if (locales && locales.length > 0) { Ext.each(locales, this.addLanguage.bind(this)); } @@ -197,7 +192,7 @@ opendxp.settings.system = Class.create({ }, { xtype: 'fieldset', - title: "Debug", + title: 'Debug', collapsible: true, collapsed: true, autoHeight: true, @@ -205,17 +200,17 @@ opendxp.settings.system = Class.create({ defaultType: 'textfield', defaults: {width: 600}, items: [{ - boxLabel: t("debug_admin_translations"), - xtype: "checkbox", - name: "general.debug_admin_translations", - checked: this.getValue("general.debug_admin_translations") + boxLabel: t('debug_admin_translations'), + xtype: 'checkbox', + name: 'general.debug_admin_translations', + checked: this.getValue('general.debug_admin_translations') }, { xtype: 'textfield', width: 650, - fieldLabel: t("email_debug_addresses") + "(CSV)" + ' *', + fieldLabel: t('email_debug_addresses') + '(CSV)' + ' *', name: 'email.debug.emailAddresses', - value: this.getValue("email.debug.email_addresses"), - emptyText: "john@doe.com,jane@doe.com" + value: this.getValue('email.debug.email_addresses'), + emptyText: 'john@doe.com,jane@doe.com' }] }, { @@ -229,34 +224,34 @@ opendxp.settings.system = Class.create({ defaults: {width: 500}, items: [ { - fieldLabel: t("main_domain"), - name: "general.domain", - value: this.getValue("general.domain") + fieldLabel: t('main_domain'), + name: 'general.domain', + value: this.getValue('general.domain') }, { - xtype: "checkbox", - boxLabel: t("redirect_unknown_domains_to_main_domain"), - name: "general.redirect_to_maindomain", - checked: this.getValue("general.redirect_to_maindomain") + xtype: 'checkbox', + boxLabel: t('redirect_unknown_domains_to_main_domain'), + name: 'general.redirect_to_maindomain', + checked: this.getValue('general.redirect_to_maindomain') }, { - fieldLabel: t("error_page") + " (" + t("default") + ")", - name: "documents.error_pages.default", - fieldCls: "input_drop_target", - value: this.getValue("documents.error_pages.default"), + fieldLabel: t('error_page') + ' (' + t('default') + ')', + name: 'documents.error_pages.default', + fieldCls: 'input_drop_target', + value: this.getValue('documents.error_pages.default'), width: 600, - xtype: "textfield", + xtype: 'textfield', listeners: { - "render": function (el) { + render: function (el) { new Ext.dd.DropZone(el.getEl(), { reference: this, - ddGroup: "element", + ddGroup: 'element', getTargetFromEvent: function (e) { return this.getEl(); }.bind(el), onNodeOver: function (target, dd, e, data) { - if (data.records.length == 1 && data.records[0].data.elementType == "document") { + if (data.records.length === 1 && data.records[0].data.elementType === 'document') { return Ext.dd.DropZone.prototype.dropAllowed; } }, @@ -264,10 +259,8 @@ opendxp.settings.system = Class.create({ onNodeDrop: function (target, dd, e, data) { if (opendxp.helpers.dragAndDropValidateSingleItem(data)) { var record = data.records[0]; - var data = record.data; - - if (data.elementType == "document") { - this.setValue(data.path); + if (record.data.elementType === 'document') { + this.setValue(record.data.path); return true; } } @@ -278,15 +271,15 @@ opendxp.settings.system = Class.create({ } }, { - xtype: "container", + xtype: 'container', width: 450, - style: "margin-top: 20px;", - id: "system_settings_errorPage_languageContainer", + style: 'margin-top: 20px;', + id: 'system_settings_errorPage_languageContainer', items: [], listeners: { beforerender: function () { // add existing language entries - var locales = this.getValue("general.valid_languages", true); + var locales = this.getValue('general.valid_languages', true); if (locales && locales.length > 0) { Ext.each(locales, this.addErrorPage.bind(this)); } @@ -303,37 +296,38 @@ opendxp.settings.system = Class.create({ autoHeight: true, labelWidth: 200, defaultType: 'textfield', - defaults: {width: 400}, + defaults: { + width: 400 + }, items: [ { fieldLabel: t('store_version_history_in_days'), name: 'documents.versions.days', - value: this.getValue("documents.versions.days"), - xtype: "numberfield", - id: "system_settings_documents_versions_days", + value: this.getValue('documents.versions.days'), + xtype: 'numberfield', + id: 'system_settings_documents_versions_days', enableKeyEvents: true, listeners: { - "change": this.checkVersionInputs.bind(this, "documents", "days"), - "afterrender": this.checkVersionInputs.bind(this, "documents", "days", "init") + change: this.checkVersionInputs.bind(this, 'documents', 'days'), + afterrender: this.checkVersionInputs.bind(this, 'documents', 'days', 'init') }, minValue: 0 }, { fieldLabel: t('store_version_history_in_steps'), name: 'documents.versions.steps', - value: this.getValue("documents.versions.steps"), - xtype: "numberfield", - id: "system_settings_documents_versions_steps", + value: this.getValue('documents.versions.steps'), + xtype: 'numberfield', + id: 'system_settings_documents_versions_steps', enableKeyEvents: true, listeners: { - "change": this.checkVersionInputs.bind(this, "documents", "steps"), - "afterrender": this.checkVersionInputs.bind(this, "documents", "steps", "init") + change: this.checkVersionInputs.bind(this, 'documents', 'steps'), + afterrender: this.checkVersionInputs.bind(this, 'documents', 'steps', 'init') }, minValue: 0 } ] - } - , + }, { xtype: 'fieldset', title: t('data_objects'), @@ -347,26 +341,26 @@ opendxp.settings.system = Class.create({ { fieldLabel: t('store_version_history_in_days'), name: 'objects.versions.days', - value: this.getValue("objects.versions.days"), - xtype: "numberfield", - id: "system_settings_objects_versions_days", + value: this.getValue('objects.versions.days'), + xtype: 'numberfield', + id: 'system_settings_objects_versions_days', enableKeyEvents: true, listeners: { - "change": this.checkVersionInputs.bind(this, "objects", "days"), - "afterrender": this.checkVersionInputs.bind(this, "objects", "days", "init") + change: this.checkVersionInputs.bind(this, 'objects', 'days'), + afterrender: this.checkVersionInputs.bind(this, 'objects', 'days', 'init') }, minValue: 0 }, { fieldLabel: t('store_version_history_in_steps'), name: 'objects.versions.steps', - value: this.getValue("objects.versions.steps"), - xtype: "numberfield", - id: "system_settings_objects_versions_steps", + value: this.getValue('objects.versions.steps'), + xtype: 'numberfield', + id: 'system_settings_objects_versions_steps', enableKeyEvents: true, listeners: { - "change": this.checkVersionInputs.bind(this, "objects", "steps"), - "afterrender": this.checkVersionInputs.bind(this, "objects", "steps", "init") + change: this.checkVersionInputs.bind(this, 'objects', 'steps'), + afterrender: this.checkVersionInputs.bind(this, 'objects', 'steps', 'init') }, minValue: 0 } @@ -385,13 +379,13 @@ opendxp.settings.system = Class.create({ { fieldLabel: t('store_version_history_in_days'), name: 'assets.versions.days', - value: this.getValue("assets.versions.days"), - xtype: "numberfield", - id: "system_settings_assets_versions_days", + value: this.getValue('assets.versions.days'), + xtype: 'numberfield', + id: 'system_settings_assets_versions_days', enableKeyEvents: true, listeners: { - "change": this.checkVersionInputs.bind(this, "assets", "days"), - "afterrender": this.checkVersionInputs.bind(this, "assets", "days", "init") + change: this.checkVersionInputs.bind(this, 'assets', 'days'), + afterrender: this.checkVersionInputs.bind(this, 'assets', 'days', 'init') }, width: 400, minValue: 0 @@ -399,13 +393,13 @@ opendxp.settings.system = Class.create({ { fieldLabel: t('store_version_history_in_steps'), name: 'assets.versions.steps', - value: this.getValue("assets.versions.steps"), - xtype: "numberfield", - id: "system_settings_assets_versions_steps", + value: this.getValue('assets.versions.steps'), + xtype: 'numberfield', + id: 'system_settings_assets_versions_steps', enableKeyEvents: true, listeners: { - "change": this.checkVersionInputs.bind(this, "assets", "steps"), - "afterrender": this.checkVersionInputs.bind(this, "assets", "steps", "init") + change: this.checkVersionInputs.bind(this, 'assets', 'steps'), + afterrender: this.checkVersionInputs.bind(this, 'assets', 'steps', 'init') }, width: 400, minValue: 0 @@ -417,7 +411,7 @@ opendxp.settings.system = Class.create({ this.panel.add(this.layout); - var tabPanel = Ext.getCmp("opendxp_panel_tabs"); + tabPanel = Ext.getCmp('opendxp_panel_tabs'); tabPanel.add(this.panel); tabPanel.setActiveItem(this.panel); @@ -428,8 +422,8 @@ opendxp.settings.system = Class.create({ }, activate: function () { - var tabPanel = Ext.getCmp("opendxp_panel_tabs"); - tabPanel.setActiveItem("opendxp_settings_system"); + var tabPanel = Ext.getCmp('opendxp_panel_tabs'); + tabPanel.setActiveItem('opendxp_settings_system'); }, save: function () { @@ -440,7 +434,7 @@ opendxp.settings.system = Class.create({ Ext.Ajax.request({ url: Routing.generate('opendxp_admin_settings_setsystem'), - method: "PUT", + method: 'PUT', params: { data: Ext.encode(values) }, @@ -451,52 +445,50 @@ opendxp.settings.system = Class.create({ try { var res = Ext.decode(response.responseText); if (res.success) { - opendxp.helpers.showNotification(t("success"), t("saved_successfully"), "success"); + opendxp.helpers.showNotification(t('success'), t('saved_successfully'), 'success'); - Ext.MessageBox.confirm(t("info"), t("reload_opendxp_changes"), function (buttonValue) { - if (buttonValue == "yes") { + Ext.MessageBox.confirm(t('info'), t('reload_opendxp_changes'), function (buttonValue) { + if (buttonValue === 'yes') { window.location.reload(); } }.bind(this)); } else { - opendxp.helpers.showNotification(t("error"), t("saving_failed"), - "error", t(res.message)); + opendxp.helpers.showNotification(t('error'), t('saving_failed'), + 'error', t(res.message)); } } catch (e) { - opendxp.helpers.showNotification(t("error"), t("saving_failed"), "error"); + opendxp.helpers.showNotification(t('error'), t('saving_failed'), 'error'); } }.bind(this) }); }, - emailMethodSelected: function (type, combo) { - var smtpFieldSet = combo.ownerCt.getComponent(type + "SmtpSettings"); + var smtpFieldSet = combo.ownerCt.getComponent(type + 'SmtpSettings'); - if (combo.getValue() == "smtp") { + if (combo.getValue() === 'smtp') { smtpFieldSet.show(); } else { smtpFieldSet.hide(); - Ext.each(smtpFieldSet.query("textfield"), function (item) { - item.setValue(""); + Ext.each(smtpFieldSet.query('textfield'), function (item) { + item.setValue(''); }); } opendxp.layout.refresh(); - }, smtpAuthSelected: function (type, combo) { - var username = combo.ownerCt.getComponent(type + "_username"); - var pass = combo.ownerCt.getComponent(type + "_password"); + var username = combo.ownerCt.getComponent(type + '_username'); + var pass = combo.ownerCt.getComponent(type + '_password'); if (!combo.getValue()) { username.hide(); pass.hide(); - username.setValue(""); - pass.setValue(""); + username.setValue(''); + pass.setValue(''); } else { username.show(); pass.show(); @@ -505,24 +497,21 @@ opendxp.settings.system = Class.create({ checkVersionInputs: function (elementType, type, field, event) { - var mappingOpposite = { - steps: "days", - days: "steps" - }; - - var value = Ext.getCmp("system_settings_" + elementType + "_versions_" + type).getValue(); + var value = Ext.getCmp('system_settings_' + elementType + '_versions_' + type).getValue(), + mappingOpposite = { + steps: 'days', + days: 'steps' + }; - if (event == "init") { - if (!value) { - return; - } + if (event === 'init' && !value) { + return; } if (value !== null) { - Ext.getCmp("system_settings_" + elementType + "_versions_" + mappingOpposite[type]).disable(); - Ext.getCmp("system_settings_" + elementType + "_versions_" + mappingOpposite[type]).setValue(""); + Ext.getCmp('system_settings_' + elementType + '_versions_' + mappingOpposite[type]).disable(); + Ext.getCmp('system_settings_' + elementType + '_versions_' + mappingOpposite[type]).setValue(''); } else { - Ext.getCmp("system_settings_" + elementType + "_versions_" + mappingOpposite[type]).enable(); + Ext.getCmp('system_settings_' + elementType + '_versions_' + mappingOpposite[type]).enable(); } }, @@ -532,70 +521,70 @@ opendxp.settings.system = Class.create({ return; } - // find the language entry in the store, because "language" can be the display value too - var index = this.languagesStore.findExact("language", language); + // find the language entry in the store, because 'language' can be the display value too + var index = this.languagesStore.findExact('language', language); if (index < 0) { - index = this.languagesStore.findExact("display", language) + index = this.languagesStore.findExact('display', language) } if (index >= 0) { var rec = this.languagesStore.getAt(index); - language = rec.get("language"); + language = rec.get('language'); // add the language to the hidden field used to send the languages to the action - var languageField = Ext.getCmp("system_settings_general_validLanguages"); - var addedLanguages = languageField.getValue().split(","); + var languageField = Ext.getCmp('system_settings_general_validLanguages'); + var addedLanguages = languageField.getValue().split(','); if (!in_array(language, addedLanguages)) { addedLanguages.push(language); - languageField.setValue(addedLanguages.join(",")); + languageField.setValue(addedLanguages.join(',')); } // add the language to the container, so that further settings for the language can be set (eg. fallback, ...) - var container = Ext.getCmp("system_settings_general_languageContainer"); + var container = Ext.getCmp('system_settings_general_languageContainer'); var lang = container.getComponent(language); if (lang) { return; } container.add({ - xtype: "fieldset", + xtype: 'fieldset', itemId: language, - title: rec.get("display"), + title: rec.get('display'), labelWidth: 250, width: 590, - style: "position: relative;", + style: 'position: relative;', items: [{ - xtype: "textfield", + xtype: 'textfield', width: 450, - fieldLabel: t("fallback_languages"), - name: "general.fallbackLanguages." + language, - value: this.getValue("general.fallback_languages." + language) + fieldLabel: t('fallback_languages'), + name: 'general.fallbackLanguages.' + language, + value: this.getValue('general.fallback_languages.' + language) }, { - xtype: "radio", - name: "general.defaultLanguageRadio", - boxLabel: t("default_language"), - checked: this.getValue("general.default_language") == language || (!this.getValue("general.default_language") && container.items.length == 0 ), + xtype: 'radio', + name: 'general.defaultLanguageRadio', + boxLabel: t('default_language'), + checked: this.getValue('general.default_language') == language || (!this.getValue('general.default_language') && container.items.length === 0), listeners: { change: function (el, checked) { if (checked) { - var defaultLanguageField = Ext.getCmp("system_settings_general_defaultLanguage"); + var defaultLanguageField = Ext.getCmp('system_settings_general_defaultLanguage'); defaultLanguageField.setValue(language); } }.bind(this) } }, { - xtype: "checkbox", - name: "general.requiredLanguage", - boxLabel: t("required_language"), - checked: this.getValue("general.required_languages", true).includes(language), + xtype: 'checkbox', + name: 'general.requiredLanguage', + boxLabel: t('required_language'), + checked: this.getValue('general.required_languages', true).includes(language), listeners: { change: function (el, checked) { - var requiredLanguagesField = Ext.getCmp("system_settings_general_requiredLanguages"); + var requiredLanguagesField = Ext.getCmp('system_settings_general_requiredLanguages'); var requiredLanguages = []; - if (requiredLanguagesField.getValue() != '') { - requiredLanguages = requiredLanguagesField.getValue().split(","); + if (requiredLanguagesField.getValue() !== '') { + requiredLanguages = requiredLanguagesField.getValue().split(','); } if (checked) { @@ -608,14 +597,14 @@ opendxp.settings.system = Class.create({ } } - requiredLanguagesField.setValue(requiredLanguages.join(",")); + requiredLanguagesField.setValue(requiredLanguages.join(',')); }.bind(this) } }, { - xtype: "button", - title: t("delete"), - iconCls: "opendxp_icon_delete", - style: "position:absolute; right: 5px; top:40px;", + xtype: 'button', + title: t('delete'), + iconCls: 'opendxp_icon_delete', + style: 'position:absolute; right: 5px; top:40px;', handler: this.removeLanguage.bind(this, language) }] }); @@ -626,33 +615,34 @@ opendxp.settings.system = Class.create({ removeLanguage: function (language) { // remove the language out of the hidden field - var languageField = Ext.getCmp("system_settings_general_validLanguages"); - var addedLanguages = languageField.getValue().split(","); + var languageField = Ext.getCmp('system_settings_general_validLanguages'); + var addedLanguages = languageField.getValue().split(','); if (in_array(language, addedLanguages)) { addedLanguages.splice(array_search(language, addedLanguages), 1); - languageField.setValue(addedLanguages.join(",")); + languageField.setValue(addedLanguages.join(',')); } // remove the required language out of the hidden field - var requiredLanguagesField = Ext.getCmp("system_settings_general_requiredLanguages"); - var addedRequiredLanguages = requiredLanguagesField.getValue().split(","); + var requiredLanguagesField = Ext.getCmp('system_settings_general_requiredLanguages'); + var addedRequiredLanguages = requiredLanguagesField.getValue().split(','); if (in_array(language, addedRequiredLanguages)) { addedRequiredLanguages.splice(array_search(language, addedRequiredLanguages), 1); - requiredLanguagesField.setValue(addedRequiredLanguages.join(",")); + requiredLanguagesField.setValue(addedRequiredLanguages.join(',')); } // remove the default language from hidden field - var defaultLanguageField = Ext.getCmp("system_settings_general_defaultLanguage"); + var defaultLanguageField = Ext.getCmp('system_settings_general_defaultLanguage'); if (defaultLanguageField.getValue() == language) { - defaultLanguageField.setValue(""); + defaultLanguageField.setValue(''); } // remove the language from the container - var container = Ext.getCmp("system_settings_general_languageContainer"); + var container = Ext.getCmp('system_settings_general_languageContainer'); var lang = container.getComponent(language); if (lang) { container.remove(lang); } + container.updateLayout(); }, @@ -662,70 +652,69 @@ opendxp.settings.system = Class.create({ return; } - // find the language entry in the store, because "language" can be the display value too - var index = this.languagesStore.findExact("language", language); + // find the language entry in the store, because 'language' can be the display value too + var index = this.languagesStore.findExact('language', language); if (index < 0) { - index = this.languagesStore.findExact("display", language) + index = this.languagesStore.findExact('display', language) } - if (index >= 0) { + if (index < 0) { + return; + } - var rec = this.languagesStore.getAt(index); - language = rec.get("language"); + var rec = this.languagesStore.getAt(index); + language = rec.get('language'); - var container = Ext.getCmp("system_settings_errorPage_languageContainer"); - var lang = container.getComponent(language); - if (lang) { - return; - } + var container = Ext.getCmp('system_settings_errorPage_languageContainer'); + var lang = container.getComponent(language); + if (lang) { + return; + } - container.add({ - xtype: "fieldset", - itemId: language, - title: rec.get("display"), - labelWidth: 250, - width: 600, - style: "position: relative;", - items: [{ - fieldLabel: t("error_page"), - name: "documents.error_pages.localized." + language, - fieldCls: "input_drop_target", - value: this.getValue("documents.error_pages.localized." + language), - width: 550, - xtype: "textfield", - listeners: { - "render": function (el) { - new Ext.dd.DropZone(el.getEl(), { - reference: this, - ddGroup: "element", - getTargetFromEvent: function (e) { - return this.getEl(); - }.bind(el), - - onNodeOver: function (target, dd, e, data) { - if (data.records.length == 1 && data.records[0].data.elementType == "document") { - return Ext.dd.DropZone.prototype.dropAllowed; + container.add({ + xtype: 'fieldset', + itemId: language, + title: rec.get('display'), + labelWidth: 250, + width: 600, + style: 'position: relative;', + items: [{ + fieldLabel: t('error_page'), + name: 'documents.error_pages.localized.' + language, + fieldCls: 'input_drop_target', + value: this.getValue('documents.error_pages.localized.' + language), + width: 550, + xtype: 'textfield', + listeners: { + render: function (el) { + new Ext.dd.DropZone(el.getEl(), { + reference: this, + ddGroup: 'element', + getTargetFromEvent: function (e) { + return this.getEl(); + }.bind(el), + onNodeOver: function (target, dd, e, data) { + if (data.records.length === 1 && data.records[0].data.elementType === 'document') { + return Ext.dd.DropZone.prototype.dropAllowed; + } + }, + onNodeDrop: function (target, dd, e, data) { + if (opendxp.helpers.dragAndDropValidateSingleItem(data)) { + var record = data.records[0]; + if (record.data.elementType === 'document') { + this.setValue(record.data.path); + return true; } - }, + } + return false; + }.bind(el) + }); + } + } + }] + }); - onNodeDrop: function (target, dd, e, data) { - if (opendxp.helpers.dragAndDropValidateSingleItem(data)) { - var record = data.records[0]; - var data = record.data; + container.updateLayout(); - if (data.elementType == "document") { - this.setValue(data.path); - return true; - } - } - return false; - }.bind(el) - }); - } - } - }] - }); - container.updateLayout(); - } } }); diff --git a/src/Controller/Admin/Document/DocumentController.php b/src/Controller/Admin/Document/DocumentController.php index 49930038..a800cb71 100644 --- a/src/Controller/Admin/Document/DocumentController.php +++ b/src/Controller/Admin/Document/DocumentController.php @@ -23,6 +23,7 @@ use OpenDxp\Bundle\AdminBundle\Controller\Traits\UserNameTrait; use OpenDxp\Bundle\AdminBundle\Event\AdminEvents; use OpenDxp\Bundle\AdminBundle\Event\ElementAdminStyleEvent; +use OpenDxp\Bundle\AdminBundle\Event\SiteCustomSettingsEvent; use OpenDxp\Cache\RuntimeCache; use OpenDxp\Config; use OpenDxp\Controller\KernelControllerEventInterface; @@ -725,8 +726,23 @@ public function publishVersionAction(Request $request): JsonResponse return $this->adminJson(['success' => true, 'treeData' => $treeData]); } + #[Route('/get-site-custom-settings', name: 'opendxp_admin_document_document_get_site_custom_settings', methods: ['POST'])] + public function getSiteCustomSettingsAction(Request $request, EventDispatcherInterface $eventDispatcher): JsonResponse + { + $site = Site::getById($request->request->getInt('id')); + + $event = new SiteCustomSettingsEvent($site); + $eventDispatcher->dispatch($event, AdminEvents::SITE_CUSTOM_SETTINGS); + + $customSettings = $event->getConfigNodes(); + + return $this->adminJson([ + 'data' => $customSettings + ]); + } + #[Route('/update-site', name: 'opendxp_admin_document_document_updatesite', methods: ['PUT'])] - public function updateSiteAction(Request $request): JsonResponse + public function updateSiteAction(Request $request, EventDispatcherInterface $eventDispatcher): JsonResponse { $domains = $request->request->getString('domains'); $domains = str_replace(' ', '', $domains); @@ -743,18 +759,32 @@ public function updateSiteAction(Request $request): JsonResponse foreach ($validLanguages as $language) { // localized error pages - $requestValue = $request->request->get('errorDocument_localized_' . $language); + $requestValue = $request->request->get(sprintf('errorDocument_localized_%s', $language)); if (isset($requestValue)) { $localizedErrorDocuments[$language] = $requestValue; } } + $event = new SiteCustomSettingsEvent($site); + $eventDispatcher->dispatch($event, AdminEvents::SITE_CUSTOM_SETTINGS); + + $customSettings = []; + foreach ($event->getConfigNodes() as $scope => $nodes) { + foreach ($nodes as $node) { + $requestValueName = sprintf('customSettings_%s_%s', $scope, $node['name']); + if ($request->request->has($requestValueName)) { + $customSettings[$scope][$node['name']] = $request->request->get($requestValueName); + } + } + } + $site->setDomains($domains); $site->setMainDomain($request->request->getString('mainDomain')); $site->setErrorDocument($request->request->getString('errorDocument')); $site->setLocalizedErrorDocuments($localizedErrorDocuments); $site->setRedirectToMainDomain($request->request->getBoolean('redirectToMainDomain')); + $site->setCustomSettings(count($customSettings) === 0 ? null : $customSettings); $site->save(); $site->setRootDocument(null); // do not send the document to the frontend diff --git a/src/Enum/SiteCustomConfigNodeType.php b/src/Enum/SiteCustomConfigNodeType.php new file mode 100644 index 00000000..db371dba --- /dev/null +++ b/src/Enum/SiteCustomConfigNodeType.php @@ -0,0 +1,24 @@ +configNodes)) { + $this->configNodes[$scope] = []; + } + + $this->configNodes[$scope][] = [ + 'type' => $type->value, + 'name' => $name, + 'label' => $label, + 'config' => $config, + ]; + } + + public function getConfigNodes(): array + { + return $this->configNodes; + } + + public function getSite(): Site + { + return $this->site; + } +} diff --git a/translations/admin_ext.ca.yaml b/translations/admin_ext.ca.yaml index d20e5c93..4f88ce8e 100644 --- a/translations/admin_ext.ca.yaml +++ b/translations/admin_ext.ca.yaml @@ -531,6 +531,7 @@ main_domain: Main Domain error_page: Error Page additional_domains: Additional Domains (one domain per line) redirect_to_main_domain: Redirect additional domains to main domain +site_custom_settings: Additional Settings debug_mode_on: DEBUG MODE duration: Duration scope: Scope diff --git a/translations/admin_ext.cs.yaml b/translations/admin_ext.cs.yaml index 1bdf8398..a9f3a88c 100644 --- a/translations/admin_ext.cs.yaml +++ b/translations/admin_ext.cs.yaml @@ -551,6 +551,7 @@ error_page: "Chybov\xE1 str\xE1nka" additional_domains: "Dal\u0161\xED dom\xE9ny (jedna na \u0159\xE1dek)" redirect_to_main_domain: "P\u0159esm\u011Brovat dal\u0161\xED dom\xE9ny na hlavn\xED dom\xE9nu" +site_custom_settings: Additional Settings debug_mode_on: "DEBUG m\xF3d" duration: "Trv\xE1n\xED" scope: Kontext (Scope) diff --git a/translations/admin_ext.de.yaml b/translations/admin_ext.de.yaml index a024f5d7..e9aa333d 100644 --- a/translations/admin_ext.de.yaml +++ b/translations/admin_ext.de.yaml @@ -555,6 +555,7 @@ main_domain: Hauptdomain error_page: Fehlerseite additional_domains: "Zus\xE4tzliche Domains (eine Domain pro Zeile)" redirect_to_main_domain: "Leite zus\xE4tzliche Domains auf die Haupt-Domain um." +site_custom_settings: "Zusätzliche Einstellungen" debug_mode_on: DEBUGMODUS duration: Dauer scope: Umfang diff --git a/translations/admin_ext.en.yaml b/translations/admin_ext.en.yaml index 10ec4ab0..614e232d 100644 --- a/translations/admin_ext.en.yaml +++ b/translations/admin_ext.en.yaml @@ -564,6 +564,7 @@ main_domain: Main Domain error_page: Error Page additional_domains: Additional Domains (one domain per line) redirect_to_main_domain: Redirect additional domains to main domain +site_custom_settings: Additional Settings debug_mode_on: DEBUG MODE duration: Duration scope: Scope diff --git a/translations/admin_ext.es.yaml b/translations/admin_ext.es.yaml index 6e6fc35c..53c79a03 100644 --- a/translations/admin_ext.es.yaml +++ b/translations/admin_ext.es.yaml @@ -528,6 +528,7 @@ main_domain: Dominio principal error_page: "P\xE1gina de error" additional_domains: "Dominios adicionales (un dominio por l\xEDnea)" redirect_to_main_domain: Redirije dominios adicionales hacia el dominio principal +site_custom_settings: Additional Settings debug_mode_on: MODO DE DEPURACION duration: "Duraci\xF3n" scope: alcance diff --git a/translations/admin_ext.fr.yaml b/translations/admin_ext.fr.yaml index 7974f53c..7528a45b 100644 --- a/translations/admin_ext.fr.yaml +++ b/translations/admin_ext.fr.yaml @@ -560,6 +560,7 @@ error_page: Page d'erreur additional_domains: "Domaines suppl\xE9mentaires (un domaine par ligne)" redirect_to_main_domain: "Rediriger les domains suppl\xE9mentaires vers le domaine principal" +site_custom_settings: Additional Settings debug_mode_on: MODE DEBUG duration: "Dur\xE9e" scope: "Port\xE9e" diff --git a/translations/admin_ext.hu.yaml b/translations/admin_ext.hu.yaml index adecfdd7..35ac45b7 100644 --- a/translations/admin_ext.hu.yaml +++ b/translations/admin_ext.hu.yaml @@ -557,6 +557,7 @@ error_page: Hiba oldal additional_domains: "Tov\xE1bbi domainek (soronk\xE9nt egy)" redirect_to_main_domain: "A tov\xE1bbi domainek \xE1tir\xE1ny\xEDt\xE1sa a f\u0151 domainre" +site_custom_settings: Additional Settings debug_mode_on: "DEBUG M\xD3D" duration: "Hossz (id\u0151)" scope: Scope diff --git a/translations/admin_ext.it.yaml b/translations/admin_ext.it.yaml index 8eff9d88..aa9b1466 100644 --- a/translations/admin_ext.it.yaml +++ b/translations/admin_ext.it.yaml @@ -541,6 +541,7 @@ main_domain: Dominio principale error_page: Pagina di errore additional_domains: Domini aggiuntivi (un dominio per linea) redirect_to_main_domain: Redireziona domini aggiuntivi verso il dominio principale +site_custom_settings: Additional Settings debug_mode_on: "MODALIT\xC0 DEBUG" duration: Durata scope: Scopo diff --git a/translations/admin_ext.nl.yaml b/translations/admin_ext.nl.yaml index 9ec7b5e0..addfb42f 100644 --- a/translations/admin_ext.nl.yaml +++ b/translations/admin_ext.nl.yaml @@ -536,6 +536,7 @@ main_domain: Hoofddomein error_page: Error-pagina additional_domains: "Extra domeinen (\xE9\xE9n domein per regel)" redirect_to_main_domain: Verwijs extra domeinen naar het hoofddomein +site_custom_settings: Additional Settings debug_mode_on: DEBUG-MODUS duration: Tijdsduur scope: Bereik diff --git a/translations/admin_ext.pl.yaml b/translations/admin_ext.pl.yaml index a083e8ed..e1c47ec1 100644 --- a/translations/admin_ext.pl.yaml +++ b/translations/admin_ext.pl.yaml @@ -546,6 +546,7 @@ main_domain: "Domena g\u0142\xF3wna" error_page: "Strona b\u0142\u0119du" additional_domains: "Dodatkowe domeny (jedna na lini\u0119)" redirect_to_main_domain: "Przekieruj dodatkowe domeny na domen\u0119 g\u0142\xF3wn\u0105" +site_custom_settings: Additional Settings debug_mode_on: TRYB DEBUGOWANIA duration: "Czas dzia\u0142ania" scope: Obszar diff --git a/translations/admin_ext.pt_br.yaml b/translations/admin_ext.pt_br.yaml index 348e3492..2e35ac3b 100644 --- a/translations/admin_ext.pt_br.yaml +++ b/translations/admin_ext.pt_br.yaml @@ -533,6 +533,7 @@ main_domain: Main Domain error_page: Error Page additional_domains: Additional Domains (one domain per line) redirect_to_main_domain: Redirect additional domains to main domain +site_custom_settings: Additional Settings debug_mode_on: DEBUG MODE duration: Duration scope: Scope diff --git a/translations/admin_ext.ro.yaml b/translations/admin_ext.ro.yaml index d20e5c93..4f88ce8e 100644 --- a/translations/admin_ext.ro.yaml +++ b/translations/admin_ext.ro.yaml @@ -531,6 +531,7 @@ main_domain: Main Domain error_page: Error Page additional_domains: Additional Domains (one domain per line) redirect_to_main_domain: Redirect additional domains to main domain +site_custom_settings: Additional Settings debug_mode_on: DEBUG MODE duration: Duration scope: Scope diff --git a/translations/admin_ext.sk.yaml b/translations/admin_ext.sk.yaml index 696d6180..aa83298e 100644 --- a/translations/admin_ext.sk.yaml +++ b/translations/admin_ext.sk.yaml @@ -550,6 +550,7 @@ main_domain: "Hlavn\xE1 dom\xE9na" error_page: "Chybov\xE1 str\xE1nka" additional_domains: "\u010Eal\u0161ie dom\xE9ny (jedna dom\xE9na na riadok)" redirect_to_main_domain: "\u010Eal\u0161ie dom\xE9ny presmerova\u0165 na hlavn\xFA +site_custom_settings: Additional Settings dom\xE9nu" debug_mode_on: "DEBUG m\xF3d" duration: Trvanie diff --git a/translations/admin_ext.sv.yaml b/translations/admin_ext.sv.yaml index de4eb1a2..18925bc8 100644 --- a/translations/admin_ext.sv.yaml +++ b/translations/admin_ext.sv.yaml @@ -531,6 +531,7 @@ main_domain: "Huvuddom\xE4n" error_page: Error Page additional_domains: Additional Domains (one domain per line) redirect_to_main_domain: Redirect additional domains to main domain +site_custom_settings: Additional Settings debug_mode_on: DEBUG MODE duration: "L\xE4ngd" scope: Scope diff --git a/translations/admin_ext.th.yaml b/translations/admin_ext.th.yaml index 000010ca..7d5eb170 100644 --- a/translations/admin_ext.th.yaml +++ b/translations/admin_ext.th.yaml @@ -579,6 +579,7 @@ error_page: "\u0E2B\u0E19\u0E49\u0E32\u0E02\u0E49\u0E2D\u0E1C\u0E34\u0E14\u0E1E\ additional_domains: "\u0E42\u0E14\u0E40\u0E21\u0E19\u0E40\u0E1E\u0E34\u0E48\u0E21\u0E40\u0E15\u0E34\u0E21 (\u0E2B\u0E19\u0E36\u0E48\u0E07\u0E42\u0E14\u0E40\u0E21\u0E19\u0E15\u0E48\u0E2D\u0E1A\u0E23\u0E23\u0E17\u0E31\u0E14)" redirect_to_main_domain: "\u0E40\u0E1B\u0E25\u0E35\u0E48\u0E22\u0E19\u0E40\u0E2A\u0E49\u0E19\u0E17\u0E32\u0E07\u0E42\u0E14\u0E40\u0E21\u0E19\u0E40\u0E1E\u0E34\u0E48\u0E21\u0E40\u0E15\u0E34\u0E21\u0E44\u0E1B\u0E22\u0E31\u0E07\u0E42\u0E14\u0E40\u0E21\u0E19\u0E2B\u0E25\u0E31\u0E01" +site_custom_settings: Additional Settings debug_mode_on: "\u0E42\u0E2B\u0E21\u0E14\u0E14\u0E35\u0E1A\u0E31\u0E01" duration: "\u0E23\u0E30\u0E22\u0E30\u0E40\u0E27\u0E25\u0E32" scope: "\u0E02\u0E2D\u0E1A\u0E40\u0E02\u0E15" diff --git a/translations/admin_ext.zh_Hans.yaml b/translations/admin_ext.zh_Hans.yaml index fb74f944..7943d811 100644 --- a/translations/admin_ext.zh_Hans.yaml +++ b/translations/admin_ext.zh_Hans.yaml @@ -531,6 +531,7 @@ main_domain: Main Domain error_page: Error Page additional_domains: Additional Domains (one domain per line) redirect_to_main_domain: Redirect additional domains to main domain +site_custom_settings: Additional Settings debug_mode_on: DEBUG MODE duration: Duration scope: Scope From 25f63756bf12a377e2711a35d12bce4581511853 Mon Sep 17 00:00:00 2001 From: Stefan Hagspiel Date: Fri, 6 Mar 2026 10:26:21 +0100 Subject: [PATCH 2/7] use single quotes for consistency across opendxp document tree JavaScript file --- public/js/opendxp/document/tree.js | 623 ++++++++++++++--------------- 1 file changed, 306 insertions(+), 317 deletions(-) diff --git a/public/js/opendxp/document/tree.js b/public/js/opendxp/document/tree.js index b582fc5b..70dbd305 100644 --- a/public/js/opendxp/document/tree.js +++ b/public/js/opendxp/document/tree.js @@ -15,15 +15,15 @@ Ext.define('documentreemodel', { extend: 'Ext.data.TreeModel', idProperty: 'id', fields: [{ - name: "id", + name: 'id', convert: undefined }, { - name: "name", + name: 'name', convert: undefined }] }); -opendxp.registerNS("opendxp.document.tree"); +opendxp.registerNS('opendxp.document.tree'); /** * @private */ @@ -36,22 +36,22 @@ opendxp.document.tree = Class.create({ this.perspectiveCfg = perspectiveCfg; if (!perspectiveCfg) { this.perspectiveCfg = { - position: "left" + position: 'left' }; } this.perspectiveCfg = new opendxp.perspective(this.perspectiveCfg); - this.position = this.perspectiveCfg.position ? this.perspectiveCfg.position : "left"; + this.position = this.perspectiveCfg.position ? this.perspectiveCfg.position : 'left'; if (!config) { this.config = { rootId: 1, rootVisible: true, loaderBaseParams: {}, - treeId: "opendxp_panel_tree_documents", - treeIconCls: "opendxp_icon_main_tree_document opendxp_icon_material", + treeId: 'opendxp_panel_tree_documents', + treeIconCls: 'opendxp_icon_main_tree_document opendxp_icon_material', treeTitle: t('documents'), - parentPanel: Ext.getCmp("opendxp_panel_tree_" + this.position) + parentPanel: Ext.getCmp('opendxp_panel_tree_' + this.position) }; } else { this.config = config; @@ -65,13 +65,12 @@ opendxp.document.tree = Class.create({ params: { id: this.config.rootId, view: this.config.customViewId, - elementType: "document" + elementType: 'document' }, success: function (response) { var res = Ext.decode(response.responseText); - var callback = function () { - }; - if (res["id"]) { + var callback = function () {}; + if (res['id']) { callback = this.init.bind(this, res); } opendxp.layout.treepanelmanager.initPanel(this.config.treeId, callback); @@ -86,7 +85,7 @@ opendxp.document.tree = Class.create({ let rootNodeConfigText = t('home'); - let rootNodeConfigIconCls = "opendxp_icon_home"; + let rootNodeConfigIconCls = 'opendxp_icon_home'; if (this.config.customViewId !== undefined && rootNodeConfig.id !== 1) { rootNodeConfigText = rootNodeConfig.key; rootNodeConfigIconCls = rootNodeConfig.iconCls; @@ -95,10 +94,9 @@ opendxp.document.tree = Class.create({ rootNodeConfig.text = rootNodeConfigText; rootNodeConfig.allowDrag = true; rootNodeConfig.iconCls = rootNodeConfigIconCls; - rootNodeConfig.cls = "opendxp_tree_node_root"; + rootNodeConfig.cls = 'opendxp_tree_node_root'; rootNodeConfig.expanded = true; - var store = Ext.create('opendxp.data.PagingTreeStore', { autoLoad: false, autoSync: false, @@ -126,7 +124,7 @@ opendxp.document.tree = Class.create({ selModel: { mode: 'MULTI' }, - region: "center", + region: 'center', id: this.config.treeId, title: this.config.treeTitle, iconCls: this.config.treeIconCls, @@ -142,7 +140,7 @@ opendxp.document.tree = Class.create({ plugins: { ptype: 'treeviewdragdrop', appendOnly: false, - ddGroup: "element" + ddGroup: 'element' }, listeners: { nodedragover: this.onTreeNodeOver.bind(this) @@ -150,13 +148,13 @@ opendxp.document.tree = Class.create({ xtype: 'opendxptreeview' }, tools: [{ - type: "right", + type: 'right', handler: opendxp.layout.treepanelmanager.toRight.bind(this), - hidden: this.position == "right" + hidden: this.position === 'right' }, { - type: "left", + type: 'left', handler: opendxp.layout.treepanelmanager.toLeft.bind(this), - hidden: this.position == "left" + hidden: this.position === 'left' }], // root: rootNodeConfig, store: store, @@ -165,21 +163,20 @@ opendxp.document.tree = Class.create({ this.tree.loadMask = new Ext.LoadMask({ target: this.tree, - msg: t("please_wait") + msg: t('please_wait') }); - this.tree.on("itemmouseenter", opendxp.helpers.treeNodeThumbnailPreview.bind(this)); - this.tree.on("itemmouseleave", opendxp.helpers.treeNodeThumbnailPreviewHide.bind(this)); + this.tree.on('itemmouseenter', opendxp.helpers.treeNodeThumbnailPreview.bind(this)); + this.tree.on('itemmouseleave', opendxp.helpers.treeNodeThumbnailPreviewHide.bind(this)); - store.on("nodebeforeexpand", function (node) { - opendxp.helpers.addTreeNodeLoadingIndicator("document", node.data.id, false); + store.on('nodebeforeexpand', function (node) { + opendxp.helpers.addTreeNodeLoadingIndicator('document', node.data.id, false); }); - store.on("nodeexpand", function (node, index, item, eOpts) { - opendxp.helpers.removeTreeNodeLoadingIndicator("document", node.data.id); + store.on('nodeexpand', function (node, index, item, eOpts) { + opendxp.helpers.removeTreeNodeLoadingIndicator('document', node.data.id); }); - this.config.parentPanel.insert(this.config.index, this.tree); this.config.parentPanel.updateLayout(); @@ -187,25 +184,21 @@ opendxp.document.tree = Class.create({ this.config.parentPanel.alreadyExpanded = true; this.tree.expand(); } - - }, getTreeNodeListeners: function () { - var treeNodeListeners = { - 'itemclick': this.onTreeNodeClick, - "itemcontextmenu": this.onTreeNodeContextmenu.bind(this), - "itemmove": this.onTreeNodeMove.bind(this), - "beforeitemmove": this.onTreeNodeBeforeMove.bind(this), - "itemmouseenter": function (el, record, item, index, e, eOpts) { + return { + itemclick: this.onTreeNodeClick, + itemcontextmenu: this.onTreeNodeContextmenu.bind(this), + itemmove: this.onTreeNodeMove.bind(this), + beforeitemmove: this.onTreeNodeBeforeMove.bind(this), + itemmouseenter: function (el, record, item, index, e, eOpts) { opendxp.helpers.treeToolTipShow(el, record, item); }, - "itemmouseleave": function () { + itemmouseleave: function () { opendxp.helpers.treeToolTipHide(); } }; - - return treeNodeListeners; }, onTreeNodeClick: function (tree, record, item, index, event, eOpts) { @@ -245,19 +238,19 @@ opendxp.document.tree = Class.create({ if (rdata && rdata.success) { // set new paths var newBasePath = newParent.data.path; - if (newBasePath == "/") { - newBasePath = ""; + if (newBasePath === '/') { + newBasePath = ''; } node.data.basePath = newBasePath; - node.data.path = node.data.basePath + "/" + node.data.text; + node.data.path = node.data.basePath + '/' + node.data.text; if (!node.data.published) { - node.data.cls = "opendxp_unpublished"; + node.data.cls = 'opendxp_unpublished'; var view = tree.getView(); var nodeEl = Ext.fly(view.getNodeByRecord(node)); - var nodeElInner = nodeEl.down(".x-grid-td"); + var nodeElInner = nodeEl.down('.x-grid-td'); if (nodeElInner) { - nodeElInner.addCls("opendxp_unpublished"); + nodeElInner.addCls('opendxp_unpublished'); } } else { delete node.data.cls; @@ -266,8 +259,8 @@ opendxp.document.tree = Class.create({ } else { tree.loadMask.hide(); - opendxp.helpers.showNotification(t("error"), t("cant_move_node_to_target"), - "error", t(rdata.message)); + opendxp.helpers.showNotification(t('error'), t('cant_move_node_to_target'), + 'error', t(rdata.message)); // we have to delay refresh between two nodes, // as there could be parent child relationship leading to race condition window.setTimeout(function () { @@ -277,7 +270,7 @@ opendxp.document.tree = Class.create({ } } catch (e) { tree.loadMask.hide(); - opendxp.helpers.showNotification(t("error"), t("cant_move_node_to_target"), "error"); + opendxp.helpers.showNotification(t('error'), t('cant_move_node_to_target'), 'error'); // we have to delay refresh between two nodes, // as there could be parent child relationship leading to race condition window.setTimeout(function () { @@ -297,7 +290,6 @@ opendxp.document.tree = Class.create({ opendxp.elementservice.updateDocument(node.data.id, params, moveCallback); }, - onTreeNodeBeforeMove: function (node, oldParent, newParent, index, eOpts) { var tree = node.getOwnerTree(); @@ -306,7 +298,6 @@ opendxp.document.tree = Class.create({ return false; } - // check for locks if (node.data.locked && oldParent.data.id != newParent.data.id) { Ext.MessageBox.alert(t('locked'), t('element_cannot_be_move_because_it_is_locked')); @@ -328,13 +319,14 @@ opendxp.document.tree = Class.create({ tree.loadMask.show(); return true; } + return false; }, onTreeNodeContextmenu: function (tree, record, item, index, e, eOpts) { e.stopEvent(); - if (opendxp.helpers.hasTreeNodeLoadingIndicator("document", record.data.id)) { + if (opendxp.helpers.hasTreeNodeLoadingIndicator('document', record.data.id)) { return; } @@ -347,28 +339,28 @@ opendxp.document.tree = Class.create({ selectedIds.push(item.id); }); - if (record.data.permissions && record.data.permissions.remove && record.data.id != 1 && !record.data.locked && perspectiveCfg.inTreeContextMenu("document.delete")) { + if (record.data.permissions && record.data.permissions.remove && record.data.id != 1 && !record.data.locked && perspectiveCfg.inTreeContextMenu('document.delete')) { menu.add(new Ext.menu.Item({ text: t('delete'), - iconCls: "opendxp_icon_delete", + iconCls: 'opendxp_icon_delete', handler: this.deleteDocument.bind(this, selectedIds.join(',')) })); } } else { var pasteMenu = []; var pasteInheritanceMenu = []; - var childSupportedDocument = (record.data.type) ? opendxp.helpers.documentTypeHasSpecificRole(record.data.type, "children_supported") : false; + var childSupportedDocument = (record.data.type) ? opendxp.helpers.documentTypeHasSpecificRole(record.data.type, 'children_supported') : false; if (childSupportedDocument && record.data.permissions && record.data.permissions.create) { - var addDocuments = perspectiveCfg.inTreeContextMenu("document.add"); - var addEmail = perspectiveCfg.inTreeContextMenu("document.addEmail"); - var addSnippet = perspectiveCfg.inTreeContextMenu("document.addSnippet"); - var addLink = perspectiveCfg.inTreeContextMenu("document.addLink"); - var addHardlink = perspectiveCfg.inTreeContextMenu("document.addHardlink"); - var addBlankDocument = perspectiveCfg.inTreeContextMenu("document.addBlankDocument"); - var addBlankEmail = perspectiveCfg.inTreeContextMenu("document.addBlankEmail"); - var addBlankSnippet = perspectiveCfg.inTreeContextMenu("document.addBlankSnippet"); + var addDocuments = perspectiveCfg.inTreeContextMenu('document.add'); + var addEmail = perspectiveCfg.inTreeContextMenu('document.addEmail'); + var addSnippet = perspectiveCfg.inTreeContextMenu('document.addSnippet'); + var addLink = perspectiveCfg.inTreeContextMenu('document.addLink'); + var addHardlink = perspectiveCfg.inTreeContextMenu('document.addHardlink'); + var addBlankDocument = perspectiveCfg.inTreeContextMenu('document.addBlankDocument'); + var addBlankEmail = perspectiveCfg.inTreeContextMenu('document.addBlankEmail'); + var addBlankSnippet = perspectiveCfg.inTreeContextMenu('document.addBlankSnippet'); if (addDocuments) { var documentMenu = { @@ -383,35 +375,35 @@ opendxp.document.tree = Class.create({ if (addBlankDocument) { // empty page documentMenu.page.push({ - text: "> " + t("blank"), - iconCls: "opendxp_icon_page opendxp_icon_overlay_add", - handler: this.addDocument.bind(this, tree, record, "page") + text: '> ' + t('blank'), + iconCls: 'opendxp_icon_page opendxp_icon_overlay_add', + handler: this.addDocument.bind(this, tree, record, 'page') }); } if (addBlankSnippet) { // empty snippet documentMenu.snippet.push({ - text: "> " + t("blank"), - iconCls: "opendxp_icon_snippet opendxp_icon_overlay_add", - handler: this.addDocument.bind(this, tree, record, "snippet") + text: '> ' + t('blank'), + iconCls: 'opendxp_icon_snippet opendxp_icon_overlay_add', + handler: this.addDocument.bind(this, tree, record, 'snippet') }); } if (addBlankEmail) { // empty email documentMenu.email.push({ - text: "> " + t("blank"), - iconCls: "opendxp_icon_email opendxp_icon_overlay_add", - handler: this.addDocument.bind(this, tree, record, "email") + text: '> ' + t('blank'), + iconCls: 'opendxp_icon_email opendxp_icon_overlay_add', + handler: this.addDocument.bind(this, tree, record, 'email') }); } //don't add pages below print containers - makes no sense - if (addDocuments && !opendxp.helpers.documentTypeHasSpecificRole(record.data.type, "only_printable_childrens")) { + if (addDocuments && !opendxp.helpers.documentTypeHasSpecificRole(record.data.type, 'only_printable_childrens')) { menu.add(new Ext.menu.Item({ text: t('add_page'), - iconCls: "opendxp_icon_page opendxp_icon_overlay_add", + iconCls: 'opendxp_icon_page opendxp_icon_overlay_add', menu: documentMenu.page, hideOnClick: false })); @@ -420,26 +412,26 @@ opendxp.document.tree = Class.create({ if (addSnippet) { menu.add(new Ext.menu.Item({ text: t('add_snippet'), - iconCls: "opendxp_icon_snippet opendxp_icon_overlay_add", + iconCls: 'opendxp_icon_snippet opendxp_icon_overlay_add', menu: documentMenu.snippet, hideOnClick: false })); } //don't add emails and links below print containers - makes no sense - if (addDocuments && !opendxp.helpers.documentTypeHasSpecificRole(record.data.type, "only_printable_childrens")) { + if (addDocuments && !opendxp.helpers.documentTypeHasSpecificRole(record.data.type, 'only_printable_childrens')) { if (addLink) { menu.add(new Ext.menu.Item({ text: t('add_link'), - iconCls: "opendxp_icon_link opendxp_icon_overlay_add", - handler: this.addDocument.bind(this, tree, record, "link") + iconCls: 'opendxp_icon_link opendxp_icon_overlay_add', + handler: this.addDocument.bind(this, tree, record, 'link') })); } if (addEmail) { menu.add(new Ext.menu.Item({ text: t('add_email'), - iconCls: "opendxp_icon_email opendxp_icon_overlay_add", + iconCls: 'opendxp_icon_email opendxp_icon_overlay_add', menu: documentMenu.email, hideOnClick: false })); @@ -449,102 +441,100 @@ opendxp.document.tree = Class.create({ if (addHardlink) { menu.add(new Ext.menu.Item({ text: t('add_hardlink'), - iconCls: "opendxp_icon_hardlink opendxp_icon_overlay_add", - handler: this.addDocument.bind(this, tree, record, "hardlink") + iconCls: 'opendxp_icon_hardlink opendxp_icon_overlay_add', + handler: this.addDocument.bind(this, tree, record, 'hardlink') })); } } - if (perspectiveCfg.inTreeContextMenu("document.addFolder")) { + if (perspectiveCfg.inTreeContextMenu('document.addFolder')) { menu.add(new Ext.menu.Item({ text: t('create_folder'), - iconCls: "opendxp_icon_folder opendxp_icon_overlay_add", - handler: this.addDocument.bind(this, tree, record, "folder") + iconCls: 'opendxp_icon_folder opendxp_icon_overlay_add', + handler: this.addDocument.bind(this, tree, record, 'folder') })); } - menu.add("-"); - + menu.add('-'); //paste - if (opendxp.cachedDocumentId && record.data.permissions && record.data.permissions.create && perspectiveCfg.inTreeContextMenu("document.paste")) { + if (opendxp.cachedDocumentId && record.data.permissions && record.data.permissions.create && perspectiveCfg.inTreeContextMenu('document.paste')) { pasteMenu.push({ - text: t("paste_recursive_as_child"), - iconCls: "opendxp_icon_paste", - handler: this.pasteInfo.bind(this, tree, record, "recursive") + text: t('paste_recursive_as_child'), + iconCls: 'opendxp_icon_paste', + handler: this.pasteInfo.bind(this, tree, record, 'recursive') }); pasteMenu.push({ - text: t("paste_recursive_updating_references"), - iconCls: "opendxp_icon_paste", - handler: this.pasteInfo.bind(this, tree, record, "recursive-update-references") + text: t('paste_recursive_updating_references'), + iconCls: 'opendxp_icon_paste', + handler: this.pasteInfo.bind(this, tree, record, 'recursive-update-references') }); pasteMenu.push({ - text: t("paste_as_child"), - iconCls: "opendxp_icon_paste", - handler: this.pasteInfo.bind(this, tree, record, "child") + text: t('paste_as_child'), + iconCls: 'opendxp_icon_paste', + handler: this.pasteInfo.bind(this, tree, record, 'child') }); pasteMenu.push({ - text: t("paste_as_language_variant"), - iconCls: "opendxp_icon_paste", - handler: this.pasteLanguageDocument.bind(this, tree, record, "child") + text: t('paste_as_language_variant'), + iconCls: 'opendxp_icon_paste', + handler: this.pasteLanguageDocument.bind(this, tree, record, 'child') }); pasteMenu.push({ - text: t("paste_recursive_as_language_variant"), - iconCls: "opendxp_icon_paste", - handler: this.pasteLanguageDocument.bind(this, tree, record, "recursive") + text: t('paste_recursive_as_language_variant'), + iconCls: 'opendxp_icon_paste', + handler: this.pasteLanguageDocument.bind(this, tree, record, 'recursive') }); pasteMenu.push({ - text: t("paste_recursive_as_language_variant_updating_references"), - iconCls: "opendxp_icon_paste", - handler: this.pasteLanguageDocument.bind(this, tree, record, "recursive-update-references") + text: t('paste_recursive_as_language_variant_updating_references'), + iconCls: 'opendxp_icon_paste', + handler: this.pasteLanguageDocument.bind(this, tree, record, 'recursive-update-references') }); pasteInheritanceMenu.push({ - text: t("paste_recursive_as_child"), - iconCls: "opendxp_icon_paste", - handler: this.pasteInfo.bind(this, tree, record, "recursive", true) + text: t('paste_recursive_as_child'), + iconCls: 'opendxp_icon_paste', + handler: this.pasteInfo.bind(this, tree, record, 'recursive', true) }); pasteInheritanceMenu.push({ - text: t("paste_recursive_updating_references"), - iconCls: "opendxp_icon_paste", - handler: this.pasteInfo.bind(this, tree, record, "recursive-update-references", true) + text: t('paste_recursive_updating_references'), + iconCls: 'opendxp_icon_paste', + handler: this.pasteInfo.bind(this, tree, record, 'recursive-update-references', true) }); pasteInheritanceMenu.push({ - text: t("paste_as_child"), - iconCls: "opendxp_icon_paste", - handler: this.pasteInfo.bind(this, tree, record, "child", true) + text: t('paste_as_child'), + iconCls: 'opendxp_icon_paste', + handler: this.pasteInfo.bind(this, tree, record, 'child', true) }); pasteInheritanceMenu.push({ - text: t("paste_as_language_variant"), - iconCls: "opendxp_icon_paste", - handler: this.pasteLanguageDocument.bind(this, tree, record, "child", true) + text: t('paste_as_language_variant'), + iconCls: 'opendxp_icon_paste', + handler: this.pasteLanguageDocument.bind(this, tree, record, 'child', true) }); pasteInheritanceMenu.push({ - text: t("paste_recursive_as_language_variant"), - iconCls: "opendxp_icon_paste", - handler: this.pasteLanguageDocument.bind(this, tree, record, "recursive", true) + text: t('paste_recursive_as_language_variant'), + iconCls: 'opendxp_icon_paste', + handler: this.pasteLanguageDocument.bind(this, tree, record, 'recursive', true) }); pasteInheritanceMenu.push({ - text: t("paste_recursive_as_language_variant_updating_references"), - iconCls: "opendxp_icon_paste", - handler: this.pasteLanguageDocument.bind(this, tree, record, "recursive-update-references", true) + text: t('paste_recursive_as_language_variant_updating_references'), + iconCls: 'opendxp_icon_paste', + handler: this.pasteLanguageDocument.bind(this, tree, record, 'recursive-update-references', true) }); } } - //paste - if (childSupportedDocument && opendxp.cutDocument && record.data.permissions && record.data.permissions.create && perspectiveCfg.inTreeContextMenu("document.pasteCut")) { + if (childSupportedDocument && opendxp.cutDocument && record.data.permissions && record.data.permissions.create && perspectiveCfg.inTreeContextMenu('document.pasteCut')) { pasteMenu.push({ - text: t("paste_cut_element"), - iconCls: "opendxp_icon_paste", + text: t('paste_cut_element'), + iconCls: 'opendxp_icon_paste', handler: function () { this.pasteCutDocument(opendxp.cutDocument, opendxp.cutDocumentParentNode, record, this.tree); @@ -554,13 +544,13 @@ opendxp.document.tree = Class.create({ }); } - if (opendxp.cachedDocumentId && record.data.permissions && record.data.permissions.create && perspectiveCfg.inTreeContextMenu("document.paste")) { + if (opendxp.cachedDocumentId && record.data.permissions && record.data.permissions.create && perspectiveCfg.inTreeContextMenu('document.paste')) { - if (record.data.type != "folder") { + if (record.data.type !== 'folder') { pasteMenu.push({ - text: t("paste_contents"), - iconCls: "opendxp_icon_paste", - handler: this.pasteInfo.bind(this, tree, record, "replace") + text: t('paste_contents'), + iconCls: 'opendxp_icon_paste', + handler: this.pasteInfo.bind(this, tree, record, 'replace') }); } } @@ -568,7 +558,7 @@ opendxp.document.tree = Class.create({ if (pasteMenu.length > 0) { menu.add(new Ext.menu.Item({ text: t('paste'), - iconCls: "opendxp_icon_paste", + iconCls: 'opendxp_icon_paste', hideOnClick: false, menu: pasteMenu })); @@ -577,66 +567,65 @@ opendxp.document.tree = Class.create({ if (pasteInheritanceMenu.length > 0) { menu.add(new Ext.menu.Item({ text: t('paste_inheritance'), - iconCls: "opendxp_icon_paste", + iconCls: 'opendxp_icon_paste', hideOnClick: false, menu: pasteInheritanceMenu })); } - if (record.data.permissions && record.data.permissions.view && perspectiveCfg.inTreeContextMenu("document.copy")) { + if (record.data.permissions && record.data.permissions.view && perspectiveCfg.inTreeContextMenu('document.copy')) { menu.add(new Ext.menu.Item({ text: t('copy'), - iconCls: "opendxp_icon_copy", + iconCls: 'opendxp_icon_copy', handler: this.copy.bind(this, tree, record) })); } - if (record.data.id != 1 && !record.data.locked && record.data.permissions && record.data.permissions.rename && perspectiveCfg.inTreeContextMenu("document.cut")) { + if (record.data.id != 1 && !record.data.locked && record.data.permissions && record.data.permissions.rename && perspectiveCfg.inTreeContextMenu('document.cut')) { menu.add(new Ext.menu.Item({ text: t('cut'), - iconCls: "opendxp_icon_cut", + iconCls: 'opendxp_icon_cut', handler: this.cut.bind(this, tree, record) })); } - if (record.data.permissions && record.data.permissions.rename && record.data.id != 1 && !record.data.locked && perspectiveCfg.inTreeContextMenu("document.rename")) { + if (record.data.permissions && record.data.permissions.rename && record.data.id != 1 && !record.data.locked && perspectiveCfg.inTreeContextMenu('document.rename')) { menu.add(new Ext.menu.Item({ text: t('rename'), - iconCls: "opendxp_icon_key opendxp_icon_overlay_go", + iconCls: 'opendxp_icon_key opendxp_icon_overlay_go', handler: this.editDocumentKey.bind(this, tree, record) })); } //publish - if (record.data.type != "folder" && !record.data.locked) { - if (record.data.published && record.data.permissions && record.data.permissions.unpublish && perspectiveCfg.inTreeContextMenu("document.unpublish")) { + if (record.data.type !== 'folder' && !record.data.locked) { + if (record.data.published && record.data.permissions && record.data.permissions.unpublish && perspectiveCfg.inTreeContextMenu('document.unpublish')) { menu.add(new Ext.menu.Item({ text: t('unpublish'), - iconCls: "opendxp_icon_unpublish", + iconCls: 'opendxp_icon_unpublish', handler: this.publishDocument.bind(this, tree, record, 'unpublish') })); - } else if (!record.data.published && record.data.permissions && record.data.permissions.publish && perspectiveCfg.inTreeContextMenu("document.publish")) { + } else if (!record.data.published && record.data.permissions && record.data.permissions.publish && perspectiveCfg.inTreeContextMenu('document.publish')) { menu.add(new Ext.menu.Item({ text: t('publish'), - iconCls: "opendxp_icon_publish", + iconCls: 'opendxp_icon_publish', handler: this.publishDocument.bind(this, tree, record, 'publish') })); } } - - if (record.data.permissions && record.data.permissions.remove && record.data.id != 1 && !record.data.locked && perspectiveCfg.inTreeContextMenu("document.delete")) { + if (record.data.permissions && record.data.permissions.remove && record.data.id != 1 && !record.data.locked && perspectiveCfg.inTreeContextMenu('document.delete')) { menu.add(new Ext.menu.Item({ text: t('delete'), - iconCls: "opendxp_icon_delete", + iconCls: 'opendxp_icon_delete', handler: this.deleteDocument.bind(this, record.data.id) })); } - if ((record.data.type == "page" || record.data.type == "hardlink") && record.data.permissions && record.data.permissions.view && perspectiveCfg.inTreeContextMenu("document.open")) { + if ((record.data.type === 'page' || record.data.type === 'hardlink') && record.data.permissions && record.data.permissions.view && perspectiveCfg.inTreeContextMenu('document.open')) { menu.add(new Ext.menu.Item({ text: t('open_in_new_window'), - iconCls: "opendxp_icon_open_window", + iconCls: 'opendxp_icon_open_window', handler: function () { window.open(record.data.url); }.bind(this) @@ -645,56 +634,56 @@ opendxp.document.tree = Class.create({ // advanced menu var advancedMenuItems = []; - var user = opendxp.globalmanager.get("user"); + var user = opendxp.globalmanager.get('user'); - if (record.data.id != 1 && record.data.permissions && record.data.permissions.publish && !record.data.locked && perspectiveCfg.inTreeContextMenu("document.convert")) { + if (record.data.id != 1 && record.data.permissions && record.data.permissions.publish && !record.data.locked && perspectiveCfg.inTreeContextMenu('document.convert')) { let conversionTargets = []; if (addDocuments) { conversionTargets.push({ - text: t("page"), - iconCls: "opendxp_icon_page", - handler: this.convert.bind(this, tree, record, "page"), - hidden: record.data.type == "page" + text: t('page'), + iconCls: 'opendxp_icon_page', + handler: this.convert.bind(this, tree, record, 'page'), + hidden: record.data.type === 'page' }); } if (addSnippet) { conversionTargets.push({ - text: t("snippet"), - iconCls: "opendxp_icon_snippet", - handler: this.convert.bind(this, tree, record, "snippet"), - hidden: record.data.type == "snippet" || !addSnippet + text: t('snippet'), + iconCls: 'opendxp_icon_snippet', + handler: this.convert.bind(this, tree, record, 'snippet'), + hidden: record.data.type === 'snippet' || !addSnippet }); } if (addEmail) { conversionTargets.push({ - text: t("email"), - iconCls: "opendxp_icon_email", - handler: this.convert.bind(this, tree, record, "email"), - hidden: record.data.type == "email" || !addEmail + text: t('email'), + iconCls: 'opendxp_icon_email', + handler: this.convert.bind(this, tree, record, 'email'), + hidden: record.data.type === 'email' || !addEmail }); } if (addLink) { conversionTargets.push({ - text: t("link"), - iconCls: "opendxp_icon_link", - handler: this.convert.bind(this, tree, record, "link"), - hidden: record.data.type == "link" || !addLink + text: t('link'), + iconCls: 'opendxp_icon_link', + handler: this.convert.bind(this, tree, record, 'link'), + hidden: record.data.type === 'link' || !addLink }); } if (addHardlink) { conversionTargets.push({ - text: t("hardlink"), - iconCls: "opendxp_icon_hardlink", - handler: this.convert.bind(this, tree, record, "hardlink"), - hidden: record.data.type == "hardlink" || !addHardlink + text: t('hardlink'), + iconCls: 'opendxp_icon_hardlink', + handler: this.convert.bind(this, tree, record, 'hardlink'), + hidden: record.data.type === 'hardlink' || !addHardlink }); } if (conversionTargets.length > 0) { advancedMenuItems.push(new Ext.menu.Item({ text: t('convert_to'), - iconCls: "opendxp_icon_convert", + iconCls: 'opendxp_icon_convert', hideOnClick: false, menu: conversionTargets })); @@ -704,38 +693,38 @@ opendxp.document.tree = Class.create({ if (childSupportedDocument && record.data.permissions && record.data.permissions.create && - perspectiveCfg.inTreeContextMenu("document.searchAndMove") && + perspectiveCfg.inTreeContextMenu('document.searchAndMove') && opendxp.helpers.hasSearchImplementation()) { advancedMenuItems.push({ text: t('search_and_move'), - iconCls: "opendxp_icon_search opendxp_icon_overlay_go", + iconCls: 'opendxp_icon_search opendxp_icon_overlay_go', handler: this.searchAndMove.bind(this, tree, record) }); } - if (record.data.id != 1 && record.data.type == "page" && (user.admin || user.isAllowed("sites"))) { + if (record.data.id != 1 && record.data.type === 'page' && (user.admin || user.isAllowed('sites'))) { if (!record.data.site) { - if (perspectiveCfg.inTreeContextMenu("document.useAsSite")) { + if (perspectiveCfg.inTreeContextMenu('document.useAsSite')) { advancedMenuItems.push({ - iconCls: "opendxp_icon_site", + iconCls: 'opendxp_icon_site', text: t('use_as_site'), handler: this.addUpdateSite.bind(this, tree, record) }); } } else { - if (perspectiveCfg.inTreeContextMenu("document.editSite")) { + if (perspectiveCfg.inTreeContextMenu('document.editSite')) { advancedMenuItems.push({ text: t('edit_site'), handler: this.addUpdateSite.bind(this, tree, record), - iconCls: "opendxp_icon_edit", + iconCls: 'opendxp_icon_edit', }); } - if (perspectiveCfg.inTreeContextMenu("document.removeSite")) { + if (perspectiveCfg.inTreeContextMenu('document.removeSite')) { advancedMenuItems.push({ text: t('remove_site'), handler: this.removeSite.bind(this, tree, record), - iconCls: "opendxp_icon_delete", + iconCls: 'opendxp_icon_delete', }); } } @@ -745,13 +734,13 @@ opendxp.document.tree = Class.create({ if (record.data.id != 1 && user.admin) { // only admins are allowed to change locks in frontend var lockMenu = []; if (record.data.lockOwner) { // add unlock - if (perspectiveCfg.inTreeContextMenu("document.unlock")) { + if (perspectiveCfg.inTreeContextMenu('document.unlock')) { lockMenu.push({ text: t('unlock'), - iconCls: "opendxp_icon_lock opendxp_icon_overlay_delete", + iconCls: 'opendxp_icon_lock opendxp_icon_overlay_delete', handler: function () { opendxp.elementservice.lockElement({ - elementType: "document", + elementType: 'document', id: record.data.id, mode: null }); @@ -759,30 +748,30 @@ opendxp.document.tree = Class.create({ }); } } else { - if (perspectiveCfg.inTreeContextMenu("document.lock")) { + if (perspectiveCfg.inTreeContextMenu('document.lock')) { lockMenu.push({ text: t('lock'), - iconCls: "opendxp_icon_lock opendxp_icon_overlay_add", + iconCls: 'opendxp_icon_lock opendxp_icon_overlay_add', handler: function () { opendxp.elementservice.lockElement({ - elementType: "document", + elementType: 'document', id: record.data.id, - mode: "self" + mode: 'self' }); }.bind(this) }); } - if (perspectiveCfg.inTreeContextMenu("document.lockAndPropagate")) { - if (record.data.type != "snippet") { + if (perspectiveCfg.inTreeContextMenu('document.lockAndPropagate')) { + if (record.data.type !== 'snippet') { lockMenu.push({ text: t('lock_and_propagate_to_children'), - iconCls: "opendxp_icon_lock opendxp_icon_overlay_go", + iconCls: 'opendxp_icon_lock opendxp_icon_overlay_go', handler: function () { opendxp.elementservice.lockElement({ - elementType: "document", + elementType: 'document', id: record.data.id, - mode: "propagate" + mode: 'propagate' }); }.bind(this) }); @@ -790,14 +779,14 @@ opendxp.document.tree = Class.create({ } } - if (record.data["locked"] && perspectiveCfg.inTreeContextMenu("document.unlockAndPropagate")) { + if (record.data['locked'] && perspectiveCfg.inTreeContextMenu('document.unlockAndPropagate')) { // add unlock and propagate to children functionality lockMenu.push({ text: t('unlock_and_propagate_to_children'), - iconCls: "opendxp_icon_lock opendxp_icon_overlay_delete", + iconCls: 'opendxp_icon_lock opendxp_icon_overlay_delete', handler: function () { opendxp.elementservice.unlockElement({ - elementType: "document", + elementType: 'document', id: record.data.id }); }.bind(this) @@ -807,7 +796,7 @@ opendxp.document.tree = Class.create({ if (lockMenu.length > 0) { advancedMenuItems.push({ text: t('lock'), - iconCls: "opendxp_icon_lock", + iconCls: 'opendxp_icon_lock', hideOnClick: false, menu: lockMenu }); @@ -819,7 +808,7 @@ opendxp.document.tree = Class.create({ if (record.data.expanded) { advancedMenuItems.push({ text: t('collapse_children'), - iconCls: "opendxp_icon_collapse_children", + iconCls: 'opendxp_icon_collapse_children', handler: function () { record.collapse(true); }.bind(this, record) @@ -827,7 +816,7 @@ opendxp.document.tree = Class.create({ } else { advancedMenuItems.push({ text: t('expand_children'), - iconCls: "opendxp_icon_expand_children", + iconCls: 'opendxp_icon_expand_children', handler: function () { record.expand(true); }.bind(this, record) @@ -835,21 +824,21 @@ opendxp.document.tree = Class.create({ } } - menu.add("-"); + menu.add('-'); if (advancedMenuItems.length) { menu.add(new Ext.menu.Item({ text: t('advanced'), - iconCls: "opendxp_icon_more", + iconCls: 'opendxp_icon_more', hideOnClick: false, menu: advancedMenuItems })); } - if (!record.data.leaf && perspectiveCfg.inTreeContextMenu("document.reload")) { + if (!record.data.leaf && perspectiveCfg.inTreeContextMenu('document.reload')) { menu.add(new Ext.menu.Item({ text: t('refresh'), - iconCls: "opendxp_icon_reload", + iconCls: 'opendxp_icon_reload', handler: opendxp.elementservice.refreshNode.bind(this, record) })); } @@ -879,59 +868,59 @@ opendxp.document.tree = Class.create({ success: function (response) { var data = Ext.decode(response.responseText); - if (data.language === "") { - opendxp.helpers.showNotification(t("error"), t("source_document_language_missing"), "error"); + if (data.language === '') { + opendxp.helpers.showNotification(t('error'), t('source_document_language_missing'), 'error'); return false; } var languagestore = []; var websiteLanguages = opendxp.settings.websiteLanguages; - var selectContent = ""; + var selectContent = ''; for (var i = 0; i < websiteLanguages.length; i++) { if (data.language != websiteLanguages[i] && !in_array(websiteLanguages[i], data.translationLinks)) { - selectContent = opendxp.available_languages[websiteLanguages[i]] + " [" + websiteLanguages[i] + "]"; + selectContent = opendxp.available_languages[websiteLanguages[i]] + ' [' + websiteLanguages[i] + ']'; languagestore.push([websiteLanguages[i], selectContent]); } } if (languagestore.length < 1) { - opendxp.helpers.showNotification(t("error"), t("paste_no_new_language_error"), "error"); + opendxp.helpers.showNotification(t('error'), t('paste_no_new_language_error'), 'error'); return false; } var pageForm = new Ext.form.FormPanel({ - title: t("select_language_for_new_document"), + title: t('select_language_for_new_document'), border: false, - bodyStyle: "padding: 10px;", + bodyStyle: 'padding: 10px;', defaults: { labelWidth: 100 }, items: [{ - xtype: "combo", - name: "language", + xtype: 'combo', + name: 'language', fieldLabel: t('language'), store: languagestore, editable: false, triggerAction: 'all', - mode: "local", + mode: 'local', }] }); var win = new Ext.Window({ width: 350, - bodyStyle: "padding: 0px 0px 10px 0px", + bodyStyle: 'padding: 0px 0px 10px 0px', items: [pageForm], - title: t("paste_as_language_variant"), + title: t('paste_as_language_variant'), buttons: [{ - text: t("cancel"), - iconCls: "opendxp_icon_cancel", + text: t('cancel'), + iconCls: 'opendxp_icon_cancel', handler: function () { win.close(); } }, { - text: t("apply"), - iconCls: "opendxp_icon_apply", + text: t('apply'), + iconCls: 'opendxp_icon_apply', handler: function () { var params = pageForm.getForm().getFieldValues(); @@ -949,7 +938,7 @@ opendxp.document.tree = Class.create({ }, populatePredefinedDocumentTypes: function (documentMenu, tree, record) { - var document_types = opendxp.globalmanager.get("document_types_store"); + var document_types = opendxp.globalmanager.get('document_types_store'); var groups = { page: {}, @@ -965,28 +954,28 @@ opendxp.document.tree = Class.create({ document_types.each(function (documentMenu, typeRecord) { menuOption = undefined; - var text = Ext.util.Format.htmlEncode(typeRecord.get("translatedName")); - if (typeRecord.get("type") == "page") { + var text = Ext.util.Format.htmlEncode(typeRecord.get('translatedName')); + if (typeRecord.get('type') === 'page') { docTypeMenu = { text: text, - iconCls: "opendxp_icon_page opendxp_icon_overlay_add", - handler: this.addDocument.bind(this, tree, record, "page", typeRecord.get("id")) + iconCls: 'opendxp_icon_page opendxp_icon_overlay_add', + handler: this.addDocument.bind(this, tree, record, 'page', typeRecord.get('id')) }; - menuOption = "page"; - } else if (typeRecord.get("type") == "snippet") { + menuOption = 'page'; + } else if (typeRecord.get('type') === 'snippet') { docTypeMenu = { text: text, - iconCls: "opendxp_icon_snippet opendxp_icon_overlay_add", - handler: this.addDocument.bind(this, tree, record, "snippet", typeRecord.get("id")) + iconCls: 'opendxp_icon_snippet opendxp_icon_overlay_add', + handler: this.addDocument.bind(this, tree, record, 'snippet', typeRecord.get('id')) }; - menuOption = "snippet"; - } else if (typeRecord.get("type") == "email") { + menuOption = 'snippet'; + } else if (typeRecord.get('type') === 'email') { docTypeMenu = { text: text, - iconCls: "opendxp_icon_email opendxp_icon_overlay_add", - handler: this.addDocument.bind(this, tree, record, "email", typeRecord.get("id")) + iconCls: 'opendxp_icon_email opendxp_icon_overlay_add', + handler: this.addDocument.bind(this, tree, record, 'email', typeRecord.get('id')) }; - menuOption = "email"; + menuOption = 'email'; } if (menuOption === undefined) { @@ -994,20 +983,20 @@ opendxp.document.tree = Class.create({ } // check if the class is within a group - if (typeRecord.get("group")) { + if (typeRecord.get('group')) { - if (!groups[menuOption][typeRecord.get("group")]) { - groups[menuOption][typeRecord.get("group")] = { - text: Ext.util.Format.htmlEncode(typeRecord.get("translatedGroup")), - iconCls: "opendxp_icon_folder", + if (!groups[menuOption][typeRecord.get('group')]) { + groups[menuOption][typeRecord.get('group')] = { + text: Ext.util.Format.htmlEncode(typeRecord.get('translatedGroup')), + iconCls: 'opendxp_icon_folder', hideOnClick: false, menu: { items: [] } }; - documentMenu[menuOption].push(groups[menuOption][typeRecord.get("group")]); + documentMenu[menuOption].push(groups[menuOption][typeRecord.get('group')]); } - groups[menuOption][typeRecord.get("group")]["menu"]["items"].push(docTypeMenu); + groups[menuOption][typeRecord.get('group')]['menu']['items'].push(docTypeMenu); } else { documentMenu[menuOption].push(docTypeMenu); } @@ -1036,22 +1025,22 @@ opendxp.document.tree = Class.create({ if (rdata && rdata.success) { // set new pathes var newBasePath = newParent.data.path; - if (newBasePath == "/") { - newBasePath = ""; + if (newBasePath === '/') { + newBasePath = ''; } document.data.basePath = newBasePath; - document.data.path = document.data.basePath + "/" + document.data.text; + document.data.path = document.data.basePath + '/' + document.data.text; } else { tree.loadMask.hide(); - opendxp.helpers.showNotification(t("error"), t("cant_move_node_to_target"), "error", t(rdata.message)); + opendxp.helpers.showNotification(t('error'), t('cant_move_node_to_target'), 'error', t(rdata.message)); } } catch (e) { - opendxp.helpers.showNotification(t("error"), t("cant_move_node_to_target"), "error"); + opendxp.helpers.showNotification(t('error'), t('cant_move_node_to_target'), 'error'); } - opendxp.elementservice.refreshNodeAllTrees("document", oldParent.id); - opendxp.elementservice.refreshNodeAllTrees("document", newParent.id); + opendxp.elementservice.refreshNodeAllTrees('document', oldParent.id); + opendxp.elementservice.refreshNodeAllTrees('document', newParent.id); newParent.expand(); this.tree.loadMask.hide(); this.updateOpenDocumentPaths(document); @@ -1061,7 +1050,7 @@ opendxp.document.tree = Class.create({ }, pasteInfo: function (tree, record, type, enableInheritance, language) { - opendxp.helpers.addTreeNodeLoadingIndicator("document", record.get('id')); + opendxp.helpers.addTreeNodeLoadingIndicator('document', record.get('id')); if (enableInheritance !== true) { enableInheritance = false; } @@ -1073,7 +1062,7 @@ opendxp.document.tree = Class.create({ enableInheritance: enableInheritance }; - if (typeof language === "string") { + if (typeof language === 'string') { params.language = language; } @@ -1096,10 +1085,10 @@ opendxp.document.tree = Class.create({ }); record.pasteWindow = new Ext.Window({ - title: t("paste"), + title: t('paste'), layout: 'fit', width: 200, - bodyStyle: "padding: 10px;", + bodyStyle: 'padding: 10px;', closable: false, plain: true, items: [record.pasteProgressBar], @@ -1116,27 +1105,27 @@ opendxp.document.tree = Class.create({ this.pasteComplete(record); } catch (e) { console.log(e); - opendxp.helpers.showNotification(t("error"), t("error_pasting_item"), "error"); - opendxp.elementservice.refreshNodeAllTrees("document", record.id); + opendxp.helpers.showNotification(t('error'), t('error_pasting_item'), 'error'); + opendxp.elementservice.refreshNodeAllTrees('document', record.id); } }.bind(this), update: function (currentStep, steps, percent) { if (record.pasteProgressBar) { var status = currentStep / steps; - record.pasteProgressBar.updateProgress(status, percent + "%"); + record.pasteProgressBar.updateProgress(status, percent + '%'); } }.bind(this), failure: function (message) { record.pasteWindow.close(); record.pasteProgressBar = null; - opendxp.helpers.showNotification(t("error"), t("error_pasting_item"), "error", t(message)); - opendxp.elementservice.refreshNodeAllTrees("document", record.id); + opendxp.helpers.showNotification(t('error'), t('error_pasting_item'), 'error', t(message)); + opendxp.elementservice.refreshNodeAllTrees('document', record.id); }.bind(this), jobs: res.pastejobs }); } else { - throw "There are no pasting jobs"; + throw 'There are no pasting jobs'; } } catch (e) { Ext.MessageBox.alert(t('error'), e); @@ -1153,8 +1142,8 @@ opendxp.document.tree = Class.create({ node.pasteWindow = null; //this.tree.loadMask.hide(); - opendxp.helpers.removeTreeNodeLoadingIndicator("document", node.id); - opendxp.elementservice.refreshNodeAllTrees("document", node.id); + opendxp.helpers.removeTreeNodeLoadingIndicator('document', node.id); + opendxp.elementservice.refreshNodeAllTrees('document', node.id); }, removeSite: function (tree, record) { @@ -1165,7 +1154,7 @@ opendxp.document.tree = Class.create({ id: record.data.id }, success: function () { - opendxp.globalmanager.get("sites").reload(); + opendxp.globalmanager.get('sites').reload(); opendxp.elementservice.refreshNode(record.parentNode); }.bind(this) }); @@ -1348,22 +1337,22 @@ opendxp.document.tree = Class.create({ var textKeyTitle; var textKeyMessage; - if (type == "page") { + if (type === 'page') { - textKeyTitle = t("add_page"); - textKeyMessage = t("enter_the_name_of_the_new_item"); + textKeyTitle = t('add_page'); + textKeyMessage = t('enter_the_name_of_the_new_item'); //create a custom form var pageForm = new Ext.form.FormPanel({ title: textKeyMessage, border: false, - bodyStyle: "padding: 10px;", + bodyStyle: 'padding: 10px;', items: [{ - xtype: "textfield", - itemId: "title", + xtype: 'textfield', + itemId: 'title', fieldLabel: t('title'), name: 'title', - width: "100%", + width: '100%', enableKeyEvents: true, listeners: { afterrender: function () { @@ -1372,21 +1361,21 @@ opendxp.document.tree = Class.create({ }.bind(this), 100); }, keyup: function (el) { - pageForm.getComponent("name").setValue(el.getValue()); - pageForm.getComponent("key").setValue(el.getValue()); + pageForm.getComponent('name').setValue(el.getValue()); + pageForm.getComponent('key').setValue(el.getValue()); }.bind(this) } }, { - xtype: "textfield", - itemId: "name", + xtype: 'textfield', + itemId: 'name', fieldLabel: t('navigation'), name: 'name', - width: "100%" + width: '100%' }, { - xtype: "textfield", - width: "100%", + xtype: 'textfield', + width: '100%', fieldLabel: t('key'), - itemId: "key", + itemId: 'key', name: 'key' }] }); @@ -1394,9 +1383,9 @@ opendxp.document.tree = Class.create({ var submitFunction = function () { var params = pageForm.getForm().getFieldValues(); messageBox.close(); - if (params["key"].length >= 1) { - params["type"] = type; - params["docTypeId"] = docTypeId; + if (params['key'].length >= 1) { + params['type'] = type; + params['docTypeId'] = docTypeId; this.addDocumentCreate(tree, record, params); } else { return; //ignore @@ -1430,16 +1419,16 @@ opendxp.document.tree = Class.create({ } else { - if (type == "folder") { - textKeyTitle = t("create_folder"); - textKeyMessage = t("enter_the_name_of_the_new_item"); + if (type === 'folder') { + textKeyTitle = t('create_folder'); + textKeyMessage = t('enter_the_name_of_the_new_item'); } else { - textKeyTitle = t("add_" + type); - textKeyMessage = t("enter_the_name_of_the_new_item"); + textKeyTitle = t('add_' + type); + textKeyMessage = t('enter_the_name_of_the_new_item'); } Ext.MessageBox.prompt(textKeyTitle, textKeyMessage, function (tree, record, type, docTypeId, button, value, object) { - if (button == "ok") { + if (button === 'ok') { this.addDocumentCreate( tree, record, @@ -1460,10 +1449,10 @@ opendxp.document.tree = Class.create({ var parameters = {}; parameters.id = id; - var doc = opendxp.globalmanager.get("document_" + id); + var doc = opendxp.globalmanager.get('document_' + id); if (doc) { - if (task == "publish") { + if (task === 'publish') { doc.publish(false); } else { doc.unpublish(false); @@ -1471,29 +1460,29 @@ opendxp.document.tree = Class.create({ } else { Ext.Ajax.request({ url: Routing.generate('opendxp_admin_document_' + type + '_save', {task: task}), - method: "PUT", + method: 'PUT', params: parameters, success: function (task, response) { try { var rdata = Ext.decode(response.responseText); if (rdata && rdata.success) { var options = { - elementType: "document", + elementType: 'document', id: record.data.id, - published: task != "unpublish" + published: task !== 'unpublish' }; opendxp.elementservice.setElementPublishedState(options); opendxp.elementservice.setElementToolbarButtons(options); opendxp.elementservice.reloadVersions(options); - opendxp.helpers.showNotification(t("success"), t("successful_" + task + "_document"), - "success"); + opendxp.helpers.showNotification(t('success'), t('successful_' + task + '_document'), + 'success'); } else { - opendxp.helpers.showNotification(t("error"), t("error_" + task + "_document"), - "error", t(rdata.message)); + opendxp.helpers.showNotification(t('error'), t('error_' + task + '_document'), + 'error', t(rdata.message)); } } catch (e) { - opendxp.helpers.showNotification(t("error"), t("error_" + task + "_document"), "error"); + opendxp.helpers.showNotification(t('error'), t('error_' + task + '_document'), 'error'); } }.bind(this, task) @@ -1503,22 +1492,22 @@ opendxp.document.tree = Class.create({ addDocumentCreate: function (tree, record, params) { - if (params["key"]) { + if (params['key']) { // check for ident filename in current level - if (opendxp.elementservice.isKeyExistingInLevel(record, params["key"])) { + if (opendxp.elementservice.isKeyExistingInLevel(record, params['key'])) { return; } - if (opendxp.elementservice.isDisallowedDocumentKey(record.id, params["key"])) { + if (opendxp.elementservice.isDisallowedDocumentKey(record.id, params['key'])) { return; } - params["sourceTree"] = tree; - params["elementType"] = "document"; - params["key"] = opendxp.helpers.getValidFilename(params["key"], "document"); - params["index"] = record.childNodes.length; - params["parentId"] = record.id; - params["url"] = Routing.generate('opendxp_admin_document_document_add'); + params['sourceTree'] = tree; + params['elementType'] = 'document'; + params['key'] = opendxp.helpers.getValidFilename(params['key'], 'document'); + params['index'] = record.childNodes.length; + params['parentId'] = record.id; + params['url'] = Routing.generate('opendxp_admin_document_document_add'); opendxp.elementservice.addDocument(params); } }, @@ -1526,7 +1515,7 @@ opendxp.document.tree = Class.create({ editDocumentKey: function (tree, record) { var options = { sourceTree: tree, - elementType: "document", + elementType: 'document', elementSubType: record.data.type, id: record.data.id, default: record.data.key @@ -1536,8 +1525,8 @@ opendxp.document.tree = Class.create({ deleteDocument: function (ids) { var options = { - "elementType": "document", - "id": ids + 'elementType': 'document', + 'id': ids }; opendxp.elementservice.deleteElement(options); }, @@ -1545,26 +1534,26 @@ opendxp.document.tree = Class.create({ convert: function (tree, record, type) { Ext.MessageBox.show({ title: t('are_you_sure'), - msg: t("all_content_will_be_lost"), + msg: t('all_content_will_be_lost'), buttons: Ext.Msg.OKCANCEL, icon: Ext.MessageBox.INFO, fn: function (type, button) { - if (button == "ok") { + if (button === 'ok') { - if (opendxp.globalmanager.exists("document_" + record.data.id)) { - var tabPanel = Ext.getCmp("opendxp_panel_tabs"); - tabPanel.remove("document_" + record.data.id); + if (opendxp.globalmanager.exists('document_' + record.data.id)) { + var tabPanel = Ext.getCmp('opendxp_panel_tabs'); + tabPanel.remove('document_' + record.data.id); } Ext.Ajax.request({ url: Routing.generate('opendxp_admin_document_document_convert'), - method: "PUT", + method: 'PUT', params: { id: record.data.id, type: type }, success: function () { - opendxp.elementservice.refreshNodeAllTrees("document", record.parentNode.id); + opendxp.elementservice.refreshNodeAllTrees('document', record.parentNode.id); }.bind(this) }); } @@ -1576,7 +1565,7 @@ opendxp.document.tree = Class.create({ var parentId = record.data.id; opendxp.helpers.searchAndMove(parentId, function () { opendxp.elementservice.refreshNode(record); - }.bind(this), "document"); + }.bind(this), 'document'); }, isKeyValid: function (key) { @@ -1591,9 +1580,9 @@ opendxp.document.tree = Class.create({ try { var openTabs = opendxp.helpers.getOpenTab(); for (var i = 0; i < openTabs.length; i++) { - if (openTabs[i].indexOf("document_") == 0 && (openTabs[i].indexOf("_page") || openTabs[i].indexOf("_snippet") || openTabs[i].indexOf("_email"))) { + if (openTabs[i].indexOf('document_') === 0 && (openTabs[i].indexOf('_page') || openTabs[i].indexOf('_snippet') || openTabs[i].indexOf('_email'))) { var documentElement = opendxp.globalmanager.get(openTabs[i].replace(/_page|_snippet|_email/gi, '')); - if (typeof documentElement.data != 'undefined' && documentElement.data.idPath.indexOf("/" + node.data.id) > 0) { + if (typeof documentElement.data !== 'undefined' && documentElement.data.idPath.indexOf('/' + node.data.id) > 0) { documentElement.resetPath(); } } From 6ad5f4e5642875d94326ac36f3f0b3d3988a5252 Mon Sep 17 00:00:00 2001 From: Stefan Hagspiel Date: Fri, 6 Mar 2026 14:16:15 +0100 Subject: [PATCH 3/7] add new documentation, examples, and tests for admin bundle extension points --- .gitignore | 4 + CLAUDE.md | 153 ++++++++++ README.md | 1 + composer.json | 8 +- docs/00_Architecture/README.md | 71 +++++ docs/10_Extension_Points/01_Events.md | 95 +++++++ .../10_Extension_Points/02_Admin_UI_Assets.md | 96 +++++++ .../03_Admin_UI_JavaScript.md | 158 +++++++++++ docs/10_Extension_Points/04_Perspectives.md | 100 +++++++ docs/10_Extension_Points/05_Permissions.md | 64 +++++ docs/10_Extension_Points/06_Deeplinks.md | 44 +++ .../07_Custom_Admin_Login.md | 37 +++ docs/10_Extension_Points/README.md | 59 ++++ docs/20_Documents/01_Site_Custom_Settings.md | 148 ++++++++++ docs/20_Documents/README.md | 7 + docs/90_Testing/README.md | 102 +++++++ docs/README.md | 43 +++ src/Event/BundleManagerEvents.php | 2 +- tests/CLAUDE.md | 267 ++++++++++++++++++ tests/Readme.md | 8 - tests/Support/Helper/Unit.php | 21 ++ tests/Support/Test/UnitTestCase.php | 28 ++ tests/Unit.suite.dist.yml | 4 + .../Event/SiteCustomSettingsEventTest.php | 97 +++++++ tests/bin/docker-compose.yml | 4 + tests/bin/init-tests.sh | 5 + 26 files changed, 1613 insertions(+), 13 deletions(-) create mode 100644 CLAUDE.md create mode 100644 docs/00_Architecture/README.md create mode 100644 docs/10_Extension_Points/01_Events.md create mode 100644 docs/10_Extension_Points/02_Admin_UI_Assets.md create mode 100644 docs/10_Extension_Points/03_Admin_UI_JavaScript.md create mode 100644 docs/10_Extension_Points/04_Perspectives.md create mode 100644 docs/10_Extension_Points/05_Permissions.md create mode 100644 docs/10_Extension_Points/06_Deeplinks.md create mode 100644 docs/10_Extension_Points/07_Custom_Admin_Login.md create mode 100644 docs/10_Extension_Points/README.md create mode 100644 docs/20_Documents/01_Site_Custom_Settings.md create mode 100644 docs/20_Documents/README.md create mode 100644 docs/90_Testing/README.md create mode 100644 docs/README.md create mode 100644 tests/CLAUDE.md delete mode 100644 tests/Readme.md create mode 100644 tests/Support/Helper/Unit.php create mode 100644 tests/Support/Test/UnitTestCase.php create mode 100644 tests/Unit.suite.dist.yml create mode 100644 tests/Unit/Event/SiteCustomSettingsEventTest.php diff --git a/.gitignore b/.gitignore index 8facea8b..3010ebb5 100644 --- a/.gitignore +++ b/.gitignore @@ -26,3 +26,7 @@ node_modules/ # files generated by Codeception /tests/_output/ /tests/Support/_generated/ + +# AI +.claude +.mcp.json \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000..c9a2f3d1 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,153 @@ +# Admin Bundle — Claude Instructions + +## Project Overview + +OpenDXP Admin Bundle provides the backend UI for OpenDXP. It is built on [ExtJS](https://www.sencha.com/products/extjs/) and depends heavily on the **opendxp core** (`../opendxp`). + +**Dependency on opendxp core:** This bundle depends heavily on opendxp core (models, base controllers, test infrastructure). The core location depends on the developer's setup — when you need to read core source files, check these paths in order and use the first one that exists: + +1. `../opendxp/` — sibling directory (monorepo / symlinked workspace) +2. `../../vendor/open-dxp/opendxp/` — project root one level up +3. `../../../vendor/open-dxp/opendxp/` — project root two levels up + +When Claude is started from **opendxp core**, admin-specific topics (UI extension, admin events, perspectives, permissions) are documented in **this** bundle — see `docs/`. + +## Source Structure + +``` +src/ +├── Command/ # CLI commands +├── Controller/Admin/ # Admin controllers (Document, Asset, DataObject, GDPR) +│ └── Document/ # Incl. DocumentController (site panel, document tree) +├── Controller/Traits/ # Shared controller traits +├── DependencyInjection/ # Bundle configuration + compiler passes +├── Enum/ # PHP enums (e.g. SiteCustomConfigNodeType) +├── Event/ # Event classes + AdminEvents constants +├── EventListener/ # Symfony event subscribers +├── Helper/ # Service helpers (e.g. GridHelperService) +├── Model/ # Admin-specific models (GridConfig, etc.) +├── Perspective/ # Perspective service logic +├── Security/ # Auth, authenticators, security tokens +├── Service/ # Grid data, workflow, etc. +├── System/ # System-level services +├── Translation/ # Translation handling +└── Twig/ # Twig extensions +``` + +**Key reference files:** +- `src/Event/AdminEvents.php` — all event name constants exposed by this bundle +- `src/Event/SiteCustomSettingsEvent.php` — example of an admin event class +- `src/Enum/SiteCustomConfigNodeType.php` — example enum for config nodes +- `src/Controller/Admin/Document/DocumentController.php` — main document/site controller + +## Documentation + +All documentation lives in `docs/`. See `docs/README.md` for the full index. + +### Structure + +``` +docs/ +├── README.md ← master index (update when adding sections) +├── 00_Architecture/ +│ └── README.md ← bundle overview, core dependency explained +├── 10_Extension_Points/ +│ ├── README.md ← what other bundles can extend +│ ├── 01_Events.md ← AdminEvents reference + subscription examples +│ ├── 02_Admin_UI_Assets.md ← loading JS/CSS into the admin UI +│ ├── 03_Admin_UI_JavaScript.md ← ExtJS event system, menus, key bindings +│ ├── 04_Perspectives.md ← backend UI perspectives configuration +│ ├── 05_Permissions.md ← adding custom permissions +│ ├── 06_Deeplinks.md ← deeplinks into admin interface +│ └── 07_Custom_Admin_Login.md ← custom login entry point +├── 20_Documents/ +│ ├── README.md +│ └── 01_Site_Custom_Settings.md ← site-specific custom settings via events +└── 90_Testing/ + └── README.md ← test conventions and how to run tests +``` + +### Naming conventions +- Folders: `NN_FunctionalArea/` — 10-step numbering leaves room for inserts +- Files: `NN_TopicName.md` — numbered within folder (01, 02, …) +- Each folder has a `README.md` as section index + +### When to document + +Document every **extension point** exposed to other bundles or applications: +- New event constant in `AdminEvents` → update `docs/10_Extension_Points/01_Events.md` +- New event class → add a doc in the relevant feature section with a full subscriber example +- New enum for config nodes → document available values and their meaning +- New controller endpoint used externally → document in the relevant feature section + +### opendxp/doc/ vs. admin-bundle/docs/ + +Content belongs in **admin-bundle/docs/** when it is about: +- Admin UI extension (JS events, menus, assets) +- AdminEvents and event subscribers +- Backend-only features (perspectives, custom login, deeplinks, permissions) +- Site/document admin panel customization + +Content belongs in **opendxp/doc/** when it is about: +- Core models and PHP API (Documents, Assets, DataObjects) +- Core events (non-admin: DocumentEvents, AssetEvents, etc.) +- Routing, MVC, deployment, infrastructure + +When opendxp/doc/ contained content that belongs here, the opendxp file becomes a redirect stub pointing to admin-bundle docs. + +### Document template + +# Feature Name + +One-line description: what it does and why it exists. + +## Overview + +Brief explanation of the feature, when to use it. + +## API Reference + +| Class / Constant | Description | +|------------------------------|-----------------------| +| `AdminEvents::CONSTANT_NAME` | When this event fires | +| `SomeEvent::addNode()` | What the method does | + +## Example + +```php + 'onEvent', + ]; + } + + public function onEvent(SomeEvent $event): void + { + // ... + } +} +``` + +## Stored Data / Result + +Where and how the data is persisted or used downstream. + +## See Also + +- [Related opendxp/doc page](https://github.com/open-dxp/opendxp/blob/1.x/doc/...) +- [AdminEvents source](https://github.com/open-dxp/admin-bundle/blob/1.x/src/Event/AdminEvents.php) + + +## Tests +See `tests/CLAUDE.md` for full test conventions, base classes, and examples. \ No newline at end of file diff --git a/README.md b/README.md index ee512683..49b65311 100644 --- a/README.md +++ b/README.md @@ -36,6 +36,7 @@ Following topics are short-cuts into the documentation for admin interface: - [Deeplinks](https://github.com/open-dxp/opendxp/blob/1.x/doc/20_Extending_OpenDxp/23_Deeplinks_into_Admin_Interface.md) - [Admin Translations](https://github.com/open-dxp/opendxp/blob/1.x/doc/06_Multi_Language_i18n/07_Admin_Translations.md) - [Extending Admin UI](https://github.com/open-dxp/opendxp/blob/1.x/doc/20_Extending_OpenDxp/13_Bundle_Developers_Guide/06_Event_Listener_UI.md) +- 🤖 [Testing with AI (Claude)](https://github.com/open-dxp/opendxp/doc/19_Development_Tools_and_Details/50_Testing_with_AI.md) - Write, run and fix tests with Claude Code *** diff --git a/composer.json b/composer.json index 8116aaed..a81c98e6 100644 --- a/composer.json +++ b/composer.json @@ -25,13 +25,13 @@ "symfony/webpack-encore-bundle": "^2.1.1" }, "require-dev": { - "codeception/codeception": "5.2.2", - "codeception/module-symfony": "^3.1", + "codeception/codeception": "^5.3.5", + "codeception/stub": "^4.3", "codeception/module-asserts": "^3.2", - "codeception/phpunit-wrapper": "^9", + "codeception/module-symfony": "^3.8", "phpstan/phpstan": "2.1.33", "phpstan/phpstan-symfony": "^2.0.9", - "phpunit/phpunit": "^9.3", + "phpunit/phpunit": "^10.5", "symfony/dotenv": "^7.4", "symfony/runtime": "^7.4" }, diff --git a/docs/00_Architecture/README.md b/docs/00_Architecture/README.md new file mode 100644 index 00000000..693cee7b --- /dev/null +++ b/docs/00_Architecture/README.md @@ -0,0 +1,71 @@ +# Architecture + +## What Is This Bundle? + +The Admin Bundle provides the **backend UI** for OpenDXP. It renders the document tree, +asset manager, object editor, user management, and all other admin panels using the +[ExtJS](https://www.sencha.com/products/extjs/) framework on the frontend and Symfony +controllers on the backend. + +## Relationship to opendxp Core + +This bundle depends heavily on the **opendxp core** (`open-dxp/opendxp`). The core provides: + +| From core | Used by admin-bundle | +|---------------------------|-------------------------------------------------| +| `OpenDxp\Model\*` | Document, Asset, DataObject, Site models | +| `OpenDxp\Event\*` | Core events (DocumentEvents, AssetEvents, etc.) | +| `OpenDxp\Controller\*` | Base controller classes | +| `OpenDxp\Tests\Support\*` | Base test classes (ModelTestCase) | +| `OpenDxp\Config` | Global configuration | + +The admin-bundle adds its **own** event layer on top (`src/Event/AdminEvents.php`) for +events that are specific to the admin UI lifecycle. + +## Source Layout + +``` +src/ +├── Command/ # CLI commands (e.g. cache warm-up, admin user management) +├── Controller/ +│ ├── Admin/ # Admin controllers, one subfolder per element type +│ │ ├── Asset/ +│ │ ├── DataObject/ +│ │ ├── Document/ # DocumentController — tree, site panel, site custom settings +│ │ └── ... +│ └── Traits/ # Shared controller traits +├── DependencyInjection/ # Bundle extension + compiler passes +├── Enum/ # PHP enums for typed config values +├── Event/ # Admin event classes + AdminEvents constants +├── EventListener/ # Symfony event subscribers (internal) +├── Helper/ # Stateless service helpers (e.g. GridHelperService) +├── Model/ # Admin-only models (GridConfig, GridConfigShare, etc.) +├── Perspective/ # Perspective resolution and serialization +├── Security/ # Admin authentication, authenticators, security tokens +├── Service/ # Application services (grid data, workflow) +├── System/ # System-level services +├── Translation/ # Admin translation handling +└── Twig/ # Twig extensions for admin templates +``` + +## Frontend Assets + +JavaScript and CSS live in `public/js/` and `public/css/`. The bundle uses +[Webpack Encore](https://symfony.com/doc/current/frontend/encore/simple-example.html) +(`webpack.config.js`) for asset compilation. + +Key JS entry points: +- `public/js/opendxp/events.js` — all frontend event constants +- `public/js/opendxp/document/tree.js` — document tree rendering + +## Configuration Namespace + +The bundle registers its configuration under `opendxp_admin`: + +```yaml +opendxp_admin: + custom_admin_path_identifier: ~ + custom_admin_route_name: ~ + user: + default_key_bindings: ~ +``` \ No newline at end of file diff --git a/docs/10_Extension_Points/01_Events.md b/docs/10_Extension_Points/01_Events.md new file mode 100644 index 00000000..b53537f9 --- /dev/null +++ b/docs/10_Extension_Points/01_Events.md @@ -0,0 +1,95 @@ +# Admin Events + +All event constants for the admin-bundle are defined in +[`src/Event/AdminEvents.php`](https://github.com/open-dxp/admin-bundle/blob/1.x/src/Event/AdminEvents.php). + +## How to Subscribe + +Implement `EventSubscriberInterface` in your bundle and return the event constant(s) you want to handle: + +```php + 'onPreSendData', + ]; + } + + public function onPreSendData(ElementAdminStyleEvent $event): void + { + // ... + } +} +``` + +Symfony autoconfiguration registers the subscriber automatically when `EventSubscriberInterface` is implemented. + +## Event Categories + +### Document Events + +| Constant | Event Class | Description | +|---|---|---| +| `DOCUMENT_GET_PRE_SEND_DATA` | — | Before document data is sent to the frontend | +| `DOCUMENT_LIST_BEFORE_LIST_LOAD` | — | Before document listing is loaded | +| `DOCUMENT_LIST_AFTER_LIST_LOAD` | — | After document listing is loaded | + +### Asset Events + +| Constant | Event Class | Description | +|---|---|---| +| `ASSET_GET_PRE_SEND_DATA` | — | Before asset data is sent to the frontend | +| `ASSET_LIST_BEFORE_LIST_LOAD` | — | Before asset listing is loaded | +| `ASSET_LIST_AFTER_LIST_LOAD` | — | After asset listing is loaded | + +### Object Events + +| Constant | Event Class | Description | +|---|---|---| +| `OBJECT_GET_PRE_SEND_DATA` | — | Before data object data is sent to the frontend | +| `OBJECT_LIST_BEFORE_LIST_LOAD` | — | Before object listing is loaded | +| `OBJECT_LIST_AFTER_LIST_LOAD` | — | After object listing is loaded | + +### Element Style Events + +| Constant | Event Class | Description | +|---|---|---| +| `ELEMENT_ADMIN_STYLE_GET_FOR_DOCUMENT` | `ElementAdminStyleEvent` | Customize admin style (icon, CSS class) for a document | +| `ELEMENT_ADMIN_STYLE_GET_FOR_ASSET` | `ElementAdminStyleEvent` | Customize admin style for an asset | +| `ELEMENT_ADMIN_STYLE_GET_FOR_OBJECT` | `ElementAdminStyleEvent` | Customize admin style for a data object | + +### Login Events + +| Constant | Event Class | Description | +|---|---|---| +| `LOGIN_CREDENTIALS` | `Login\LoginCredentialsEvent` | After login credentials are submitted | +| `LOGIN_FAILED` | `Login\LoginFailedEvent` | After a failed login attempt | + +### Perspective Events + +| Constant | Event Class | Description | +|---|---|---| +| `PERSPECTIVE_PRE_GET_RUNTIME` | — | Before perspective runtime data is assembled | +| `PERSPECTIVE_POST_GET_RUNTIME` | — | After perspective runtime data is assembled | + +### Site Events + +| Constant | Event Class | Description | +|---|---|---| +| `SITE_CUSTOM_SETTINGS` | `SiteCustomSettingsEvent` | Fired when the site panel renders or saves, allows injecting custom config fields | + +See [Site Custom Settings](../20_Documents/01_Site_Custom_Settings.md) for a full example. + +## See Also + +- [AdminEvents source](https://github.com/open-dxp/admin-bundle/blob/1.x/src/Event/AdminEvents.php) +- [Core events (non-admin)](https://github.com/open-dxp/opendxp/blob/1.x/doc/20_Extending_OpenDxp/11_Event_API_and_Event_Manager.md) \ No newline at end of file diff --git a/docs/10_Extension_Points/02_Admin_UI_Assets.md b/docs/10_Extension_Points/02_Admin_UI_Assets.md new file mode 100644 index 00000000..605230de --- /dev/null +++ b/docs/10_Extension_Points/02_Admin_UI_Assets.md @@ -0,0 +1,96 @@ +# Loading Assets in the Admin UI + +If you need to load custom JS or CSS into the admin or editmode UI, you have two options. + +## Option 1: Via OpenDXP Bundle Interface (recommended) + +Add [`OpenDxpBundleAdminClassicInterface`](https://github.com/open-dxp/opendxp/blob/1.x/lib/Extension/Bundle/OpenDxpBundleAdminClassicInterface.php) +to your bundle class. Use [`BundleAdminClassicTrait`](https://github.com/open-dxp/opendxp/blob/1.x/lib/Extension/Bundle/Traits/BundleAdminClassicTrait.php) +to implement the four required methods: + +```php +use OpenDxp\Extension\Bundle\AbstractOpenDxpBundle; +use OpenDxp\Extension\Bundle\OpenDxpBundleAdminClassicInterface; +use OpenDxp\Extension\Bundle\Traits\BundleAdminClassicTrait; + +class MyBundle extends AbstractOpenDxpBundle implements OpenDxpBundleAdminClassicInterface +{ + use BundleAdminClassicTrait; + + public function getJsPaths(): array + { + return ['/bundles/mybundle/js/admin.js']; + } + + public function getCssPaths(): array + { + return ['/bundles/mybundle/css/admin.css']; + } +} +``` + +### With Webpack Encore + +Use [`EncoreHelper`](https://github.com/open-dxp/opendxp/blob/1.x/lib/Helper/EncoreHelper.php) +to resolve built file paths from `entrypoints.json`: + +```php +use OpenDxp\Helper\EncoreHelper; + +public function getJsPaths(): array +{ + return EncoreHelper::getBuildPathsFromEntrypoints( + $this->getPath() . '/public/build/mybundle/entrypoints.json' + ); +} + +public function getCssPaths(): array +{ + return EncoreHelper::getBuildPathsFromEntrypoints( + $this->getPath() . '/public/build/mybundle/entrypoints.json', + 'css' + ); +} +``` + +## Option 2: Via Event Listener + +Subscribe to events from [`BundleManagerEvents`](https://github.com/open-dxp/opendxp/blob/1.x/lib/Event/BundleManagerEvents.php): + +```php + 'onJsPaths', + BundleManagerEvents::CSS_PATHS => 'onCssPaths', + ]; + } + + public function onJsPaths(PathsEvent $event): void + { + $event->addPaths(['/bundles/app/js/admin.js']); + } + + public function onCssPaths(PathsEvent $event): void + { + $event->addPaths(['/bundles/app/css/admin.css']); + } +} +``` + +Assets registered via either method are loaded last on OpenDXP startup, in registration order. + +## See Also + +- [Admin UI JavaScript](03_Admin_UI_JavaScript.md) — how to use the loaded JS to extend the UI +- [BundleManagerEvents source](https://github.com/open-dxp/opendxp/blob/1.x/lib/Event/BundleManagerEvents.php) \ No newline at end of file diff --git a/docs/10_Extension_Points/03_Admin_UI_JavaScript.md b/docs/10_Extension_Points/03_Admin_UI_JavaScript.md new file mode 100644 index 00000000..29252547 --- /dev/null +++ b/docs/10_Extension_Points/03_Admin_UI_JavaScript.md @@ -0,0 +1,158 @@ +# Admin UI JavaScript + +The OpenDXP backend UI is built with [ExtJS](https://www.sencha.com/products/extjs/). +Custom JS loaded via [Admin UI Assets](02_Admin_UI_Assets.md) runs in the same context +and can hook into the admin lifecycle using the event system defined in +[`public/js/opendxp/events.js`](https://github.com/open-dxp/admin-bundle/blob/1.x/public/js/opendxp/events.js). + +## Listening to the Ready Event + +The entry point for any UI extension is `opendxp.events.opendxpReady`: + +```javascript +document.addEventListener(opendxp.events.opendxpReady, (e) => { + console.log('OpenDXP is ready', e.detail); +}); +``` + +## Validating Object Data Before Save + +Use `preventDefault()` and `stopPropagation()` to cancel a save: + +```javascript +document.addEventListener(opendxp.events.preSaveObject, (e) => { + const confirmed = confirm(`Save ${e.detail.object.data.general.className}?`); + if (!confirmed) { + e.preventDefault(); + e.stopPropagation(); + opendxp.helpers.showNotification(t('Info'), t('saving_failed'), 'info'); + } +}); +``` + +## Adding a Main Navigation Item + +Hook into `opendxp.events.preMenuBuild` to add top-level navigation: + +```javascript +opendxp.plugin.mybundle = Class.create({ + initialize: function () { + document.addEventListener(opendxp.events.preMenuBuild, this.preMenuBuild.bind(this)); + }, + + preMenuBuild: function (e) { + let menu = e.detail.menu; + + menu.mybundle = { + label: t('myBundleLabel'), + iconCls: 'opendxp_main_nav_icon_myIcon', + priority: 42, + items: [], + shadow: false, + handler: this.openMyBundle, + noSubmenus: true, + cls: 'opendxp_navigation_flyout', + }; + }, + + openMyBundle: function (e) { + try { + opendxp.globalmanager.get('plugin_opendxp_mybundle').activate(); + } catch (e) { + opendxp.globalmanager.add('plugin_opendxp_mybundle', new opendxp.plugin.mybundle()); + } + } +}); + +var myBundle = new opendxp.plugin.mybundle(); +``` + +## Adding a Submenu to an Existing Menu + +Push into an existing menu's `items` array: + +```javascript +opendxp.registerNS('opendxp.bundle.glossary.startup'); + +opendxp.bundle.glossary.startup = Class.create({ + initialize: function () { + document.addEventListener(opendxp.events.preMenuBuild, this.preMenuBuild.bind(this)); + }, + + preMenuBuild: function (e) { + let menu = e.detail.menu; + const user = opendxp.globalmanager.get('user'); + const perspectiveCfg = opendxp.globalmanager.get('perspective'); + + if (menu.extras && user.isAllowed('glossary') && perspectiveCfg.inToolbar('extras.glossary')) { + menu.extras.items.push({ + text: t('glossary'), + iconCls: 'opendxp_nav_icon_glossary', + priority: 5, + itemId: 'opendxp_menu_extras_glossary', + handler: this.editGlossary, + }); + } + }, + + editGlossary: function () { + try { + opendxp.globalmanager.get('bundle_glossary').activate(); + } catch (e) { + opendxp.globalmanager.add('bundle_glossary', new opendxp.bundle.glossary.settings()); + } + } +}); + +const opendxpBundleGlossary = new opendxp.bundle.glossary.startup(); +``` + +## Adding Custom Key Bindings + +Define the key binding in `config.yaml`: + +```yaml +opendxp_admin: + user: + default_key_bindings: + glossary: + key: 'G' + action: glossary # must match the function name added to keyBindingMapping + alt: true + shift: true +``` + +Then register the binding in JS using `opendxp.events.preRegisterKeyBindings`: + +```javascript +opendxp.bundle.glossary.startup = Class.create({ + initialize: function () { + document.addEventListener(opendxp.events.preRegisterKeyBindings, this.registerKeyBinding.bind(this)); + }, + + registerKeyBinding: function (e) { + const user = opendxp.globalmanager.get('user'); + if (user.isAllowed('glossary')) { + opendxp.helpers.keyBindingMapping.glossary = function () { + opendxpBundleGlossary.editGlossary(); + }; + } + } +}); +``` + +## I18n in JavaScript + +Translations registered server-side are available via `t()`: + +```javascript +t('my_translation_key') +``` + +See [opendxp/doc — i18n for bundles](https://github.com/open-dxp/opendxp/blob/1.x/doc/06_Multi_Language_i18n/07_Admin_Translations.md) +for how to register translation keys server-side. + +## See Also + +- [Admin UI Assets](02_Admin_UI_Assets.md) — how to load your JS files +- [events.js source](https://github.com/open-dxp/admin-bundle/blob/1.x/public/js/opendxp/events.js) \ No newline at end of file diff --git a/docs/10_Extension_Points/04_Perspectives.md b/docs/10_Extension_Points/04_Perspectives.md new file mode 100644 index 00000000..30ad8cc1 --- /dev/null +++ b/docs/10_Extension_Points/04_Perspectives.md @@ -0,0 +1,100 @@ +# Perspectives + +Perspectives allow creating different views in the backend UI and customizing the standard layout. +They can be combined with [Custom Views](https://github.com/open-dxp/opendxp/blob/1.x/doc/05_Objects/01_Object_Classes/05_Class_Settings/20_Custom_Views.md). + +> **Security Note** +> Perspectives and Custom Views are not intended to restrict access to data — use permissions for that. + +You can define per-perspective: +- Which trees are visible and where (left/right) +- Which toolbar menus and items are shown +- Which portlets are available on the dashboard +- Navigation and welcome screen elements + +Access to individual perspectives can be restricted via user/role settings. + +## Configuration File + +The configuration lives in `var/config/perspectives/` (YAML format). +Format follows the environment configuration — see +[Configuration Environments](https://github.com/open-dxp/opendxp/blob/1.x/doc/21_Deployment/03_Configuration_Environments.md). + +## Configuration Reference + +| Key | Type | Description | +|--------------------------------------------------|---------|---------------------------------------------------| +| `[name]["icon"]` | string | Path to perspective icon | +| `[name]["iconCls"]` | string | CSS class for the icon | +| `[name]["elementTree"]` | array | Tree definitions (type, position, expanded, etc.) | +| `[name]["elementTree"][i]["type"]` | string | `documents`, `objects`, `assets`, `customview` | +| `[name]["elementTree"][i]["position"]` | string | `left` or `right` | +| `[name]["elementTree"][i]["id"]` | integer | Custom view ID (only for type `customview`) | +| `[name]["toolbar"]` | array | Per-menu visibility and item configuration | +| `[name]["toolbar"][menuName]["hidden"]` | boolean | Hide the entire menu | +| `[name]["toolbar"][menuName]["items"][itemName]` | boolean | Show/hide individual items | + +## Example + +A catalog-admin perspective that shows only a product custom view and assets: + +**Custom view** (`var/config/perspectives/perspective.yaml`): +```yaml +4e9f892c-7734-f5fa-d6f0-31e7f9787ffc: + name: Cars + treetype: object + position: left + rootfolder: '/Product Data/Cars' + showroot: false + sort: 3 + icon: /bundles/opendxpadmin/img/flat-white-icons/automotive.svg + classes: CAR +``` + +**Perspective** (`var/config/perspectives/example.yaml`): +```yaml +demo: + elementTree: + - + type: customview + position: left + sort: 0 + expanded: false + hidden: false + id: 4e9f892c-7734-f5fa-d6f0-31e7f9787ffc + - + type: assets + position: right + sort: 0 + expanded: false + hidden: false + iconCls: opendxp_nav_icon_perspective + toolbar: + file: + hidden: true + marketing: + hidden: true + extras: + hidden: true + settings: + hidden: true + search: + hidden: false + items: + quickSearch: false + documents: true + assets: false + objects: false +``` + +## Perspective Events + +Subscribe to `AdminEvents::PERSPECTIVE_PRE_GET_RUNTIME` or `AdminEvents::PERSPECTIVE_POST_GET_RUNTIME` +to modify perspective data programmatically. + +See [01_Events.md](01_Events.md) for subscription examples. + +## See Also + +- [AdminEvents](01_Events.md) +- [Custom Views](https://github.com/open-dxp/opendxp/blob/1.x/doc/05_Objects/01_Object_Classes/05_Class_Settings/20_Custom_Views.md) \ No newline at end of file diff --git a/docs/10_Extension_Points/05_Permissions.md b/docs/10_Extension_Points/05_Permissions.md new file mode 100644 index 00000000..0b699b67 --- /dev/null +++ b/docs/10_Extension_Points/05_Permissions.md @@ -0,0 +1,64 @@ +# Custom Permissions + +You can add custom permission keys to OpenDXP that appear in the user/role settings panel +and can be checked both server-side and client-side. + +## Step 1: Register the Permission + +Add your permission key to the `users_permission_definitions` table: + +```sql +INSERT INTO users_permission_definitions (key) VALUES ('my_custom_permission'); +``` + +After this, the permission appears in the Users/Roles admin panel and can be assigned. + +## Step 2: Check the Permission Server-Side + +Inside an admin controller that extends `UserAwareController`: + +```php +getOpenDxpUser(); + + if ($openDxpUser?->isAllowed('my_custom_permission')) { + // authorized + } + + return $this->jsonResponse(['success' => true]); + } +} +``` + +## Step 3: Check the Permission Client-Side + +In your bundle's JavaScript (loaded via [Admin UI Assets](02_Admin_UI_Assets.md)): + +```javascript +document.addEventListener(opendxp.events.opendxpReady, (e) => { + if (opendxp.currentuser.permissions.indexOf('my_custom_permission') >= 0) { + // user has the permission — show/enable UI element + } +}); +``` + +## See Also + +- [Admin UI JavaScript](03_Admin_UI_JavaScript.md) +- [Users & Roles](https://github.com/open-dxp/opendxp/blob/1.x/doc/22_Administration_of_OpenDxp/07_Users_and_Roles.md) \ No newline at end of file diff --git a/docs/10_Extension_Points/06_Deeplinks.md b/docs/10_Extension_Points/06_Deeplinks.md new file mode 100644 index 00000000..84fde565 --- /dev/null +++ b/docs/10_Extension_Points/06_Deeplinks.md @@ -0,0 +1,44 @@ +# Deeplinks Into the Admin Interface + +OpenDXP supports deeplinks that open a specific element directly inside the admin UI +from an external application. + +## URL Schema + +``` +https://YOUR-HOST/admin/login/deeplink?TYPE_ID_SUBTYPE +``` + +## Examples + +### Documents + +```text +https://acme.com/admin/login/deeplink?document_123_page +https://acme.com/admin/login/deeplink?document_45_snippet +https://acme.com/admin/login/deeplink?document_67_link +https://acme.com/admin/login/deeplink?document_8_hardlink +https://acme.com/admin/login/deeplink?document_9_email +``` + +### Assets + +```text +https://acme.com/admin/login/deeplink?asset_23_image +https://acme.com/admin/login/deeplink?asset_34_document +https://acme.com/admin/login/deeplink?asset_56_folder +https://acme.com/admin/login/deeplink?asset_78_video +``` + +### Data Objects + +```text +https://acme.com/admin/login/deeplink?object_24_object +https://acme.com/admin/login/deeplink?object_98_variant +https://acme.com/admin/login/deeplink?object_66_folder +``` + +## Behaviour + +If the user is not yet logged in, the deeplink URL redirects to the login page first and +then opens the target element after successful authentication. \ No newline at end of file diff --git a/docs/10_Extension_Points/07_Custom_Admin_Login.md b/docs/10_Extension_Points/07_Custom_Admin_Login.md new file mode 100644 index 00000000..b4973798 --- /dev/null +++ b/docs/10_Extension_Points/07_Custom_Admin_Login.md @@ -0,0 +1,37 @@ +# Custom Admin Login Entry Point + +The default admin login is served at `/admin`. You can change this to a custom path +to reduce exposure of the admin panel. + +## Configuration + +Add the custom identifier to `config/config.yaml`: + +```yaml +opendxp_admin: + custom_admin_path_identifier: min20CharCustomToken +``` + +> `custom_admin_path_identifier` must be at least 20 characters long. + +## Custom Route + +Add a custom route entry in `config/routes.yaml`: + +```yaml +my_custom_admin_entry_point: + path: /my-custom-login-page + controller: OpenDxp\Bundle\CoreBundle\Controller\PublicServicesController::customAdminEntryPointAction +``` + +When this route is called, an admin cookie is set in the browser which is then validated +for all subsequent `/admin` calls. + +## Custom Route Name + +If you want to use a different route name (e.g. for the "login as user" link in user administration): + +```yaml +opendxp_admin: + custom_admin_route_name: myCustomAdminRoute +``` \ No newline at end of file diff --git a/docs/10_Extension_Points/README.md b/docs/10_Extension_Points/README.md new file mode 100644 index 00000000..7e2db189 --- /dev/null +++ b/docs/10_Extension_Points/README.md @@ -0,0 +1,59 @@ +# Extension Points + +This section documents how other bundles and applications can extend or customize +the admin UI provided by this bundle. + +## Overview + +The admin-bundle exposes extension points at two levels: + +**PHP (server-side)** +- Event system via `AdminEvents` constants and typed event classes +- Custom permissions registered in the database + +**JavaScript (client-side)** +- JS/CSS injection into the admin UI via bundle interface or event listeners +- ExtJS UI events for adding menus, panels, key bindings + +## Topics + +| # | Topic | Summary | +|---------------------------------|---------------------|------------------------------------------------------------------| +| [01](01_Events.md) | Events | All `AdminEvents` constants, when they fire, subscriber examples | +| [02](02_Admin_UI_Assets.md) | Admin UI Assets | How to load custom JS and CSS into the admin backend | +| [03](03_Admin_UI_JavaScript.md) | Admin UI JavaScript | ExtJS events, adding navigation items, key bindings | +| [04](04_Perspectives.md) | Perspectives | Configuring different backend UI layouts | +| [05](05_Permissions.md) | Permissions | Adding custom permission keys | +| [06](06_Deeplinks.md) | Deeplinks | Linking directly into admin from external apps | +| [07](07_Custom_Admin_Login.md) | Custom Admin Login | Changing the `/admin` entry point | + +## How Events Work + +The admin-bundle follows the standard Symfony event dispatcher pattern. +Event constants are defined as `public const string` on `AdminEvents`. +Event classes live in `src/Event/` and extend `Symfony\Contracts\EventDispatcher\Event`. + +Other bundles subscribe to admin events by implementing `EventSubscriberInterface`: + +```php +use OpenDxp\Bundle\AdminBundle\Event\AdminEvents; +use Symfony\Component\EventDispatcher\EventSubscriberInterface; + +class MyListener implements EventSubscriberInterface +{ + public static function getSubscribedEvents(): array + { + return [ + AdminEvents::SOME_EVENT => 'onSomeEvent', + ]; + } + + public function onSomeEvent(SomeAdminEvent $event): void + { + // ... + } +} +``` + +Register the listener in your bundle's `services.yaml` — Symfony autoconfiguration +picks up `EventSubscriberInterface` automatically. \ No newline at end of file diff --git a/docs/20_Documents/01_Site_Custom_Settings.md b/docs/20_Documents/01_Site_Custom_Settings.md new file mode 100644 index 00000000..37ad75ce --- /dev/null +++ b/docs/20_Documents/01_Site_Custom_Settings.md @@ -0,0 +1,148 @@ +# Site Custom Settings + +The site configuration panel in the admin UI can be extended with custom fields +by subscribing to the `AdminEvents::SITE_CUSTOM_SETTINGS` event. + +This allows other bundles to add their own configuration fields (inputs, dropdowns, checkboxes) +that are stored per-site in `Site::getCustomSettings()` / `Site::setCustomSettings()`. + +## When the Event Fires + +The event fires in two situations: +1. **GET** — when the site panel renders (`getSiteCustomSettingsAction`), to know which fields to display +2. **PUT** — when the site is saved (`updateSiteAction`), to know which request values to persist + +## API Reference + +### `AdminEvents::SITE_CUSTOM_SETTINGS` + +```php +use OpenDxp\Bundle\AdminBundle\Event\AdminEvents; + +AdminEvents::SITE_CUSTOM_SETTINGS // 'opendxp.admin.site.customSettings' +``` + +### `SiteCustomSettingsEvent` + +| Method | Description | +|---------------------------------------------------------|-----------------------------------------| +| `getSite(): Site` | The site being configured | +| `addConfigNode(type, scope, name, label, config): void` | Register a custom field | +| `getConfigNodes(): array` | All registered fields, grouped by scope | + +### `SiteCustomConfigNodeType` (enum) + +| Case | ExtJS field type | Use for | +|------------|------------------|-------------------------------| +| `INPUT` | `textfield` | Single-line text | +| `TEXT` | `textarea` | Multi-line text | +| `CHECKBOX` | `checkbox` | Boolean toggle | +| `DROPDOWN` | `combobox` | Select from a list of options | + +### `addConfigNode()` Parameters + +| Parameter | Type | Description | +|-----------|----------------------------|-------------------------------------------------------------------------------------------------------| +| `$type` | `SiteCustomConfigNodeType` | The field type | +| `$scope` | `string` | Groups fields in the panel (e.g. `'app'`, `'seo'`); also used as key prefix when reading back values | +| `$name` | `string` | Field identifier within the scope | +| `$label` | `string` | Display label in the UI | +| `$config` | `array` | Additional ExtJS field config (e.g. `store` for dropdowns, `required`) | + +## Stored Data + +Values are persisted via `Site::setCustomSettings()` after save, grouped by scope. + +### Reading Values + +`getCustomSettings()` accepts an optional scope argument: + +```php +$site = Site::getById($id); + +// read a single scope — returns [] if the scope doesn't exist +$appSettings = $site->getCustomSettings('app'); +$zone = $appSettings['zone'] ?? null; + +// read all scopes at once +$all = $site->getCustomSettings(); +// $all === ['app' => ['zone' => 'store'], 'seo' => ['tracking_id' => 'UA-123']] +``` + +## Example: Adding a Zone Dropdown + +This example adds a required dropdown: + +```php + 'addPropertyToCustomSettings', + ]; + } + + public function addPropertyToCustomSettings(SiteCustomSettingsEvent $event): void + { + $event->addConfigNode( + type: SiteCustomConfigNodeType::DROPDOWN, + scope: 'app', + name: 'my_property', + label: 'My Property', + config: [ + 'required' => true, + 'store' => [ + ['label' => 'Property 1', 'value' => 'property_1'], + ['label' => 'Property 2', 'value' => 'property_2'], + ], + ] + ); + } +} +``` + +Register in `services.yaml` (autoconfiguration handles `EventSubscriberInterface`): + +```yaml +services: + MyBundle\EventListener\Admin\SiteCustomConfigListener: + tags: + - { name: kernel.event_subscriber } +``` + +## Multiple Scopes + +Multiple bundles can each add their own scoped fields independently: + +```php +// Bundle A +$event->addConfigNode(SiteCustomConfigNodeType::INPUT, 'seo', 'tracking_id', 'GA Tracking ID', []); + +// Bundle B +$event->addConfigNode(SiteCustomConfigNodeType::CHECKBOX, 'myapp', 'feature_enabled', 'Enable Feature', []); +``` + +Results in: +```php +$site->getCustomSettings() === [ + 'seo' => ['tracking_id' => 'UA-123456'], + 'myapp' => ['feature_enabled' => '1'], +] +``` + +## See Also + +- [`AdminEvents::SITE_CUSTOM_SETTINGS` source](https://github.com/open-dxp/admin-bundle/blob/1.x/src/Event/AdminEvents.php) +- [`SiteCustomSettingsEvent` source](https://github.com/open-dxp/admin-bundle/blob/1.x/src/Event/SiteCustomSettingsEvent.php) +- [`SiteCustomConfigNodeType` source](https://github.com/open-dxp/admin-bundle/blob/1.x/src/Enum/SiteCustomConfigNodeType.php) +- [Admin Events overview](../10_Extension_Points/01_Events.md) \ No newline at end of file diff --git a/docs/20_Documents/README.md b/docs/20_Documents/README.md new file mode 100644 index 00000000..899d640b --- /dev/null +++ b/docs/20_Documents/README.md @@ -0,0 +1,7 @@ +# Documents + +Admin UI features specific to document and site management. + +| Topic | Description | +|---|---| +| [Site Custom Settings](01_Site_Custom_Settings.md) | Inject custom fields into the site configuration panel via events | \ No newline at end of file diff --git a/docs/90_Testing/README.md b/docs/90_Testing/README.md new file mode 100644 index 00000000..16db6eed --- /dev/null +++ b/docs/90_Testing/README.md @@ -0,0 +1,102 @@ +# Testing + +This bundle uses **Codeception** for integration tests and plain **PHPUnit** for unit tests. + +## Test Structure + +``` +tests/ +├── Model/ # Integration tests — require a running OpenDXP environment +│ ├── GridHelper/ # Grid/listing logic tests +│ └── Permissions/ # Permission model tests +└── Support/ + ├── Helper/ # Codeception module helpers + └── UnitTester.php # Tester actor (generated by Codeception) +``` + +Configuration: `codeception.dist.yml` in the bundle root. + +## Test Base Classes + +| Base Class | From | Use When | +|--------------------------------------------|--------------------|-------------------------------------------------------------------------| +| `OpenDxp\Tests\Support\Test\ModelTestCase` | `open-dxp/opendxp` | Test needs DB, models, or a running OpenDXP (documents, objects, sites) | +| `PHPUnit\Framework\TestCase` | `PHPUnit` | Pure unit test — event classes, enums, services without I/O | + +## When to Write Which Type + +**Unit tests** (no base class, no DB): +- New event classes (`SiteCustomSettingsEvent`, etc.) +- New enum cases (`SiteCustomConfigNodeType`) +- Service methods that only process data structures + +**Integration tests** (`ModelTestCase`): +- Permission checks against real user/role objects +- Grid helpers that run queries +- Controller-level behaviour that reads/writes models + +## Unit Test Example + +Testing `SiteCustomSettingsEvent` in isolation: + +```php +createMock(Site::class); + $event = new SiteCustomSettingsEvent($site); + + $event->addConfigNode(SiteCustomConfigNodeType::INPUT, 'seo', 'title', 'SEO Title', []); + $event->addConfigNode(SiteCustomConfigNodeType::CHECKBOX, 'seo', 'noindex', 'No Index', []); + $event->addConfigNode(SiteCustomConfigNodeType::DROPDOWN, 'i18n', 'zone', 'Zone', ['store' => []]); + + $nodes = $event->getConfigNodes(); + + self::assertCount(2, $nodes['seo']); + self::assertCount(1, $nodes['i18n']); + self::assertSame('input', $nodes['seo'][0]['type']); + self::assertSame('zone', $nodes['i18n'][0]['name']); + } +} +``` + +Place unit tests in `tests/Unit/` (create the folder if it does not exist yet). + +## Integration Test Example + +Existing tests in `tests/Model/Permissions/` extend `ModelTestCase`: + +```php +class ModelDocumentPermissionsTest extends AbstractPermissionTest +{ + // uses OpenDxp\Tests\Support\Test\ModelTestCase via AbstractPermissionTest +} +``` + +## Running Tests + +```bash +# Run all tests (from bundle root) +vendor/bin/codecept run + +# Run a specific suite +vendor/bin/codecept run Unit + +# Run a specific test file +vendor/bin/codecept run Unit tests/Unit/Event/SiteCustomSettingsEventTest.php +``` + +> Integration tests require a configured OpenDXP environment with a running database. +> See opendxp core docs: `doc/19_Development_Tools_and_Details/29_Testing/`. \ No newline at end of file diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 00000000..5d0881c6 --- /dev/null +++ b/docs/README.md @@ -0,0 +1,43 @@ +# Admin Bundle Documentation + +This is the documentation root for the **OpenDXP Admin Bundle**. + +The Admin Bundle provides the backend UI for OpenDXP (based on ExtJS) and defines the +extension points that other bundles use to customize the admin interface. + +--- + +## Sections + +### [00 Architecture](00_Architecture/README.md) +Bundle overview, relationship to opendxp core, and source structure. + +### [10 Extension Points](10_Extension_Points/README.md) +How other bundles and applications can extend the admin UI. + +| Topic | Description | +|----------------------------------------------------------------------|-------------------------------------------------------------------| +| [Events](10_Extension_Points/01_Events.md) | All `AdminEvents` constants — when they fire and how to subscribe | +| [Admin UI Assets](10_Extension_Points/02_Admin_UI_Assets.md) | Loading custom JS and CSS into the admin UI | +| [Admin UI JavaScript](10_Extension_Points/03_Admin_UI_JavaScript.md) | ExtJS event system, adding menus, key bindings | +| [Perspectives](10_Extension_Points/04_Perspectives.md) | Configuring backend UI perspectives | +| [Permissions](10_Extension_Points/05_Permissions.md) | Adding custom permissions | +| [Deeplinks](10_Extension_Points/06_Deeplinks.md) | Deeplinks into the admin interface | +| [Custom Admin Login](10_Extension_Points/07_Custom_Admin_Login.md) | Custom admin login entry point | + +### [20 Documents](20_Documents/README.md) +Admin UI features specific to document and site management. + +| Topic | Description | +|-----------------------------------------------------------------|------------------------------------------------------| +| [Site Custom Settings](20_Documents/01_Site_Custom_Settings.md) | Adding custom fields to the site configuration panel | + +### [90 Testing](90_Testing/README.md) +How to write and run tests for this bundle. + +--- + +## Quick Reference + +- **Event constants:** `src/Event/AdminEvents.php` +- **opendxp core docs:** MVC, models, routing, deployment → see opendxp `doc/` (location depends on setup, see `CLAUDE.md`) \ No newline at end of file diff --git a/src/Event/BundleManagerEvents.php b/src/Event/BundleManagerEvents.php index 4767942b..57ed4b9d 100644 --- a/src/Event/BundleManagerEvents.php +++ b/src/Event/BundleManagerEvents.php @@ -17,7 +17,7 @@ namespace OpenDxp\Bundle\AdminBundle\Event; /** - * @TODO this class is only here for BC reasons and should be removed in Pimcore 12 + * @TODO this class is only here for BC reasons and should be removed in Admin Bundle 2.0 */ final class BundleManagerEvents extends \OpenDxp\Event\BundleManagerEvents { diff --git a/tests/CLAUDE.md b/tests/CLAUDE.md new file mode 100644 index 00000000..991183ea --- /dev/null +++ b/tests/CLAUDE.md @@ -0,0 +1,267 @@ +# Tests — Claude Instructions + +## Framework & Setup + +Tests use **Codeception**. Configuration: `codeception.dist.yml` in the bundle root. + +The bootstrap (`tests/_bootstrap.php`) resolves the OpenDXP environment in this order: +1. `vendor/autoload.php` in the bundle itself (standalone) +2. `../../../../vendor/autoload.php` (installed as part of a project) +3. `$OPENDXP_PROJECT_ROOT/vendor/autoload.php` (via env variable) + +Integration tests require a fully running OpenDXP environment with a database. + +## Directory Structure + +``` +tests/ +├── Model/ # Integration tests (need DB + running OpenDXP) +│ ├── GridHelper/ +│ │ └── GridHelperTest.php +│ └── Permissions/ +│ ├── AbstractPermissionTest.php +│ ├── ModelAssetPermissionsTest.php +│ ├── ModelDataObjectPermissionsTest.php +│ └── ModelDocumentPermissionsTest.php +├── Unit/ # Unit tests (no DB, no OpenDXP bootstrap) +│ └── Event/ # ← place event/enum unit tests here +└── Support/ + ├── Helper/ + │ └── Model.php + └── UnitTester.php +``` + +`tests/Unit/` does not exist yet — create it when adding the first unit test. + +## Base Classes + +| Situation | Base Class | Location | +|---|---|---| +| Test needs DB, models, real OpenDXP objects | `ModelTestCase` | `OpenDxp\Tests\Support\Test\ModelTestCase` (from `../opendxp`) | +| Pure logic, no I/O, no DB | `TestCase` | `PHPUnit\Framework\TestCase` | + +**Rule of thumb:** +- New event class or enum → `PHPUnit\Framework\TestCase` +- Controller behaviour, permissions, grid queries → `ModelTestCase` + +Never use `ModelTestCase` for things that don't need the DB — it requires a full OpenDXP bootstrap and slows everything down. + +## Unit Test — Event Classes + +Event classes are pure data containers and easy to test without any infrastructure. + +Example: `ElementAdminStyleEvent` carries the element, its admin style, and an optional +context (tree, editor, search). A unit test verifies the getters, setters, and context constants: + +```php +createMock(ElementInterface::class); + $adminStyle = new AdminStyle($element); + + $event = new ElementAdminStyleEvent($element, $adminStyle, ElementAdminStyleEvent::CONTEXT_TREE); + + self::assertSame($element, $event->getElement()); + self::assertSame($adminStyle, $event->getAdminStyle()); + self::assertSame(ElementAdminStyleEvent::CONTEXT_TREE, $event->getContext()); + } + + public function testContextIsNullByDefault(): void + { + $element = $this->createMock(ElementInterface::class); + + $event = new ElementAdminStyleEvent($element, new AdminStyle($element)); + + self::assertNull($event->getContext()); + } + + public function testSetContextUpdatesValue(): void + { + $element = $this->createMock(ElementInterface::class); + $event = new ElementAdminStyleEvent($element, new AdminStyle($element)); + + $event->setContext(ElementAdminStyleEvent::CONTEXT_EDITOR); + + self::assertSame(ElementAdminStyleEvent::CONTEXT_EDITOR, $event->getContext()); + } + + public function testContextConstantsAreDistinct(): void + { + self::assertNotSame(ElementAdminStyleEvent::CONTEXT_TREE, ElementAdminStyleEvent::CONTEXT_EDITOR); + self::assertNotSame(ElementAdminStyleEvent::CONTEXT_EDITOR, ElementAdminStyleEvent::CONTEXT_SEARCH); + self::assertNotSame(ElementAdminStyleEvent::CONTEXT_TREE, ElementAdminStyleEvent::CONTEXT_SEARCH); + } +} +``` + +Note: `ElementInterface` is an interface and can be mocked normally. For final model classes +(like `Site`), use `new ClassName()` directly instead of `createMock()` — see `UnitTestCase::createSite()` +as an example of how to encapsulate that in the base class. + +## Integration Test — Permissions / Model + +Integration tests extend `AbstractPermissionTest` which itself extends `ModelTestCase`. +They use `Codeception\Stub` to wire controllers without a full HTTP stack. + +See `tests/Model/Permissions/ModelDocumentPermissionsTest.php` for a working example. + +Key pattern: +```php +// build a stubbed controller with a mocked user +$controller = $this->buildController(DocumentController::class, $user); + +// call the action directly +$response = $controller->treeGetChildrenByIdAction($request); + +// assert on the JSON response +$data = json_decode($response->getContent(), true); +self::assertTrue($data['success']); +``` + +## Naming Conventions + +- Unit tests: `tests/Unit/{Namespace}/{ClassName}Test.php` +- Integration tests: `tests/Model/{Feature}/{ClassName}Test.php` +- Test methods: `test` prefix + descriptive camelCase (`testAddConfigNodeGroupsByScope`) +- One `Test.php` per source class being tested + +*** + +## Running Tests + +### Prerequisites +First time starts: Is the MCP tool `opendxp-testkit` available in this session? + +**No → Run setup now:** + +Ask the developer this question and wait for the answer: +> "Where is your local `docker-testkit` directory? (absolute path)" + +Once the path is provided: + +- Write `.mcp.json` in the bundle root: + +```json +{ + "mcpServers": { + "opendxp-testkit": { + "type": "stdio", + "command": "node", + "args": [ + "/mcp-server/index.js" + ] + } + } +} +``` + +- Add `.mcp.json` to `.gitignore` if not already present +- Tell the developer: "Please restart Claude Code — `opendxp-testkit` will be available after restart." +- Stop. Wait for restart. + +**Yes → Normal workflow:** + +--- + +### Workflow: Running tests + +#### Step 1 — Check status + +Call `get_status()`. The result shows: + +- `Configured bundle` — which bundle is currently set in the testkit +- `ddev running` — whether ddev is running + +Decide based on the result: + +| Situation | Action | +|--------------------------------------|------------------------------------------------------------------------------------------| +| `ddev running: false` | Call `set_bundle("BUNDLE_DIR_NAME")` → ddev will be started → use `with_composer=true` | +| `ddev running: true`, wrong bundle | Call `set_bundle("BUNDLE_DIR_NAME")` → ddev will be restarted → use `with_composer=true` | +| `ddev running: true`, correct bundle | Proceed to step 2 — no `with_composer` needed | + +`BUNDLE_DIR_NAME` = `basename` of this directory (e.g. `ecommerce-bundle`) + +> **Note on rsync:** Files are **always** synced into the container via rsync — no ddev restart is needed after code changes. + +#### Step 2 — Run tests + +``` +run_codeception(test_path="tests/...", with_composer=true/false) +``` + +- Only set `test_path` when the user specifies a particular test, otherwise omit it (runs all tests) +- Do **not** set `debug` by default (see rules below) + +**`test_path` formats:** +| What the user wants | `test_path` value | +|---------------------|-------------------| +| All tests | *(omit)* | +| A specific test class | `tests/Model//GridHelper/GridHelperTest.php` | +| A specific test folder | `tests/Unit/Event` | +| All unit tests | `tests/Unit` | +| All model/integration tests | `tests/Model` | + +#### Step 3 — On test failure + +If tests fail, ask the user: +> "Tests failed. Should I re-run with `--debug` for detailed output?" + +Only if the user agrees: `run_codeception(debug=true, ...)` + +--- + +### Workflow: PHPStan + +`run_phpstan()` is a standalone task — only run it when the user explicitly asks for it. + +``` +run_phpstan(level=6) ← default level, adjust on request +``` + +--- + +### Rules + +- Write code and tests in this directory only — never touch `app/` inside the testkit +- `with_composer=true` after `set_bundle` calls or if something changed in `composer.json` +- `debug=true` only with explicit user consent after a failed test run +- PHPStan only on explicit request + +--- + +### Fallback: Without MCP + +Only use these commands if `opendxp-testkit` is not available in this session: + +```bash +# all suites +vendor/bin/codecept run + +# unit tests only (no DB needed) +vendor/bin/codecept run Unit + +# integration tests only (DB required) +vendor/bin/codecept run Model + +# single file +vendor/bin/codecept run Unit tests/Unit/Event/SiteCustomSettingsEventTest.php +``` + +For integration tests, set `OPENDXP_PROJECT_ROOT` if running outside a full project: +```bash +OPENDXP_PROJECT_ROOT=/path/to/project vendor/bin/codecept run Model +``` \ No newline at end of file diff --git a/tests/Readme.md b/tests/Readme.md deleted file mode 100644 index 50af476e..00000000 --- a/tests/Readme.md +++ /dev/null @@ -1,8 +0,0 @@ -# Running and creating tests - -Tests can be executed locally in a separate docker-compose. This docker-compose also sets up database -accordingly. - -To run, just execute [/bin/init-tests.sh](./bin/init-tests.sh) script and follow instructions there. - -Additional tests may be added following codeception best practises. diff --git a/tests/Support/Helper/Unit.php b/tests/Support/Helper/Unit.php new file mode 100644 index 00000000..446133f3 --- /dev/null +++ b/tests/Support/Helper/Unit.php @@ -0,0 +1,21 @@ +createSite()); + + self::assertSame([], $event->getConfigNodes()); + } + + public function testGetSiteReturnsSite(): void + { + $site = $this->createSite(); + $event = new SiteCustomSettingsEvent($site); + + self::assertSame($site, $event->getSite()); + } + + public function testAddConfigNodeGroupsByScope(): void + { + $event = new SiteCustomSettingsEvent($this->createSite()); + + $event->addConfigNode(SiteCustomConfigNodeType::INPUT, 'seo', 'title', 'SEO Title', []); + $event->addConfigNode(SiteCustomConfigNodeType::CHECKBOX, 'seo', 'noindex', 'No Index', []); + $event->addConfigNode(SiteCustomConfigNodeType::DROPDOWN, 'i18n', 'zone', 'Zone', ['store' => []]); + $event->addConfigNode(SiteCustomConfigNodeType::TEXT, 'app', 'description', 'Description', []); + + $nodes = $event->getConfigNodes(); + + self::assertCount(2, $nodes['seo']); + self::assertCount(1, $nodes['i18n']); + self::assertCount(1, $nodes['app']); + } + + public function testAddConfigNodeBuildsCorrectStructure(): void + { + $event = new SiteCustomSettingsEvent($this->createSite()); + + $event->addConfigNode( + SiteCustomConfigNodeType::DROPDOWN, + 'app', + 'my_field', + 'My Field', + ['required' => true, 'store' => [['label' => 'A', 'value' => 'a']]] + ); + + $node = $event->getConfigNodes()['app'][0]; + + self::assertSame(SiteCustomConfigNodeType::DROPDOWN->value, $node['type']); + self::assertSame('my_field', $node['name']); + self::assertSame('My Field', $node['label']); + self::assertSame(['required' => true, 'store' => [['label' => 'A', 'value' => 'a']]], $node['config']); + } + + public function testMultipleNodesInSameScopeAreAppended(): void + { + $event = new SiteCustomSettingsEvent($this->createSite()); + + $event->addConfigNode(SiteCustomConfigNodeType::INPUT, 'app', 'first', 'First', []); + $event->addConfigNode(SiteCustomConfigNodeType::INPUT, 'app', 'second', 'Second', []); + $event->addConfigNode(SiteCustomConfigNodeType::INPUT, 'app', 'third', 'Third', []); + + self::assertCount(3, $event->getConfigNodes()['app']); + self::assertSame('first', $event->getConfigNodes()['app'][0]['name']); + self::assertSame('second', $event->getConfigNodes()['app'][1]['name']); + self::assertSame('third', $event->getConfigNodes()['app'][2]['name']); + } + + public function testNodeTypeValuesMatchExpectedExtJsTypes(): void + { + self::assertSame('input', SiteCustomConfigNodeType::INPUT->value); + self::assertSame('text', SiteCustomConfigNodeType::TEXT->value); + self::assertSame('checkbox', SiteCustomConfigNodeType::CHECKBOX->value); + self::assertSame('combobox', SiteCustomConfigNodeType::DROPDOWN->value); + } +} \ No newline at end of file diff --git a/tests/bin/docker-compose.yml b/tests/bin/docker-compose.yml index 7e8028ac..5f598ac7 100644 --- a/tests/bin/docker-compose.yml +++ b/tests/bin/docker-compose.yml @@ -1,3 +1,7 @@ +# +# ATTENTION! +# This file is deprecated and will be removed with admin-bundle 2.0 +# version: '3.0' services: db: diff --git a/tests/bin/init-tests.sh b/tests/bin/init-tests.sh index 0edc2fff..742b8bba 100755 --- a/tests/bin/init-tests.sh +++ b/tests/bin/init-tests.sh @@ -1,5 +1,10 @@ #!/bin/bash +# +# ATTENTION! +# This file is deprecated and will be removed with admin-bundle 2.0 +# + docker-compose down -v --remove-orphans docker-compose up -d From f654ec04be710c61a1b9b899b5424a7ee975c3b2 Mon Sep 17 00:00:00 2001 From: Stefan Hagspiel Date: Fri, 6 Mar 2026 14:29:05 +0100 Subject: [PATCH 4/7] add dynamic field configuration support via typed DTOs in admin bundle --- docs/20_Documents/01_Site_Custom_Settings.md | 85 ++++++++----------- .../SiteCustomSettings/CheckboxNodeConfig.php | 41 +++++++++ .../SiteCustomSettings/DropdownNodeConfig.php | 45 ++++++++++ .../SiteCustomSettings/InputNodeConfig.php | 39 +++++++++ .../NodeConfigInterface.php | 26 ++++++ src/Dto/SiteCustomSettings/TextNodeConfig.php | 39 +++++++++ src/Event/SiteCustomSettingsEvent.php | 10 +-- .../Event/SiteCustomSettingsEventTest.php | 47 +++++----- 8 files changed, 260 insertions(+), 72 deletions(-) create mode 100644 src/Dto/SiteCustomSettings/CheckboxNodeConfig.php create mode 100644 src/Dto/SiteCustomSettings/DropdownNodeConfig.php create mode 100644 src/Dto/SiteCustomSettings/InputNodeConfig.php create mode 100644 src/Dto/SiteCustomSettings/NodeConfigInterface.php create mode 100644 src/Dto/SiteCustomSettings/TextNodeConfig.php diff --git a/docs/20_Documents/01_Site_Custom_Settings.md b/docs/20_Documents/01_Site_Custom_Settings.md index 37ad75ce..a5f029d2 100644 --- a/docs/20_Documents/01_Site_Custom_Settings.md +++ b/docs/20_Documents/01_Site_Custom_Settings.md @@ -4,7 +4,7 @@ The site configuration panel in the admin UI can be extended with custom fields by subscribing to the `AdminEvents::SITE_CUSTOM_SETTINGS` event. This allows other bundles to add their own configuration fields (inputs, dropdowns, checkboxes) -that are stored per-site in `Site::getCustomSettings()` / `Site::setCustomSettings()`. +that are stored per-site in `Site::getCustomSettings()`. ## When the Event Fires @@ -24,30 +24,31 @@ AdminEvents::SITE_CUSTOM_SETTINGS // 'opendxp.admin.site.customSettings' ### `SiteCustomSettingsEvent` -| Method | Description | -|---------------------------------------------------------|-----------------------------------------| -| `getSite(): Site` | The site being configured | -| `addConfigNode(type, scope, name, label, config): void` | Register a custom field | -| `getConfigNodes(): array` | All registered fields, grouped by scope | +| Method | Description | +|---------------------------------------------------|-----------------------------------------| +| `getSite(): Site` | The site being configured | +| `addConfigNode(config, scope, name, label): void` | Register a custom field | +| `getConfigNodes(): array` | All registered fields, grouped by scope | -### `SiteCustomConfigNodeType` (enum) +### `addConfigNode()` Parameters -| Case | ExtJS field type | Use for | -|------------|------------------|-------------------------------| -| `INPUT` | `textfield` | Single-line text | -| `TEXT` | `textarea` | Multi-line text | -| `CHECKBOX` | `checkbox` | Boolean toggle | -| `DROPDOWN` | `combobox` | Select from a list of options | +| Parameter | Type | Description | +|-----------|-----------------------|------------------------------------------------------------------------------------------| +| `$config` | `NodeConfigInterface` | Typed DTO — determines field type and available options | +| `$scope` | `string` | Groups fields in the panel (e.g. `'app'`, `'seo'`); used as key when reading back values | +| `$name` | `string` | Field identifier within the scope | +| `$label` | `string` | Display label in the UI | -### `addConfigNode()` Parameters +### Config DTOs (`src/Dto/SiteCustomSettings/`) + +Each DTO corresponds to one ExtJS field type and exposes only the options that are valid for it: -| Parameter | Type | Description | -|-----------|----------------------------|-------------------------------------------------------------------------------------------------------| -| `$type` | `SiteCustomConfigNodeType` | The field type | -| `$scope` | `string` | Groups fields in the panel (e.g. `'app'`, `'seo'`); also used as key prefix when reading back values | -| `$name` | `string` | Field identifier within the scope | -| `$label` | `string` | Display label in the UI | -| `$config` | `array` | Additional ExtJS field config (e.g. `store` for dropdowns, `required`) | +| DTO | ExtJS type | Options | +|----------------------|-------------|---------------------------------------------------| +| `InputNodeConfig` | `textfield` | `required` | +| `TextNodeConfig` | `textarea` | `required` | +| `CheckboxNodeConfig` | `checkbox` | `checkedValue`, `uncheckedValue` | +| `DropdownNodeConfig` | `combobox` | `store`, `required`, `displayField`, `valueField` | ## Stored Data @@ -71,14 +72,12 @@ $all = $site->getCustomSettings(); ## Example: Adding a Zone Dropdown -This example adds a required dropdown: - ```php 'addPropertyToCustomSettings', + AdminEvents::SITE_CUSTOM_SETTINGS => 'addZoneConfig', ]; } - public function addPropertyToCustomSettings(SiteCustomSettingsEvent $event): void + public function addZoneConfig(SiteCustomSettingsEvent $event): void { $event->addConfigNode( - type: SiteCustomConfigNodeType::DROPDOWN, - scope: 'app', - name: 'my_property', - label: 'My Property', - config: [ - 'required' => true, - 'store' => [ - ['label' => 'Property 1', 'value' => 'property_1'], - ['label' => 'Property 2', 'value' => 'property_2'], + config: new DropdownNodeConfig( + store: [ + ['label' => 'Zone 1', 'value' => 'zone_1'], + ['label' => 'Zone 2', 'value' => 'zone_2'], ], - ] + required: true, + ), + scope: 'app', + name: 'zone', + label: 'Zone', ); } } ``` -Register in `services.yaml` (autoconfiguration handles `EventSubscriberInterface`): - -```yaml -services: - MyBundle\EventListener\Admin\SiteCustomConfigListener: - tags: - - { name: kernel.event_subscriber } -``` - ## Multiple Scopes Multiple bundles can each add their own scoped fields independently: ```php // Bundle A -$event->addConfigNode(SiteCustomConfigNodeType::INPUT, 'seo', 'tracking_id', 'GA Tracking ID', []); +$event->addConfigNode(new InputNodeConfig(), 'seo', 'tracking_id', 'GA Tracking ID'); // Bundle B -$event->addConfigNode(SiteCustomConfigNodeType::CHECKBOX, 'myapp', 'feature_enabled', 'Enable Feature', []); +$event->addConfigNode(new CheckboxNodeConfig(), 'myapp', 'feature_enabled', 'Enable Feature'); ``` Results in: @@ -144,5 +133,5 @@ $site->getCustomSettings() === [ - [`AdminEvents::SITE_CUSTOM_SETTINGS` source](https://github.com/open-dxp/admin-bundle/blob/1.x/src/Event/AdminEvents.php) - [`SiteCustomSettingsEvent` source](https://github.com/open-dxp/admin-bundle/blob/1.x/src/Event/SiteCustomSettingsEvent.php) -- [`SiteCustomConfigNodeType` source](https://github.com/open-dxp/admin-bundle/blob/1.x/src/Enum/SiteCustomConfigNodeType.php) +- [Config DTOs source](https://github.com/open-dxp/admin-bundle/blob/1.x/src/Dto/SiteCustomSettings/) - [Admin Events overview](../10_Extension_Points/01_Events.md) \ No newline at end of file diff --git a/src/Dto/SiteCustomSettings/CheckboxNodeConfig.php b/src/Dto/SiteCustomSettings/CheckboxNodeConfig.php new file mode 100644 index 00000000..b9821feb --- /dev/null +++ b/src/Dto/SiteCustomSettings/CheckboxNodeConfig.php @@ -0,0 +1,41 @@ + $this->checkedValue, + 'uncheckedValue' => $this->uncheckedValue, + ]; + } +} \ No newline at end of file diff --git a/src/Dto/SiteCustomSettings/DropdownNodeConfig.php b/src/Dto/SiteCustomSettings/DropdownNodeConfig.php new file mode 100644 index 00000000..3283c402 --- /dev/null +++ b/src/Dto/SiteCustomSettings/DropdownNodeConfig.php @@ -0,0 +1,45 @@ + $this->store, + 'required' => $this->required, + 'displayField' => $this->displayField, + 'valueField' => $this->valueField, + ]; + } +} \ No newline at end of file diff --git a/src/Dto/SiteCustomSettings/InputNodeConfig.php b/src/Dto/SiteCustomSettings/InputNodeConfig.php new file mode 100644 index 00000000..c632d8d1 --- /dev/null +++ b/src/Dto/SiteCustomSettings/InputNodeConfig.php @@ -0,0 +1,39 @@ + $this->required, + ]; + } +} \ No newline at end of file diff --git a/src/Dto/SiteCustomSettings/NodeConfigInterface.php b/src/Dto/SiteCustomSettings/NodeConfigInterface.php new file mode 100644 index 00000000..b8f4f303 --- /dev/null +++ b/src/Dto/SiteCustomSettings/NodeConfigInterface.php @@ -0,0 +1,26 @@ + $this->required, + ]; + } +} \ No newline at end of file diff --git a/src/Event/SiteCustomSettingsEvent.php b/src/Event/SiteCustomSettingsEvent.php index adb0a689..1a8b1fd1 100644 --- a/src/Event/SiteCustomSettingsEvent.php +++ b/src/Event/SiteCustomSettingsEvent.php @@ -17,7 +17,7 @@ namespace OpenDxp\Bundle\AdminBundle\Event; -use OpenDxp\Bundle\AdminBundle\Enum\SiteCustomConfigNodeType; +use OpenDxp\Bundle\AdminBundle\Dto\SiteCustomSettings\NodeConfigInterface; use OpenDxp\Model\Site; use Symfony\Contracts\EventDispatcher\Event; @@ -29,17 +29,17 @@ public function __construct(private readonly Site $site) { } - public function addConfigNode(SiteCustomConfigNodeType $type, string $scope, string $name, string $label, array $config): void + public function addConfigNode(NodeConfigInterface $config, string $scope, string $name, string $label): void { if (!array_key_exists($scope, $this->configNodes)) { $this->configNodes[$scope] = []; } $this->configNodes[$scope][] = [ - 'type' => $type->value, + 'type' => $config->getType()->value, 'name' => $name, 'label' => $label, - 'config' => $config, + 'config' => $config->toArray(), ]; } @@ -52,4 +52,4 @@ public function getSite(): Site { return $this->site; } -} +} \ No newline at end of file diff --git a/tests/Unit/Event/SiteCustomSettingsEventTest.php b/tests/Unit/Event/SiteCustomSettingsEventTest.php index 8edb622a..26f075be 100644 --- a/tests/Unit/Event/SiteCustomSettingsEventTest.php +++ b/tests/Unit/Event/SiteCustomSettingsEventTest.php @@ -16,6 +16,10 @@ namespace OpenDxp\Bundle\AdminBundle\Tests\Unit\Event; +use OpenDxp\Bundle\AdminBundle\Dto\SiteCustomSettings\CheckboxNodeConfig; +use OpenDxp\Bundle\AdminBundle\Dto\SiteCustomSettings\DropdownNodeConfig; +use OpenDxp\Bundle\AdminBundle\Dto\SiteCustomSettings\InputNodeConfig; +use OpenDxp\Bundle\AdminBundle\Dto\SiteCustomSettings\TextNodeConfig; use OpenDxp\Bundle\AdminBundle\Enum\SiteCustomConfigNodeType; use OpenDxp\Bundle\AdminBundle\Event\SiteCustomSettingsEvent; use OpenDxp\Bundle\AdminBundle\Tests\Support\Test\UnitTestCase; @@ -41,10 +45,10 @@ public function testAddConfigNodeGroupsByScope(): void { $event = new SiteCustomSettingsEvent($this->createSite()); - $event->addConfigNode(SiteCustomConfigNodeType::INPUT, 'seo', 'title', 'SEO Title', []); - $event->addConfigNode(SiteCustomConfigNodeType::CHECKBOX, 'seo', 'noindex', 'No Index', []); - $event->addConfigNode(SiteCustomConfigNodeType::DROPDOWN, 'i18n', 'zone', 'Zone', ['store' => []]); - $event->addConfigNode(SiteCustomConfigNodeType::TEXT, 'app', 'description', 'Description', []); + $event->addConfigNode(new InputNodeConfig(), 'seo', 'title', 'SEO Title'); + $event->addConfigNode(new CheckboxNodeConfig(), 'seo', 'noindex', 'No Index'); + $event->addConfigNode(new DropdownNodeConfig(), 'i18n', 'zone', 'Zone'); + $event->addConfigNode(new TextNodeConfig(), 'app', 'description', 'Description'); $nodes = $event->getConfigNodes(); @@ -58,11 +62,13 @@ public function testAddConfigNodeBuildsCorrectStructure(): void $event = new SiteCustomSettingsEvent($this->createSite()); $event->addConfigNode( - SiteCustomConfigNodeType::DROPDOWN, + new DropdownNodeConfig( + store: [['label' => 'A', 'value' => 'a']], + required: true, + ), 'app', 'my_field', 'My Field', - ['required' => true, 'store' => [['label' => 'A', 'value' => 'a']]] ); $node = $event->getConfigNodes()['app'][0]; @@ -70,28 +76,31 @@ public function testAddConfigNodeBuildsCorrectStructure(): void self::assertSame(SiteCustomConfigNodeType::DROPDOWN->value, $node['type']); self::assertSame('my_field', $node['name']); self::assertSame('My Field', $node['label']); - self::assertSame(['required' => true, 'store' => [['label' => 'A', 'value' => 'a']]], $node['config']); + self::assertTrue($node['config']['required']); + self::assertSame([['label' => 'A', 'value' => 'a']], $node['config']['store']); } public function testMultipleNodesInSameScopeAreAppended(): void { $event = new SiteCustomSettingsEvent($this->createSite()); - $event->addConfigNode(SiteCustomConfigNodeType::INPUT, 'app', 'first', 'First', []); - $event->addConfigNode(SiteCustomConfigNodeType::INPUT, 'app', 'second', 'Second', []); - $event->addConfigNode(SiteCustomConfigNodeType::INPUT, 'app', 'third', 'Third', []); + $event->addConfigNode(new InputNodeConfig(), 'app', 'first', 'First'); + $event->addConfigNode(new InputNodeConfig(), 'app', 'second', 'Second'); + $event->addConfigNode(new InputNodeConfig(), 'app', 'third', 'Third'); - self::assertCount(3, $event->getConfigNodes()['app']); - self::assertSame('first', $event->getConfigNodes()['app'][0]['name']); - self::assertSame('second', $event->getConfigNodes()['app'][1]['name']); - self::assertSame('third', $event->getConfigNodes()['app'][2]['name']); + $nodes = $event->getConfigNodes()['app']; + + self::assertCount(3, $nodes); + self::assertSame('first', $nodes[0]['name']); + self::assertSame('second', $nodes[1]['name']); + self::assertSame('third', $nodes[2]['name']); } - public function testNodeTypeValuesMatchExpectedExtJsTypes(): void + public function testEachDtoReturnsCorrectType(): void { - self::assertSame('input', SiteCustomConfigNodeType::INPUT->value); - self::assertSame('text', SiteCustomConfigNodeType::TEXT->value); - self::assertSame('checkbox', SiteCustomConfigNodeType::CHECKBOX->value); - self::assertSame('combobox', SiteCustomConfigNodeType::DROPDOWN->value); + self::assertSame(SiteCustomConfigNodeType::INPUT, (new InputNodeConfig())->getType()); + self::assertSame(SiteCustomConfigNodeType::TEXT, (new TextNodeConfig())->getType()); + self::assertSame(SiteCustomConfigNodeType::CHECKBOX, (new CheckboxNodeConfig())->getType()); + self::assertSame(SiteCustomConfigNodeType::DROPDOWN, (new DropdownNodeConfig())->getType()); } } \ No newline at end of file From f02950ec777683d2dfc291daafe4c440588dea37 Mon Sep 17 00:00:00 2001 From: Stefan Hagspiel Date: Mon, 9 Mar 2026 13:28:29 +0100 Subject: [PATCH 5/7] add checkbox value type support for custom settings, improve documentation formatting, and adjust UI field label width --- README.md | 10 +++- docs/10_Extension_Points/01_Events.md | 56 +++++++++---------- docs/20_Documents/README.md | 4 +- public/js/opendxp/document/tree.js | 2 +- .../Admin/Document/DocumentController.php | 7 ++- 5 files changed, 45 insertions(+), 34 deletions(-) diff --git a/README.md b/README.md index 49b65311..68c833d3 100644 --- a/README.md +++ b/README.md @@ -31,13 +31,19 @@ And much more ... Following topics are short-cuts into the documentation for admin interface: +### Starting with OpenDXP Core - [Getting Started](https://github.com/open-dxp/opendxp/blob/1.x/doc/01_Getting_Started/06_Create_a_First_Project.md) - [User & Roles](https://github.com/open-dxp/opendxp/blob/1.x/doc/22_Administration_of_OpenDxp/07_Users_and_Roles.md) -- [Deeplinks](https://github.com/open-dxp/opendxp/blob/1.x/doc/20_Extending_OpenDxp/23_Deeplinks_into_Admin_Interface.md) - [Admin Translations](https://github.com/open-dxp/opendxp/blob/1.x/doc/06_Multi_Language_i18n/07_Admin_Translations.md) -- [Extending Admin UI](https://github.com/open-dxp/opendxp/blob/1.x/doc/20_Extending_OpenDxp/13_Bundle_Developers_Guide/06_Event_Listener_UI.md) + +### Admin Documentation +- [Architecture](docs/00_Architecture/README.md) +- [Extension_Points](docs/10_Extension_Points) +- [Deeplinks](docs/10_Extension_Points/06_Deeplinks.md) - 🤖 [Testing with AI (Claude)](https://github.com/open-dxp/opendxp/doc/19_Development_Tools_and_Details/50_Testing_with_AI.md) - Write, run and fix tests with Claude Code +=> [Full Documentation](docs/README.md) + *** ## Upstream Origin & Version Transparency diff --git a/docs/10_Extension_Points/01_Events.md b/docs/10_Extension_Points/01_Events.md index b53537f9..78d2e718 100644 --- a/docs/10_Extension_Points/01_Events.md +++ b/docs/10_Extension_Points/01_Events.md @@ -37,54 +37,54 @@ Symfony autoconfiguration registers the subscriber automatically when `EventSubs ### Document Events -| Constant | Event Class | Description | -|---|---|---| -| `DOCUMENT_GET_PRE_SEND_DATA` | — | Before document data is sent to the frontend | -| `DOCUMENT_LIST_BEFORE_LIST_LOAD` | — | Before document listing is loaded | -| `DOCUMENT_LIST_AFTER_LIST_LOAD` | — | After document listing is loaded | +| Constant | Event Class | Description | +|----------------------------------|-------------|----------------------------------------------| +| `DOCUMENT_GET_PRE_SEND_DATA` | — | Before document data is sent to the frontend | +| `DOCUMENT_LIST_BEFORE_LIST_LOAD` | — | Before document listing is loaded | +| `DOCUMENT_LIST_AFTER_LIST_LOAD` | — | After document listing is loaded | ### Asset Events -| Constant | Event Class | Description | -|---|---|---| -| `ASSET_GET_PRE_SEND_DATA` | — | Before asset data is sent to the frontend | -| `ASSET_LIST_BEFORE_LIST_LOAD` | — | Before asset listing is loaded | -| `ASSET_LIST_AFTER_LIST_LOAD` | — | After asset listing is loaded | +| Constant | Event Class | Description | +|-------------------------------|-------------|-------------------------------------------| +| `ASSET_GET_PRE_SEND_DATA` | — | Before asset data is sent to the frontend | +| `ASSET_LIST_BEFORE_LIST_LOAD` | — | Before asset listing is loaded | +| `ASSET_LIST_AFTER_LIST_LOAD` | — | After asset listing is loaded | ### Object Events -| Constant | Event Class | Description | -|---|---|---| -| `OBJECT_GET_PRE_SEND_DATA` | — | Before data object data is sent to the frontend | -| `OBJECT_LIST_BEFORE_LIST_LOAD` | — | Before object listing is loaded | -| `OBJECT_LIST_AFTER_LIST_LOAD` | — | After object listing is loaded | +| Constant | Event Class | Description | +|--------------------------------|-------------|-------------------------------------------------| +| `OBJECT_GET_PRE_SEND_DATA` | — | Before data object data is sent to the frontend | +| `OBJECT_LIST_BEFORE_LIST_LOAD` | — | Before object listing is loaded | +| `OBJECT_LIST_AFTER_LIST_LOAD` | — | After object listing is loaded | ### Element Style Events -| Constant | Event Class | Description | -|---|---|---| +| Constant | Event Class | Description | +|----------------------------------------|--------------------------|--------------------------------------------------------| | `ELEMENT_ADMIN_STYLE_GET_FOR_DOCUMENT` | `ElementAdminStyleEvent` | Customize admin style (icon, CSS class) for a document | -| `ELEMENT_ADMIN_STYLE_GET_FOR_ASSET` | `ElementAdminStyleEvent` | Customize admin style for an asset | -| `ELEMENT_ADMIN_STYLE_GET_FOR_OBJECT` | `ElementAdminStyleEvent` | Customize admin style for a data object | +| `ELEMENT_ADMIN_STYLE_GET_FOR_ASSET` | `ElementAdminStyleEvent` | Customize admin style for an asset | +| `ELEMENT_ADMIN_STYLE_GET_FOR_OBJECT` | `ElementAdminStyleEvent` | Customize admin style for a data object | ### Login Events -| Constant | Event Class | Description | -|---|---|---| +| Constant | Event Class | Description | +|---------------------|-------------------------------|---------------------------------------| | `LOGIN_CREDENTIALS` | `Login\LoginCredentialsEvent` | After login credentials are submitted | -| `LOGIN_FAILED` | `Login\LoginFailedEvent` | After a failed login attempt | +| `LOGIN_FAILED` | `Login\LoginFailedEvent` | After a failed login attempt | ### Perspective Events -| Constant | Event Class | Description | -|---|---|---| -| `PERSPECTIVE_PRE_GET_RUNTIME` | — | Before perspective runtime data is assembled | -| `PERSPECTIVE_POST_GET_RUNTIME` | — | After perspective runtime data is assembled | +| Constant | Event Class | Description | +|--------------------------------|-------------|----------------------------------------------| +| `PERSPECTIVE_PRE_GET_RUNTIME` | — | Before perspective runtime data is assembled | +| `PERSPECTIVE_POST_GET_RUNTIME` | — | After perspective runtime data is assembled | ### Site Events -| Constant | Event Class | Description | -|---|---|---| +| Constant | Event Class | Description | +|------------------------|---------------------------|-----------------------------------------------------------------------------------| | `SITE_CUSTOM_SETTINGS` | `SiteCustomSettingsEvent` | Fired when the site panel renders or saves, allows injecting custom config fields | See [Site Custom Settings](../20_Documents/01_Site_Custom_Settings.md) for a full example. diff --git a/docs/20_Documents/README.md b/docs/20_Documents/README.md index 899d640b..57e0f0a1 100644 --- a/docs/20_Documents/README.md +++ b/docs/20_Documents/README.md @@ -2,6 +2,6 @@ Admin UI features specific to document and site management. -| Topic | Description | -|---|---| +| Topic | Description | +|----------------------------------------------------|-------------------------------------------------------------------| | [Site Custom Settings](01_Site_Custom_Settings.md) | Inject custom fields into the site configuration panel via events | \ No newline at end of file diff --git a/public/js/opendxp/document/tree.js b/public/js/opendxp/document/tree.js index 70dbd305..96497f0a 100644 --- a/public/js/opendxp/document/tree.js +++ b/public/js/opendxp/document/tree.js @@ -1705,7 +1705,7 @@ opendxp.document.tree = Class.create({ xtype: configNode.type, fieldLabel: configNode.label, name: 'customSettings.' + scope + '.' + configNode.name, - labelWidth: 100, + labelWidth: 200, anchor: '100%', }; diff --git a/src/Controller/Admin/Document/DocumentController.php b/src/Controller/Admin/Document/DocumentController.php index a800cb71..69befdde 100644 --- a/src/Controller/Admin/Document/DocumentController.php +++ b/src/Controller/Admin/Document/DocumentController.php @@ -774,7 +774,12 @@ public function updateSiteAction(Request $request, EventDispatcherInterface $eve foreach ($nodes as $node) { $requestValueName = sprintf('customSettings_%s_%s', $scope, $node['name']); if ($request->request->has($requestValueName)) { - $customSettings[$scope][$node['name']] = $request->request->get($requestValueName); + $value = $request->request->get($requestValueName); + if ($node['type'] === OpenDxp\Bundle\AdminBundle\Enum\SiteCustomConfigNodeType::CHECKBOX->value) { + $value = $value === 'true'; + } + + $customSettings[$scope][$node['name']] = $value; } } } From 24bc25fd0ef22b416d11163983ee5b3b2e36e17f Mon Sep 17 00:00:00 2001 From: Stefan Hagspiel Date: Mon, 9 Mar 2026 15:43:50 +0100 Subject: [PATCH 6/7] bump open-dxp dependency to ^1.3.0 in admin bundle --- composer.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/composer.json b/composer.json index a81c98e6..391ea3a7 100644 --- a/composer.json +++ b/composer.json @@ -21,7 +21,7 @@ "cbschuld/browser.php": "^1.9.6", "endroid/qr-code": "^4 || ^5.1", "phpoffice/phpspreadsheet": "^2.2 || ^3.3", - "open-dxp/opendxp": "^1.2.0", + "open-dxp/opendxp": "^1.3.0", "symfony/webpack-encore-bundle": "^2.1.1" }, "require-dev": { From 0259e50b2d68447b20eafdbc0d0d80f0c5b27fee Mon Sep 17 00:00:00 2001 From: Stefan Hagspiel Date: Mon, 9 Mar 2026 15:59:00 +0100 Subject: [PATCH 7/7] mark custom settings node config DTOs as `final` for improved stability and clarity --- src/Dto/SiteCustomSettings/CheckboxNodeConfig.php | 2 +- src/Dto/SiteCustomSettings/DropdownNodeConfig.php | 2 +- src/Dto/SiteCustomSettings/InputNodeConfig.php | 2 +- src/Dto/SiteCustomSettings/TextNodeConfig.php | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/Dto/SiteCustomSettings/CheckboxNodeConfig.php b/src/Dto/SiteCustomSettings/CheckboxNodeConfig.php index b9821feb..965c7dbe 100644 --- a/src/Dto/SiteCustomSettings/CheckboxNodeConfig.php +++ b/src/Dto/SiteCustomSettings/CheckboxNodeConfig.php @@ -18,7 +18,7 @@ use OpenDxp\Bundle\AdminBundle\Enum\SiteCustomConfigNodeType; -readonly class CheckboxNodeConfig implements NodeConfigInterface +final readonly class CheckboxNodeConfig implements NodeConfigInterface { public function __construct( public bool|string $checkedValue = true, diff --git a/src/Dto/SiteCustomSettings/DropdownNodeConfig.php b/src/Dto/SiteCustomSettings/DropdownNodeConfig.php index 3283c402..9661f1e9 100644 --- a/src/Dto/SiteCustomSettings/DropdownNodeConfig.php +++ b/src/Dto/SiteCustomSettings/DropdownNodeConfig.php @@ -18,7 +18,7 @@ use OpenDxp\Bundle\AdminBundle\Enum\SiteCustomConfigNodeType; -readonly class DropdownNodeConfig implements NodeConfigInterface +final readonly class DropdownNodeConfig implements NodeConfigInterface { public function __construct( public array $store = [], diff --git a/src/Dto/SiteCustomSettings/InputNodeConfig.php b/src/Dto/SiteCustomSettings/InputNodeConfig.php index c632d8d1..5ded1247 100644 --- a/src/Dto/SiteCustomSettings/InputNodeConfig.php +++ b/src/Dto/SiteCustomSettings/InputNodeConfig.php @@ -18,7 +18,7 @@ use OpenDxp\Bundle\AdminBundle\Enum\SiteCustomConfigNodeType; -readonly class InputNodeConfig implements NodeConfigInterface +final readonly class InputNodeConfig implements NodeConfigInterface { public function __construct( public bool $required = false, diff --git a/src/Dto/SiteCustomSettings/TextNodeConfig.php b/src/Dto/SiteCustomSettings/TextNodeConfig.php index 970df5c6..e60a0a63 100644 --- a/src/Dto/SiteCustomSettings/TextNodeConfig.php +++ b/src/Dto/SiteCustomSettings/TextNodeConfig.php @@ -18,7 +18,7 @@ use OpenDxp\Bundle\AdminBundle\Enum\SiteCustomConfigNodeType; -readonly class TextNodeConfig implements NodeConfigInterface +final readonly class TextNodeConfig implements NodeConfigInterface { public function __construct( public bool $required = false,