diff --git a/bundles/AdminBundle/public/css/icons.css b/bundles/AdminBundle/public/css/icons.css index db18d15eb83..9aa132f64e3 100644 --- a/bundles/AdminBundle/public/css/icons.css +++ b/bundles/AdminBundle/public/css/icons.css @@ -335,6 +335,10 @@ background: url(/bundles/pimcoreadmin/img/flat-white-icons/bricks.svg) center center no-repeat !important; } +.pimcore_nav_icon_selectoptions { + background: url(/bundles/pimcoreadmin/img/flat-white-icons/expand.svg) center center no-repeat !important; +} + .pimcore_nav_icon_quantityValue { background: url(/bundles/pimcoreadmin/img/flat-white-icons/calculator.svg) center center no-repeat !important; } diff --git a/bundles/AdminBundle/public/js/pimcore/layout/toolbar.js b/bundles/AdminBundle/public/js/pimcore/layout/toolbar.js index 0c92cca4cd7..b2667663283 100644 --- a/bundles/AdminBundle/public/js/pimcore/layout/toolbar.js +++ b/bundles/AdminBundle/public/js/pimcore/layout/toolbar.js @@ -594,6 +594,15 @@ pimcore.layout.toolbar = Class.create({ }); } + if (perspectiveCfg.inToolbar('settings.objects.selectoptions') && user.isAllowed('selectoptions')) { + objectMenu.menu.items.push({ + text: t('selectoptions'), + iconCls: 'pimcore_nav_icon_selectoptions', + itemId: 'pimcore_menu_settings_data_objects_selectoptions', + handler: this.editSelectOptions + }); + } + if (perspectiveCfg.inToolbar("settings.objects.quantityValue") && user.isAllowed("quantityValueUnits")) { objectMenu.menu.items.push({ text: t("quantityValue_field"), @@ -787,7 +796,7 @@ pimcore.layout.toolbar = Class.create({ } }); } - + // help menu menu.settings = { label: t('settings'), @@ -1176,6 +1185,14 @@ pimcore.layout.toolbar = Class.create({ } }, + editSelectOptions: function () { + try { + pimcore.globalmanager.get('selectoptions').activate(); + } catch (e) { + pimcore.globalmanager.add('selectoptions', new pimcore.object.selectoptions()); + } + }, + clearCache: function (params) { Ext.Msg.confirm(t('warning'), t('system_performance_stability_warning'), function(btn){ if (btn == 'yes'){ diff --git a/bundles/AdminBundle/public/js/pimcore/object/classes/data/multiselect.js b/bundles/AdminBundle/public/js/pimcore/object/classes/data/multiselect.js index e36b6c1195b..1827a551129 100644 --- a/bundles/AdminBundle/public/js/pimcore/object/classes/data/multiselect.js +++ b/bundles/AdminBundle/public/js/pimcore/object/classes/data/multiselect.js @@ -277,24 +277,14 @@ pimcore.object.classes.data.multiselect = Class.create(pimcore.object.classes.da triggerAction: "all", editable: false, forceSelection: true - }, - { - xtype: "textfield", - fieldLabel: t("options_provider_class"), - width: 600, - name: "optionsProviderClass", - value: datax.optionsProviderClass - }, - { - xtype: "textfield", - fieldLabel: t("options_provider_data"), - width: 600, - value: datax.optionsProviderData, - name: "optionsProviderData" - }, - valueGrid + } ]; + specificItems = specificItems.concat( + pimcore.object.helpers.selectField.getOptionsProviderFields(datax, valueGrid) + ); + specificItems.push(valueGrid); + return specificItems; }, @@ -331,6 +321,7 @@ pimcore.object.classes.data.multiselect = Class.create(pimcore.object.classes.da height: source.datax.height, maxItems: source.datax.maxItems, renderType: source.datax.renderType, + optionsProviderType: source.datax.optionsProviderType, optionsProviderClass: source.datax.optionsProviderClass, optionsProviderData: source.datax.optionsProviderData }); diff --git a/bundles/AdminBundle/public/js/pimcore/object/classes/data/select.js b/bundles/AdminBundle/public/js/pimcore/object/classes/data/select.js index e45b2b3ecaa..6663c720a3d 100644 --- a/bundles/AdminBundle/public/js/pimcore/object/classes/data/select.js +++ b/bundles/AdminBundle/public/js/pimcore/object/classes/data/select.js @@ -271,21 +271,9 @@ pimcore.object.classes.data.select = Class.create(pimcore.object.classes.data.da value: datax.defaultValueGenerator }); - items.push({ - xtype: "textfield", - fieldLabel: t("options_provider_class"), - width: 600, - name: "optionsProviderClass", - value: datax.optionsProviderClass - }); - - items.push({ - xtype: "textfield", - fieldLabel: t("options_provider_data"), - width: 600, - value: datax.optionsProviderData, - name: "optionsProviderData" - }); + items = items.concat( + pimcore.object.helpers.selectField.getOptionsProviderFields(datax, valueGrid) + ) items.push(valueGrid); return items; @@ -318,6 +306,7 @@ pimcore.object.classes.data.select = Class.create(pimcore.object.classes.data.da { options: source.datax.options, width: source.datax.width, + optionsProviderType: source.datax.optionsProviderType, optionsProviderClass: source.datax.optionsProviderClass, optionsProviderData: source.datax.optionsProviderData, defaultValue: source.datax.defaultValue, diff --git a/bundles/AdminBundle/public/js/pimcore/object/helpers/grid.js b/bundles/AdminBundle/public/js/pimcore/object/helpers/grid.js index 22859aad136..f8ba5eeb808 100644 --- a/bundles/AdminBundle/public/js/pimcore/object/helpers/grid.js +++ b/bundles/AdminBundle/public/js/pimcore/object/helpers/grid.js @@ -84,7 +84,11 @@ pimcore.object.helpers.grid = Class.create({ var key = fieldConfig.key; var readerFieldConfig = {name: key}; // dynamic select returns data + options on cell level - if ((type == "select" || type == "multiselect") && fieldConfig.layout.optionsProviderClass) { + if ( + (type == "select" || type == "multiselect") + && fieldConfig.layout.optionsProviderType !== pimcore.object.helpers.selectField.OPTIONS_PROVIDER_TYPE_CLASS + && fieldConfig.layout.optionsProviderClass + ) { if (typeof noBatchColumns != "undefined") { if (fieldConfig.layout.dynamicOptions) { noBatchColumns.push(key); diff --git a/bundles/AdminBundle/public/js/pimcore/object/helpers/reservedWords.js b/bundles/AdminBundle/public/js/pimcore/object/helpers/reservedWords.js new file mode 100644 index 00000000000..39e72925fea --- /dev/null +++ b/bundles/AdminBundle/public/js/pimcore/object/helpers/reservedWords.js @@ -0,0 +1,55 @@ +/** + * Pimcore + * + * This source file is available under two different licenses: + * - GNU General Public License version 3 (GPLv3) + * - Pimcore Commercial License (PCL) + * Full copyright and license information is available in + * LICENSE.md which is distributed with this source code. + * + * @copyright Copyright (c) Pimcore GmbH (http://www.pimcore.org) + * @license http://www.pimcore.org/license GPLv3 and PCL + */ + +pimcore.registerNS('pimcore.object.helpers.reservedWords'); + +pimcore.object.helpers.reservedWords = { + // https://www.php.net/manual/en/reserved.keywords.php + phpReservedKeywords: [ + 'abstract', 'and', 'array', 'as', 'break', 'callable', 'case', 'catch', 'class', 'clone', 'const', 'continue', + 'declare', 'default', 'die', 'do', 'echo', 'else', 'elseif', 'empty', 'enddeclare', 'endfor', 'endforeach', + 'endif', 'endswitch', 'endwhile', 'eval', 'exit', 'extends', 'final', 'finally', 'fn', 'for', 'foreach', + 'function', 'global', 'goto', 'if', 'implements', 'include', 'include_once', 'instanceof', 'insteadof', + 'interface', 'isset', 'list', 'match', 'namespace', 'new', 'or', 'print', 'private', 'protected', 'public', + 'readonly', 'require', 'require_once', 'return', 'static', 'switch', 'throw', 'trait', 'try', 'unset', 'use', + 'var', 'while', 'xor', 'yield', 'yield_from' + ], + + // https://www.php.net/manual/en/reserved.classes.php + phpReservedClasses: [ + 'self', 'static', 'parent' + ], + + // https://www.php.net/manual/en/reserved.other-reserved-words.php + phpOtherReservedWords: [ + 'int', 'float', 'bool', 'string', 'true', 'false', 'null', 'void', 'iterable', 'object', 'mixed', 'never', + 'enum', 'resource', 'numeric' + ], + + pimcore: [ + // Pimcore + 'data', 'folder', 'permissions', 'dao', 'concrete', 'items' + ], + + isReservedWord: function (word) { + return in_arrayi(word, this.getAllReservedWords()); + }, + + getAllReservedWords: function () { + return this.phpReservedKeywords.concat( + this.phpReservedClasses, + this.phpOtherReservedWords, + this.pimcore + ); + } +}; diff --git a/bundles/AdminBundle/public/js/pimcore/object/helpers/selectField.js b/bundles/AdminBundle/public/js/pimcore/object/helpers/selectField.js new file mode 100644 index 00000000000..5a069ae6fa9 --- /dev/null +++ b/bundles/AdminBundle/public/js/pimcore/object/helpers/selectField.js @@ -0,0 +1,163 @@ +/** + * Pimcore + * + * This source file is available under two different licenses: + * - GNU General Public License version 3 (GPLv3) + * - Pimcore Commercial License (PCL) + * Full copyright and license information is available in + * LICENSE.md which is distributed with this source code. + * + * @copyright Copyright (c) Pimcore GmbH (http://www.pimcore.org) + * @license http://www.pimcore.org/license GPLv3 and PCL + */ + +pimcore.registerNS('pimcore.object.helpers.selectField'); + +/** + * @private + */ +pimcore.object.helpers.selectField = { + OPTIONS_PROVIDER_TYPE_CONFIGURE: 'configure', + OPTIONS_PROVIDER_TYPE_SELECT_OPTIONS: 'select_options', + OPTIONS_PROVIDER_TYPE_CLASS: 'class', + + selectOptionsStore: null, + + /** + * @param {Object} datax + * @param {Ext.grid.Panel} valueGrid + * @returns {[ + * Ext.form.field.ComboBox, + * Ext.form.field.Text, + * Ext.form.field.Text, + * Ext.form.field.ComboBox + * ]} + */ + getOptionsProviderFields: function (datax, valueGrid) { + var selectOptionsSelector = Ext.create('Ext.form.field.ComboBox', { + fieldLabel: t('selectoptions'), + emptyText: '', + value: datax.optionsProviderData, + hidden: true, + valueField: 'id', + displayField: 'text', + editable: false, + forceSelection: true, + queryMode: 'local', + store: this.getSelectOptionsStore(), + listeners: { + change: function (comboBox, newValue) { + optionsProviderClass.setValue('\\Pimcore\\Bundle\\AdminBundle\\OptionsProvider\\SelectOptionsOptionsProvider'); + optionsProviderData.setValue(newValue); + } + } + }); + + var optionsProviderClass = Ext.create('Ext.form.field.Text', { + fieldLabel: t('options_provider_class'), + width: 600, + name: 'optionsProviderClass', + hidden: true, + value: datax.optionsProviderClass + }); + + var optionsProviderData = Ext.create('Ext.form.field.Text', { + fieldLabel: t('options_provider_data'), + width: 600, + value: datax.optionsProviderData, + hidden: true, + name: 'optionsProviderData' + }); + + var toggleFields = function (optionsProviderType) { + switch (optionsProviderType) { + case this.OPTIONS_PROVIDER_TYPE_SELECT_OPTIONS: + optionsProviderClass.hide(); + optionsProviderData.hide(); + selectOptionsSelector.show(); + valueGrid.hide(); + break; + case this.OPTIONS_PROVIDER_TYPE_CLASS: + optionsProviderClass.show(); + optionsProviderData.show(); + selectOptionsSelector.hide(); + valueGrid.hide(); + break; + // Configure + default: + optionsProviderClass.hide(); + optionsProviderData.hide(); + selectOptionsSelector.hide(); + valueGrid.show(); + } + }.bind(this) + + var typeValue = this.OPTIONS_PROVIDER_TYPE_CONFIGURE; + if (datax.optionsProviderType) { + typeValue = datax.optionsProviderType; + // Legacy fallback in case no type is set and a class/service is configured + } else if (datax.optionsProviderClass) { + typeValue = this.OPTIONS_PROVIDER_TYPE_CLASS; + } + + toggleFields(typeValue); + + var optionsProviderType = Ext.create('Ext.form.field.ComboBox', { + name: 'optionsProviderType', + fieldLabel: t('options_provider_type'), + value: typeValue, + valueField: 'value', + displayField: 'label', + editable: false, + forceSelection: true, + queryMode: 'local', + store: Ext.create('Ext.data.Store', { + fields: ['value', 'label'], + data: [ + {value: this.OPTIONS_PROVIDER_TYPE_CONFIGURE, label: t('options_provider_type_configure')}, + {value: this.OPTIONS_PROVIDER_TYPE_SELECT_OPTIONS, label: t('options_provider_type_select_options')}, + {value: this.OPTIONS_PROVIDER_TYPE_CLASS, label: t('options_provider_type_class')} + ] + }), + listeners: { + change: function (comboBox, newValue) { + toggleFields(newValue); + } + } + }); + + return [ + optionsProviderType, + optionsProviderClass, + optionsProviderData, + selectOptionsSelector + ]; + }, + + /** + * @returns {Ext.data.JsonStore} + */ + getSelectOptionsStore: function () { + if (this.selectOptionsStore === null) { + this.selectOptionsStore = Ext.create('Ext.data.JsonStore', { + fields: [ + {name: 'id'}, + {name: 'text'} + ], + autoLoad: true, + proxy: { + type: 'ajax', + url: Routing.generate('pimcore_admin_dataobject_class_selectoptionstree'), + reader: { + type: 'json' + }, + extraParams: { + grouped: 0 + } + }, + }); + } + + return this.selectOptionsStore; + } +}; diff --git a/bundles/AdminBundle/public/js/pimcore/object/selectoptions.js b/bundles/AdminBundle/public/js/pimcore/object/selectoptions.js new file mode 100644 index 00000000000..8826e0d4fd3 --- /dev/null +++ b/bundles/AdminBundle/public/js/pimcore/object/selectoptions.js @@ -0,0 +1,257 @@ +/** + * Pimcore + * + * This source file is available under two different licenses: + * - GNU General Public License version 3 (GPLv3) + * - Pimcore Commercial License (PCL) + * Full copyright and license information is available in + * LICENSE.md which is distributed with this source code. + * + * @copyright Copyright (c) Pimcore GmbH (http://www.pimcore.org) + * @license http://www.pimcore.org/license GPLv3 and PCL + */ + +pimcore.registerNS('pimcore.object.selectoptions'); + +/** + * @private + */ +pimcore.object.selectoptions = Class.create({ + initialize: function () { + this.getTabPanel(); + }, + + getTabPanel: function () { + if (!this.panel) { + this.panel = new Ext.Panel({ + id: 'pimcore_selectoptions', + title: t('selectoptions'), + iconCls: 'pimcore_icon_select', + border: false, + layout: 'border', + closable:true, + items: [this.getTree(), this.getEditPanel()] + }); + + var tabPanel = Ext.getCmp('pimcore_panel_tabs'); + tabPanel.add(this.panel); + tabPanel.setActiveItem('pimcore_selectoptions'); + + this.panel.on('destroy', function () { + pimcore.globalmanager.remove('selectoptions'); + }.bind(this)); + + pimcore.layout.refresh(); + } + + return this.panel; + }, + + getTree: function () { + if (!this.tree) { + this.store = Ext.create('Ext.data.TreeStore', { + autoLoad: false, + autoSync: true, + proxy: { + type: 'ajax', + url: Routing.generate('pimcore_admin_dataobject_class_selectoptionstree'), + reader: { + type: 'json' + + }, + extraParams: { + grouped: 1 + } + } + }); + + this.tree = Ext.create('Ext.tree.Panel', { + id: 'pimcore_panel_selectoptions_tree', + store: this.store, + region: 'west', + autoScroll:true, + animate:false, + containerScroll: true, + width: 200, + split: true, + root: { + id: '0' + }, + listeners: this.getTreeNodeListeners(), + rootVisible: false, + tbar: { + cls: 'pimcore_toolbar_border_bottom', + items: [ + { + text: t('add'), + iconCls: 'pimcore_icon_select pimcore_icon_overlay_add', + handler: this.addDefinition.bind(this), + disabled: !pimcore.settings['select-options-writeable'] + } + ] + } + }); + + this.tree.on('render', function () { + this.getRootNode().expand(); + }); + } + + return this.tree; + }, + + getEditPanel: function () { + if (!this.editPanel) { + this.editPanel = Ext.create('Ext.tab.Panel', { + region: "center", + plugins: + [ + Ext.create('Ext.ux.TabCloseMenu', { + showCloseAll: true, + showCloseOthers: true + }), + Ext.create('Ext.ux.TabReorderer', {}) + ] + }); + } + + return this.editPanel; + }, + + getTreeNodeListeners: function () { + var treeNodeListeners = { + 'itemclick': this.onTreeNodeClick.bind(this), + 'itemcontextmenu': this.onTreeNodeContextmenu.bind(this), + 'beforeitemmove': this.onTreeNodeBeforeMove.bind(this) + }; + return treeNodeListeners; + }, + + onTreeNodeClick: function (tree, record) { + if (!record.isLeaf()) { + return; + } + this.openSelectOptions(record.data.id); + }, + + openSelectOptions: function (id) { + if (Ext.getCmp('pimcore_selectoptions_editor_panel_' + id)) { + this.getEditPanel().setActiveTab(Ext.getCmp('pimcore_selectoptions_editor_panel_' + id)); + return; + } + + Ext.Ajax.request({ + url: Routing.generate('pimcore_admin_dataobject_class_selectoptionsget'), + params: { + id: id + }, + success: this.addDefinitionPanel.bind(this) + }); + }, + + addDefinitionPanel: function (response) { + var data = Ext.decode(response.responseText); + new pimcore.object.selectoptionsitems.definition( + data, + this, + this.openSelectOptions.bind(this, data.id), + 'pimcore_selectoptions_editor_panel_' + ); + pimcore.layout.refresh(); + }, + + onTreeNodeContextmenu: function (tree, record, item, index, e, eOpts) { + if (!record.isLeaf()) { + return; + } + + e.stopEvent(); + tree.select(); + + var menu = new Ext.menu.Menu(); + menu.add(new Ext.menu.Item({ + text: t('delete'), + iconCls: 'pimcore_icon_select pimcore_icon_overlay_delete', + handler: this.deleteDefinition.bind(this, tree, record) + })); + + menu.showAt(e.pageX, e.pageY); + }, + + onTreeNodeBeforeMove: function (node, oldParent, newParent, index, eOpts ) { + return pimcore.helpers.treeDragDropValidate(node, oldParent, newParent); + }, + + addDefinition: function () { + Ext.MessageBox.prompt(' ', t('enter_the_name_of_the_new_item'), + this.addDefinitionComplete.bind(this), null, null, ''); + }, + + addDefinitionComplete: function (button, value, object) { + var isValidName = /^[A-Z][a-zA-Z0-9]*$/; + + if ( + button !== 'ok' + || value.length < 3 + || !isValidName.test(value) + || pimcore.object.helpers.reservedWords.isReservedWord(value) + ) { + if (button !== 'cancel') { + Ext.Msg.alert(' ', t('failed_to_create_new_item_select_options')); + } + return; + } + + Ext.Ajax.request({ + url: Routing.generate('pimcore_admin_dataobject_class_selectoptionsupdate'), + method: 'POST', + params: { + id: value, + task: 'add' + }, + success: function (response) { + this.tree.getStore().load(); + + var data = Ext.decode(response.responseText); + if (!data) { + return; + } + + if (data.success) { + this.openSelectOptions(data.id); + pimcore.object.helpers.selectField.getSelectOptionsStore().reload(); + } else { + pimcore.helpers.showNotification(t('error'), data.message, 'error', response.responseText); + } + }.bind(this) + }); + }, + + activate: function () { + Ext.getCmp('pimcore_panel_tabs').setActiveItem('pimcore_selectoptions'); + }, + + deleteDefinition: function (tree, record) { + Ext.Msg.confirm(t('delete'), sprintf(t('delete_message_advanced'), t('selectoptions'), record.data.text), function (btn) { + if (btn === 'yes') { + Ext.Ajax.request({ + url: Routing.generate('pimcore_admin_dataobject_class_selectoptionsdelete'), + method: 'DELETE', + params: { + id: record.data.id + }, + success: function (response) { + var data = Ext.decode(response.responseText); + if (data && data.success === false) { + pimcore.helpers.showNotification(t('error'), data.message, 'error', response.responseText); + return; + } + + this.getEditPanel().removeAll(); + record.remove(); + }.bind(this) + }); + } + }.bind(this)); + } +}); diff --git a/bundles/AdminBundle/public/js/pimcore/object/selectoptionsitems/definition.js b/bundles/AdminBundle/public/js/pimcore/object/selectoptionsitems/definition.js new file mode 100644 index 00000000000..7b602f0770b --- /dev/null +++ b/bundles/AdminBundle/public/js/pimcore/object/selectoptionsitems/definition.js @@ -0,0 +1,483 @@ +/** + * Pimcore + * + * This source file is available under two different licenses: + * - GNU General Public License version 3 (GPLv3) + * - Pimcore Commercial License (PCL) + * Full copyright and license information is available in + * LICENSE.md which is distributed with this source code. + * + * @copyright Copyright (c) Pimcore GmbH (http://www.pimcore.org) + * @license http://www.pimcore.org/license GPLv3 and PCL + */ + +pimcore.registerNS('pimcore.object.selectoptionsitems.definition'); + +/** + * @private + */ +pimcore.object.selectoptionsitems.definition = Class.create({ + parentPanel: null, + data: { + id: null, + enumName: null, + group: null, + useTraits: '', + implementsInterfaces: '', + selectOptions: [] + }, + editorPrefix: null, + reopen: null, + + panel: null, + formPanel: null, + groupField: null, + useTraitsField: null, + implementsInterfacesField: null, + optionsEditorGrid: null, + selectionModel: null, + + initialize: function (data, parentPanel, reopen, editorPrefix) { + this.parentPanel = parentPanel; + this.data = data; + this.editorPrefix = editorPrefix; + this.reopen = reopen; + + this.addLayout(); + }, + + getId: function () { + return this.data.id; + }, + + addLayout: function () { + this.panel = new Ext.Panel({ + border: false, + layout: 'border', + closable: true, + autoScroll: true, + title: 'ID: ' + this.data.id, + id: this.editorPrefix + this.getId(), + items: [ + this.createEditPanel() + ], + buttons: this.createPanelButtons() + }); + + this.parentPanel.getEditPanel().add(this.panel); + + this.editpanel.add(this.getFormPanel()); + // this.setCurrentNode('root'); + this.parentPanel.getEditPanel().setActiveTab(this.panel); + + pimcore.layout.refresh(); + }, + + createEditPanel: function () { + this.editpanel = Ext.create('Ext.Panel', { + region: 'center', + bodyStyle: 'padding: 10px;', + autoScroll: true + }); + return this.editpanel; + }, + + createPanelButtons: function () { + return [ + { + text: t('reload_definition'), + handler: this.onRefresh.bind(this), + iconCls: 'pimcore_icon_reload' + }, + { + text: t('save'), + iconCls: 'pimcore_icon_apply', + handler: this.save.bind(this), + disabled: !this.data.isWriteable + } + ]; + }, + + getFormPanel: function () { + this.formPanel = new Ext.form.FormPanel({ + title: '' + t('general_settings') + '', + bodyStyle: 'padding: 10px; border-top: 1px solid #606060 !important;', + autoScroll: true, + defaults: { + labelWidth: 200 + }, + items: [ + this.createPhpEnumNameField(), + this.createUseTraitsField(), + this.createImplementsInterfacesField(), + this.createGroupField(), + this.createOptionsEditorGrid(), + { + xtype: 'displayfield', + fieldLabel: '' + t('used_by_class') + '', + labelSeparator: '' + }, + this.createUsagesGrid() + ] + }); + + this.formPanel.on('afterrender', function () { + this.usagesStore.reload() + }.bind(this)); + + return this.formPanel; + }, + + /** + * @returns {Ext.form.field.Text} + */ + createPhpEnumNameField: function () { + return Ext.create('Ext.form.field.Text', { + width: 600, + name: 'phpEnumName', + fieldLabel: t('PHP Enum Name'), + disabled: true, + renderer: Ext.util.Format.htmlEncode, + value: this.data.enumName + }); + }, + + /** + * @returns {Ext.form.field.Text} + */ + createUseTraitsField: function () { + this.useTraitsField = Ext.create('Ext.form.field.Text', { + width: 600, + name: 'useTraits', + fieldLabel: t('use_traits'), + value: this.data.useTraits + }); + return this.useTraitsField; + }, + + /** + * @returns {Ext.form.field.Text} + */ + createImplementsInterfacesField: function () { + this.implementsInterfacesField = Ext.create('Ext.form.field.Text', { + width: 600, + name: 'implementsInterfaces', + fieldLabel: t('implements_interfaces'), + value: this.data.implementsInterfaces + }); + return this.implementsInterfacesField; + }, + + /** + * @returns {Ext.form.field.Text} + */ + createGroupField: function () { + this.groupField = Ext.create('Ext.form.field.Text', { + width: 600, + name: 'group', + fieldLabel: t('group'), + value: this.data.group + }); + return this.groupField; + }, + + /** + * @returns {Ext.grid.Panel} + */ + createOptionsEditorGrid: function () { + var valueStore = new Ext.data.Store({ + fields: [ + 'label', + {name: 'value', allowBlank: false}, + 'name' + ], + proxy: { + type: 'memory' + }, + data: this.data.selectOptions + }); + + // Modified copy of the select field implementation + this.optionsEditorGrid = Ext.create('Ext.grid.Panel', { + viewConfig: { + plugins: [ + { + ptype: 'gridviewdragdrop', + dragroup: 'selectoptionsselect' + } + ] + }, + tbar: [ + { + xtype: 'tbtext', + text: t('selection_options') + }, + '-', + { + xtype: 'button', + iconCls: 'pimcore_icon_add', + handler: function () { + var u = { + label: '', + value: '' + }; + + let selection = this.selectionModel.getSelection(); + var idx; + if (selection.length > 0) { + let selectedRow = selection[0]; + idx = valueStore.indexOf(selectedRow) + 1; + } else { + idx = valueStore.getCount(); + } + valueStore.insert(idx, u); + this.selectionModel.select(idx); + }.bind(this) + } + ], + style: 'margin-top: 10px', + store: valueStore, + selModel: Ext.create('Ext.selection.RowModel', {}), + clicksToEdit: 1, + columnLines: true, + columns: this.createOptionsEditorGridColumns(), + autoHeight: true, + plugins: [ + Ext.create('Ext.grid.plugin.CellEditing', { + clicksToEdit: 1, + listeners: { + edit: function (editor, e) { + if (!e.record.get('value')) { + e.record.set('value', e.record.get('label')); + } + }, + beforeedit: function (editor, e) { + if (e.field === 'value') { + return !!e.value; + } + return true; + }, + validateedit: function (editor, e) { + if (e.field !== 'value') { + return true; + } + + // Iterate to all store data + for (var i = 0; i < valueStore.data.length; i++) { + var existingRecord = valueStore.getAt(i); + if (i != e.rowIdx && existingRecord.get('value') === e.value) { + return false; + } + } + return true; + } + } + }) + ] + }); + + this.selectionModel = this.optionsEditorGrid.getSelectionModel(); + return this.optionsEditorGrid; + }, + + createOptionsEditorGridColumns: function () { + return [ + { + text: t('display_name'), + sortable: true, + dataIndex: 'label', + editor: new Ext.form.TextField({}), + renderer: function (value) { + return replace_html_event_attributes(strip_tags(value, 'div,span,b,strong,em,i,small,sup,sub')); + }, + flex: 1 + }, + { + text: t('value'), + sortable: true, + dataIndex: 'value', + editor: new Ext.form.TextField({ + allowBlank: false + }), + flex: 1 + }, + { + text: t('name'), + sortable: true, + dataIndex: 'name', + editor: { + xtype: 'textfield', + enableKeyEvents: true, + listeners: { + keyup: function (field) { + var value = field.getValue(); + if (typeof value === 'string') { + // Only allow alphanumeric and underscore characters + field.setValue( + value.replace(/[^A-Za-z0-9_]/g, '') + ); + } + } + } + }, + flex: 1 + }, + { + xtype: 'actioncolumn', + menuText: t('up'), + width: 40, + items: [ + { + tooltip: t('up'), + icon: '/bundles/pimcoreadmin/img/flat-color-icons/up.svg', + handler: function (grid, rowIndex) { + if (rowIndex > 0) { + var rec = grid.getStore().getAt(rowIndex); + grid.getStore().removeAt(rowIndex); + grid.getStore().insert(--rowIndex, [rec]); + this.selectionModel.select(rowIndex); + } + }.bind(this) + } + ] + }, + { + xtype: 'actioncolumn', + menuText: t('down'), + width: 40, + items: [ + { + tooltip: t('down'), + icon: '/bundles/pimcoreadmin/img/flat-color-icons/down.svg', + handler: function (grid, rowIndex) { + if (rowIndex < (grid.getStore().getCount() - 1)) { + var rec = grid.getStore().getAt(rowIndex); + grid.getStore().removeAt(rowIndex); + grid.getStore().insert(++rowIndex, [rec]); + this.selectionModel.select(rowIndex); + } + }.bind(this) + } + ] + }, + { + xtype: 'actioncolumn', + menuText: t('remove'), + width: 40, + items: [ + { + tooltip: t('remove'), + icon: '/bundles/pimcoreadmin/img/flat-color-icons/delete.svg', + handler: function (grid, rowIndex) { + grid.getStore().removeAt(rowIndex); + }.bind(this) + } + ] + } + ]; + }, + + /** + * @returns {Ext.grid.GridPanel} + */ + createUsagesGrid: function () { + return Ext.create('Ext.grid.GridPanel', { + frame: false, + autoScroll: true, + store: this.createUsagesStore(), + columnLines: true, + stripeRows: true, + plugins: ['gridfilters'], + width: 600, + columns: [ + {text: t('class'), sortable: true, dataIndex: 'class', filter: 'string', flex: 1}, + {text: t('field'), sortable: true, dataIndex: 'field', filter: 'string', flex: 1} + ], + viewConfig: { + forceFit: true + } + }); + }, + + /** + * @returns {Ext.data.ArrayStore} + */ + createUsagesStore: function () { + this.usagesStore = Ext.create('Ext.data.ArrayStore', { + proxy: { + url: Routing.generate('pimcore_admin_dataobject_class_getselectoptionsusages'), + type: 'ajax', + reader: { + type: 'json' + }, + extraParams: { + id: this.data.id + } + }, + fields: ['class', 'field'] + }); + return this.usagesStore; + }, + + save: function (showSuccess = true) { + var reload = false; + var newGroup = this.groupField.getValue(); + if (newGroup !== this.data.group) { + this.data.group = newGroup; + reload = true; + } + + var formData = this.getFormData(); + + Ext.Ajax.request({ + url: Routing.generate('pimcore_admin_dataobject_class_selectoptionsupdate'), + method: 'PUT', + params: formData, + success: showSuccess ? this.saveOnComplete.bind(this, reload) : null + }); + }, + + getFormData: function () { + // Collect select options + var selectOptions = []; + var valueStore = this.optionsEditorGrid.getStore(); + valueStore.commitChanges(); + valueStore.each(function (record) { + selectOptions.push({ + label: record.get('label'), + value: record.get('value'), + name: record.get('name') + }); + }); + + return { + id: this.data.id, + group: this.groupField.getValue(), + useTraits: this.useTraitsField.getValue(), + implementsInterfaces: this.implementsInterfacesField.getValue(), + selectOptions: Ext.encode(selectOptions) + }; + }, + + saveOnComplete: function (reload, response) { + var rdata = Ext.decode(response.responseText); + if (rdata && rdata.success) { + if (reload) { + this.parentPanel.tree.getStore().load(); + } + pimcore.helpers.showNotification(t('success'), t('saved_successfully'), 'success'); + return; + } + + if (rdata && rdata.message) { + pimcore.helpers.showNotification(t('error'), rdata.message, 'error'); + } else { + throw 'save was not successful, see log files in /var/log'; + } + }, + + onRefresh: function() { + this.parentPanel.getEditPanel().remove(this.panel); + this.reopen(); + } +}); diff --git a/bundles/AdminBundle/public/js/pimcore/object/tags/select.js b/bundles/AdminBundle/public/js/pimcore/object/tags/select.js index 848273d1c5c..f4fbc74ab23 100644 --- a/bundles/AdminBundle/public/js/pimcore/object/tags/select.js +++ b/bundles/AdminBundle/public/js/pimcore/object/tags/select.js @@ -99,7 +99,10 @@ pimcore.object.tags.select = Class.create(pimcore.object.tags.abstract, { }, getGridColumnConfig:function (field) { - if (field.layout.optionsProviderClass) { + if ( + field.layout.optionsProviderType !== pimcore.object.helpers.selectField.OPTIONS_PROVIDER_TYPE_CLASS + && field.layout.optionsProviderClass + ) { return this.getGridColumnConfigDynamic(field); } else { return this.getGridColumnConfigStatic(field); diff --git a/bundles/AdminBundle/src/Controller/Admin/DataObject/ClassController.php b/bundles/AdminBundle/src/Controller/Admin/DataObject/ClassController.php index f570aa3a8cb..4a85844b71f 100644 --- a/bundles/AdminBundle/src/Controller/Admin/DataObject/ClassController.php +++ b/bundles/AdminBundle/src/Controller/Admin/DataObject/ClassController.php @@ -33,6 +33,7 @@ use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\Session\Attribute\AttributeBagInterface; use Symfony\Component\HttpKernel\Event\ControllerEvent; +use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; use Symfony\Component\Routing\Annotation\Route; use Symfony\Contracts\EventDispatcher\EventDispatcherInterface; @@ -1857,6 +1858,7 @@ public function onKernelControllerEvent(ControllerEvent $event): void 'getTreeAction', 'fieldcollectionListAction', 'fieldcollectionTreeAction', 'fieldcollectionGetAction', 'getClassDefinitionForColumnConfigAction', 'objectbrickListAction', 'objectbrickTreeAction', 'objectbrickGetAction', 'objectbrickDeleteAction', 'objectbrickUpdateAction', 'importObjectbrickAction', 'exportObjectbrickAction', 'bulkCommitAction', 'doBulkExportAction', 'bulkExportAction', 'importFieldcollectionAction', 'exportFieldcollectionAction', // permissions for listed write operations handled separately in action methods + 'selectOptionsGetAction', 'selectOptionsTreeAction', 'selectOptionsUpdateAction', 'getSelectOptionsUsagesAction', 'selectOptionsDeleteAction', ]; $this->checkActionPermission($event, 'classes', $unrestrictedActions); @@ -1925,6 +1927,24 @@ public function getBrickUsagesAction(Request $request): Response return $this->adminJson($result); } + #[Route('/get-select-options-usages', name: 'getselectoptionsusages', methods: [Request::METHOD_GET])] + public function getSelectOptionsUsagesAction(Request $request): Response + { + $usages = []; + $id = $request->get(DataObject\SelectOptions\Config::PROPERTY_ID); + $selectOptionsConfiguration = $this->getSelectOptionsConfig($id); + foreach ($selectOptionsConfiguration->getFieldsUsedIn() as $className => $fieldNames) { + foreach ($fieldNames as $fieldName) { + $usages[] = [ + 'class' => $className, + 'field' => $fieldName, + ]; + } + } + + return $this->adminJson($usages); + } + /** * @Route("/get-icons", name="geticons", methods={"GET"}) * @@ -2090,4 +2110,138 @@ public function videoAllowedTypesAction(Request $request): Response return $this->adminJson($res); } + + /** + * SELECT OPTIONS + */ + + #[Route('/select-options-get', name: 'selectoptionsget', methods: [Request::METHOD_GET])] + public function selectOptionsGetAction(Request $request): JsonResponse + { + $this->checkPermission('selectoptions'); + $id = $request->get(DataObject\SelectOptions\Config::PROPERTY_ID); + $selectOptionsConfiguration = $this->getSelectOptionsConfig($id); + + $data = $selectOptionsConfiguration->getObjectVars(); + $data['isWriteable'] = $selectOptionsConfiguration->isWriteable(); + $data['enumName'] = $selectOptionsConfiguration->getEnumName(true); + + return $this->adminJson($data); + } + + #[Route('/select-options-update', name: 'selectoptionsupdate', methods: [Request::METHOD_PUT, Request::METHOD_POST])] + public function selectOptionsUpdateAction(Request $request, EventDispatcherInterface $eventDispatcher): JsonResponse + { + $this->checkPermission('selectoptions'); + try { + $id = $request->get(DataObject\SelectOptions\Config::PROPERTY_ID); + + if ($request->get('task') === 'add') { + if ((new DataObject\SelectOptions\Config\Listing())->hasConfig($id)) { + throw new \Exception('Select options with the same ID already exists (lower/upper cases may be different)'); + } + } + + $group = $request->get(DataObject\SelectOptions\Config::PROPERTY_GROUP); + $useTraits = $request->get(DataObject\SelectOptions\Config::PROPERTY_USE_TRAITS, ''); + $implementsInterfaces = $request->get(DataObject\SelectOptions\Config::PROPERTY_IMPLEMENTS_INTERFACES, ''); + $selectOptionsData = $request->get(DataObject\SelectOptions\Config::PROPERTY_SELECT_OPTIONS, 'null'); + $selectOptionsConfiguration = DataObject\SelectOptions\Config::createFromData( + [ + DataObject\SelectOptions\Config::PROPERTY_ID => $id, + DataObject\SelectOptions\Config::PROPERTY_GROUP => $group, + DataObject\SelectOptions\Config::PROPERTY_USE_TRAITS => $useTraits, + DataObject\SelectOptions\Config::PROPERTY_IMPLEMENTS_INTERFACES => $implementsInterfaces, + DataObject\SelectOptions\Config::PROPERTY_SELECT_OPTIONS => $this->decodeJson($selectOptionsData), + ] + ); + + $event = new GenericEvent($this, [ + 'selectOptionsConfiguration' => $selectOptionsConfiguration, + ]); + $eventDispatcher->dispatch($event, AdminEvents::CLASS_SELECTOPTIONS_UPDATE_CONFIGURATION); + /** @var DataObject\SelectOptions\Config $selectOptionsConfiguration */ + $selectOptionsConfiguration = $event->getArgument('selectOptionsConfiguration'); + + $selectOptionsConfiguration->save(); + + return $this->adminJson(['success' => true, 'id' => $selectOptionsConfiguration->getId()]); + } catch (\Exception $exception) { + Logger::error($exception->getMessage()); + + return $this->adminJson(['success' => false, 'message' => $exception->getMessage()]); + } + } + + #[Route('/select-options-tree', name: 'selectoptionstree', methods: [Request::METHOD_GET, Request::METHOD_POST])] + public function selectOptionsTreeAction(Request $request, EventDispatcherInterface $eventDispatcher): JsonResponse + { + $this->checkPermission('selectoptions'); + $configurations = $groups = []; + + $selectOptionConfigs = new DataObject\SelectOptions\Config\Listing(); + foreach ($selectOptionConfigs as $selectOptionConfig) { + $id = $selectOptionConfig->getId(); + $configurationData = [ + 'id' => $id, + 'text' => $id, + 'leaf' => true, + 'iconCls' => 'pimcore_icon_select', + ]; + + if ((int)$request->get('grouped', 0) === 0 || !$selectOptionConfig->hasGroup()) { + $configurations[] = $configurationData; + continue; + } + + $group = $selectOptionConfig->getGroup(); + if (!isset($groups[$group])) { + $groups[$group] = [ + 'id' => 'group_' . $id, + 'text' => htmlspecialchars($group), + 'expandable' => true, + 'leaf' => false, + 'allowChildren' => true, + 'iconCls' => 'pimcore_icon_folder', + 'group' => $group, + 'children' => [], + ]; + } + $groups[$group]['children'][] = $configurationData; + } + + foreach ($groups as $group) { + $configurations[] = $group; + } + + $event = new GenericEvent($this, [ + 'list' => $configurations, + ]); + $eventDispatcher->dispatch($event, AdminEvents::CLASS_SELECTOPTIONS_LIST_PRE_SEND_DATA); + + return $this->adminJson($configurations); + } + + #[Route('/select-options-delete', name: 'selectoptionsdelete', methods: [Request::METHOD_DELETE])] + public function selectOptionsDeleteAction(Request $request): JsonResponse + { + $this->checkPermission('selectoptions'); + + try { + $id = $request->get(DataObject\SelectOptions\Config::PROPERTY_ID); + $this->getSelectOptionsConfig($id)->delete(); + return $this->adminJson(['success' => true]); + } catch (\Exception $exception) { + return $this->adminJson(['success' => false, 'message' => $exception->getMessage()]); + } + } + + protected function getSelectOptionsConfig(string $id): DataObject\SelectOptions\Config + { + $selectOptions = DataObject\SelectOptions\Config::getById($id); + if ($selectOptions === null) { + throw new NotFoundHttpException('Not Found', code: 1677133720896); + } + return $selectOptions; + } } diff --git a/bundles/AdminBundle/src/Controller/Admin/IndexController.php b/bundles/AdminBundle/src/Controller/Admin/IndexController.php index 6a7bdd4b0f4..83fe7e00f80 100644 --- a/bundles/AdminBundle/src/Controller/Admin/IndexController.php +++ b/bundles/AdminBundle/src/Controller/Admin/IndexController.php @@ -242,6 +242,7 @@ protected function buildPimcoreSettings(Request $request, array &$templateParams 'custom-views-writeable' => \Pimcore\CustomView\Config::isWriteable(), 'class-definition-writeable' => isset($_SERVER['PIMCORE_CLASS_DEFINITION_WRITABLE']) ? (bool)$_SERVER['PIMCORE_CLASS_DEFINITION_WRITABLE'] : true, 'object-custom-layout-writeable' => (new CustomLayout())->isWriteable(), + 'select-options-writeable' => (new \Pimcore\Model\DataObject\SelectOptions\Config())->isWriteable(), ]; $this diff --git a/bundles/AdminBundle/src/OptionsProvider/SelectOptionsOptionsProvider.php b/bundles/AdminBundle/src/OptionsProvider/SelectOptionsOptionsProvider.php new file mode 100644 index 00000000000..7fdc93ba554 --- /dev/null +++ b/bundles/AdminBundle/src/OptionsProvider/SelectOptionsOptionsProvider.php @@ -0,0 +1,60 @@ +getOptionsProviderData(); + $selectOptionsConfiguration = Config::getById($configurationId); + if ($selectOptionsConfiguration === null) { + throw new \Exception('Missing select options configuration ' . $configurationId, 1677137682677); + } + + return array_map( + fn (SelectOption $selectOption) => [ + 'value' => $selectOption->getValue(), + 'key' => $selectOption->getLabel(), + ], + $selectOptionsConfiguration->getSelectOptions(), + ); + } + + public function hasStaticOptions(array $context, Data $fieldDefinition): bool + { + return true; + } + + public function getDefaultValue(array $context, Data $fieldDefinition): ?string + { + if ($fieldDefinition instanceof Data\Select) { + return $fieldDefinition->getDefaultValue(); + } + return null; + } +} diff --git a/bundles/AdminBundle/templates/admin/index/index.html.twig b/bundles/AdminBundle/templates/admin/index/index.html.twig index 9a24245fc0c..32e93b89eda 100644 --- a/bundles/AdminBundle/templates/admin/index/index.html.twig +++ b/bundles/AdminBundle/templates/admin/index/index.html.twig @@ -294,6 +294,8 @@ "pimcore/object/helpers/optionEditor.js", "pimcore/object/helpers/imageGalleryDropZone.js", "pimcore/object/helpers/imageGalleryPanel.js", + "pimcore/object/helpers/selectField.js", + "pimcore/object/helpers/reservedWords.js", "pimcore/element/tag/configuration.js", "pimcore/element/tag/assignment.js", "pimcore/element/tag/tree.js", @@ -498,6 +500,8 @@ "pimcore/object/importcolumn/value/DefaultValue.js", "pimcore/object/objectbrick.js", "pimcore/object/objectbricks/field.js", + "pimcore/object/selectoptions.js", + "pimcore/object/selectoptionsitems/definition.js", "pimcore/object/tags/abstract.js", "pimcore/object/tags/abstractRelations.js", "pimcore/object/tags/block.js", diff --git a/bundles/AdminBundle/translations/admin.en.yaml b/bundles/AdminBundle/translations/admin.en.yaml index 09fa9e5f15f..35bc913b456 100644 --- a/bundles/AdminBundle/translations/admin.en.yaml +++ b/bundles/AdminBundle/translations/admin.en.yaml @@ -754,6 +754,7 @@ prefix: Prefix load: Load saving_failed: 'Saving failed!' failed_to_create_new_item: 'Failed to create new item, please try again.' +failed_to_create_new_item_select_options: 'Failed to create new item. Name must be UpperCamelCase format, at least 3 characters long and may not contain any reserved words.' bundle: Bundle product: Product index_field_selection_field: 'Index Field Selection' diff --git a/bundles/AdminBundle/translations/admin_ext.en.yaml b/bundles/AdminBundle/translations/admin_ext.en.yaml index 02c247ff82c..61d829cad37 100644 --- a/bundles/AdminBundle/translations/admin_ext.en.yaml +++ b/bundles/AdminBundle/translations/admin_ext.en.yaml @@ -496,6 +496,10 @@ predefined_hotspot_data_templates: Predefined data templates hide_locale_labels_when_tabs_reached: Hide locale labels after number of tabs classificationstore_error_addkey_msg: Error adding Key classificationstore_dialog_keygroup_search: Key/Group Search +options_provider_type: Options source +options_provider_type_configure: Configure +options_provider_type_select_options: Select Options +options_provider_type_class: Class / Service options_provider_class: Options Provider Class or Service Name options_provider_data: Options Provider Data show_applogger_tab: Show App Logger Tab @@ -619,6 +623,7 @@ every: Every categories: Categories revision: Build objectbricks: Objectbricks +selectoptions: Select Options class_definitions: Class Definitions custom_layout_changed: Layout was changed in the meantime. Please reload the layout and try again rule_violation: Rule Violation diff --git a/bundles/CoreBundle/config/class_builder.yaml b/bundles/CoreBundle/config/class_builder.yaml index 3d1cc2f4ba6..e542de5958e 100644 --- a/bundles/CoreBundle/config/class_builder.yaml +++ b/bundles/CoreBundle/config/class_builder.yaml @@ -47,3 +47,9 @@ services: class: 'Pimcore\DataObject\ClassBuilder\PHPObjectBrickContainerClassDumper' public: true + Pimcore\DataObject\ClassBuilder\PHPSelectOptionsEnumDumperInterface: + class: 'Pimcore\DataObject\ClassBuilder\PHPSelectOptionsEnumDumper' + public: true + + Pimcore\DataObject\ClassBuilder\SelectOptionsEnumBuilderInterface: + class: 'Pimcore\DataObject\ClassBuilder\SelectOptionsEnumBuilder' diff --git a/bundles/CoreBundle/src/Command/ClassesDefinitionsBuildCommand.php b/bundles/CoreBundle/src/Command/ClassesDefinitionsBuildCommand.php index ddc0f937ba7..5a1285fe125 100644 --- a/bundles/CoreBundle/src/Command/ClassesDefinitionsBuildCommand.php +++ b/bundles/CoreBundle/src/Command/ClassesDefinitionsBuildCommand.php @@ -21,6 +21,7 @@ use Pimcore\DataObject\ClassBuilder\PHPFieldCollectionClassDumperInterface; use Pimcore\DataObject\ClassBuilder\PHPObjectBrickClassDumperInterface; use Pimcore\DataObject\ClassBuilder\PHPObjectBrickContainerClassDumperInterface; +use Pimcore\DataObject\ClassBuilder\PHPSelectOptionsEnumDumperInterface; use Pimcore\Model\DataObject; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Output\OutputInterface; @@ -35,6 +36,7 @@ public function __construct( protected PHPFieldCollectionClassDumperInterface $collectionClassDumper, protected PHPObjectBrickClassDumperInterface $brickClassDumper, protected PHPObjectBrickContainerClassDumperInterface $brickContainerClassDumper, + protected PHPSelectOptionsEnumDumperInterface $selectOptionsEnumDumper, ) { parent::__construct(); } @@ -80,6 +82,11 @@ protected function execute(InputInterface $input, OutputInterface $output): int $this->collectionClassDumper->dumpPHPClass($fcDefinition); } + $selectOptionConfigurations = new DataObject\SelectOptions\Config\Listing(); + foreach ($selectOptionConfigurations as $selectOptionConfiguration) { + $this->selectOptionsEnumDumper->dumpPHPEnum($selectOptionConfiguration); + } + return 0; } } diff --git a/bundles/CoreBundle/src/Command/ClassesRebuildCommand.php b/bundles/CoreBundle/src/Command/ClassesRebuildCommand.php index 39bed14f14c..4d31b7e9554 100644 --- a/bundles/CoreBundle/src/Command/ClassesRebuildCommand.php +++ b/bundles/CoreBundle/src/Command/ClassesRebuildCommand.php @@ -145,6 +145,19 @@ protected function execute(InputInterface $input, OutputInterface $output): int $fc->save(false); } + if ($output->isVerbose()) { + $output->writeln('---------------------'); + $output->writeln('Saving all select options'); + } + $selectOptionConfigurations = new DataObject\SelectOptions\Config\Listing(); + foreach ($selectOptionConfigurations as $selectOptionConfiguration) { + if ($output->isVerbose()) { + $output->writeln(sprintf('%s saved', $selectOptionConfiguration->getId())); + } + + $selectOptionConfiguration->generateEnumFiles(); + } + return 0; } } diff --git a/bundles/CoreBundle/src/DependencyInjection/Configuration.php b/bundles/CoreBundle/src/DependencyInjection/Configuration.php index e25ced67965..f8f5a103d71 100644 --- a/bundles/CoreBundle/src/DependencyInjection/Configuration.php +++ b/bundles/CoreBundle/src/DependencyInjection/Configuration.php @@ -682,6 +682,31 @@ private function addObjectsNode(ArrayNodeDefinition $rootNode): void ->end() ->end() ->end() + ->arrayNode('select_options') + ->addDefaultsIfNotSet() + ->children() + ->arrayNode('definitions') + ->normalizeKeys(false) + ->prototype('array') + ->children() + ->scalarNode('id')->end() + ->scalarNode('group')->end() + ->scalarNode('useTraits')->end() + ->scalarNode('implementsInterfaces')->end() + ->arrayNode('selectOptions') + ->prototype('array') + ->children() + ->scalarNode('value')->end() + ->scalarNode('label')->end() + ->scalarNode('name')->end() + ->end() + ->end() + ->end() + ->end() + ->end() + ->end() + ->end() + ->end() ->end(); $classDefinitionsNode = $objectsNode ->children() @@ -1897,6 +1922,7 @@ private function addWriteTargetNodes(ArrayNodeDefinition $rootNode): void $this->addStorageNode($storageNode, 'custom_views', '/var/config/custom_views'); $this->addStorageNode($storageNode, 'data_hub', '/var/config/data_hub'); $this->addStorageNode($storageNode, 'object_custom_layouts', '/var/config/object_custom_layouts'); + $this->addStorageNode($storageNode, 'select_options', '/var/config/select-options'); } private function addStorageNode(NodeBuilder $node, string $name, string $folder): void diff --git a/bundles/CoreBundle/src/Migrations/Version20230221073317.php b/bundles/CoreBundle/src/Migrations/Version20230221073317.php new file mode 100644 index 00000000000..fcc38e7c6d4 --- /dev/null +++ b/bundles/CoreBundle/src/Migrations/Version20230221073317.php @@ -0,0 +1,41 @@ +addSql("INSERT IGNORE INTO users_permission_definitions (`key`) VALUES('selectoptions');"); + } + + public function down(Schema $schema): void + { + $this->addSql("DELETE FROM users_permission_definitions WHERE `key` = 'selectoptions'"); + } +} diff --git a/bundles/InstallBundle/src/Installer.php b/bundles/InstallBundle/src/Installer.php index c58beb85f0c..4729c66b28f 100644 --- a/bundles/InstallBundle/src/Installer.php +++ b/bundles/InstallBundle/src/Installer.php @@ -779,6 +779,7 @@ protected function insertDatabaseContents(): void $userPermissions = [ 'assets', 'classes', + 'selectoptions', 'clear_cache', 'clear_fullpage_cache', 'clear_temp_files', diff --git a/config/dao-classmap.php b/config/dao-classmap.php index e3470def7f0..4961487bd58 100644 --- a/config/dao-classmap.php +++ b/config/dao-classmap.php @@ -60,6 +60,8 @@ 'Pimcore\\Model\\DataObject\\Objectbrick\\Definition' => 'Pimcore\\Model\\DataObject\\Objectbrick\\Definition\\Dao', 'Pimcore\\Model\\DataObject\\QuantityValue\\Unit' => 'Pimcore\\Model\\DataObject\\QuantityValue\\Unit\\Dao', 'Pimcore\\Model\\DataObject\\QuantityValue\\Unit\\Listing' => 'Pimcore\\Model\\DataObject\\QuantityValue\\Unit\\Listing\\Dao', + 'Pimcore\\Model\\DataObject\\SelectOptions\\Config' => 'Pimcore\\Model\\DataObject\\SelectOptions\\Config\\Dao', + 'Pimcore\\Model\\DataObject\\SelectOptions\\Config\\Listing' => 'Pimcore\\Model\\DataObject\\SelectOptions\\Config\\Listing\\Dao', 'Pimcore\\Model\\DataObject\\Service' => 'Pimcore\\Model\\Element\\Dao', 'Pimcore\\Model\\Dependency' => 'Pimcore\\Model\\Dependency\\Dao', 'Pimcore\\Model\\Document' => 'Pimcore\\Model\\Document\\Dao', diff --git a/doc/05_Objects/01_Object_Classes/01_Data_Types/30_Dynamic_Select_Types.md b/doc/05_Objects/01_Object_Classes/01_Data_Types/30_Dynamic_Select_Types.md index 875137680cd..c5264e26d39 100644 --- a/doc/05_Objects/01_Object_Classes/01_Data_Types/30_Dynamic_Select_Types.md +++ b/doc/05_Objects/01_Object_Classes/01_Data_Types/30_Dynamic_Select_Types.md @@ -10,14 +10,19 @@ Note that there are two ways to define an options provider. Either simply specify the class name ... -![Select Field](../../../img/dynselect1.png) +![Select Field](../../../img/dynamic_select_class.png) ... or the name of a Symfony service (notice the prefix). -![Select Field](../../../img/dynselect1b.png) +![Select Field](../../../img/dynamic_select_service.png) -The services.yaml would then look like this one ... +The services.yaml would then look like this: -![Select Field](../../../img/dynselect1a.png) +```yaml +services: + website.optionsprovider: + class: Website\OptionsProvider + public: true +``` Depending on your datatype you have to implement the appropriate interface. diff --git a/doc/05_Objects/01_Object_Classes/01_Data_Types/77_Select_Options.md b/doc/05_Objects/01_Object_Classes/01_Data_Types/77_Select_Options.md new file mode 100644 index 00000000000..796afc39bac --- /dev/null +++ b/doc/05_Objects/01_Object_Classes/01_Data_Types/77_Select_Options.md @@ -0,0 +1,70 @@ +# Select Options + +Select options are predefined sets of options which may be used for (multi)select fields. + +![Fieldcollection Configuration](../../../img/classes-datatypes-selectoptions-editor.png) + +The 'Name' column is optional, unless the value can't be converted to a valid PHP enum case. +This applies to values starting with a number or certain symbols. +A name may contain alphanumeric characters and underscores. + +## Field configuration + +Set the options source to 'Select Options' and select one of the select options sets. + +![Fieldcollection Configuration](../../../img/classes-datatypes-selectoptions-selector.png) + +## Working with PHP API + +The configuration in the first screenshot generates the [backed enum](https://www.php.net/manual/en/language.enumerations.backed.php) below. + +```php + '10', + self::Twenty => 'Twenty', + self::Check => '√', + self::C => '©', + self::Multiple_Word_Value => 'Multiple Word Value', + }; + } +} +``` + +### Retrieve available option values + +Provided by the `EnumGetValuesTrait`. + +```php +TestOptions::getValues(); +``` + +### Map select value to enum + +Provided by the `EnumTryFromNullableTrait`. + +```php +$value = $product->getSelectField(); +$testOption = TestOptions::tryFromNullable($value); +if ($testOption !== null) { + $label = $testOption->getLabel(); +} +``` diff --git a/doc/05_Objects/01_Object_Classes/01_Data_Types/80_Select_Types.md b/doc/05_Objects/01_Object_Classes/01_Data_Types/80_Select_Types.md index b7ea760b9af..8d20e9340a6 100644 --- a/doc/05_Objects/01_Object_Classes/01_Data_Types/80_Select_Types.md +++ b/doc/05_Objects/01_Object_Classes/01_Data_Types/80_Select_Types.md @@ -9,7 +9,11 @@ multiselect, values are stored as a comma separated list. For select and multiselect the options can be defined with a value and display value in the class definition: -![Select Field](../../../img/classes-datatypes-select2.png) +![Select Field](../../../img/classes-datatypes-select-configure.png) + +It's also possible to retrieve options from difference sources. +* [Select Options](./77_Select_Options.md) +* [Class / Service](./30_Dynamic_Select_Types.md) Country and language have fixed option values. For the language field the options can be limited to available system languages. The country and language select field are also available as multi select fields. diff --git a/doc/05_Objects/01_Object_Classes/01_Data_Types/README.md b/doc/05_Objects/01_Object_Classes/01_Data_Types/README.md index 10d099a64ea..55385b34314 100644 --- a/doc/05_Objects/01_Object_Classes/01_Data_Types/README.md +++ b/doc/05_Objects/01_Object_Classes/01_Data_Types/README.md @@ -42,6 +42,12 @@ The entire list of data types is indicated below: | countries | combo box with multiple select and predefined country list | | languages | combo box with multiple select and combo box with multiple select and predefined language | +### [Select Options](./77_Select_Options.md) + +| Name | Description | +|----------------|------------------------------------------------| +| select options | Manage select options for (multi)select fields | + ### [Dynamic Select Datatypes](./30_Dynamic_Select_Types.md) | Name | Description | diff --git a/doc/21_Deployment/03_Configuration_Environments.md b/doc/21_Deployment/03_Configuration_Environments.md index f0b5bef3ea4..c9c1c7d296f 100644 --- a/doc/21_Deployment/03_Configuration_Environments.md +++ b/doc/21_Deployment/03_Configuration_Environments.md @@ -107,6 +107,10 @@ pimcore: target: 'symfony-config' options: directory: '/var/www/html/var/config/object_custom_layouts' + select_options: + target: 'symfony-config' + options: + directory: '/var/www/html/var/config/select-options' ``` #### Production environment with `symfony-config` diff --git a/doc/22_Administration_of_Pimcore/07_Users_and_Roles.md b/doc/22_Administration_of_Pimcore/07_Users_and_Roles.md index ab3d9ddfe7e..c00457427cb 100644 --- a/doc/22_Administration_of_Pimcore/07_Users_and_Roles.md +++ b/doc/22_Administration_of_Pimcore/07_Users_and_Roles.md @@ -46,6 +46,7 @@ The following list outlines what the different system permissions (available for * **Redirects**: User can create and modify redirects * **Reports**: User has access to reports module * **Seemode**: Seemode available/not available for user +* **Select Options**: Object [select options editor](../05_Objects/01_Object_Classes/01_Data_Types/77_Select_Options.md) * **SEO Document Editor**: User has access to SEO document editor * **System Settings**: User has access to system settings * **Tag & Snippet Management**: User can create and modify entries in tag & snippet management diff --git a/doc/img/classes-datatypes-select-configure.png b/doc/img/classes-datatypes-select-configure.png new file mode 100644 index 00000000000..08a59723b55 Binary files /dev/null and b/doc/img/classes-datatypes-select-configure.png differ diff --git a/doc/img/classes-datatypes-select2.png b/doc/img/classes-datatypes-select2.png deleted file mode 100644 index 04650009761..00000000000 Binary files a/doc/img/classes-datatypes-select2.png and /dev/null differ diff --git a/doc/img/classes-datatypes-selectoptions-editor.png b/doc/img/classes-datatypes-selectoptions-editor.png new file mode 100644 index 00000000000..22ed58858a9 Binary files /dev/null and b/doc/img/classes-datatypes-selectoptions-editor.png differ diff --git a/doc/img/classes-datatypes-selectoptions-selector.png b/doc/img/classes-datatypes-selectoptions-selector.png new file mode 100644 index 00000000000..69fc491d845 Binary files /dev/null and b/doc/img/classes-datatypes-selectoptions-selector.png differ diff --git a/doc/img/dynamic_select_class.png b/doc/img/dynamic_select_class.png new file mode 100644 index 00000000000..cc45e9392c9 Binary files /dev/null and b/doc/img/dynamic_select_class.png differ diff --git a/doc/img/dynamic_select_service.png b/doc/img/dynamic_select_service.png new file mode 100644 index 00000000000..dd36e2e8c3d Binary files /dev/null and b/doc/img/dynamic_select_service.png differ diff --git a/doc/img/dynselect1.png b/doc/img/dynselect1.png deleted file mode 100644 index 0feb51c9300..00000000000 Binary files a/doc/img/dynselect1.png and /dev/null differ diff --git a/doc/img/dynselect1a.png b/doc/img/dynselect1a.png deleted file mode 100644 index 225b17f0e28..00000000000 Binary files a/doc/img/dynselect1a.png and /dev/null differ diff --git a/doc/img/dynselect1b.png b/doc/img/dynselect1b.png deleted file mode 100644 index c81e4d37753..00000000000 Binary files a/doc/img/dynselect1b.png and /dev/null differ diff --git a/lib/DataObject/ClassBuilder/PHPSelectOptionsEnumDumper.php b/lib/DataObject/ClassBuilder/PHPSelectOptionsEnumDumper.php new file mode 100644 index 00000000000..73a774ecb1f --- /dev/null +++ b/lib/DataObject/ClassBuilder/PHPSelectOptionsEnumDumper.php @@ -0,0 +1,35 @@ +getPhpClassFile(); + $enum = $this->enumBuilder->buildEnum($config); + + File::put($filePath, $enum); + } +} diff --git a/lib/DataObject/ClassBuilder/PHPSelectOptionsEnumDumperInterface.php b/lib/DataObject/ClassBuilder/PHPSelectOptionsEnumDumperInterface.php new file mode 100644 index 00000000000..e2af8d39b3a --- /dev/null +++ b/lib/DataObject/ClassBuilder/PHPSelectOptionsEnumDumperInterface.php @@ -0,0 +1,24 @@ +getTemplate(); + + return strtr( + $template, + [ + '%namespace%' => $config->getNamespace(), + '%enumName%' => $config->getEnumName(), + '%interfaces%' => $this->generateInterfaces($config), + '%traits%' => $this->generateTraits($config), + '%cases%' => $this->generateCases($config), + '%labelMatches%' => $this->generateLabelMatches($config), + ] + ); + } + + protected function generateInterfaces(Config $config): string + { + $interfaces = $this->parseClasses($config->getImplementsInterfaces()); + + $baseInterface = '\\' . SelectOptionsInterface::class; + $extendsBaseInterface = false; + $implements = []; + foreach ($interfaces as $interface) { + if (!interface_exists($interface)) { + throw new \Exception('Interface ' . $interface . ' does not exist', 1676878234790); + } + + if (is_subclass_of($interface, $baseInterface)) { + $extendsBaseInterface = true; + } + + $implements[] = $interface; + } + + // Add base interface if none of the custom interfaces extend it + if (!$extendsBaseInterface) { + array_unshift($implements, $baseInterface); + } + + return implode(', ', $implements); + } + + protected function generateTraits(Config $config): string + { + $template = 'use %trait%;'; + + $traits = $this->parseClasses($config->getUseTraits()); + // Prepend default traits + array_unshift( + $traits, + '\\' . EnumGetValuesTrait::class, + '\\' . EnumTryFromNullableTrait::class, + ); + + $uses = []; + foreach ($traits as $trait) { + if (!trait_exists($trait)) { + throw new \Exception('Trait ' . $trait . ' does not exist', 1676878234791); + } + $uses[] = strtr($template, ['%trait%' => $trait]); + } + + return $this->implodeTemplateValues($uses, addEndLineBreak: true); + } + + /** + * @return string[] + */ + protected function parseClasses(string $classes): array + { + // Prefix backslash + return array_map( + fn (string $class) => '\\' . ltrim($class), + explode_and_trim(',', $classes) + ); + } + + protected function generateCases(Config $config): string + { + $template = 'case %caseName% = \'%optionValue%\';'; + + $cases = $caseNames = []; + foreach ($config->getSelectOptions() as $selectOption) { + $caseName = $this->generateCaseName($selectOption); + if (isset($caseNames[$caseName])) { + throw new \Exception( + sprintf( + 'Case \'%s\' for value \'%s\' already exists for value \'%s\'. Configure a name or ensure the alphanumeric characters are unique.', + $caseName, + $selectOption->getValue(), + $caseNames[$caseName] + ), + 1676890789419 + ); + } + + // Store value for unique check + $value = $selectOption->getValue(); + $caseNames[$caseName] = $value; + + $cases[] = strtr( + $template, + [ + '%caseName%' => $caseName, + '%optionValue%' => $this->escapeSingleQuote($value), + ] + ); + } + + return $this->implodeTemplateValues($cases); + } + + protected function generateLabelMatches(Config $config): string + { + if (!$config->hasSelectOptions()) { + return 'default => \'\''; + } + + $template = 'self::%caseName% => \'%optionLabel%\','; + + $labelMatches = []; + foreach ($config->getSelectOptions() as $selectOption) { + $label = $selectOption->hasLabel() ? $selectOption->getLabel() : $selectOption->getValue(); + $labelMatches[] = strtr( + $template, + [ + '%caseName%' => $this->generateCaseName($selectOption), + '%optionLabel%' => $this->escapeSingleQuote($label), + ] + ); + } + + return $this->implodeTemplateValues($labelMatches, 12); + } + + protected function implodeTemplateValues(array $lines, int $indent = 4, bool $addEndLineBreak = false): string + { + $content = implode("\n" . $this->indent($indent), $lines); + if ($addEndLineBreak && (bool)count($lines)) { + $content .= "\n"; + } + return $content; + } + + protected function indent(int $spaces = 4): string + { + return str_repeat(' ', $spaces); + } + + protected function escapeSingleQuote(string $value): string + { + return str_replace("'", "\\'", $value); + } + + protected function generateCaseName(SelectOption $selectOption): string + { + $selectOptionName = $this->getSelectOptionName($selectOption); + + // Start with a letter or underscore, followed by zero or more alphanumeric and underscore characters + if (!preg_match('/^[A-Z-a-z_][A-Za-z0-9_]*$/', $selectOptionName)) { + throw new \Exception( + sprintf( + 'Invalid name \'%s\' for option with value \'%s\'. Must be alphanumeric and start with a letter (underscores allowed). Configure a name or use a different value.', + $selectOptionName, + $selectOption->getValue() + ), + 1676896232762 + ); + } + + // Use prefix to assure the case doesn't start with a numeric value + return $selectOptionName; + } + + protected function getSelectOptionName(SelectOption $selectOption): string + { + if ($selectOption->hasName()) { + return $selectOption->getName(); + } + + // Attempt to convert value to case name + $value = $selectOption->getValue(); + // Apply slug to remove invalid characters + $caseName = (new AsciiSlugger()) + ->slug($value, '_', 'en') + ->toString(); + $caseName = $this->toUpperCamelCase($caseName); + + if (empty($caseName) && $caseName !== '0') { + throw new \Exception( + 'Unable to convert value \'' . $value . '\' to case name. Configure a name or use a different value.', + 1676895007458 + ); + } + + return $caseName; + } + + protected function toUpperCamelCase(string $value): string + { + return str_replace( + ' ', + '_', + ucwords( + str_replace('_', ' ', $value) + ) + ); + } + + protected function getTemplate(): string + { + return 'getAllPhpReservedWords(), + ...static::PIMCORE, + ]; + } + + public function isReservedWord(string $word): bool + { + return in_array( + strtolower($word), + $this->getAllReservedWords(), + true + ); + } +} diff --git a/lib/Kernel.php b/lib/Kernel.php index aee8bfff635..7e6f969a452 100644 --- a/lib/Kernel.php +++ b/lib/Kernel.php @@ -184,6 +184,10 @@ public function registerContainerConfiguration(LoaderInterface $loader): void 'storageDirectoryEnvVariableName' => 'PIMCORE_CONFIG_STORAGE_DIR_OBJECT_CUSTOM_LAYOUTS', 'defaultStorageDirectoryName' => 'custom-layouts', ], + [ + 'storageDirectoryEnvVariableName' => 'PIMCORE_CONFIG_STORAGE_DIR_SELECT_OPTIONS', + 'defaultStorageDirectoryName' => 'select-options', + ], ]; $loader->load(function (ContainerBuilder $container) use ($loader, $configArray) { diff --git a/models/DataObject/ClassDefinition/Data/Multiselect.php b/models/DataObject/ClassDefinition/Data/Multiselect.php index 0d395cd09ff..5c4540627da 100644 --- a/models/DataObject/ClassDefinition/Data/Multiselect.php +++ b/models/DataObject/ClassDefinition/Data/Multiselect.php @@ -34,13 +34,15 @@ class Multiselect extends Data implements NormalizerInterface, LayoutDefinitionEnrichmentInterface, FieldDefinitionEnrichmentInterface, - DataContainerAwareInterface + DataContainerAwareInterface, + OptionsProviderInterface { use DataObject\Traits\SimpleComparisonTrait; use DataObject\Traits\SimpleNormalizerTrait; use DataObject\ClassDefinition\DynamicOptionsProvider\SelectionProviderTrait; use DataObject\Traits\DataHeightTrait; use DataObject\Traits\DataWidthTrait; + use OptionsProviderTrait; /** * Available options to select @@ -65,24 +67,6 @@ class Multiselect extends Data implements */ public ?string $renderType = null; - /** - * Options provider class - * - * @internal - * - * @var string|null - */ - public ?string $optionsProviderClass = null; - - /** - * Options provider data - * - * @internal - * - * @var string|null - */ - public ?string $optionsProviderData = null; - /** * @internal */ @@ -203,7 +187,7 @@ public function getDataForGrid(?array $data, Concrete $object = null, array $par DataObject\ClassDefinition\Helper\OptionsProviderResolver::MODE_MULTISELECT ); - if ($optionsProvider === null) { + if ($this->useConfiguredOptions() || $optionsProvider === null) { return $this->getDataForEditmode($data, $object, $params); } @@ -425,26 +409,6 @@ public function synchronizeWithMasterDefinition(DataObject\ClassDefinition\Data $this->options = $masterDefinition->options; } - public function getOptionsProviderClass(): ?string - { - return $this->optionsProviderClass; - } - - public function setOptionsProviderClass(?string $optionsProviderClass): void - { - $this->optionsProviderClass = $optionsProviderClass; - } - - public function getOptionsProviderData(): ?string - { - return $this->optionsProviderData; - } - - public function setOptionsProviderData(?string $optionsProviderData): void - { - $this->optionsProviderData = $optionsProviderData; - } - public function appendData(?array $existingData, array $additionalData): array { if (!is_array($existingData)) { @@ -482,7 +446,7 @@ public function isEqual(mixed $oldValue, mixed $newValue): bool public function jsonSerialize(): mixed { - if ($this->getOptionsProviderClass() && Service::doRemoveDynamicOptions()) { + if (!$this->useConfiguredOptions() && $this->getOptionsProviderClass() && Service::doRemoveDynamicOptions()) { $this->options = null; } @@ -496,7 +460,7 @@ public function resolveBlockedVars(): array { $blockedVars = parent::resolveBlockedVars(); - if ($this->getOptionsProviderClass()) { + if (!$this->useConfiguredOptions() && $this->getOptionsProviderClass()) { $blockedVars[] = 'options'; } @@ -536,7 +500,7 @@ public function preSave(mixed $containerDefinition, array $params = []): void $this->getOptionsProviderClass(), DataObject\ClassDefinition\Helper\OptionsProviderResolver::MODE_MULTISELECT ); - if ($optionsProvider) { + if (!$this->useConfiguredOptions() && $optionsProvider !== null) { $context = []; $context['fieldname'] = $this->getName(); diff --git a/models/DataObject/ClassDefinition/Data/OptionsProviderInterface.php b/models/DataObject/ClassDefinition/Data/OptionsProviderInterface.php new file mode 100644 index 00000000000..e0298ca6f1f --- /dev/null +++ b/models/DataObject/ClassDefinition/Data/OptionsProviderInterface.php @@ -0,0 +1,44 @@ +optionsProviderType; + } + + public function setOptionsProviderType(?string $optionsProviderType): void + { + $this->optionsProviderType = $optionsProviderType; + } + + public function getOptionsProviderClass(): ?string + { + return $this->optionsProviderClass; + } + + public function setOptionsProviderClass(?string $optionsProviderClass): void + { + $this->optionsProviderClass = $optionsProviderClass; + } + + public function getOptionsProviderData(): ?string + { + return $this->optionsProviderData; + } + + public function setOptionsProviderData(?string $optionsProviderData): void + { + $this->optionsProviderData = $optionsProviderData; + } + + public function useConfiguredOptions(): bool + { + return $this->getOptionsProviderType() === OptionsProviderInterface::TYPE_CONFIGURE + // Legacy fallback in case no type was set yet and no class/service was configured + || ($this->getOptionsProviderType() === null && empty($this->getOptionsProviderClass())); + } +} diff --git a/models/DataObject/ClassDefinition/Data/Select.php b/models/DataObject/ClassDefinition/Data/Select.php index b79b21645b4..37d9c78a30d 100644 --- a/models/DataObject/ClassDefinition/Data/Select.php +++ b/models/DataObject/ClassDefinition/Data/Select.php @@ -33,13 +33,15 @@ class Select extends Data implements \JsonSerializable, NormalizerInterface, LayoutDefinitionEnrichmentInterface, - FieldDefinitionEnrichmentInterface + FieldDefinitionEnrichmentInterface, + OptionsProviderInterface { use Model\DataObject\Traits\SimpleComparisonTrait; use DataObject\Traits\SimpleNormalizerTrait; use DataObject\Traits\DefaultValueTrait; use DataObject\ClassDefinition\DynamicOptionsProvider\SelectionProviderTrait; use DataObject\Traits\DataWidthTrait; + use OptionsProviderTrait; /** * Available options to select @@ -57,24 +59,6 @@ class Select extends Data implements */ public ?string $defaultValue = null; - /** - * Options provider class - * - * @internal - * - * @var string|null - */ - public ?string $optionsProviderClass = null; - - /** - * Options provider data - * - * @internal - * - * @var string|null - */ - public ?string $optionsProviderData = null; - /** * Column length * @@ -302,6 +286,7 @@ public function synchronizeWithMasterDefinition(DataObject\ClassDefinition\Data $this->options = $masterDefinition->options; $this->columnLength = $masterDefinition->columnLength; $this->defaultValue = $masterDefinition->defaultValue; + $this->optionsProviderType = $masterDefinition->optionsProviderType; $this->optionsProviderClass = $masterDefinition->optionsProviderClass; $this->optionsProviderData = $masterDefinition->optionsProviderData; } @@ -316,26 +301,6 @@ public function setDefaultValue(?string $defaultValue): void $this->defaultValue = $defaultValue; } - public function getOptionsProviderClass(): ?string - { - return $this->optionsProviderClass; - } - - public function setOptionsProviderClass(?string $optionsProviderClass): void - { - $this->optionsProviderClass = $optionsProviderClass; - } - - public function getOptionsProviderData(): ?string - { - return $this->optionsProviderData; - } - - public function setOptionsProviderData(?string $optionsProviderData): void - { - $this->optionsProviderData = $optionsProviderData; - } - /** * { @inheritdoc } */ @@ -372,7 +337,7 @@ public function getDataForGrid(?string $data, Concrete $object = null, array $pa DataObject\ClassDefinition\Helper\OptionsProviderResolver::MODE_SELECT ); - if ($optionsProvider) { + if (!$this->useConfiguredOptions() && $optionsProvider !== null) { $context = $params['context'] ?? []; $context['object'] = $object; if ($object) { @@ -440,7 +405,7 @@ protected function doGetDefaultValue(Concrete $object, array $context = []): ?st $this->getOptionsProviderClass(), DataObject\ClassDefinition\Helper\OptionsProviderResolver::MODE_SELECT ); - if ($optionsProvider) { + if (!$this->useConfiguredOptions() && $optionsProvider !== null) { $context['object'] = $object; $context['class'] = $object->getClass(); @@ -457,7 +422,7 @@ protected function doGetDefaultValue(Concrete $object, array $context = []): ?st public function jsonSerialize(): mixed { - if ($this->getOptionsProviderClass() && Service::doRemoveDynamicOptions()) { + if (!$this->useConfiguredOptions() && $this->getOptionsProviderClass() && Service::doRemoveDynamicOptions()) { $this->options = null; } @@ -471,7 +436,7 @@ public function resolveBlockedVars(): array { $blockedVars = parent::resolveBlockedVars(); - if ($this->getOptionsProviderClass()) { + if (!$this->useConfiguredOptions() && $this->getOptionsProviderClass()) { $blockedVars[] = 'options'; } diff --git a/models/DataObject/ClassDefinition/DynamicOptionsProvider/SelectionProviderTrait.php b/models/DataObject/ClassDefinition/DynamicOptionsProvider/SelectionProviderTrait.php index 19daf21317c..cbaed5beb0a 100644 --- a/models/DataObject/ClassDefinition/DynamicOptionsProvider/SelectionProviderTrait.php +++ b/models/DataObject/ClassDefinition/DynamicOptionsProvider/SelectionProviderTrait.php @@ -26,6 +26,10 @@ trait SelectionProviderTrait { protected function doEnrichDefinitionDefinition(/*?Concrete */ ?DataObject\Concrete $object, string $fieldname, string $purpose, int $mode, /** array */ array $context = []): void { + if ($this->getOptionsProviderType() === Data\OptionsProviderInterface::TYPE_CONFIGURE) { + return; + } + $optionsProvider = DataObject\ClassDefinition\Helper\OptionsProviderResolver::resolveProvider( $this->getOptionsProviderClass(), $mode diff --git a/models/DataObject/ClassDefinition/Service.php b/models/DataObject/ClassDefinition/Service.php index 414e86963d8..79c469721bc 100644 --- a/models/DataObject/ClassDefinition/Service.php +++ b/models/DataObject/ClassDefinition/Service.php @@ -82,7 +82,7 @@ private static function removeDynamicOptionsFromLayoutDefinition(mixed &$layout) if (is_array($children)) { foreach ($children as $child) { if ($child instanceof DataObject\ClassDefinition\Data\Select) { - if ($child->getOptionsProviderClass()) { + if (!$child->useConfiguredOptions() && $child->getOptionsProviderClass()) { $child->options = null; } } diff --git a/models/DataObject/SelectOptions/Config.php b/models/DataObject/SelectOptions/Config.php new file mode 100644 index 00000000000..81b2ab3f793 --- /dev/null +++ b/models/DataObject/SelectOptions/Config.php @@ -0,0 +1,382 @@ +id; + } + + /** + * @return $this + */ + public function setId(string $id): static + { + $reservedWordsHelper = new ReservedWordsHelper(); + if ($reservedWordsHelper->isReservedWord($id)) { + throw new \Exception( + 'ID must not be one of reserved words: ' . implode(', ', $reservedWordsHelper->getAllReservedWords()), + 1677241981466 + ); + } + + $this->id = $id; + return $this; + } + + public function getGroup(): ?string + { + return $this->group; + } + + /** + * @return $this + */ + public function setGroup(?string $group): static + { + $this->group = $group; + return $this; + } + + public function hasGroup(): bool + { + return !empty($this->group); + } + + public function getUseTraits(): string + { + return $this->useTraits; + } + + /** + * @return $this + */ + public function setUseTraits(string $useTraits): static + { + $this->useTraits = $useTraits; + return $this; + } + + public function getImplementsInterfaces(): string + { + return $this->implementsInterfaces; + } + + /** + * @return $this + */ + public function setImplementsInterfaces(string $implementsInterfaces): static + { + $this->implementsInterfaces = $implementsInterfaces; + return $this; + } + + /** + * @return Data\SelectOption[] + */ + public function getSelectOptions(): array + { + return $this->selectOptions; + } + + /** + * @return array[] + */ + public function getSelectOptionsAsData(): array + { + return array_map( + fn(Data\SelectOption $selectOption) => $selectOption->toArray(), + $this->getSelectOptions() + ); + } + + /** + * @return $this + */ + public function setSelectOptions(Data\SelectOption ...$selectOptions): static + { + $this->selectOptions = $selectOptions; + return $this; + } + + /** + * @return $this + */ + public function setSelectOptionsFromData(array $selectOptionsData): static + { + $selectOptions = []; + foreach ($selectOptionsData as $selectOptionData) { + $selectOptions[] = Data\SelectOption::createFromData($selectOptionData); + } + + return $this->setSelectOptions(...$selectOptions); + } + + public function hasSelectOptions(): bool + { + return !empty($this->selectOptions); + } + + public static function getById(string $id): ?Config + { + $cacheKey = self::getCacheKey($id); + + try { + $selectOptions = RuntimeCache::get($cacheKey); + if (!$selectOptions instanceof self) { + throw new \Exception('Select options in registry is invalid', 1678353750987); + } + + // TODO is this needed? +// $selectOptions->setId($id); + } catch (\Exception $e) { + try { + $selectOptions = new self(); + /** @var Config\Dao $dao */ + $dao = $selectOptions->getDao(); + $dao->getById($id); + RuntimeCache::set($cacheKey, $selectOptions); + } catch (NotFoundException $e) { + return null; + } + } + + return $selectOptions; + } + + protected static function getCacheKey(string $key): string + { + return 'selectoptions_' . $key; + } + + public static function createFromData(array $data): static + { + // Check whether ID is available + $id = $data[static::PROPERTY_ID] ?? null; + if (empty($id)) { + throw new \Exception('ID is mandatory for select options definition', 1676646778230); + } + + $group = $data[static::PROPERTY_GROUP] ?? null; + $useTraits = $data[static::PROPERTY_USE_TRAITS] ?? ''; + $implementsInterfaces = $data[static::PROPERTY_IMPLEMENTS_INTERFACES] ?? ''; + $selectOptionsData = $data[static::PROPERTY_SELECT_OPTIONS] ?? []; + + return (new static()) + ->setId($id) + ->setGroup($group) + ->setUseTraits($useTraits) + ->setImplementsInterfaces($implementsInterfaces) + ->setSelectOptionsFromData($selectOptionsData); + } + + public function save(): void + { + $this->getDao()->save(); + } + + /** + * @return array + */ + public function jsonSerialize(): array + { + return [ + static::PROPERTY_ID => $this->getId(), + static::PROPERTY_GROUP => $this->getGroup(), + static::PROPERTY_USE_TRAITS => $this->getUseTraits(), + static::PROPERTY_IMPLEMENTS_INTERFACES => $this->getImplementsInterfaces(), + static::PROPERTY_SELECT_OPTIONS => $this->getSelectOptions(), + ]; + } + + /** + * @return array Class name as key and field names as value + */ + public function getFieldsUsedIn(): array + { + $definitions = [ + ...(new ClassDefinition\Listing())->load(), + ...(new Fieldcollection\Definition\Listing())->load(), + ...(new Objectbrick\Definition\Listing())->load(), + ]; + + $fieldsUsedIn = []; + foreach ($definitions as $definition) { + $prefix = match (get_class($definition)) { + ClassDefinition::class => 'Class', + Fieldcollection\Definition::class => 'Field Collection', + Objectbrick\Definition::class => 'Objectbrick', + default => 'Unknown', + }; + + $fieldsUsedIn = [ + ...$fieldsUsedIn, + ...$this->getFieldsUsedInClass($definition, $prefix), + ]; + } + + // Add classification store select fields + foreach ((new Classificationstore\KeyConfig\Listing())->load() as $keyConfiguration) { + $fieldDefinition = Classificationstore\Service::getFieldDefinitionFromKeyConfig($keyConfiguration); + if ($this->isConfiguredAsOptionsProvider($fieldDefinition)) { + $fieldsUsedIn['Classification Store ' . $keyConfiguration->getStoreId()][] = $fieldDefinition->getName(); + } + } + + return $fieldsUsedIn; + } + + /** + * @return array + */ + protected function getFieldsUsedInClass( + ClassDefinition|Fieldcollection\Definition|Objectbrick\Definition $definition, + string $prefix + ): array { + if (method_exists($definition, 'getName')) { + $definitionName = $definition->getName(); + } else { + $definitionName = $definition->getKey(); + } + + $fieldsUsedIn = []; + foreach ($this->getAllFieldDefinitions($definition) as $fieldDefinition) { + if ($this->isConfiguredAsOptionsProvider($fieldDefinition)) { + $fieldsUsedIn[$prefix . ' ' . $definitionName][] = $fieldDefinition->getName(); + } + } + + return $fieldsUsedIn; + } + + protected function isConfiguredAsOptionsProvider(?ClassDefinition\Data $fieldDefinition): bool + { + if ( + $fieldDefinition === null + || !$fieldDefinition instanceof ClassDefinition\Data\OptionsProviderInterface + || empty($fieldDefinition->getOptionsProviderType()) + || $fieldDefinition->getOptionsProviderType() === ClassDefinition\Data\OptionsProviderInterface::TYPE_CONFIGURE + ) { + return false; + } + + $configuredClass = trim($fieldDefinition->getOptionsProviderClass() ?? '', '\\'); + $configuredEnumName = trim($fieldDefinition->getOptionsProviderData() ?? '', '\\ '); + + $expectedEnumNames = [ + $this->getEnumName(), + $this->getEnumName(true), + ]; + + return $configuredClass === SelectOptionsOptionsProvider::class + && in_array($configuredEnumName, $expectedEnumNames, true); + } + + /** + * @return ClassDefinition\Data[] + */ + protected function getAllFieldDefinitions( + ClassDefinition|Fieldcollection\Definition|Objectbrick\Definition $definition + ): array { + $fieldDefinitions = $definition->getFieldDefinitions(['suppressEnrichment' => true]); + $localizedFieldDefinition = $definition->getFieldDefinition('localizedfields', ['suppressEnrichment' => true]); + if ($localizedFieldDefinition instanceof ClassDefinition\Data\Localizedfields) { + $fieldDefinitions = [ + ...$fieldDefinitions, + ...$localizedFieldDefinition->getFieldDefinitions(['suppressEnrichment' => true]) + ]; + } + + return $fieldDefinitions; + } + + /** + * @throws \Exception if configured interfaces or traits don't exist + * + * @internal + */ + public function generateEnumFiles(): void + { + \Pimcore::getContainer()->get(PHPSelectOptionsEnumDumperInterface::class)->dumpPHPEnum($this); + } + + /** + * @internal + */ + public function getPhpClassFile(): string + { + return $this->locateFile(ucfirst($this->getId()), 'DataObject/SelectOptions/%s.php'); + } + + public function getEnumName(bool $prependNamespace = false): string + { + $className = $this->getId(); + if (!$prependNamespace) { + return $className; + } + + return $this->getNamespace() . '\\' . $className; + } + + public function getNamespace(): string + { + return 'Pimcore\\Model\\DataObject\\SelectOptions'; + } +} diff --git a/models/DataObject/SelectOptions/Config/Dao.php b/models/DataObject/SelectOptions/Config/Dao.php new file mode 100644 index 00000000000..8c8d6622a10 --- /dev/null +++ b/models/DataObject/SelectOptions/Config/Dao.php @@ -0,0 +1,168 @@ +getParameter('pimcore.config'); + + $storageDirectory = LocationAwareConfigRepository::getStorageDirectoryFromSymfonyConfig($config, self::CONFIG_KEY, 'PIMCORE_CONFIG_STORAGE_DIR_SELECT_OPTIONS'); + $writeTarget = LocationAwareConfigRepository::getWriteTargetFromSymfonyConfig($config, self::CONFIG_KEY, self::WRITE_TARGET); + + parent::configure([ + 'containerConfig' => $config['objects']['select_options']['definitions'], + 'settingsStoreScope' => 'pimcore_select_options', + 'storageDirectory' => $storageDirectory, + 'writeTarget' => $writeTarget, + 'options' => $config['storage'][self::CONFIG_KEY]['options'], + ]); + } + + public function getById(?string $id = null): void + { + if ($id !== null) { + $this->model->setId($id); + } + + $data = $this->getDataByName($this->model->getId()); + if ($data && $id !== null) { + $data['id'] = $id; + } + + if (empty($data)) { + throw new Model\Exception\NotFoundException( + sprintf( + 'Select options with ID "%s" does not exist.', + $this->model->getId() + ), + 1678366154585 + ); + } + + $selectOptionsData = $data[Model\DataObject\SelectOptions\Config::PROPERTY_SELECT_OPTIONS] ?? []; + $this->model->setSelectOptionsFromData($selectOptionsData); + + unset($data[Model\DataObject\SelectOptions\Config::PROPERTY_SELECT_OPTIONS]); + $this->assignVariablesToModel($data); + } + + public function exists(string $name): bool + { + return (bool) $this->getDataByName($this->model->getId()); + } + + public function save(): void + { + $this->validateId(); + + $this->saveConfiguration(); + $this->model->generateEnumFiles(); + } + + protected function saveConfiguration(): void + { + $dataRaw = $this->model->getObjectVars(); + $data = []; + $allowedProperties = [ + Model\DataObject\SelectOptions\Config::PROPERTY_ID, + Model\DataObject\SelectOptions\Config::PROPERTY_USE_TRAITS, + Model\DataObject\SelectOptions\Config::PROPERTY_IMPLEMENTS_INTERFACES, + Model\DataObject\SelectOptions\Config::PROPERTY_GROUP, + ]; + + foreach ($dataRaw as $key => $value) { + if (in_array($key, $allowedProperties)) { + $data[$key] = $value; + } + } + + $data[Model\DataObject\SelectOptions\Config::PROPERTY_SELECT_OPTIONS] = $this->model->getSelectOptionsAsData(); + + $this->saveData($this->model->getId(), $data); + } + + protected function validateId(): void + { + $id = $this->model->getId(); + if (empty($id)) { + throw new \Exception('A select options definition needs an ID to be saved!', 1676639722696); + } + + if (!preg_match('/[A-Z][a-zA-Z0-9]+/', $id)) { + throw new \Exception('Invalid ID: Must start with capital letter, followed by alphanumeric characters', 1676639634486); + } + } + + /** + * {@inheritdoc} + */ + protected function prepareDataStructureForYaml(string $id, mixed $data): mixed + { + return [ + 'pimcore' => [ + 'objects' => [ + 'select_options' => [ + 'definitions' => [ + $id => $data, + ], + ], + ], + ], + ]; + } + + public function delete(): void + { + $this->reportFieldsUsedIn(); + $this->deleteData($this->model->getId()); + @unlink($this->model->getPhpClassFile()); + } + + protected function reportFieldsUsedIn(): void + { + $fieldsUsedIn = $this->model->getFieldsUsedIn(); + if (empty($fieldsUsedIn)) { + return; + } + + $report = []; + foreach ($fieldsUsedIn as $className => $fieldNames) { + $report[] = $className . ': ' . implode(', ', $fieldNames); + } + + throw new \Exception( + 'Select options are still used by ' . implode(' / ', $report), + 1676887977650 + ); + } +} diff --git a/models/DataObject/SelectOptions/Config/Listing.php b/models/DataObject/SelectOptions/Config/Listing.php new file mode 100644 index 00000000000..4db56c4b3b1 --- /dev/null +++ b/models/DataObject/SelectOptions/Config/Listing.php @@ -0,0 +1,89 @@ +selectOptions === null) { + $this->getDao()->loadList(); + } + + return $this->selectOptions; + } + + /** + * @param Model\DataObject\SelectOptions\Config[]|null $selectOptions + * + * @return $this + */ + public function setSelectOptions(?array $selectOptions): static + { + $this->selectOptions = $selectOptions; + + return $this; + } + + /** + * Alias of getSelectOptions() + * + * @return Model\DataObject\SelectOptions\Config[] + */ + public function load(): array + { + return $this->getSelectOptions(); + } + /** + * @return \ArrayIterator<\Pimcore\Model\DataObject\SelectOptions\Config> + */ + public function getIterator(): \ArrayIterator + { + return new \ArrayIterator($this->getSelectOptions()); + } + + public function hasConfig(?string $id): bool + { + if (empty($id)) { + return false; + } + + $matchId = strtolower($id); + foreach ($this as $selectOptionConfig) { + if (strtolower($selectOptionConfig->getId()) === $matchId) { + return true; + } + } + + return false; + } +} diff --git a/models/DataObject/SelectOptions/Config/Listing/Dao.php b/models/DataObject/SelectOptions/Config/Listing/Dao.php new file mode 100644 index 00000000000..cb61318090a --- /dev/null +++ b/models/DataObject/SelectOptions/Config/Listing/Dao.php @@ -0,0 +1,43 @@ +loadIdList() as $id) { + $configs[] = Config::getById($id); + } + + $this->model->setSelectOptions($configs); + + return $configs; + } + + public function getTotalCount(): int + { + return count($this->loadList()); + } +} diff --git a/models/DataObject/SelectOptions/Data/SelectOption.php b/models/DataObject/SelectOptions/Data/SelectOption.php new file mode 100644 index 00000000000..c44019f40ce --- /dev/null +++ b/models/DataObject/SelectOptions/Data/SelectOption.php @@ -0,0 +1,116 @@ +value; + } + + /** + * @return $this + */ + public function setValue(string $value): static + { + $this->value = $value; + return $this; + } + + public function hasValue(): bool + { + return $this->value !== ''; + } + + public function getLabel(): string + { + return $this->label; + } + + public function hasLabel(): bool + { + return $this->label !== ''; + } + + /** + * @return $this + */ + public function setLabel(string $label): static + { + $this->label = $label; + return $this; + } + + public function getName(): string + { + return $this->name; + } + + /** + * @return $this + */ + public function setName(string $name): static + { + $this->name = $name; + return $this; + } + + public function hasName(): bool + { + return $this->name !== ''; + } + + /** + * @return array + */ + public function jsonSerialize(): array + { + return $this->toArray(); + } + + /** + * @return array + */ + public function toArray(): array + { + return [ + static::PROPERTY_VALUE => $this->getValue(), + static::PROPERTY_LABEL => $this->getLabel(), + static::PROPERTY_NAME => $this->getName(), + ]; + } + + public static function createFromData(array $data): static + { + $value = $data[static::PROPERTY_VALUE] ?? ''; + $label = $data[static::PROPERTY_LABEL] ?? ''; + $name = $data[static::PROPERTY_NAME] ?? ''; + + return (new static($value, $label, $name)); + } +} diff --git a/models/DataObject/SelectOptions/Traits/EnumGetValuesTrait.php b/models/DataObject/SelectOptions/Traits/EnumGetValuesTrait.php new file mode 100644 index 00000000000..e7ae4659454 --- /dev/null +++ b/models/DataObject/SelectOptions/Traits/EnumGetValuesTrait.php @@ -0,0 +1,14 @@ +getOptionsProviderClass()) { + if ( + $def instanceof Model\DataObject\ClassDefinition\Data\Select + && !$def->useConfiguredOptions() + && $def->getOptionsProviderClass() + ) { $data[$dataKey . '%options'] = $def->getOptions(); } } @@ -813,7 +817,7 @@ public static function getOptionsForSelectField(string|Concrete $object, ClassDe DataObject\ClassDefinition\Helper\OptionsProviderResolver::MODE_MULTISELECT ); - if ($optionsProvider instanceof DataObject\ClassDefinition\DynamicOptionsProvider\MultiSelectOptionsProviderInterface) { + if (!$definition->useConfiguredOptions() && $optionsProvider instanceof DataObject\ClassDefinition\DynamicOptionsProvider\MultiSelectOptionsProviderInterface) { $_options = $optionsProvider->getOptions(['fieldname' => $definition->getName()], $definition); } else { $_options = $definition->getOptions(); diff --git a/tests/_bootstrap.php b/tests/_bootstrap.php index 3d9110d031f..b1737328f09 100644 --- a/tests/_bootstrap.php +++ b/tests/_bootstrap.php @@ -41,6 +41,7 @@ $_ENV['PIMCORE_WRITE_TARGET_PERSPECTIVES'] = 'settings-store'; $_ENV['PIMCORE_WRITE_TARGET_CUSTOM_VIEWS'] = 'settings-store'; $_ENV['PIMCORE_WRITE_TARGET_OBJECT_CUSTOM_LAYOUTS'] = 'settings-store'; +$_ENV['PIMCORE_WRITE_TARGET_SELECT_OPTIONS'] = 'settings-store'; include PIMCORE_PROJECT_ROOT . '/vendor/autoload.php'; \Pimcore\Bootstrap::setProjectRoot();