diff --git a/app/assets/javascripts/app/controllers/_application_controller/form.coffee b/app/assets/javascripts/app/controllers/_application_controller/form.coffee
index b923a64b2..01e1ec987 100644
--- a/app/assets/javascripts/app/controllers/_application_controller/form.coffee
+++ b/app/assets/javascripts/app/controllers/_application_controller/form.coffee
@@ -531,7 +531,10 @@ class App.ControllerForm extends App.Controller
else
param[item.name].push value
else
- param[item.name] = value
+ if item.multiselect && typeof value is 'string'
+ param[item.name] = new Array(value)
+ else
+ param[item.name] = value
# verify if we have not checked checkboxes
uncheckParam = {}
diff --git a/app/assets/javascripts/app/controllers/_ui_element/_application_selector.coffee b/app/assets/javascripts/app/controllers/_ui_element/_application_selector.coffee
index 179362102..b0cfa7f4d 100644
--- a/app/assets/javascripts/app/controllers/_ui_element/_application_selector.coffee
+++ b/app/assets/javascripts/app/controllers/_ui_element/_application_selector.coffee
@@ -29,6 +29,7 @@ class App.UiElement.ApplicationSelector
'integer$': [__('is'), __('is not')]
'^radio$': [__('is'), __('is not')]
'^select$': [__('is'), __('is not')]
+ '^multiselect$': [__('contains all'), __('contains one'), __('contains all not'), __('contains one not')]
'^tree_select$': [__('is'), __('is not')]
'^input$': [__('contains'), __('contains not')]
'^richtext$': [__('contains'), __('contains not')]
@@ -44,6 +45,7 @@ class App.UiElement.ApplicationSelector
'integer$': [__('is'), __('is not'), __('has changed')]
'^radio$': [__('is'), __('is not'), __('has changed')]
'^select$': [__('is'), __('is not'), __('has changed')]
+ '^multiselect$': [__('contains all'), __('contains one'), __('contains all not'), __('contains one not')]
'^tree_select$': [__('is'), __('is not'), __('has changed')]
'^input$': [__('contains'), __('contains not'), __('has changed')]
'^richtext$': [__('contains'), __('contains not'), __('has changed')]
diff --git a/app/assets/javascripts/app/controllers/_ui_element/_application_ui_element.coffee b/app/assets/javascripts/app/controllers/_ui_element/_application_ui_element.coffee
index 4a950bc02..3a3c55fb4 100644
--- a/app/assets/javascripts/app/controllers/_ui_element/_application_ui_element.coffee
+++ b/app/assets/javascripts/app/controllers/_ui_element/_application_ui_element.coffee
@@ -72,6 +72,27 @@ class App.UiElement.ApplicationUiElement
}
attribute.sortBy = null
+ @getConfigCustomSortOptionList: (attribute) ->
+ if attribute.customsort && attribute.customsort is 'on'
+ if !_.isEmpty(attribute.options)
+ selection = attribute.options
+ attribute.options = []
+ if _.isArray(selection)
+ attribute.options = @getConfigOptionListArray(attribute, selection)
+ else
+ keys = _.keys(selection)
+ for key in keys
+ name_new = selection[key]
+ if attribute.translate
+ name_new = App.i18n.translatePlain(name_new)
+ attribute.options.push {
+ name: name_new
+ value: key
+ }
+ attribute.sortBy = null
+ else
+ @getConfigOptionList(attribute)
+
@getRelationOptionList: (attribute, params) ->
# build options list based on relation
diff --git a/app/assets/javascripts/app/controllers/_ui_element/core_workflow_condition.coffee b/app/assets/javascripts/app/controllers/_ui_element/core_workflow_condition.coffee
index da94185f7..22263237e 100644
--- a/app/assets/javascripts/app/controllers/_ui_element/core_workflow_condition.coffee
+++ b/app/assets/javascripts/app/controllers/_ui_element/core_workflow_condition.coffee
@@ -53,6 +53,7 @@ class App.UiElement.core_workflow_condition extends App.UiElement.ApplicationSel
'boolean$': [__('is'), __('is not'), __('is set'), __('not set'), _('has changed'), __('changed to')]
'integer$': [__('is'), __('is not'), __('is set'), __('not set'), _('has changed'), __('changed to')]
'^select$': [__('is'), __('is not'), __('is set'), __('not set'), _('has changed'), __('changed to')]
+ '^multiselect$': [__('contains'), __('contains not'), __('contains all'), __('contains all not'), __('is set'), __('not set'), __('has changed'), __('changed to')]
'^tree_select$': [__('is'), __('is not'), __('is set'), __('not set'), _('has changed'), __('changed to')]
'^(input|textarea|richtext)$': [__('is'), __('is not'), __('is set'), __('not set'), _('has changed'), __('changed to'), __('regex match'), __('regex mismatch')]
@@ -147,7 +148,7 @@ class App.UiElement.core_workflow_condition extends App.UiElement.ApplicationSel
}
for row in App[groupMeta.model].configure_attributes
- continue if !_.contains(['input', 'textarea', 'richtext', 'select', 'integer', 'boolean', 'active', 'tree_select', 'autocompletion_ajax'], row.tag)
+ continue if !_.contains(['input', 'textarea', 'richtext', 'multiselect', 'select', 'integer', 'boolean', 'active', 'tree_select', 'autocompletion_ajax'], row.tag)
continue if groupKey is 'ticket' && _.contains(['number', 'title'], row.name)
# ignore passwords and relations
@@ -155,7 +156,7 @@ class App.UiElement.core_workflow_condition extends App.UiElement.ApplicationSel
config = _.clone(row)
if config.tag is 'textarea'
config.expanding = false
- if config.tag is 'select'
+ if /^((multi)?select)$/.test(config.tag)
config.multiple = true
config.default = undefined
if config.type is 'email' || config.type is 'tel'
diff --git a/app/assets/javascripts/app/controllers/_ui_element/core_workflow_perform.coffee b/app/assets/javascripts/app/controllers/_ui_element/core_workflow_perform.coffee
index 8d114cac8..53c048aeb 100644
--- a/app/assets/javascripts/app/controllers/_ui_element/core_workflow_perform.coffee
+++ b/app/assets/javascripts/app/controllers/_ui_element/core_workflow_perform.coffee
@@ -40,7 +40,7 @@ class App.UiElement.core_workflow_perform extends App.UiElement.ApplicationSelec
'boolean$': ['show', 'hide', 'remove', 'set_mandatory', 'set_optional', 'set_readonly', 'unset_readonly', 'add_option', 'remove_option', 'set_fixed_to']
'integer$': ['show', 'hide', 'remove', 'set_mandatory', 'set_optional', 'set_readonly', 'unset_readonly']
'^date': ['show', 'hide', 'remove', 'set_mandatory', 'set_optional', 'set_readonly', 'unset_readonly']
- '^select$': ['show', 'hide', 'remove', 'set_mandatory', 'set_optional', 'set_readonly', 'unset_readonly', 'add_option', 'remove_option', 'set_fixed_to', 'select', 'auto_select']
+ '^(multi)?select$': ['show', 'hide', 'remove', 'set_mandatory', 'set_optional', 'set_readonly', 'unset_readonly', 'add_option', 'remove_option', 'set_fixed_to', 'select', 'auto_select']
'^tree_select$': ['show', 'hide', 'remove', 'set_mandatory', 'set_optional', 'set_readonly', 'unset_readonly', 'add_option', 'remove_option', 'set_fixed_to', 'select', 'auto_select']
'^(input|textarea)$': ['show', 'hide', 'remove', 'set_mandatory', 'set_optional', 'set_readonly', 'unset_readonly', 'fill_in', 'fill_in_empty']
@@ -64,7 +64,7 @@ class App.UiElement.core_workflow_perform extends App.UiElement.ApplicationSelec
continue
for row in App[groupMeta.model].configure_attributes
- continue if !_.contains(['input', 'textarea', 'select', 'integer', 'boolean', 'tree_select', 'date', 'datetime'], row.tag)
+ continue if !_.contains(['input', 'textarea', 'select', 'multiselect', 'integer', 'boolean', 'tree_select', 'date', 'datetime'], row.tag)
continue if _.contains(['created_at', 'updated_at'], row.name)
continue if groupKey is 'ticket' && _.contains(['number', 'organization_id', 'title', 'escalation_at', 'first_response_escalation_at', 'update_escalation_at', 'close_escalation_at', 'last_contact_at', 'last_contact_agent_at', 'last_contact_customer_at', 'first_response_at', 'close_at'], row.name)
@@ -73,7 +73,7 @@ class App.UiElement.core_workflow_perform extends App.UiElement.ApplicationSelec
config = _.clone(row)
if config.tag is 'boolean'
config.tag = 'select'
- if config.tag is 'select'
+ if /^((multi)?select)$/.test(config.tag)
config.multiple = true
config.default = undefined
if config.type is 'email' || config.type is 'tel'
@@ -121,14 +121,14 @@ class App.UiElement.core_workflow_perform extends App.UiElement.ApplicationSelec
currentOperator = elementRow.find('.js-operator option:selected').attr('value')
name = @buildValueName(elementFull, elementRow, groupAndAttribute, elements, meta, attribute)
- if !_.contains(['add_option', 'remove_option', 'set_fixed_to', 'select', 'execute', 'fill_in', 'fill_in_empty'], currentOperator)
+ if !_.contains(['add_option', 'remove_option', 'set_fixed_to', 'select', 'multiselect', 'execute', 'fill_in', 'fill_in_empty'], currentOperator)
elementRow.find('.js-value').addClass('hide').html('')
return
super(elementFull, elementRow, groupAndAttribute, elements, meta, attribute)
@buildValueConfigMultiple: (config, meta) ->
- if _.contains(['add_option', 'remove_option', 'set_fixed_to', 'select'], meta.operator)
+ if _.contains(['add_option', 'remove_option', 'set_fixed_to', 'select', 'multiselect'], meta.operator)
config.multiple = true
config.nulloption = true
else
diff --git a/app/assets/javascripts/app/controllers/_ui_element/multiselect.coffee b/app/assets/javascripts/app/controllers/_ui_element/multiselect.coffee
new file mode 100644
index 000000000..c633d789d
--- /dev/null
+++ b/app/assets/javascripts/app/controllers/_ui_element/multiselect.coffee
@@ -0,0 +1,73 @@
+# coffeelint: disable=camel_case_classes
+class App.UiElement.multiselect extends App.UiElement.ApplicationUiElement
+ @render: (attribute, params, form = {}) ->
+
+ # set multiple option
+ attribute.multiple = 'multiple'
+
+ if attribute.class
+ attribute.class = "#{attribute.class} multiselect"
+ else
+ attribute.class = 'multiselect'
+
+ if form.rejectNonExistentValues
+ attribute.rejectNonExistentValues = true
+
+ # add deleted historical options if required
+ @addDeletedOptions(attribute, params)
+
+ # build options list based on config
+ @getConfigCustomSortOptionList(attribute)
+
+ # build options list based on relation
+ @getRelationOptionList(attribute, params)
+
+ # sort attribute.options
+ @sortOptions(attribute, params)
+
+ # find selected/checked item of list
+ @selectedOptions(attribute, params)
+
+ # disable item of list
+ @disabledOptions(attribute, params)
+
+ # filter attributes
+ @filterOption(attribute, params)
+
+ # return item
+ $( App.view('generic/select')(attribute: attribute) )
+
+ # 1. If attribute.value is not among the current options, then search within historical options
+ # 2. If attribute.value is not among current and historical options, then add the value itself as an option
+ @addDeletedOptions: (attribute) ->
+ return if !_.isEmpty(attribute.relation) # do not apply for attributes with relation, relations will fill options automatically
+ return if attribute.rejectNonExistentValues
+ value = attribute.value
+ return if !value
+ return if _.isArray(value)
+ return if !attribute.options
+ return if !_.isObject(attribute.options)
+ return if value of attribute.options
+ return if value in (temp for own prop, temp of attribute.options)
+
+ if _.isArray(attribute.options)
+ # Array of Strings (value)
+ return if value of attribute.options
+
+ # Array of Objects (for ordering purposes)
+ return if attribute.options.filter((elem) -> elem.value == value) isnt null
+ else
+ # regular Object
+ return if value in (temp for own prop, temp of attribute.options)
+
+ if attribute.historical_options && value of attribute.historical_options
+ attribute.options[value] = attribute.historical_options[value]
+ else
+ attribute.options[value] = value
+
+ @_selectedOptionsIsSelected: (value, record) ->
+ if _.isArray(value)
+ for valueItem in value
+ if @_selectedOptionsIsSelectedItem(valueItem, record)
+ return true
+ false
diff --git a/app/assets/javascripts/app/controllers/_ui_element/object_manager_attribute.coffee b/app/assets/javascripts/app/controllers/_ui_element/object_manager_attribute.coffee
index 25d02e1c2..eea267856 100644
--- a/app/assets/javascripts/app/controllers/_ui_element/object_manager_attribute.coffee
+++ b/app/assets/javascripts/app/controllers/_ui_element/object_manager_attribute.coffee
@@ -7,14 +7,8 @@ class App.UiElement.object_manager_attribute extends App.UiElement.ApplicationUi
if params.data_option_new && !_.isEmpty(params.data_option_new)
params.data_option = params.data_option_new
- if attribute.value == 'select' && params.data_option? && params.data_option.options?
- sorted = _.map(
- params.data_option.options, (value, key) ->
- key = '' if !key || !key.toString
- value = '' if !value || !value.toString
- [key.toString(), value.toString()]
- )
- params.data_option.sorted = sorted.sort( (a, b) -> a[1].localeCompare(b[1]) )
+ if /^((multi)?select)$/.test(attribute.value) && params.data_option? && params.data_option.options?
+ params.data_option.mapped = @mapDataOptions(params.data_option)
item = $(App.view('object_manager/attribute')(attribute: attribute))
@@ -28,6 +22,7 @@ class App.UiElement.object_manager_attribute extends App.UiElement.ApplicationUi
localItem = localForm.closest('.js-data')
localItem.find('.js-dataMap').html(element)
localItem.find('.js-dataScreens').html(@dataScreens(attribute, localParams, params))
+ @addDragAndDrop(localItem)
options =
datetime: __('Datetime')
@@ -38,6 +33,7 @@ class App.UiElement.object_manager_attribute extends App.UiElement.ApplicationUi
tree_select: __('Tree Select')
boolean: __('Boolean')
integer: __('Integer')
+ multiselect: __('Multiselect')
# if attribute already exists, do not allow to change it anymore
if params.data_type
@@ -373,6 +369,56 @@ class App.UiElement.object_manager_attribute extends App.UiElement.ApplicationUi
)
item.find('.js-inputLinkTemplate').html(inputLinkTemplate.form)
+ @multiselect: (item, localParams, params) ->
+ item.find('.js-add').on('click', (e) ->
+ addRow = $(e.target).closest('tr')
+ key = addRow.find('.js-key').val()
+ value = addRow.find('.js-value').val()
+ addRow.find('.js-selected[value]').attr('value', key)
+ selected = addRow.find('.js-selected').prop('checked')
+ newRow = item.find('.js-template').clone().removeClass('js-template')
+ newRow.find('.js-key').val(key)
+ newRow.find('.js-value').val(value)
+ newRow.find('.js-value[value]').attr('name', "data_option::options::#{key}")
+ newRow.find('.js-selected').prop('checked', selected)
+ newRow.find('.js-selected').val(key)
+ newRow.find('.js-selected').attr('name', 'data_option::default')
+ item.find('.js-Table tr').last().before(newRow)
+ addRow.find('.js-key').val('')
+ addRow.find('.js-value').val('')
+ addRow.find('.js-selected').prop('checked', false)
+ )
+ item.on('change', '.js-key', (e) ->
+ key = $(e.target).val()
+ valueField = $(e.target).closest('tr').find('.js-value[name]')
+ valueField.attr('name', "data_option::options::#{key}")
+ )
+ item.on('click', '.js-remove', (e) ->
+ $(e.target).closest('tr').remove()
+ )
+ lastSelected = undefined
+ item.on('click', '.js-selected', (e) ->
+ checked = $(e.target).prop('checked')
+ value = $(e.target).attr('value')
+ if checked && lastSelected && lastSelected is value
+ $(e.target).prop('checked', false)
+ lastSelected = false
+ return
+ lastSelected = value
+ )
+ configureAttributes = [
+ # coffeelint: disable=no_interpolation_in_single_quotes
+ { name: 'data_option::linktemplate', display: 'Link-Template', tag: 'input', type: 'text', null: true, default: '', placeholder: 'https://example.com/?q=#{ticket.attribute_name}' },
+ # coffeelint: enable=no_interpolation_in_single_quotes
+ ]
+ inputLinkTemplate = new App.ControllerForm(
+ model:
+ configure_attributes: configureAttributes
+ noFieldset: true
+ params: params
+ )
+ item.find('.js-inputLinkTemplate').html(inputLinkTemplate.form)
+
@buildRow: (element, child, level = 0, parentElement) ->
newRow = element.find('.js-template').clone().removeClass('js-template')
newRow.find('.js-key').attr('level', level)
@@ -494,3 +540,37 @@ class App.UiElement.object_manager_attribute extends App.UiElement.ApplicationUi
item.find('.js-autocompletionDefault').html(autocompletionDefault.form)
item.find('.js-autocompletionUrl').html(autocompletionUrl.form)
item.find('.js-autocompletionMethod').html(autocompletionMethod.form)
+
+ @addDragAndDrop: (item) ->
+ dndOptions =
+ tolerance: 'pointer'
+ distance: 15
+ opacity: 0.6
+ forcePlaceholderSize: true
+ items: 'tr'
+ helper: (e, tr) ->
+ originals = tr.children()
+ helper = tr.clone()
+ helper.children().each (index) ->
+ # Set helper cell sizes to match the original sizes
+ $(@).width( originals.eq(index).outerWidth() )
+ return helper
+ item.find('tbody.table-sortable').sortable(dndOptions)
+
+ @mapDataOptions: ({options, customsort}) ->
+ if _.isArray(options)
+ mappedOptions = options.map(({name, value}) ->
+ value = '' if !value || !value.toString
+ name = '' if !name || !name.toString
+ [value.toString(), name.toString()]
+ )
+ else
+ mappedOptions = _.map(
+ options, (value, key) ->
+ key = '' if !key || !key.toString
+ value = '' if !value || !value.toString
+ [key.toString(), value.toString()]
+ )
+ return mappedOptions if customsort? && customsort is 'on'
+
+ mappedOptions.sort( (a, b) -> a[1].localeCompare(b[1]) )
diff --git a/app/assets/javascripts/app/controllers/_ui_element/select.coffee b/app/assets/javascripts/app/controllers/_ui_element/select.coffee
index 13a830dd4..a7996c125 100644
--- a/app/assets/javascripts/app/controllers/_ui_element/select.coffee
+++ b/app/assets/javascripts/app/controllers/_ui_element/select.coffee
@@ -15,7 +15,7 @@ class App.UiElement.select extends App.UiElement.ApplicationUiElement
@addDeletedOptions(attribute, params)
# build options list based on config
- @getConfigOptionList(attribute, params)
+ @getConfigCustomSortOptionList(attribute)
# build options list based on relation
@getRelationOptionList(attribute, params)
diff --git a/app/assets/javascripts/app/controllers/object_manager.coffee b/app/assets/javascripts/app/controllers/object_manager.coffee
index a6a8603f6..61af9ef2f 100644
--- a/app/assets/javascripts/app/controllers/object_manager.coffee
+++ b/app/assets/javascripts/app/controllers/object_manager.coffee
@@ -39,9 +39,16 @@ treeParams = (e, params) ->
params.data_option.options = tree
params
+multiselectParams = (params) ->
+ return params if !params.data_type || params.data_type isnt 'multiselect'
+
+ if typeof params.data_option.default is 'string'
+ params.data_option.default = new Array(params.data_option.default)
+ params
+
setSelectDefaults = (el) ->
data_type = el.find('select[name=data_type]').val()
- return if data_type isnt 'select' && data_type isnt 'boolean'
+ return if !/^((multi)?select)$/.test(data_type) && data_type isnt 'boolean'
el.find('.js-value, .js-valueTrue, .js-valueFalse').each(->
element = $(@)
@@ -54,6 +61,19 @@ setSelectDefaults = (el) ->
element.val(key_value)
)
+customsortDataOptions = ({target}, params) ->
+ return params if !params.data_option || params.data_option.customsort isnt 'on'
+
+ options = []
+ $(target).closest('.modal').find('table.js-Table tr.input-data-row').each( ->
+ $element = $(@)
+ name = $element.find('input.js-value').val().trim()
+ value = $element.find('input.js-key').val().trim()
+ options.push({name, value})
+ )
+ params.data_option.options = options
+ params
+
class ObjectManager extends App.ControllerTabs
requiredPermission: 'admin.object'
constructor: ->
@@ -198,6 +218,8 @@ class New extends App.ControllerGenericNew
params = @formParam(e.target)
params = treeParams(e, params)
+ params = multiselectParams(params)
+ params = customsortDataOptions(e, params)
# show attributes for create_middle in two column style
if params.screens && params.screens.create_middle
@@ -261,6 +283,8 @@ class Edit extends App.ControllerGenericEdit
params = @formParam(e.target)
params = treeParams(e, params)
+ params = multiselectParams(params)
+ params = customsortDataOptions(e, params)
# show attributes for create_middle in two column style
if params.screens && params.screens.create_middle
diff --git a/app/assets/javascripts/app/controllers/ticket_zoom/form_handler_core_workflow.coffee b/app/assets/javascripts/app/controllers/ticket_zoom/form_handler_core_workflow.coffee
index 6c4158f34..75c6768dc 100644
--- a/app/assets/javascripts/app/controllers/ticket_zoom/form_handler_core_workflow.coffee
+++ b/app/assets/javascripts/app/controllers/ticket_zoom/form_handler_core_workflow.coffee
@@ -124,16 +124,22 @@ class App.FormHandlerCoreWorkflow
coreWorkflowRestrictions[classname][item.name] = App.FormHandlerCoreWorkflow.restrictValuesAttributeCache(attribute, values)
valueFound = false
- for value in values
+ if item.tag is 'multiselect'
+ if _.isArray(paramValue)
+ paramValue = _.intersection(paramValue, values)
+ if paramValue.length > 0
+ valueFound = true
+ else
+ for value in values
- # false values are valid values e.g. for boolean fields (be careful)
- if value isnt undefined && paramValue isnt undefined && value isnt null && paramValue isnt null
- if value.toString() == paramValue.toString()
- valueFound = true
- break
- if _.isArray(paramValue) && _.contains(paramValue, value.toString())
- valueFound = true
- break
+ # false values are valid values e.g. for boolean fields (be careful)
+ continue if value is undefined
+ continue if value is null
+ continue if paramValue is undefined
+ continue if paramValue is null
+ continue if value.toString() != paramValue.toString()
+ valueFound = true
+ break
item.filter = values
if valueFound
diff --git a/app/assets/javascripts/app/views/object_manager/attribute/multiselect.jst.eco b/app/assets/javascripts/app/views/object_manager/attribute/multiselect.jst.eco
new file mode 100644
index 000000000..8db494781
--- /dev/null
+++ b/app/assets/javascripts/app/views/object_manager/attribute/multiselect.jst.eco
@@ -0,0 +1,71 @@
+
+
+
+
+
+
+
+
diff --git a/app/assets/javascripts/app/views/object_manager/attribute/select.jst.eco b/app/assets/javascripts/app/views/object_manager/attribute/select.jst.eco
index 0ec8ac85e..9799ce436 100644
--- a/app/assets/javascripts/app/views/object_manager/attribute/select.jst.eco
+++ b/app/assets/javascripts/app/views/object_manager/attribute/select.jst.eco
@@ -2,15 +2,17 @@
+
+
+
-
\ No newline at end of file
+
diff --git a/app/assets/javascripts/application.js b/app/assets/javascripts/application.js
index d1ec091de..bdb48e09b 100644
--- a/app/assets/javascripts/application.js
+++ b/app/assets/javascripts/application.js
@@ -225,6 +225,7 @@ jQuery.fn.removeAttrs = function(regex) {
// changes
// - set type based on data('field-type')
// - also catch [disabled] params
+// - return multiselect type to make sure that the data is always array
jQuery.fn.extend( {
serializeArrayWithType: function() {
var r20 = /%20/g,
@@ -248,27 +249,29 @@ jQuery.fn.extend( {
( this.checked || !rcheckableType.test( type ) );
} )
.map( function( i, elem ) {
- var $elem = jQuery( this );
- var val = $elem.val();
- var type = $elem.data('field-type');
+ var $elem = jQuery( this );
+ var val = $elem.val();
+ var type = $elem.data('field-type');
+ var multiple = $elem.prop('multiple');
+ var multiselect = multiple && $elem.hasClass('multiselect');
var result;
if ( val == null ) {
// be sure that also null values are transferred
// https://github.com/zammad/zammad/issues/944
if ($elem.prop('multiple')) {
- result = { name: elem.name, value: null, type: type }
+ result = { name: elem.name, value: null, type: type, multiselect: multiselect }
} else {
result = null
}
}
else if ( jQuery.isArray( val ) ) {
result = jQuery.map( val, function( val ) {
- return { name: elem.name, value: val.replace( rCRLF, "\r\n" ), type: type };
+ return { name: elem.name, value: val.replace( rCRLF, "\r\n" ), type: type, multiselect: multiselect };
} );
}
else {
- result = { name: elem.name, value: val.replace( rCRLF, "\r\n" ), type: type };
+ result = { name: elem.name, value: val.replace( rCRLF, "\r\n" ), type: type, multiselect: multiselect };
}
return result;
} ).get();
diff --git a/app/assets/stylesheets/zammad.scss b/app/assets/stylesheets/zammad.scss
index abd6da804..5030a1770 100644
--- a/app/assets/stylesheets/zammad.scss
+++ b/app/assets/stylesheets/zammad.scss
@@ -3680,6 +3680,7 @@ ol.tabs li {
.table-draggable & {
vertical-align: middle;
+ cursor: move;
}
}
diff --git a/app/controllers/object_manager_attributes_controller.rb b/app/controllers/object_manager_attributes_controller.rb
index 7e5ea15cf..4f3d40321 100644
--- a/app/controllers/object_manager_attributes_controller.rb
+++ b/app/controllers/object_manager_attributes_controller.rb
@@ -98,7 +98,7 @@ class ObjectManagerAttributesController < ApplicationController
if permitted[:data_option]
if !permitted[:data_option].key?(:default)
- permitted[:data_option][:default] = if permitted[:data_type].match?(%r{^(input|select|tree_select)$})
+ permitted[:data_option][:default] = if permitted[:data_type].match?(%r{^(input|select|multiselect|tree_select)$})
''
end
end
diff --git a/app/models/concerns/checks_core_workflow.rb b/app/models/concerns/checks_core_workflow.rb
index cf03cebd8..2fac344f6 100644
--- a/app/models/concerns/checks_core_workflow.rb
+++ b/app/models/concerns/checks_core_workflow.rb
@@ -39,7 +39,11 @@ module ChecksCoreWorkflow
end
def restricted_value?(perform_result, key)
- perform_result[:restrict_values][key].any? { |value| value.to_s == self[key].to_s }
+ if self[key].is_a?(Array)
+ (self[key].map(&:to_s) - perform_result[:restrict_values][key].map(&:to_s)).blank?
+ else
+ perform_result[:restrict_values][key].any? { |value| value.to_s == self[key].to_s }
+ end
end
def check_mandatory(perform_result)
diff --git a/app/models/core_workflow/attributes.rb b/app/models/core_workflow/attributes.rb
index ffc948ab4..57ae9cfdf 100644
--- a/app/models/core_workflow/attributes.rb
+++ b/app/models/core_workflow/attributes.rb
@@ -234,8 +234,8 @@ class CoreWorkflow::Attributes
return values if values == ['']
saved_value = saved_attribute_value(attribute)
- if saved_value.present? && values.exclude?(saved_value)
- values |= Array(saved_value.to_s)
+ if saved_value.present?
+ values |= Array(saved_value).map(&:to_s)
end
if attribute[:nulloption] && values.exclude?('')
diff --git a/app/models/core_workflow/result/backend.rb b/app/models/core_workflow/result/backend.rb
index d719cfe44..cdb3271d2 100644
--- a/app/models/core_workflow/result/backend.rb
+++ b/app/models/core_workflow/result/backend.rb
@@ -25,19 +25,19 @@ class CoreWorkflow::Result::Backend
def saved_value
# make sure we have a saved object
- return if @result_object.attributes.saved_only.blank?
+ return [] if @result_object.attributes.saved_only.blank?
# we only want to have the saved value in the restrictions
# if no changes happend to the form. If the users does changes
# to the form then also the saved value should get removed
- return if @result_object.attributes.selected.changed?
+ return [] if @result_object.attributes.selected.changed?
# attribute can be blank e.g. in custom development
# or if attribute is only available in the frontend but not
# in the backend
- return if attribute.blank?
+ return [] if attribute.blank?
- @result_object.attributes.saved_attribute_value(attribute).to_s
+ Array(@result_object.attributes.saved_attribute_value(attribute)).map(&:to_s)
end
def attribute
diff --git a/app/models/core_workflow/result/remove_option.rb b/app/models/core_workflow/result/remove_option.rb
index 8f651c9d2..b0e09777a 100644
--- a/app/models/core_workflow/result/remove_option.rb
+++ b/app/models/core_workflow/result/remove_option.rb
@@ -10,7 +10,7 @@ class CoreWorkflow::Result::RemoveOption < CoreWorkflow::Result::BaseOption
def config_value
result = Array(@perform_config['remove_option'])
- result -= Array(saved_value)
+ result -= saved_value
result
end
end
diff --git a/app/models/core_workflow/result/set_fixed_to.rb b/app/models/core_workflow/result/set_fixed_to.rb
index e4a31f81e..91d3381a1 100644
--- a/app/models/core_workflow/result/set_fixed_to.rb
+++ b/app/models/core_workflow/result/set_fixed_to.rb
@@ -13,7 +13,7 @@ class CoreWorkflow::Result::SetFixedTo < CoreWorkflow::Result::BaseOption
def config_value
result = Array(@perform_config['set_fixed_to'])
- result |= Array(saved_value)
+ result |= saved_value
result
end
diff --git a/app/models/object_manager/attribute.rb b/app/models/object_manager/attribute.rb
index 7bd209fa9..5aa593a76 100644
--- a/app/models/object_manager/attribute.rb
+++ b/app/models/object_manager/attribute.rb
@@ -9,6 +9,7 @@ class ObjectManager::Attribute < ApplicationModel
user_autocompletion
checkbox
select
+ multiselect
tree_select
datetime
date
@@ -42,6 +43,9 @@ class ObjectManager::Attribute < ApplicationModel
before_validation :set_base_options
+ before_create :ensure_multiselect
+ before_update :ensure_multiselect
+
scope :active, -> { where(active: true) }
scope :editable, -> { where(editable: true) }
scope :for_object, lambda { |name_or_klass|
@@ -588,11 +592,17 @@ to send no browser reload event, pass false
data_type = nil
case attribute.data_type
- when %r{^input|select|tree_select|richtext|textarea|checkbox$}
+ when %r{^(input|select|tree_select|richtext|textarea|checkbox)$}
data_type = :string
- when %r{^integer|user_autocompletion$}
+ when %r{^(multiselect)$}
+ data_type = if Rails.application.config.db_column_array
+ :string
+ else
+ :json
+ end
+ when %r{^(integer|user_autocompletion)$}
data_type = :integer
- when %r{^boolean|active$}
+ when %r{^(boolean|active)$}
data_type = :boolean
when %r{^datetime$}
data_type = :datetime
@@ -603,7 +613,7 @@ to send no browser reload event, pass false
# change field
if model.column_names.include?(attribute.name)
case attribute.data_type
- when %r{^input|select|tree_select|richtext|textarea|checkbox$}
+ when %r{^(input|select|tree_select|richtext|textarea|checkbox)$}
ActiveRecord::Migration.change_column(
model.table_name,
attribute.name,
@@ -611,7 +621,21 @@ to send no browser reload event, pass false
limit: attribute.data_option[:maxlength],
null: true
)
- when %r{^integer|user_autocompletion|datetime|date$}, %r{^boolean|active$}
+ when 'multiselect'
+ options = {
+ null: true,
+ }
+ if Rails.application.config.db_column_array
+ options[:array] = true
+ end
+
+ ActiveRecord::Migration.change_column(
+ model.table_name,
+ attribute.name,
+ data_type,
+ options,
+ )
+ when %r{^(integer|user_autocompletion|datetime|date)$}, %r{^(boolean|active)$}
ActiveRecord::Migration.change_column(
model.table_name,
attribute.name,
@@ -635,7 +659,7 @@ to send no browser reload event, pass false
# create field
case attribute.data_type
- when %r{^input|select|tree_select|richtext|textarea|checkbox$}
+ when %r{^(input|select|tree_select|richtext|textarea|checkbox)$}
ActiveRecord::Migration.add_column(
model.table_name,
attribute.name,
@@ -643,7 +667,21 @@ to send no browser reload event, pass false
limit: attribute.data_option[:maxlength],
null: true
)
- when %r{^integer|user_autocompletion$}, %r{^boolean|active$}, %r{^datetime|date$}
+ when 'multiselect'
+ options = {
+ null: true,
+ }
+ if Rails.application.config.db_column_array
+ options[:array] = true
+ end
+
+ ActiveRecord::Migration.add_column(
+ model.table_name,
+ attribute.name,
+ data_type,
+ options,
+ )
+ when %r{^(integer|user_autocompletion)$}, %r{^(boolean|active)$}, %r{^(datetime|date)$}
ActiveRecord::Migration.add_column(
model.table_name,
attribute.name,
@@ -866,7 +904,7 @@ is certain attribute used by triggers, overviews or schedulers
local_data_option[:null] = true if local_data_option[:null].nil?
case data_type
- when %r{^((tree_)?select|checkbox)$}
+ when %r{^((multi|tree_)?select|checkbox)$}
local_data_option[:nulloption] = true if local_data_option[:nulloption].nil?
local_data_option[:maxlength] ||= 255
end
@@ -889,7 +927,7 @@ is certain attribute used by triggers, overviews or schedulers
end
def data_type_must_not_change
- allowable_changes = %w[tree_select select input checkbox]
+ allowable_changes = %w[tree_select select multiselect input checkbox]
return if !data_type_changed?
return if (data_type_change - allowable_changes).empty?
@@ -961,7 +999,7 @@ is certain attribute used by triggers, overviews or schedulers
data_option_maxlength_check
when 'integer'
data_option_min_max_check
- when %r{^((tree_)?select|checkbox)$}
+ when %r{^((multi|tree_)?select|checkbox)$}
data_option_default_check + data_option_relation_check
when 'boolean'
data_option_default_check + data_option_nil_check
@@ -971,4 +1009,11 @@ is certain attribute used by triggers, overviews or schedulers
[]
end
end
+
+ def ensure_multiselect
+ return if data_type != 'multiselect'
+ return if data_option && data_option[:multiple] == true
+
+ data_option[:multiple] = true
+ end
end
diff --git a/app/models/ticket.rb b/app/models/ticket.rb
index 936f6db1a..f030c4cc1 100644
--- a/app/models/ticket.rb
+++ b/app/models/ticket.rb
@@ -684,7 +684,6 @@ condition example
end
next
end
-
if selector['operator'] == 'is'
if selector['pre_condition'] == 'not_set'
if attributes[1].match?(%r{^(created_by|updated_by|owner|customer|user)_id})
@@ -779,65 +778,81 @@ condition example
query += "#{attribute} NOT #{like} (?)"
value = "%#{selector['value']}%"
bind_params.push value
- elsif selector['operator'] == 'contains all' && attributes[0] == 'ticket' && attributes[1] == 'tags'
- query += "? = (
- SELECT
- COUNT(*)
- FROM
- tag_objects,
- tag_items,
- tags
- WHERE
- tickets.id = tags.o_id AND
- tag_objects.id = tags.tag_object_id AND
- tag_objects.name = 'Ticket' AND
- tag_items.id = tags.tag_item_id AND
- tag_items.name IN (?)
- )"
- bind_params.push selector['value'].count
- bind_params.push selector['value']
- elsif selector['operator'] == 'contains one' && attributes[0] == 'ticket' && attributes[1] == 'tags'
- tables += ', tag_objects, tag_items, tags'
- query += "
- tickets.id = tags.o_id AND
- tag_objects.id = tags.tag_object_id AND
- tag_objects.name = 'Ticket' AND
- tag_items.id = tags.tag_item_id AND
- tag_items.name IN (?)"
+ elsif selector['operator'] == 'contains all'
+ if attributes[0] == 'ticket' && attributes[1] == 'tags'
+ query += "? = (
+ SELECT
+ COUNT(*)
+ FROM
+ tag_objects,
+ tag_items,
+ tags
+ WHERE
+ tickets.id = tags.o_id AND
+ tag_objects.id = tags.tag_object_id AND
+ tag_objects.name = 'Ticket' AND
+ tag_items.id = tags.tag_item_id AND
+ tag_items.name IN (?)
+ )"
+ bind_params.push selector['value'].count
+ bind_params.push selector['value']
+ elsif Ticket.column_names.include?(attributes[1])
+ query += SqlHelper.new(object: Ticket).array_contains_all(attributes[1], selector['value'])
+ end
+ elsif selector['operator'] == 'contains one' && attributes[0] == 'ticket'
+ if attributes[1] == 'tags'
+ tables += ', tag_objects, tag_items, tags'
+ query += "
+ tickets.id = tags.o_id AND
+ tag_objects.id = tags.tag_object_id AND
+ tag_objects.name = 'Ticket' AND
+ tag_items.id = tags.tag_item_id AND
+ tag_items.name IN (?)"
- bind_params.push selector['value']
- elsif selector['operator'] == 'contains all not' && attributes[0] == 'ticket' && attributes[1] == 'tags'
- query += "0 = (
- SELECT
- COUNT(*)
- FROM
- tag_objects,
- tag_items,
- tags
- WHERE
- tickets.id = tags.o_id AND
- tag_objects.id = tags.tag_object_id AND
- tag_objects.name = 'Ticket' AND
- tag_items.id = tags.tag_item_id AND
- tag_items.name IN (?)
- )"
- bind_params.push selector['value']
- elsif selector['operator'] == 'contains one not' && attributes[0] == 'ticket' && attributes[1] == 'tags'
- query += "(
- SELECT
- COUNT(*)
- FROM
- tag_objects,
- tag_items,
- tags
- WHERE
- tickets.id = tags.o_id AND
- tag_objects.id = tags.tag_object_id AND
- tag_objects.name = 'Ticket' AND
- tag_items.id = tags.tag_item_id AND
- tag_items.name IN (?)
- ) BETWEEN 0 AND 0"
- bind_params.push selector['value']
+ bind_params.push selector['value']
+ elsif Ticket.column_names.include?(attributes[1])
+ query += SqlHelper.new(object: Ticket).array_contains_one(attributes[1], selector['value'])
+ end
+ elsif selector['operator'] == 'contains all not' && attributes[0] == 'ticket'
+ if attributes[1] == 'tags'
+ query += "0 = (
+ SELECT
+ COUNT(*)
+ FROM
+ tag_objects,
+ tag_items,
+ tags
+ WHERE
+ tickets.id = tags.o_id AND
+ tag_objects.id = tags.tag_object_id AND
+ tag_objects.name = 'Ticket' AND
+ tag_items.id = tags.tag_item_id AND
+ tag_items.name IN (?)
+ )"
+ bind_params.push selector['value']
+ elsif Ticket.column_names.include?(attributes[1])
+ query += SqlHelper.new(object: Ticket).array_contains_all(attributes[1], selector['value'], negated: true)
+ end
+ elsif selector['operator'] == 'contains one not' && attributes[0] == 'ticket'
+ if attributes[1] == 'tags'
+ query += "(
+ SELECT
+ COUNT(*)
+ FROM
+ tag_objects,
+ tag_items,
+ tags
+ WHERE
+ tickets.id = tags.o_id AND
+ tag_objects.id = tags.tag_object_id AND
+ tag_objects.name = 'Ticket' AND
+ tag_items.id = tags.tag_item_id AND
+ tag_items.name IN (?)
+ ) BETWEEN 0 AND 0"
+ bind_params.push selector['value']
+ elsif Ticket.column_names.include?(attributes[1])
+ query += SqlHelper.new(object: Ticket).array_contains_one(attributes[1], selector['value'], negated: true)
+ end
elsif selector['operator'] == 'before (absolute)'
query += "#{attribute} <= ?"
bind_params.push selector['value']
diff --git a/config/initializers/db_preferences.rb b/config/initializers/db_preferences.rb
index 855b0bc41..8f679e0d3 100644
--- a/config/initializers/db_preferences.rb
+++ b/config/initializers/db_preferences.rb
@@ -3,6 +3,7 @@
case ActiveRecord::Base.connection_config[:adapter]
when 'mysql2'
Rails.application.config.db_4bytes_utf8 = false
+ Rails.application.config.db_column_array = false
Rails.application.config.db_case_sensitive = false
Rails.application.config.db_like = 'LIKE'
Rails.application.config.db_null_byte = true
@@ -15,6 +16,7 @@ when 'mysql2'
end
when 'postgresql'
Rails.application.config.db_4bytes_utf8 = true
+ Rails.application.config.db_column_array = true
Rails.application.config.db_case_sensitive = true
Rails.application.config.db_like = 'ILIKE'
Rails.application.config.db_null_byte = false
diff --git a/i18n/zammad.pot b/i18n/zammad.pot
index ccd861825..cca599518 100644
--- a/i18n/zammad.pot
+++ b/i18n/zammad.pot
@@ -433,6 +433,7 @@ msgstr ""
#: app/assets/javascripts/app/views/integration/ldap_wizard.jst.eco
#: app/assets/javascripts/app/views/integration/placetel.jst.eco
#: app/assets/javascripts/app/views/integration/sipgate.jst.eco
+#: app/assets/javascripts/app/views/object_manager/attribute/multiselect.jst.eco
#: app/assets/javascripts/app/views/object_manager/attribute/select.jst.eco
#: app/assets/javascripts/app/views/object_manager/attribute/tree_select.jst.eco
#: app/assets/javascripts/app/views/object_manager/index.jst.eco
@@ -516,6 +517,7 @@ msgstr ""
#: app/assets/javascripts/app/views/layout_ref/scheduler_modal.jst.eco
#: app/assets/javascripts/app/views/layout_ref/sla_modal.jst.eco
#: app/assets/javascripts/app/views/microsoft365/list.jst.eco
+#: app/assets/javascripts/app/views/object_manager/attribute/multiselect.jst.eco
#: app/assets/javascripts/app/views/object_manager/attribute/select.jst.eco
#: app/assets/javascripts/app/views/profile/linked_accounts.jst.eco
#: app/assets/javascripts/app/views/tag/index.jst.eco
@@ -1594,6 +1596,11 @@ msgstr ""
msgid "Check the response and payload for detailed information:"
msgstr ""
+#: app/assets/javascripts/app/views/object_manager/attribute/multiselect.jst.eco
+#: app/assets/javascripts/app/views/object_manager/attribute/select.jst.eco
+msgid "Check this box if you want to customise how options are been sorted. If checkbox is disabled, values are sorted in alphabetical order."
+msgstr ""
+
#: app/assets/javascripts/app/controllers/_integration/check_mk.coffee
msgid "Checkmk"
msgstr ""
@@ -2418,6 +2425,7 @@ msgstr ""
#: app/assets/javascripts/app/views/channel/chat.jst.eco
#: app/assets/javascripts/app/views/generic/multi_locales.jst.eco
#: app/assets/javascripts/app/views/object_manager/attribute/boolean.jst.eco
+#: app/assets/javascripts/app/views/object_manager/attribute/multiselect.jst.eco
#: app/assets/javascripts/app/views/object_manager/attribute/select.jst.eco
#: db/seeds/settings.rb
msgid "Default"
@@ -3220,6 +3228,7 @@ msgstr ""
#: app/assets/javascripts/app/models/object_manager_attribute.coffee
#: app/assets/javascripts/app/views/object_manager/attribute/boolean.jst.eco
+#: app/assets/javascripts/app/views/object_manager/attribute/multiselect.jst.eco
#: app/assets/javascripts/app/views/object_manager/attribute/select.jst.eco
#: app/assets/javascripts/app/views/object_manager/index.jst.eco
msgid "Display"
@@ -5283,6 +5292,7 @@ msgid "Keep messages on server"
msgstr ""
#: app/assets/javascripts/app/views/object_manager/attribute/boolean.jst.eco
+#: app/assets/javascripts/app/views/object_manager/attribute/multiselect.jst.eco
#: app/assets/javascripts/app/views/object_manager/attribute/select.jst.eco
#: app/assets/javascripts/app/views/object_manager/attribute/tree_select.jst.eco
msgid "Key"
@@ -5981,6 +5991,10 @@ msgstr ""
msgid "Moved out"
msgstr ""
+#: app/assets/javascripts/app/controllers/_ui_element/object_manager_attribute.coffee
+msgid "Multiselect"
+msgstr ""
+
#: db/seeds/overviews.rb
msgid "My Organization Tickets"
msgstr ""
@@ -7498,6 +7512,7 @@ msgstr ""
#: app/assets/javascripts/app/views/integration/placetel.jst.eco
#: app/assets/javascripts/app/views/integration/sipgate.jst.eco
#: app/assets/javascripts/app/views/navigation/menu_cti_ringing.jst.eco
+#: app/assets/javascripts/app/views/object_manager/attribute/multiselect.jst.eco
#: app/assets/javascripts/app/views/object_manager/attribute/select.jst.eco
#: app/assets/javascripts/app/views/profile/devices.jst.eco
#: app/assets/javascripts/app/views/twitter/search_term.jst.eco
@@ -9839,6 +9854,11 @@ msgstr ""
msgid "Use client storage to cache data to enhance performance of application."
msgstr ""
+#: app/assets/javascripts/app/views/object_manager/attribute/multiselect.jst.eco
+#: app/assets/javascripts/app/views/object_manager/attribute/select.jst.eco
+msgid "Use custom option sort"
+msgstr ""
+
#: app/assets/javascripts/app/models/application.coffee
msgid "Use one line per URI"
msgstr ""
@@ -10830,18 +10850,22 @@ msgid "connected"
msgstr ""
#: app/assets/javascripts/app/controllers/_ui_element/_application_selector.coffee
+#: app/assets/javascripts/app/controllers/_ui_element/core_workflow_condition.coffee
msgid "contains"
msgstr ""
#: app/assets/javascripts/app/controllers/_ui_element/_application_selector.coffee
+#: app/assets/javascripts/app/controllers/_ui_element/core_workflow_condition.coffee
msgid "contains all"
msgstr ""
#: app/assets/javascripts/app/controllers/_ui_element/_application_selector.coffee
+#: app/assets/javascripts/app/controllers/_ui_element/core_workflow_condition.coffee
msgid "contains all not"
msgstr ""
#: app/assets/javascripts/app/controllers/_ui_element/_application_selector.coffee
+#: app/assets/javascripts/app/controllers/_ui_element/core_workflow_condition.coffee
msgid "contains not"
msgstr ""
@@ -11015,6 +11039,7 @@ msgid "h"
msgstr ""
#: app/assets/javascripts/app/controllers/_ui_element/_application_selector.coffee
+#: app/assets/javascripts/app/controllers/_ui_element/core_workflow_condition.coffee
#: app/assets/javascripts/app/views/object_manager/index.jst.eco
msgid "has changed"
msgstr ""
diff --git a/lib/notification_factory/renderer.rb b/lib/notification_factory/renderer.rb
index 0acce4d79..d0cd1071e 100644
--- a/lib/notification_factory/renderer.rb
+++ b/lib/notification_factory/renderer.rb
@@ -199,14 +199,19 @@ examples how to use
def display_value(object, method_name, previous_method_names, key)
return key if method_name != 'value' ||
- !key.instance_of?(String)
+ (!key.instance_of?(String) && !key.instance_of?(Array))
attributes = ObjectManager::Attribute
.where(object_lookup_id: ObjectLookup.by_name(object.class.to_s))
.where(name: previous_method_names.split('.').last)
- return key if attributes.count.zero? || attributes.first.data_type != 'select'
-
- attributes.first.data_option['options'][key] || key
+ case attributes.first.data_type
+ when 'select'
+ attributes.first.data_option['options'][key] || key
+ when 'multiselect'
+ key.map { |k| attributes.first.data_option['options'][k] || k }
+ else
+ key
+ end
end
end
diff --git a/lib/sql_helper.rb b/lib/sql_helper.rb
index acf488f53..89b53d61d 100644
--- a/lib/sql_helper.rb
+++ b/lib/sql_helper.rb
@@ -6,6 +6,14 @@ class SqlHelper
@object = object
end
+ def db_column(column)
+ "#{ActiveRecord::Base.connection.quote_table_name(@object.table_name)}.#{ActiveRecord::Base.connection.quote_column_name(column)}"
+ end
+
+ def db_value(value)
+ ActiveRecord::Base.connection.quote_string(value)
+ end
+
def get_param_key(key, params)
sort_by = []
if params[key].present? && params[key].is_a?(String)
@@ -97,7 +105,7 @@ order_by = [
def set_sql_order_default(sql, default)
if sql.blank? && default.present?
- sql.push("#{ActiveRecord::Base.connection.quote_table_name(@object.table_name)}.#{ActiveRecord::Base.connection.quote_column_name(default)}")
+ sql.push(db_column(default))
end
sql
end
@@ -128,7 +136,7 @@ sql = 'tickets.created_at, tickets.updated_at'
next if value.blank?
next if order_by[index].blank?
- sql.push("#{ActiveRecord::Base.connection.quote_table_name(@object.table_name)}.#{ActiveRecord::Base.connection.quote_column_name(value)}")
+ sql.push(db_column(value))
end
sql = set_sql_order_default(sql, default)
@@ -162,11 +170,34 @@ sql = 'tickets.created_at ASC, tickets.updated_at DESC'
next if value.blank?
next if order_by[index].blank?
- sql.push("#{ActiveRecord::Base.connection.quote_table_name(@object.table_name)}.#{ActiveRecord::Base.connection.quote_column_name(value)} #{order_by[index]}")
+ sql.push("#{db_column(value)} #{order_by[index]}")
end
sql = set_sql_order_default(sql, default)
sql.join(', ')
end
+
+ def array_contains_all(attribute, value, negated: false)
+ value = [''] if value.blank?
+ value = Array(value)
+ result = if Rails.application.config.db_column_array
+ "(#{db_column(attribute)} @> ARRAY[#{value.map { |v| "'#{db_value(v)}'" }.join(',')}]::varchar[])"
+ else
+ "JSON_CONTAINS(#{db_column(attribute)}, '#{db_value(value.to_json)}', '$')"
+ end
+ negated ? "NOT(#{result})" : "(#{result})"
+ end
+
+ def array_contains_one(attribute, value, negated: false)
+ value = [''] if value.blank?
+ value = Array(value)
+ result = if Rails.application.config.db_column_array
+ "(#{db_column(attribute)} && ARRAY[#{value.map { |v| "'#{db_value(v)}'" }.join(',')}]::varchar[])"
+ else
+ value.map { |v| "JSON_CONTAINS(#{db_column(attribute)}, '#{db_value(v.to_json)}', '$')" }.join(' OR ')
+ end
+ negated ? "NOT(#{result})" : "(#{result})"
+ end
+
end
diff --git a/spec/db/migrate/check_for_object_attributes_spec.rb b/spec/db/migrate/check_for_object_attributes_spec.rb
index 038604862..0eaa38f2e 100644
--- a/spec/db/migrate/check_for_object_attributes_spec.rb
+++ b/spec/db/migrate/check_for_object_attributes_spec.rb
@@ -32,6 +32,13 @@ RSpec.describe CheckForObjectAttributes, type: :db_migration do
expect { migrate }
.not_to change { attribute.reload.data_option }
end
+
+ it 'does not change multiselect attribute' do
+ attribute = create(:object_manager_attribute_multiselect)
+
+ expect { migrate }
+ .not_to change { attribute.reload.data_option }
+ end
end
context 'for #data_option key:' do
diff --git a/spec/factories/object_manager_attribute.rb b/spec/factories/object_manager_attribute.rb
index f4e5f58fc..be66876b0 100644
--- a/spec/factories/object_manager_attribute.rb
+++ b/spec/factories/object_manager_attribute.rb
@@ -156,6 +156,28 @@ FactoryBot.define do
end
end
+ factory :object_manager_attribute_multiselect, parent: :object_manager_attribute do
+ default { '' }
+
+ data_type { 'multiselect' }
+ data_option do
+ {
+ 'default' => default,
+ 'options' => {
+ 'key_1' => 'value_1',
+ 'key_2' => 'value_2',
+ 'key_3' => 'value_3',
+ },
+ 'relation' => '',
+ 'nulloption' => true,
+ 'multiple' => true,
+ 'null' => true,
+ 'translate' => true,
+ 'maxlength' => 255
+ }
+ end
+ end
+
factory :object_manager_attribute_tree_select, parent: :object_manager_attribute do
default { '' }
diff --git a/spec/lib/notification_factory/renderer_spec.rb b/spec/lib/notification_factory/renderer_spec.rb
index 9806f4010..b6244f7b6 100644
--- a/spec/lib/notification_factory/renderer_spec.rb
+++ b/spec/lib/notification_factory/renderer_spec.rb
@@ -60,84 +60,250 @@ RSpec.describe NotificationFactory::Renderer do
end
context 'when handling ObjectManager::Attribute usage', db_strategy: :reset do
-
- it 'correctly renders simple select attributes' do
- create :object_manager_attribute_select, name: 'select'
+ before do
+ create_object_manager_attribute
ObjectManager::Attribute.migration_execute
-
- ticket = create :ticket, customer: @user, select: 'key_1'
-
- renderer = build :notification_factory_renderer,
- objects: { ticket: ticket },
- template: '#{ticket.select} _SEPERATOR_ #{ticket.select.value}'
-
- expect(renderer.render).to eq 'key_1 _SEPERATOR_ value_1'
end
- it 'correctly renders select attributes on chained user object' do
- create :object_manager_attribute_select,
- object_lookup_id: ObjectLookup.by_name('User'),
- name: 'select'
- ObjectManager::Attribute.migration_execute
-
- user = User.where(firstname: 'Nicole').first
- user.select = 'key_2'
- user.save
- ticket = create :ticket, customer: user
-
- renderer = build :notification_factory_renderer,
- objects: { ticket: ticket },
- template: '#{ticket.customer.select} _SEPERATOR_ #{ticket.customer.select.value}'
-
- expect(renderer.render).to eq 'key_2 _SEPERATOR_ value_2'
+ let(:renderer) do
+ build :notification_factory_renderer,
+ objects: { ticket: ticket },
+ template: template
end
- it 'correctly renders select attributes on chained group object' do
- create :object_manager_attribute_select,
- object_lookup_id: ObjectLookup.by_name('Group'),
- name: 'select'
- ObjectManager::Attribute.migration_execute
-
- ticket = create :ticket, customer: @user
- group = ticket.group
- group.select = 'key_3'
- group.save
-
- renderer = build :notification_factory_renderer,
- objects: { ticket: ticket },
- template: '#{ticket.group.select} _SEPERATOR_ #{ticket.group.select.value}'
-
- expect(renderer.render).to eq 'key_3 _SEPERATOR_ value_3'
+ shared_examples 'correctly rendering the attributes' do
+ it 'correctly renders the attributes' do
+ expect(renderer.render).to eq expected_render
+ end
end
- it 'correctly renders select attributes on chained organization object' do
- create :object_manager_attribute_select,
- object_lookup_id: ObjectLookup.by_name('Organization'),
- name: 'select'
- ObjectManager::Attribute.migration_execute
+ context 'with a simple select attribute' do
+ let(:create_object_manager_attribute) do
+ create :object_manager_attribute_select, name: 'select'
+ end
+ let(:ticket) { create :ticket, customer: @user, select: 'key_1' }
+ let(:template) { '#{ticket.select} _SEPERATOR_ #{ticket.select.value}' }
+ let(:expected_render) { 'key_1 _SEPERATOR_ value_1' }
- @user.organization.select = 'key_2'
- @user.organization.save
- ticket = create :ticket, customer: @user
-
- renderer = build :notification_factory_renderer,
- objects: { ticket: ticket },
- template: '#{ticket.customer.organization.select} _SEPERATOR_ #{ticket.customer.organization.select.value}'
-
- expect(renderer.render).to eq 'key_2 _SEPERATOR_ value_2'
+ it_behaves_like 'correctly rendering the attributes'
end
- it 'correctly renders tree select attributes' do
- create :object_manager_attribute_tree_select, name: 'tree_select'
- ObjectManager::Attribute.migration_execute
+ context 'with select attribute on chained user object' do
+ let(:create_object_manager_attribute) do
+ create :object_manager_attribute_select,
+ object_lookup_id: ObjectLookup.by_name('User'),
+ name: 'select'
+ end
- ticket = create :ticket, customer: @user, tree_select: 'Incident::Hardware::Laptop'
+ let(:user) do
+ user = User.where(firstname: 'Nicole').first
+ user.select = 'key_2'
+ user.save
+ user
+ end
- renderer = build :notification_factory_renderer,
- objects: { ticket: ticket },
- template: '#{ticket.tree_select} _SEPERATOR_ #{ticket.tree_select.value}'
+ let(:ticket) { create :ticket, customer: user }
+ let(:template) { '#{ticket.customer.select} _SEPERATOR_ #{ticket.customer.select.value}' }
+ let(:expected_render) { 'key_2 _SEPERATOR_ value_2' }
- expect(renderer.render).to eq 'Incident::Hardware::Laptop _SEPERATOR_ Incident::Hardware::Laptop'
+ it_behaves_like 'correctly rendering the attributes'
+ end
+
+ context 'with select attribute on chained group object' do
+ let(:create_object_manager_attribute) do
+ create :object_manager_attribute_select,
+ object_lookup_id: ObjectLookup.by_name('Group'),
+ name: 'select'
+ end
+ let(:template) { '#{ticket.group.select} _SEPERATOR_ #{ticket.group.select.value}' }
+ let(:expected_render) { 'key_3 _SEPERATOR_ value_3' }
+
+ let(:ticket) { create :ticket, customer: @user }
+
+ before do
+ group = ticket.group
+ group.select = 'key_3'
+ group.save
+ end
+
+ it_behaves_like 'correctly rendering the attributes'
+ end
+
+ context 'with select attribute on chained organization object' do
+ let(:create_object_manager_attribute) do
+ create :object_manager_attribute_select,
+ object_lookup_id: ObjectLookup.by_name('Organization'),
+ name: 'select'
+ end
+
+ let(:user) do
+ @user.organization.select = 'key_2'
+ @user.organization.save
+ @user
+ end
+
+ let(:ticket) { create :ticket, customer: user }
+ let(:template) { '#{ticket.customer.organization.select} _SEPERATOR_ #{ticket.customer.organization.select.value}' }
+ let(:expected_render) { 'key_2 _SEPERATOR_ value_2' }
+
+ it_behaves_like 'correctly rendering the attributes'
+ end
+
+ context 'with multiselect' do
+ context 'with a simple multiselect attribute' do
+ let(:create_object_manager_attribute) do
+ create :object_manager_attribute_multiselect, name: 'multiselect'
+ end
+ let(:ticket) { create :ticket, customer: @user, multiselect: ['key_1'] }
+ let(:template) { '#{ticket.multiselect} _SEPERATOR_ #{ticket.multiselect.value}' }
+ let(:expected_render) { 'key_1 _SEPERATOR_ value_1' }
+
+ it_behaves_like 'correctly rendering the attributes'
+ end
+
+ context 'with single multiselect attribute on chained user object' do
+ let(:create_object_manager_attribute) do
+ create :object_manager_attribute_multiselect,
+ object_lookup_id: ObjectLookup.by_name('User'),
+ name: 'multiselect'
+ end
+
+ let(:user) do
+ user = User.where(firstname: 'Nicole').first
+ user.multiselect = ['key_2']
+ user.save
+ user
+ end
+
+ let(:ticket) { create :ticket, customer: user }
+ let(:template) { '#{ticket.customer.multiselect} _SEPERATOR_ #{ticket.customer.multiselect.value}' }
+ let(:expected_render) { 'key_2 _SEPERATOR_ value_2' }
+
+ it_behaves_like 'correctly rendering the attributes'
+ end
+
+ context 'with single multiselect attribute on chained group object' do
+ let(:create_object_manager_attribute) do
+ create :object_manager_attribute_multiselect,
+ object_lookup_id: ObjectLookup.by_name('Group'),
+ name: 'multiselect'
+ end
+ let(:template) { '#{ticket.group.multiselect} _SEPERATOR_ #{ticket.group.multiselect.value}' }
+ let(:expected_render) { 'key_3 _SEPERATOR_ value_3' }
+
+ let(:ticket) { create :ticket, customer: @user }
+
+ before do
+ group = ticket.group
+ group.multiselect = ['key_3']
+ group.save
+ end
+
+ it_behaves_like 'correctly rendering the attributes'
+ end
+
+ context 'with single multiselect attribute on chained organization object' do
+ let(:create_object_manager_attribute) do
+ create :object_manager_attribute_multiselect,
+ object_lookup_id: ObjectLookup.by_name('Organization'),
+ name: 'multiselect'
+ end
+
+ let(:user) do
+ @user.organization.multiselect = ['key_2']
+ @user.organization.save
+ @user
+ end
+
+ let(:ticket) { create :ticket, customer: user }
+ let(:template) { '#{ticket.customer.organization.multiselect} _SEPERATOR_ #{ticket.customer.organization.multiselect.value}' }
+ let(:expected_render) { 'key_2 _SEPERATOR_ value_2' }
+
+ it_behaves_like 'correctly rendering the attributes'
+ end
+
+ context 'with a multiple multiselect attribute' do
+ let(:create_object_manager_attribute) do
+ create :object_manager_attribute_multiselect, name: 'multiselect'
+ end
+ let(:ticket) { create :ticket, customer: @user, multiselect: %w[key_1 key_2] }
+ let(:template) { '#{ticket.multiselect} _SEPERATOR_ #{ticket.multiselect.value}' }
+ let(:expected_render) { 'key_1, key_2 _SEPERATOR_ value_1, value_2' }
+
+ it_behaves_like 'correctly rendering the attributes'
+ end
+
+ context 'with multiple multiselect attribute on chained user object' do
+ let(:create_object_manager_attribute) do
+ create :object_manager_attribute_multiselect,
+ object_lookup_id: ObjectLookup.by_name('User'),
+ name: 'multiselect'
+ end
+
+ let(:user) do
+ user = User.where(firstname: 'Nicole').first
+ user.multiselect = %w[key_2 key_3]
+ user.save
+ user
+ end
+
+ let(:ticket) { create :ticket, customer: user }
+ let(:template) { '#{ticket.customer.multiselect} _SEPERATOR_ #{ticket.customer.multiselect.value}' }
+ let(:expected_render) { 'key_2, key_3 _SEPERATOR_ value_2, value_3' }
+
+ it_behaves_like 'correctly rendering the attributes'
+ end
+
+ context 'with multiple multiselect attribute on chained group object' do
+ let(:create_object_manager_attribute) do
+ create :object_manager_attribute_multiselect,
+ object_lookup_id: ObjectLookup.by_name('Group'),
+ name: 'multiselect'
+ end
+ let(:template) { '#{ticket.group.multiselect} _SEPERATOR_ #{ticket.group.multiselect.value}' }
+ let(:expected_render) { 'key_3, key_1 _SEPERATOR_ value_3, value_1' }
+
+ let(:ticket) { create :ticket, customer: @user }
+
+ before do
+ group = ticket.group
+ group.multiselect = %w[key_3 key_1]
+ group.save
+ end
+
+ it_behaves_like 'correctly rendering the attributes'
+ end
+
+ context 'with multiple multiselect attribute on chained organization object' do
+ let(:create_object_manager_attribute) do
+ create :object_manager_attribute_multiselect,
+ object_lookup_id: ObjectLookup.by_name('Organization'),
+ name: 'multiselect'
+ end
+
+ let(:user) do
+ @user.organization.multiselect = %w[key_2 key_1]
+ @user.organization.save
+ @user
+ end
+
+ let(:ticket) { create :ticket, customer: user }
+ let(:template) { '#{ticket.customer.organization.multiselect} _SEPERATOR_ #{ticket.customer.organization.multiselect.value}' }
+ let(:expected_render) { 'key_2, key_1 _SEPERATOR_ value_2, value_1' }
+
+ it_behaves_like 'correctly rendering the attributes'
+ end
+ end
+
+ context 'with a tree select attribute' do
+ let(:create_object_manager_attribute) do
+ create :object_manager_attribute_tree_select, name: 'tree_select'
+ end
+ let(:ticket) { create :ticket, customer: @user, tree_select: 'Incident::Hardware::Laptop' }
+ let(:template) { '#{ticket.tree_select} _SEPERATOR_ #{ticket.tree_select.value}' }
+ let(:expected_render) { 'Incident::Hardware::Laptop _SEPERATOR_ Incident::Hardware::Laptop' }
+
+ it_behaves_like 'correctly rendering the attributes'
end
end
end
diff --git a/spec/models/core_workflow_spec.rb b/spec/models/core_workflow_spec.rb
index 4b35060f7..0212071ed 100644
--- a/spec/models/core_workflow_spec.rb
+++ b/spec/models/core_workflow_spec.rb
@@ -299,6 +299,37 @@ RSpec.describe CoreWorkflow, type: :model do
end
end
+ describe '.perform - Default - Restrict values for multiselect fields', db_strategy: :reset do
+ let(:field_name) { SecureRandom.uuid }
+
+ before do
+ create :object_manager_attribute_multiselect, name: field_name, display: field_name
+ ObjectManager::Attribute.migration_execute
+ end
+
+ context 'without saved values' do
+ it 'does return the correct list of selectable values' do
+ expect(result[:restrict_values][field_name]).to eq(['', 'key_1', 'key_2', 'key_3'])
+ end
+ end
+
+ context 'with saved values' do
+ let(:payload) do
+ base_payload.merge('params' => {
+ 'id' => ticket.id,
+ })
+ end
+
+ before do
+ ticket.reload.update(field_name.to_sym => %w[key_2 key_3])
+ end
+
+ it 'does return the correct list of selectable values' do
+ expect(result[:restrict_values][field_name]).to eq(['', 'key_1', 'key_2', 'key_3'])
+ end
+ end
+ end
+
describe '.perform - Custom - Pending Time' do
it 'does not show pending time for non pending state' do
expect(result[:visibility]['pending_time']).to eq('remove')
diff --git a/spec/models/trigger_spec.rb b/spec/models/trigger_spec.rb
index 9885ee3da..2ed35e275 100644
--- a/spec/models/trigger_spec.rb
+++ b/spec/models/trigger_spec.rb
@@ -931,4 +931,220 @@ RSpec.describe Trigger, type: :model do
end
end
end
+
+ describe 'multiselect triggers', db_strategy: :reset do
+
+ let(:attribute_name) { 'multiselect' }
+
+ let(:condition) do
+ { "ticket.#{attribute_name}" => { 'operator' => operator, 'value' => trigger_values } }
+ end
+
+ let(:perform) do
+ { 'article.note' => { 'subject' => 'Test subject note', 'internal' => 'true', 'body' => 'Test body note' } }
+ end
+
+ before do
+ create :object_manager_attribute_multiselect, name: attribute_name
+ ObjectManager::Attribute.migration_execute
+
+ described_class.destroy_all # Default DB state includes three sample triggers
+ trigger # create subject trigger
+ end
+
+ context 'when ticket is updated with a multiselect trigger condition', authenticated_as: :owner, db_strategy: :reset do
+ let(:options) do
+ {
+ a: 'a',
+ b: 'b',
+ c: 'c',
+ d: 'd',
+ e: 'e',
+ }
+ end
+
+ let(:trigger_values) { %w[a b c] }
+ let(:group) { create(:group) }
+ let(:owner) { create(:admin, group_ids: [group.id]) }
+ let!(:ticket) { create(:ticket, group: group,) }
+
+ before do
+ ticket.update_attribute(attribute_name, ticket_multiselect_values)
+ end
+
+ shared_examples 'updating the ticket with the trigger condition' do
+ it 'updates the ticket with the trigger condition' do
+ expect { TransactionDispatcher.commit }
+ .to change(Ticket::Article, :count).by(1)
+ end
+ end
+
+ shared_examples 'not updating the ticket with the trigger condition' do
+ it 'does not update the ticket with the trigger condition' do
+ expect { TransactionDispatcher.commit }
+ .to not_change(Ticket::Article, :count)
+ end
+ end
+
+ context "with 'contains all' used" do
+ let(:operator) { 'contains all' }
+
+ context 'when updated value is the same with trigger value' do
+ let(:ticket_multiselect_values) { trigger_values }
+
+ it_behaves_like 'updating the ticket with the trigger condition'
+ end
+
+ context 'when updated value is different from the trigger value' do
+ let(:ticket_multiselect_values) { options.values - trigger_values }
+
+ it_behaves_like 'not updating the ticket with the trigger condition'
+ end
+
+ context 'when no value is selected' do
+ let(:ticket_multiselect_values) { ['-'] }
+
+ it_behaves_like 'not updating the ticket with the trigger condition'
+ end
+
+ context 'when all value is selected' do
+ let(:ticket_multiselect_values) { options.values }
+
+ it_behaves_like 'updating the ticket with the trigger condition'
+ end
+
+ context 'when updated value contains one of the trigger value' do
+ let(:ticket_multiselect_values) { [trigger_values.first] }
+
+ it_behaves_like 'not updating the ticket with the trigger condition'
+ end
+
+ context 'when updated value does not contain one of the trigger value' do
+ let(:ticket_multiselect_values) { options.values - [trigger_values.first] }
+
+ it_behaves_like 'not updating the ticket with the trigger condition'
+ end
+ end
+
+ context "with 'contains one' used" do
+ let(:operator) { 'contains one' }
+
+ context 'when updated value is the same with trigger value' do
+ let(:ticket_multiselect_values) { trigger_values }
+
+ it_behaves_like 'updating the ticket with the trigger condition'
+ end
+
+ context 'when updated value is different from the trigger value' do
+ let(:ticket_multiselect_values) { options.values - trigger_values }
+
+ it_behaves_like 'not updating the ticket with the trigger condition'
+ end
+
+ context 'when no value is selected' do
+ let(:ticket_multiselect_values) { ['-'] }
+
+ it_behaves_like 'not updating the ticket with the trigger condition'
+ end
+
+ context 'when all value is selected' do
+ let(:ticket_multiselect_values) { options.values }
+
+ it_behaves_like 'updating the ticket with the trigger condition'
+ end
+
+ context 'when updated value contains only one of the trigger value' do
+ let(:ticket_multiselect_values) { [trigger_values.first] }
+
+ it_behaves_like 'updating the ticket with the trigger condition'
+ end
+
+ context 'when updated value does not contain one of the trigger value' do
+ let(:ticket_multiselect_values) { options.values - [trigger_values.first] }
+
+ it_behaves_like 'updating the ticket with the trigger condition'
+ end
+ end
+
+ context "with 'contains all not' used" do
+ let(:operator) { 'contains all not' }
+
+ context 'when updated value is the same with trigger value' do
+ let(:ticket_multiselect_values) { trigger_values }
+
+ it_behaves_like 'not updating the ticket with the trigger condition'
+ end
+
+ context 'when updated value is different from the trigger value' do
+ let(:ticket_multiselect_values) { options.values - trigger_values }
+
+ it_behaves_like 'updating the ticket with the trigger condition'
+ end
+
+ context 'when no value is selected' do
+ let(:ticket_multiselect_values) { ['-'] }
+
+ it_behaves_like 'updating the ticket with the trigger condition'
+ end
+
+ context 'when all value is selected' do
+ let(:ticket_multiselect_values) { options.values }
+
+ it_behaves_like 'not updating the ticket with the trigger condition'
+ end
+
+ context 'when updated value contains only one of the trigger value' do
+ let(:ticket_multiselect_values) { [trigger_values.first] }
+
+ it_behaves_like 'updating the ticket with the trigger condition'
+ end
+
+ context 'when updated value does not contain one of the trigger value' do
+ let(:ticket_multiselect_values) { options.values - [trigger_values.first] }
+
+ it_behaves_like 'updating the ticket with the trigger condition'
+ end
+ end
+
+ context "with 'contains one not' used" do
+ let(:operator) { 'contains one not' }
+
+ context 'when updated value is the same with trigger value' do
+ let(:ticket_multiselect_values) { trigger_values }
+
+ it_behaves_like 'not updating the ticket with the trigger condition'
+ end
+
+ context 'when updated value is different from the trigger value' do
+ let(:ticket_multiselect_values) { options.values - trigger_values }
+
+ it_behaves_like 'updating the ticket with the trigger condition'
+ end
+
+ context 'when no value is selected' do
+ let(:ticket_multiselect_values) { ['-'] }
+
+ it_behaves_like 'updating the ticket with the trigger condition'
+ end
+
+ context 'when all value is selected' do
+ let(:ticket_multiselect_values) { options.values }
+
+ it_behaves_like 'not updating the ticket with the trigger condition'
+ end
+
+ context 'when updated value contains only one of the trigger value' do
+ let(:ticket_multiselect_values) { [trigger_values.first] }
+
+ it_behaves_like 'not updating the ticket with the trigger condition'
+ end
+
+ context 'when updated value does not contain one of the trigger value' do
+ let(:ticket_multiselect_values) { options.values - [trigger_values.first] }
+
+ it_behaves_like 'not updating the ticket with the trigger condition'
+ end
+ end
+ end
+ end
end
diff --git a/spec/support/db_strategies.rb b/spec/support/db_strategies.rb
index fd21fb2f5..34621d3bb 100644
--- a/spec/support/db_strategies.rb
+++ b/spec/support/db_strategies.rb
@@ -16,4 +16,16 @@ RSpec.configure do |config|
end
end
end
+
+ config.filter_run_excluding db_adapter: lambda { |adapter|
+ adapter_config = ActiveRecord::Base.connection_config[:adapter]
+ case adapter
+ when :postgresql
+ adapter_config != 'postgresql'
+ when :mysql
+ adapter_config != 'mysql2'
+ else
+ false
+ end
+ }
end
diff --git a/spec/system/examples/core_workflow_examples.rb b/spec/system/examples/core_workflow_examples.rb
index a04049404..d7a45bfeb 100644
--- a/spec/system/examples/core_workflow_examples.rb
+++ b/spec/system/examples/core_workflow_examples.rb
@@ -619,6 +619,206 @@ RSpec.shared_examples 'core workflow' do
end
end
+ describe 'modify multiselect attribute', authenticated_as: :authenticate, db_strategy: :reset do
+ def authenticate
+ create(:object_manager_attribute_multiselect, object_name: object_name, name: field_name, display: field_name, screens: screens)
+ ObjectManager::Attribute.migration_execute
+ true
+ end
+
+ describe 'action - show' do
+ before do
+ create(:core_workflow,
+ object: object_name,
+ perform: {
+ "#{object_name.downcase}.#{field_name}": {
+ operator: 'show',
+ show: 'true'
+ },
+ })
+ end
+
+ it 'does perform' do
+ before_it.call
+ expect(page).to have_selector("select[name='#{field_name}']", wait: 10)
+ end
+ end
+
+ describe 'action - hide' do
+ before do
+ create(:core_workflow,
+ object: object_name,
+ perform: {
+ "#{object_name.downcase}.#{field_name}": {
+ operator: 'hide',
+ hide: 'true'
+ },
+ })
+ end
+
+ it 'does perform' do
+ before_it.call
+ expect(page).to have_selector(".form-group[data-attribute-name='#{field_name}'].is-hidden", visible: :hidden, wait: 10)
+ end
+ end
+
+ describe 'action - remove' do
+ before do
+ create(:core_workflow,
+ object: object_name,
+ perform: {
+ "#{object_name.downcase}.#{field_name}": {
+ operator: 'remove',
+ remove: 'true'
+ },
+ })
+ end
+
+ it 'does perform' do
+ before_it.call
+ expect(page).to have_selector(".form-group[data-attribute-name='#{field_name}'].is-removed", visible: :hidden, wait: 10)
+ end
+ end
+
+ describe 'action - set_optional' do
+ before do
+ create(:core_workflow,
+ object: object_name,
+ perform: {
+ "#{object_name.downcase}.#{field_name}": {
+ operator: 'set_optional',
+ set_optional: 'true'
+ },
+ })
+ end
+
+ it 'does perform' do
+ before_it.call
+ expect(page.find("div[data-attribute-name='#{field_name}'] div.formGroup-label label")).to have_no_text('*', wait: 10)
+ end
+ end
+
+ describe 'action - set_mandatory' do
+ before do
+ create(:core_workflow,
+ object: object_name,
+ perform: {
+ "#{object_name.downcase}.#{field_name}": {
+ operator: 'set_mandatory',
+ set_mandatory: 'true'
+ },
+ })
+ end
+
+ it 'does perform' do
+ before_it.call
+ expect(page.find("div[data-attribute-name='#{field_name}'] div.formGroup-label label")).to have_text('*', wait: 10)
+ end
+ end
+
+ describe 'action - unset_readonly' do
+ before do
+ create(:core_workflow,
+ object: object_name,
+ perform: {
+ "#{object_name.downcase}.#{field_name}": {
+ operator: 'unset_readonly',
+ unset_readonly: 'true'
+ },
+ })
+ end
+
+ it 'does perform' do
+ before_it.call
+ expect(page).to have_no_selector("div[data-attribute-name='#{field_name}'].is-readonly", wait: 10)
+ end
+ end
+
+ describe 'action - set_readonly' do
+ before do
+ create(:core_workflow,
+ object: object_name,
+ perform: {
+ "#{object_name.downcase}.#{field_name}": {
+ operator: 'set_readonly',
+ set_readonly: 'true'
+ },
+ })
+ end
+
+ it 'does perform' do
+ before_it.call
+ expect(page).to have_selector("div[data-attribute-name='#{field_name}'].is-readonly", wait: 10)
+ end
+ end
+
+ describe 'action - restrict values' do
+ before do
+ create(:core_workflow,
+ object: object_name,
+ perform: {
+ "#{object_name.downcase}.#{field_name}": {
+ operator: 'set_fixed_to',
+ set_fixed_to: %w[key_1 key_3]
+ },
+ })
+ end
+
+ it 'does perform' do
+ before_it.call
+ expect(page).to have_selector("select[name='#{field_name}'] option[value='key_1']", wait: 10)
+ expect(page).to have_no_selector("select[name='#{field_name}'] option[value='key_2']", wait: 10)
+ expect(page).to have_selector("select[name='#{field_name}'] option[value='key_3']", wait: 10)
+ end
+ end
+
+ describe 'action - select' do
+ before do
+ create(:core_workflow,
+ object: object_name,
+ perform: {
+ "#{object_name.downcase}.#{field_name}": {
+ operator: 'select',
+ select: ['key_3']
+ },
+ })
+ end
+
+ it 'does perform' do
+ before_it.call
+ wait(5).until { page.find("select[name='#{field_name}']").value == ['key_3'] }
+ expect(page.find("select[name='#{field_name}']").value).to eq(['key_3'])
+ end
+ end
+
+ describe 'action - auto select' do
+ before do
+ create(:core_workflow,
+ object: object_name,
+ perform: {
+ "#{object_name.downcase}.#{field_name}": {
+ operator: 'set_fixed_to',
+ set_fixed_to: ['', 'key_3'],
+ },
+ })
+ create(:core_workflow,
+ object: object_name,
+ perform: {
+ "#{object_name.downcase}.#{field_name}": {
+ operator: 'auto_select',
+ auto_select: 'true',
+ },
+ })
+ end
+
+ it 'does perform' do
+ before_it.call
+ wait(5).until { page.find("select[name='#{field_name}']").value == ['key_3'] }
+ expect(page.find("select[name='#{field_name}']").value).to eq(['key_3'])
+ end
+ end
+ end
+
describe 'modify boolean attribute', authenticated_as: :authenticate, db_strategy: :reset do
def authenticate
create(:object_manager_attribute_boolean, object_name: object_name, name: field_name, display: field_name, screens: screens)
diff --git a/spec/system/manage/trigger_spec.rb b/spec/system/manage/trigger_spec.rb
index 7fa5aa2ee..108ed290b 100644
--- a/spec/system/manage/trigger_spec.rb
+++ b/spec/system/manage/trigger_spec.rb
@@ -38,6 +38,29 @@ RSpec.describe 'Manage > Trigger', type: :system do
expect(find('.js-value select')).to be_multiple
end
end
+
+ it 'enables selection of multiple values for multiselect attribute' do
+ attribute = create_attribute :object_manager_attribute_multiselect,
+ data_option: {
+ options: {
+ 'name 1': 'name 1',
+ 'name 2': 'name 2',
+ },
+ default: '',
+ null: false,
+ relation: '',
+ maxlength: 255,
+ nulloption: true,
+ }
+
+ open_new_trigger_dialog
+
+ within '.modal .ticket_selector' do
+ find('.js-attributeSelector select').select(attribute.display)
+
+ expect(find('.js-value select')).to be_multiple
+ end
+ end
end
it 'sets a customer email address with no @ character' do
@@ -100,4 +123,158 @@ RSpec.describe 'Manage > Trigger', type: :system do
end
end
end
+
+ context 'when ticket is updated with a multiselect trigger condition', authenticated_as: :owner, db_strategy: :reset do
+ let(:options) do
+ {
+ a: 'a',
+ b: 'b',
+ c: 'c',
+ d: 'd',
+ e: 'e',
+ }
+ end
+
+ let(:trigger_values) { %w[a b c] }
+
+ let!(:attribute) do
+ create_attribute :object_manager_attribute_multiselect,
+ data_option: {
+ options: options,
+ default: '',
+ null: false,
+ relation: '',
+ maxlength: 255,
+ nulloption: true,
+ },
+ name: 'multiselect',
+ screens: attributes_for(:required_screen)
+ end
+
+ let(:group) { create(:group) }
+ let(:owner) { create(:admin, group_ids: [group.id]) }
+ let!(:ticket) { create(:ticket, group: group,) }
+
+ before do
+ visit '/#manage/trigger'
+ click_on 'New Trigger'
+
+ modal_ready
+
+ within '.modal' do
+ fill_in 'Name', with: 'Test Trigger'
+ within '.ticket_selector' do
+ find('.js-attributeSelector select').select attribute.display
+ find('.js-operator select').select operator
+ trigger_values.each { |value| find('.js-value select').select value }
+ end
+
+ within '.ticket_perform_action' do
+ find('.js-attributeSelector select').select 'Note'
+
+ within '.js-setArticle' do
+ fill_in 'Subject', with: 'Test subject note'
+ find('[data-name="perform::article.note::body"]').set 'Test body note'
+ end
+ end
+
+ click_button
+ end
+
+ visit "#ticket/zoom/#{ticket.id}"
+
+ ticket_multiselect_values.each do |value|
+ within '.sidebar-content .multiselect select' do
+ select value
+ end
+ end
+
+ click_button 'Update'
+
+ end
+
+ shared_examples 'updating the ticket with the trigger condition' do
+ it 'updates the ticket with the trigger condition' do
+ wait.until { ticket.multiselect_previously_changed? && ticket.articles.present? }
+ expect(ticket.articles).not_to be_empty
+ expect(page).to have_text 'Test body note', wait: 5
+ end
+ end
+
+ context "with 'contains all' used" do
+ let(:operator) { 'contains all' }
+
+ context 'when updated value is the same with trigger value' do
+ let(:ticket_multiselect_values) { trigger_values }
+
+ it_behaves_like 'updating the ticket with the trigger condition'
+ end
+
+ context 'when all value is selected' do
+ let(:ticket_multiselect_values) { options.values }
+
+ it_behaves_like 'updating the ticket with the trigger condition'
+ end
+ end
+
+ context "with 'contains one' used" do
+ let(:operator) { 'contains one' }
+
+ context 'when updated value is the same with trigger value' do
+ let(:ticket_multiselect_values) { trigger_values }
+
+ it_behaves_like 'updating the ticket with the trigger condition'
+ end
+
+ context 'when all value is selected' do
+ let(:ticket_multiselect_values) { options.values }
+
+ it_behaves_like 'updating the ticket with the trigger condition'
+ end
+
+ context 'when updated value contains only one of the trigger value' do
+ let(:ticket_multiselect_values) { [trigger_values.first] }
+
+ it_behaves_like 'updating the ticket with the trigger condition'
+ end
+
+ context 'when updated value does not contain one of the trigger value' do
+ let(:ticket_multiselect_values) { options.values - [trigger_values.first] }
+
+ it_behaves_like 'updating the ticket with the trigger condition'
+ end
+ end
+
+ context "with 'contains all not' used" do
+ let(:operator) { 'contains all not' }
+
+ context 'when updated value is different from the trigger value' do
+ let(:ticket_multiselect_values) { options.values - trigger_values }
+
+ it_behaves_like 'updating the ticket with the trigger condition'
+ end
+
+ context 'when updated value contains only one of the trigger value' do
+ let(:ticket_multiselect_values) { [trigger_values.first] }
+
+ it_behaves_like 'updating the ticket with the trigger condition'
+ end
+
+ context 'when updated value does not contain one of the trigger value' do
+ let(:ticket_multiselect_values) { options.values - [trigger_values.first] }
+
+ it_behaves_like 'updating the ticket with the trigger condition'
+ end
+ end
+
+ context "with 'contains one not' used" do
+ let(:operator) { 'contains one not' }
+
+ context 'when updated value is different from the trigger value' do
+ let(:ticket_multiselect_values) { options.values - trigger_values }
+
+ it_behaves_like 'updating the ticket with the trigger condition'
+ end
+ end
+ end
end
diff --git a/spec/system/system/object_manager_spec.rb b/spec/system/system/object_manager_spec.rb
index 71e88feb2..46e6224f8 100644
--- a/spec/system/system/object_manager_spec.rb
+++ b/spec/system/system/object_manager_spec.rb
@@ -107,6 +107,10 @@ RSpec.describe 'System > Objects', type: :system do
['Text', 'Select', 'Integer', 'Datetime', 'Date', 'Boolean', 'Tree Select'].each do |data_type|
include_examples 'create and remove field with migration', data_type
end
+
+ context 'with Multiselect' do
+ include_examples 'create and remove field with migration', 'Multiselect'
+ end
end
context 'when creating and modifying tree select fields', db_strategy: :reset do
@@ -168,25 +172,113 @@ RSpec.describe 'System > Objects', type: :system do
# lexicographically ordered list of option strings
let(:options) { %w[0 000.000 1 100.100 100.200 2 200.100 200.200 3 ä b n ö p sr ß st t ü v] }
let(:options_hash) { options.reverse.to_h { |o| [o, o] } }
+ let(:cutomsort_options) { ['0', '1', '2', '3', 'v', 'ü', 't', 'st', 'ß', 'sr', 'p', 'ö', 'n', 'b', 'ä', '200.200', '200.100', '100.200', '100.100', '000.000'] }
- let(:object_attribute) do
- attribute = create(:object_manager_attribute_select, data_option: { options: options_hash, default: 0 }, position: 999)
+ before do
+ object_attribute
ObjectManager::Attribute.migration_execute
- attribute
+
+ refresh
+
+ visit '/#system/object_manager'
+ click 'tbody tr:last-child td:first-child'
end
- it 'preserves the sorting correctly' do
- object_attribute
- page.refresh
- visit '/#system/object_manager'
- click 'tbody tr:last-child'
+ shared_examples 'sorting options correctly' do
+ shared_examples 'preserving the sorting correctly' do
+ it 'preserves the sorting correctly' do
+ sorted_dialog_values = all('table.settings-list tbody tr td input.js-key').map(&:value).reject { |x| x == '' }
+ expect(sorted_dialog_values).to eq(expected_options)
- sorted_dialog_values = all('table.settings-list tbody tr td:first-child input').map(&:value).reject { |x| x == '' }
- expect(sorted_dialog_values).to eq(options)
+ visit '/#ticket/create'
+ sorted_ticket_values = all("select[name=#{object_attribute.name}] option").map(&:value).reject { |x| x == '' }
+ expect(sorted_ticket_values).to eq(expected_options)
+ end
+ end
- visit '/#ticket/create'
- sorted_ticket_values = all("select[name=#{object_attribute.name}] option").map(&:value).reject { |x| x == '' }
- expect(sorted_ticket_values).to eq(options)
+ context 'with no customsort' do
+ let(:data_option) { { options: options_hash, default: 0 } }
+ let(:expected_options) { options } # sort lexicographically
+
+ it_behaves_like 'preserving the sorting correctly'
+ end
+
+ context 'with customsort' do
+ let(:options_hash) { options.reverse.collect { |o| { name: o, value: o } } }
+ let(:data_option) { { options: options_hash, default: 0, customsort: 'on' } }
+ let(:expected_options) { options.reverse } # preserves sorting from backend
+
+ it_behaves_like 'preserving the sorting correctly'
+ end
+ end
+
+ shared_examples 'sorting options correctly using drag and drop' do
+ shared_examples 'preserving drag and drop sorting correctly' do
+ it 'preserves drag and drop sorting correctly' do
+ sorted_dialog_values = all('table.settings-list tbody tr td input.js-key').map(&:value).reject { |x| x == '' }
+ expect(sorted_dialog_values).to eq(expected_options)
+ end
+ end
+
+ context 'with drag and drop sorting' do
+ let(:options) { %w[0 1 d u w] }
+ let(:options_hash) { options.to_h { |o| [o, o] } }
+
+ before do
+ # use drag and drop to reverse sort the options
+ within '.modal form' do
+ within '.js-dataMap table.js-Table .table-sortable' do
+ rows = all('tr.input-data-row td.table-draggable')
+ target = rows.last
+ pos = rows.size - 1
+ rows.each do |row|
+ next if pos <= 0
+
+ row.drag_to target
+ pos -= 1
+ end
+ end
+ click_button 'Submit'
+ end
+
+ click '.js-execute', wait: 7.minutes
+ # expect(page).to have_text('please reload your browser')
+ click '.modal-content button.js-submit'
+
+ refresh
+
+ visit '/#system/object_manager'
+ click 'tbody tr:last-child td:first-child'
+ end
+
+ context 'with no customsort' do
+ let(:data_option) { { options: options_hash, default: 0 } }
+ let(:expected_options) { options } # sort lexicographically
+
+ it_behaves_like 'preserving drag and drop sorting correctly'
+ end
+
+ context 'with customsort' do
+ let(:data_option) { { options: options_hash, default: 0, customsort: 'on' } }
+ let(:expected_options) { options.reverse } # preserves sorting from backend
+
+ it_behaves_like 'preserving drag and drop sorting correctly'
+ end
+ end
+ end
+
+ context 'with multiselect attribute' do
+ let(:object_attribute) { create(:object_manager_attribute_multiselect, data_option: data_option, position: 999) }
+
+ it_behaves_like 'sorting options correctly'
+ it_behaves_like 'sorting options correctly using drag and drop'
+ end
+
+ context 'with select attribute' do
+ let(:object_attribute) { create(:object_manager_attribute_select, data_option: data_option, position: 999) }
+
+ it_behaves_like 'sorting options correctly'
+ it_behaves_like 'sorting options correctly using drag and drop'
end
end
@@ -364,6 +456,34 @@ RSpec.describe 'System > Objects', type: :system do
expect(ObjectManager::Attribute.last.data_option['options']).to eq(expected_data_options)
end
+ it 'checks smart defaults for multiselect field' do
+ fill_in 'Name', with: 'multiselect1'
+ find('input[name=display]').set('multiselect1')
+
+ page.find('select[name=data_type]').select('Multiselect')
+
+ page.first('div.js-add').click
+ page.first('div.js-add').click
+ page.first('div.js-add').click
+
+ counter = 0
+ page.all('.js-key').each do |field|
+ field.set(counter)
+ counter += 1
+ end
+
+ page.all('.js-value')[-2].set('special 2')
+ page.find('.js-submit').click
+
+ expected_data_options = {
+ '0' => '0',
+ '1' => '1',
+ '2' => 'special 2',
+ }
+
+ expect(ObjectManager::Attribute.last.data_option['options']).to eq(expected_data_options)
+ end
+
it 'checks smart defaults for boolean field' do
fill_in 'Name', with: 'bool1'
find('input[name=display]').set('bool1')
@@ -528,4 +648,99 @@ RSpec.describe 'System > Objects', type: :system do
expect { page.find('.js-submit').click }.to change(ObjectManager::Attribute, :count).by(1)
end
end
+
+ context 'with drag and drop custom sort', db_strategy: :reset do
+ before do
+ visit '/#system/object_manager'
+ page.find('.js-new').click
+
+ page.find('select[name=data_type]').select data_type
+ fill_in 'Name', with: attribute_name
+ find('input[name=display]').set attribute_name
+ end
+
+ let(:attribute) { ObjectManager::Attribute.find_by(name: attribute_name) }
+ let(:data_options) do
+ {
+ '1' => 'one',
+ '2' => 'two',
+ '3' => 'three',
+ '4' => 'four',
+ '5' => 'five'
+ }
+ end
+
+ shared_examples 'having a custom sort option' do
+ it 'has a custom option checkbox' do
+ within '.modal-dialog form' do
+ expect(page).to have_field('data_option::customsort', type: 'checkbox', visible: :all)
+ end
+ end
+
+ context 'a context' do
+ before do
+ within '.modal-dialog form' do
+ within 'tr.input-add-row' do
+ 5.times.each { first('div.js-add').click }
+ end
+
+ keys = data_options.keys
+ all_value_input = all('tr.input-data-row .js-value')
+ all_key_input = all('tr.input-data-row .js-key')
+
+ keys.each_with_index do |key, index|
+ all_key_input[index].set key
+ all_value_input[index].set data_options[key]
+ end
+ end
+ end
+
+ context 'with custom checkbox checked' do
+ it 'saves a customsort data option attribute' do
+ within '.modal-dialog form' do
+ check 'data_option::customsort', allow_label_click: true
+ click_button
+ end
+
+ # Update Database
+ click 'div.js-execute'
+ # Reload browser
+ refresh
+
+ expect(attribute['data_option']).to include('customsort' => 'on')
+ end
+ end
+
+ context 'with custom checkbox unchecked' do
+ it 'does not have a customsort data option attribute' do
+ within '.modal-dialog form' do
+ uncheck 'data_option::customsort', allow_label_click: true
+ click_button
+ end
+
+ # Update Database
+ click 'div.js-execute'
+ # Reload browser
+ refresh
+
+ expect(attribute['data_option']).not_to include('customsort' => 'on')
+ end
+ end
+ end
+ end
+
+ context 'when attribute is multiselect' do
+ let(:data_type) { 'Multiselect' }
+ let(:attribute_name) { 'multiselect_test' }
+
+ it_behaves_like 'having a custom sort option'
+ end
+
+ context 'when attribute is select' do
+ let(:data_type) { 'Select' }
+ let(:attribute_name) { 'select_test' }
+
+ it_behaves_like 'having a custom sort option'
+ end
+ end
end
diff --git a/spec/system/ticket/zoom_spec.rb b/spec/system/ticket/zoom_spec.rb
index 188661bf9..54f020df1 100644
--- a/spec/system/ticket/zoom_spec.rb
+++ b/spec/system/ticket/zoom_spec.rb
@@ -2376,4 +2376,68 @@ RSpec.describe 'Ticket zoom', type: :system do
expect(page).to have_select('state_id', selected: 'new')
end
end
+
+ describe 'Multiselect displaying and saving', authenticated_as: :authenticate, db_strategy: :reset do
+ let(:field_name) { SecureRandom.uuid }
+ let(:ticket) { create(:ticket, group: Group.find_by(name: 'Users'), field_name => %w[key_2 key_3]) }
+
+ def authenticate
+ create :object_manager_attribute_multiselect, name: field_name, display: field_name, screens: {
+ 'edit' => {
+ 'ticket.agent' => {
+ 'shown' => true,
+ 'required' => false,
+ }
+ }
+ }
+ ObjectManager::Attribute.migration_execute
+ ticket
+ true
+ end
+
+ before do
+ visit "#ticket/zoom/#{ticket.id}"
+ end
+
+ def multiselect_value
+ page.find("select[name='#{field_name}']").value
+ end
+
+ def multiselect_set(values)
+ multiselect_unset_all
+ values = Array(values)
+ values.each do |value|
+ page.find("select[name='#{field_name}']").select(value)
+ end
+ end
+
+ def multiselect_unset_all
+ values = page.all("select[name='#{field_name}'] option").map(&:text)
+ values.each do |value|
+ page.find("select[name='#{field_name}']").unselect(value)
+ end
+ end
+
+ it 'does show values properly and can save values also' do
+
+ # check ticket state rendering
+ wait(5).until { multiselect_value == %w[key_2 key_3] }
+ expect(multiselect_value).to eq(%w[key_2 key_3])
+
+ # save 2 values
+ multiselect_set(%w[value_1 value_2])
+ click '.js-submit'
+ expect(ticket.reload[field_name]).to eq(%w[key_1 key_2])
+
+ # save 1 value
+ multiselect_set(['value_1'])
+ click '.js-submit'
+ expect(ticket.reload[field_name]).to eq(['key_1'])
+
+ # unset all values
+ multiselect_unset_all
+ click '.js-submit'
+ expect(ticket.reload[field_name]).to be_nil
+ end
+ end
end