diff --git a/www/Makefile b/www/Makefile index c975387c..455fbeec 100644 --- a/www/Makefile +++ b/www/Makefile @@ -43,6 +43,7 @@ JSSRC= \ form/CalendarEvent.js \ form/PermissionPathSelector.js \ form/GroupSelector.js \ + form/GroupFilter.js \ data/RunningTasksStore.js \ button/TaskButton.js \ config/UserView.js \ diff --git a/www/css/ext6-pbs.css b/www/css/ext6-pbs.css index 88095dd8..f283bf0b 100644 --- a/www/css/ext6-pbs.css +++ b/www/css/ext6-pbs.css @@ -280,3 +280,8 @@ span.snapshot-comment-column { .info-pointer div.right-aligned { cursor: pointer; } + +.x-fieldcontainer-default-cell > .x-grid-cell-inner { + padding-top: 0px; + padding-bottom: 0px; +} diff --git a/www/form/GroupFilter.js b/www/form/GroupFilter.js new file mode 100644 index 00000000..453152e2 --- /dev/null +++ b/www/form/GroupFilter.js @@ -0,0 +1,371 @@ +Ext.define('PBS.form.GroupFilter', { + extend: 'Ext.form.FieldContainer', + alias: 'widget.pbsGroupFilter', + mixins: ['Proxmox.Mixin.CBind'], + + cindData: {}, + + controller: { + xclass: 'Ext.app.ViewController', + + removeReferences: function(record) { + for (const widget of Object.keys(record.widgets || {})) { + delete record[widget]; + } + + delete record.widgets; + }, + + cleanupReferences: function(grid) { + let me = this; + + // break cyclic reference + grid.getStore()?.each(me.removeReferences); + }, + + removeFilter: function(field) { + let me = this; + let record = field.getWidgetRecord(); + if (record === undefined) { + // this is sometimes called before a record/column is initialized + return; + } + + // break cyclic reference + me.removeReferences(record); + + me.lookup('grid').getStore().remove(record); + me.updateRealField(); + }, + + addFilter: function() { + let me = this; + me.lookup('grid').getStore().add({}); + me.updateRealField(); + }, + + onTypeChange: function(field, value) { + let me = this; + let record = field.getWidgetRecord(); + if (record === undefined) { + return; + } + + record.set('type', value); + record.commit(); + if (record.widgets) { + me.setInputValue(record.widgets, record); + } + me.updateRealField(); + }, + + onInputChange: function(field, value) { + let me = this; + if (value === null) { + return; + } + let record = field.record; + if (record === undefined) { + // this is sometimes called before a record/column is initialized + return; + } + record.set('input', value); + record.commit(); + + me.updateRealField(); + }, + + parseGroupFilter: function(filter) { + let [, type, input] = filter.match(/^(type|group|regex):(.*)$/); + return { + type, + input, + }; + }, + + onValueChange: function(field, values) { + let me = this; + let grid = me.lookup('grid'); + if (!values || values.length === 0) { + grid.getStore().removeAll(); + return; + } + let records = values.map((filter) => me.parseGroupFilter(filter)); + grid.getStore().setData(records); + }, + + setInputValue: function(widgets, rec) { + let { type, regex, group } = widgets; + + type.setHidden(true); + type.setDisabled(true); + type.setValue(undefined); + + regex.setHidden(true); + regex.setDisabled(true); + regex.setValue(undefined); + + group.setHidden(true); + group.setDisabled(true); + group.setValue(undefined); + + let field; + if (rec.data.type === 'type') { + field = type; + } else if (rec.data.type === 'regex') { + field = regex; + } else if (rec.data.type === 'group') { + field = group; + } else { + return; + } + + field.setHidden(false); + field.setDisabled(false); + field.setValue(rec.data.input); + }, + + newInputColumn: function(col, widget, rec) { + let me = this; + let view = me.getView(); + + let type = widget.down('pbsGroupTypeSelector'); + let regex = widget.down('textfield[type=regex]'); + let group = widget.down('pbsGroupSelector'); + + group.getStore().setData(view.dsStore.getData()); + + // add a widget reference to the record so we can acces them + // from the other column + rec.widgets = { + type, + regex, + group, + }; + + // add a record reference so we can access the record from + // the change handler + type.record = rec; + regex.record = rec; + group.record = rec; + + // CAUTION we just created a cyclic reference, we have to delete + // that on filter removal! + + me.setInputValue(rec.widgets, rec); + }, + + updateRealField: function() { + let me = this; + + let filter = []; + me.lookup('grid').getStore().each((rec) => { + if (rec.data.type && rec.data.input) { + filter.push(`${rec.data.type}:${rec.data.input}`); + } + }); + + let field = me.lookup('realfield'); + field.suspendEvent('change'); + field.setValue(filter); + field.resumeEvent('change'); + }, + + control: { + 'grid pbsGroupFilterTypeSelector': { + change: 'onTypeChange', + }, + 'grid fieldcontainer field': { + change: 'onInputChange', + }, + 'grid button': { + click: 'removeFilter', + }, + 'field[reference=realfield]': { + change: 'onValueChange', + }, + 'grid': { + beforedestroy: 'cleanupReferences', + }, + }, + }, + + onDestroy: function() { + let me = this; + + me.dsStore.destroy(); + delete me.dsStore; + }, + + setDsStoreUrl: function(url) { + let me = this; + me.dsStore.getProxy().setUrl(url); + }, + + updateGroupSelectors: function() { + let me = this; + let url; + if (me.remote) { + url = `/api2/json/config/remote/${me.remote}/scan/${me.datastore}/groups`; + } else if (me.datastore) { + url = `/api2/json/admin/datastore/${me.datastore}/groups`; + } + me.setDsStoreUrl(url); + me.dsStore.load({ + callback: (records) => { + me.query('pbsGroupSelector').forEach((selector) => { + selector.getStore().setData(records); + }); + }, + }); + }, + + setLocalDatastore: function(datastore) { + let me = this; + if (me.remote === undefined && me.datastore === datastore) { + return; + } + me.remote = undefined; + me.datastore = datastore; + me.updateGroupSelectors(); + }, + + setRemoteDatastore: function(remote, datastore) { + let me = this; + if (me.remote === remote && me.datastore === datastore) { + return; + } + me.remote = remote; + me.datastore = datastore; + me.updateGroupSelectors(); + }, + + items: [ + { + xtype: 'grid', + reference: 'grid', + margin: '0 0 5 0', + scrollable: true, + height: 300, + store: { + fields: ['type', 'input'], + }, + emptyText: gettext('Include all groups'), + viewConfig: { + deferEmptyText: false, + }, + columns: [ + { + text: gettext('Filter Type'), + xtype: 'widgetcolumn', + dataIndex: 'type', + flex: 1, + widget: { + xtype: 'pbsGroupFilterTypeSelector', + isFormField: false, + }, + }, + { + text: gettext('Filter Value'), + xtype: 'widgetcolumn', + flex: 1, + onWidgetAttach: 'newInputColumn', + widget: { + padding: 0, + bodyPadding: 0, + xtype: 'fieldcontainer', + layout: 'fit', + defaults: { + margin: 0, + }, + items: [ + { + hidden: true, + xtype: 'pbsGroupTypeSelector', + isFormField: false, + }, + { + hidden: true, + xtype: 'textfield', + type: 'regex', + isFormField: false, + }, + { + hidden: true, + xtype: 'pbsGroupSelector', + isFormField: false, + }, + ], + }, + }, + { + xtype: 'widgetcolumn', + width: 40, + widget: { + xtype: 'button', + iconCls: 'fa fa-trash-o', + }, + }, + ], + }, + { + xtype: 'hiddenfield', + reference: 'realfield', + setValue: function(value) { + let me = this; + me.value = value; + me.checkChange(); + }, + getValue: function() { + return this.value; + }, + getSubmitValue: function() { + return this.value; + }, + cbind: { + name: '{name}', + }, + }, + { + xtype: 'button', + text: gettext('Add'), + iconCls: 'fa fa-plus-circle', + handler: 'addFilter', + }, + ], + + initComponent: function() { + let me = this; + me.callParent(); + me.dsStore = Ext.create('Ext.data.Store', { + sorters: 'group', + model: 'pbs-groups', + }); + }, +}); + +Ext.define('PBS.form.GroupFilterTypeSelector', { + extend: 'Proxmox.form.KVComboBox', + alias: 'widget.pbsGroupFilterTypeSelector', + + allowBlank: false, + + comboItems: [ + ['type', gettext('Type')], + ['group', gettext('Group')], + ['regex', gettext('Regex')], + ], +}); + +Ext.define('PBS.form.GroupTypeSelector', { + extend: 'Proxmox.form.KVComboBox', + alias: 'widget.pbsGroupTypeSelector', + + allowBlank: false, + + comboItems: [ + ['vm', gettext('VM')], + ['ct', gettext('CT')], + ['host', gettext('Host')], + ], +});