From 93932055dde5be71b4dabee31692057f14f2956f Mon Sep 17 00:00:00 2001 From: Bola Ahmed Buari Date: Thu, 20 Jan 2022 11:07:12 +0100 Subject: [PATCH] Fixes #3917 - Multiselect field support. --- .../_application_controller/form.coffee | 5 +- .../_ui_element/_application_selector.coffee | 2 + .../_application_ui_element.coffee | 21 ++ .../core_workflow_condition.coffee | 5 +- .../_ui_element/core_workflow_perform.coffee | 10 +- .../_ui_element/multiselect.coffee | 73 +++++ .../object_manager_attribute.coffee | 96 +++++- .../app/controllers/_ui_element/select.coffee | 2 +- .../app/controllers/object_manager.coffee | 26 +- .../form_handler_core_workflow.coffee | 24 +- .../attribute/multiselect.jst.eco | 71 +++++ .../object_manager/attribute/select.jst.eco | 27 +- app/assets/javascripts/application.js | 15 +- app/assets/stylesheets/zammad.scss | 1 + .../object_manager_attributes_controller.rb | 2 +- app/models/concerns/checks_core_workflow.rb | 6 +- app/models/core_workflow/attributes.rb | 4 +- app/models/core_workflow/result/backend.rb | 8 +- .../core_workflow/result/remove_option.rb | 2 +- .../core_workflow/result/set_fixed_to.rb | 2 +- app/models/object_manager/attribute.rb | 65 +++- app/models/ticket.rb | 133 ++++---- config/initializers/db_preferences.rb | 2 + i18n/zammad.pot | 25 ++ lib/notification_factory/renderer.rb | 13 +- lib/sql_helper.rb | 37 ++- .../check_for_object_attributes_spec.rb | 7 + spec/factories/object_manager_attribute.rb | 22 ++ .../lib/notification_factory/renderer_spec.rb | 296 ++++++++++++++---- spec/models/core_workflow_spec.rb | 31 ++ spec/models/trigger_spec.rb | 216 +++++++++++++ spec/support/db_strategies.rb | 12 + .../system/examples/core_workflow_examples.rb | 200 ++++++++++++ spec/system/manage/trigger_spec.rb | 177 +++++++++++ spec/system/system/object_manager_spec.rb | 241 +++++++++++++- spec/system/ticket/zoom_spec.rb | 64 ++++ 36 files changed, 1739 insertions(+), 204 deletions(-) create mode 100644 app/assets/javascripts/app/controllers/_ui_element/multiselect.coffee create mode 100644 app/assets/javascripts/app/views/object_manager/attribute/multiselect.jst.eco 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 @@ +
+ + + + + + + <% if @params.data_option && @params.data_option.mapped: %> + <% for [key, display] in @params.data_option.mapped: %> + + + + + + + <% end %> + <% end %> + + +
<%- @T('Key') %> + <%- @T('Display') %> + <%- @T('Default') %> + <%- @T('Action') %> +
<%- @Icon('draggable') %> + + + + + checked<% end %>/> + +
+ <%- @Icon('trash') %> <%- @T('Remove') %> +
+
+ + + + + + + +
+ <%- @Icon('plus-small') %> <%- @T('Add') %> +
+
+ + + + + +
+ +
+
+
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 @@ + - - <% if @params.data_option && @params.data_option.sorted: %> - <% for [key, display] in @params.data_option.sorted: %> - + + <% if @params.data_option && @params.data_option.mapped: %> + <% for [key, display] in @params.data_option.mapped: %> + + + +
<%- @T('Key') %> <%- @T('Display') %> <%- @T('Default') %> <%- @T('Action') %>
<%- @Icon('draggable') %> @@ -23,7 +25,8 @@ <% end %> <% end %> -
@@ -38,7 +41,8 @@
- + + +
+ +
- \ 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