Fixes #3917 - Multiselect field support.

This commit is contained in:
Bola Ahmed Buari 2022-01-20 11:07:12 +01:00 committed by Rolf Schmidt
parent db0eb45fe1
commit 93932055dd
36 changed files with 1739 additions and 204 deletions

View file

@ -531,7 +531,10 @@ class App.ControllerForm extends App.Controller
else else
param[item.name].push value param[item.name].push value
else 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 # verify if we have not checked checkboxes
uncheckParam = {} uncheckParam = {}

View file

@ -29,6 +29,7 @@ class App.UiElement.ApplicationSelector
'integer$': [__('is'), __('is not')] 'integer$': [__('is'), __('is not')]
'^radio$': [__('is'), __('is not')] '^radio$': [__('is'), __('is not')]
'^select$': [__('is'), __('is not')] '^select$': [__('is'), __('is not')]
'^multiselect$': [__('contains all'), __('contains one'), __('contains all not'), __('contains one not')]
'^tree_select$': [__('is'), __('is not')] '^tree_select$': [__('is'), __('is not')]
'^input$': [__('contains'), __('contains not')] '^input$': [__('contains'), __('contains not')]
'^richtext$': [__('contains'), __('contains not')] '^richtext$': [__('contains'), __('contains not')]
@ -44,6 +45,7 @@ class App.UiElement.ApplicationSelector
'integer$': [__('is'), __('is not'), __('has changed')] 'integer$': [__('is'), __('is not'), __('has changed')]
'^radio$': [__('is'), __('is not'), __('has changed')] '^radio$': [__('is'), __('is not'), __('has changed')]
'^select$': [__('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')] '^tree_select$': [__('is'), __('is not'), __('has changed')]
'^input$': [__('contains'), __('contains not'), __('has changed')] '^input$': [__('contains'), __('contains not'), __('has changed')]
'^richtext$': [__('contains'), __('contains not'), __('has changed')] '^richtext$': [__('contains'), __('contains not'), __('has changed')]

View file

@ -72,6 +72,27 @@ class App.UiElement.ApplicationUiElement
} }
attribute.sortBy = null 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) -> @getRelationOptionList: (attribute, params) ->
# build options list based on relation # build options list based on relation

View file

@ -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')] 'boolean$': [__('is'), __('is not'), __('is set'), __('not set'), _('has changed'), __('changed to')]
'integer$': [__('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')] '^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')] '^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')] '^(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 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) continue if groupKey is 'ticket' && _.contains(['number', 'title'], row.name)
# ignore passwords and relations # ignore passwords and relations
@ -155,7 +156,7 @@ class App.UiElement.core_workflow_condition extends App.UiElement.ApplicationSel
config = _.clone(row) config = _.clone(row)
if config.tag is 'textarea' if config.tag is 'textarea'
config.expanding = false config.expanding = false
if config.tag is 'select' if /^((multi)?select)$/.test(config.tag)
config.multiple = true config.multiple = true
config.default = undefined config.default = undefined
if config.type is 'email' || config.type is 'tel' if config.type is 'email' || config.type is 'tel'

View file

@ -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'] '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'] 'integer$': ['show', 'hide', 'remove', 'set_mandatory', 'set_optional', 'set_readonly', 'unset_readonly']
'^date': ['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'] '^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'] '^(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 continue
for row in App[groupMeta.model].configure_attributes 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 _.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) 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) config = _.clone(row)
if config.tag is 'boolean' if config.tag is 'boolean'
config.tag = 'select' config.tag = 'select'
if config.tag is 'select' if /^((multi)?select)$/.test(config.tag)
config.multiple = true config.multiple = true
config.default = undefined config.default = undefined
if config.type is 'email' || config.type is 'tel' 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') currentOperator = elementRow.find('.js-operator option:selected').attr('value')
name = @buildValueName(elementFull, elementRow, groupAndAttribute, elements, meta, attribute) 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('<input type="hidden" name="' + name + '" value="true" />') elementRow.find('.js-value').addClass('hide').html('<input type="hidden" name="' + name + '" value="true" />')
return return
super(elementFull, elementRow, groupAndAttribute, elements, meta, attribute) super(elementFull, elementRow, groupAndAttribute, elements, meta, attribute)
@buildValueConfigMultiple: (config, meta) -> @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.multiple = true
config.nulloption = true config.nulloption = true
else else

View file

@ -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

View file

@ -7,14 +7,8 @@ class App.UiElement.object_manager_attribute extends App.UiElement.ApplicationUi
if params.data_option_new && !_.isEmpty(params.data_option_new) if params.data_option_new && !_.isEmpty(params.data_option_new)
params.data_option = params.data_option_new params.data_option = params.data_option_new
if attribute.value == 'select' && params.data_option? && params.data_option.options? if /^((multi)?select)$/.test(attribute.value) && params.data_option? && params.data_option.options?
sorted = _.map( params.data_option.mapped = @mapDataOptions(params.data_option)
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]) )
item = $(App.view('object_manager/attribute')(attribute: attribute)) 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 = localForm.closest('.js-data')
localItem.find('.js-dataMap').html(element) localItem.find('.js-dataMap').html(element)
localItem.find('.js-dataScreens').html(@dataScreens(attribute, localParams, params)) localItem.find('.js-dataScreens').html(@dataScreens(attribute, localParams, params))
@addDragAndDrop(localItem)
options = options =
datetime: __('Datetime') datetime: __('Datetime')
@ -38,6 +33,7 @@ class App.UiElement.object_manager_attribute extends App.UiElement.ApplicationUi
tree_select: __('Tree Select') tree_select: __('Tree Select')
boolean: __('Boolean') boolean: __('Boolean')
integer: __('Integer') integer: __('Integer')
multiselect: __('Multiselect')
# if attribute already exists, do not allow to change it anymore # if attribute already exists, do not allow to change it anymore
if params.data_type 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) 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) -> @buildRow: (element, child, level = 0, parentElement) ->
newRow = element.find('.js-template').clone().removeClass('js-template') newRow = element.find('.js-template').clone().removeClass('js-template')
newRow.find('.js-key').attr('level', level) 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-autocompletionDefault').html(autocompletionDefault.form)
item.find('.js-autocompletionUrl').html(autocompletionUrl.form) item.find('.js-autocompletionUrl').html(autocompletionUrl.form)
item.find('.js-autocompletionMethod').html(autocompletionMethod.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]) )

View file

@ -15,7 +15,7 @@ class App.UiElement.select extends App.UiElement.ApplicationUiElement
@addDeletedOptions(attribute, params) @addDeletedOptions(attribute, params)
# build options list based on config # build options list based on config
@getConfigOptionList(attribute, params) @getConfigCustomSortOptionList(attribute)
# build options list based on relation # build options list based on relation
@getRelationOptionList(attribute, params) @getRelationOptionList(attribute, params)

View file

@ -39,9 +39,16 @@ treeParams = (e, params) ->
params.data_option.options = tree params.data_option.options = tree
params 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) -> setSelectDefaults = (el) ->
data_type = el.find('select[name=data_type]').val() 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(-> el.find('.js-value, .js-valueTrue, .js-valueFalse').each(->
element = $(@) element = $(@)
@ -54,6 +61,19 @@ setSelectDefaults = (el) ->
element.val(key_value) 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 class ObjectManager extends App.ControllerTabs
requiredPermission: 'admin.object' requiredPermission: 'admin.object'
constructor: -> constructor: ->
@ -198,6 +218,8 @@ class New extends App.ControllerGenericNew
params = @formParam(e.target) params = @formParam(e.target)
params = treeParams(e, params) params = treeParams(e, params)
params = multiselectParams(params)
params = customsortDataOptions(e, params)
# show attributes for create_middle in two column style # show attributes for create_middle in two column style
if params.screens && params.screens.create_middle if params.screens && params.screens.create_middle
@ -261,6 +283,8 @@ class Edit extends App.ControllerGenericEdit
params = @formParam(e.target) params = @formParam(e.target)
params = treeParams(e, params) params = treeParams(e, params)
params = multiselectParams(params)
params = customsortDataOptions(e, params)
# show attributes for create_middle in two column style # show attributes for create_middle in two column style
if params.screens && params.screens.create_middle if params.screens && params.screens.create_middle

View file

@ -124,16 +124,22 @@ class App.FormHandlerCoreWorkflow
coreWorkflowRestrictions[classname][item.name] = App.FormHandlerCoreWorkflow.restrictValuesAttributeCache(attribute, values) coreWorkflowRestrictions[classname][item.name] = App.FormHandlerCoreWorkflow.restrictValuesAttributeCache(attribute, values)
valueFound = false 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) # 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 continue if value is undefined
if value.toString() == paramValue.toString() continue if value is null
valueFound = true continue if paramValue is undefined
break continue if paramValue is null
if _.isArray(paramValue) && _.contains(paramValue, value.toString()) continue if value.toString() != paramValue.toString()
valueFound = true valueFound = true
break break
item.filter = values item.filter = values
if valueFound if valueFound

View file

@ -0,0 +1,71 @@
<div>
<table class="settings-list js-Table" style="width: 100%;">
<thead>
<tr>
<th style="width: 36px" class="table-draggable"></th>
<th><%- @T('Key') %>
<th><%- @T('Display') %>
<th style="width: 30px"><%- @T('Default') %>
<th style="width: 30px"><%- @T('Action') %>
</thead>
<tbody class="table-sortable">
<% if @params.data_option && @params.data_option.mapped: %>
<% for [key, display] in @params.data_option.mapped: %>
<tr class="input-data-row">
<td class="table-draggable"><%- @Icon('draggable') %></td>
<td class="settings-list-control-cell">
<input class="form-control form-control--small js-key" type="text" value="<%= key %>" required/>
</td>
<td class="settings-list-control-cell">
<input class="form-control form-control--small js-value" type="text" value="<%= display %>" name="data_option::options::<%= key %>" required/>
</td>
<td class="settings-list-row-control">
<input class="js-selected" type="checkbox" name="data_option::default" value="<%= key %>" <% if _.include(@params.data_option.default, key): %>checked<% end %>/>
</td>
<td class="settings-list-row-control">
<div class="btn btn--text js-remove">
<%- @Icon('trash') %> <%- @T('Remove') %>
</div>
</td>
<% end %>
<% end %>
<tr class="input-add-row">
<td class="settings-list-control-cell">
<td class="settings-list-control-cell">
<input class="form-control form-control--small js-key" type="text" placeholder="<%- @T('Key') %>"/>
<td class="settings-list-control-cell">
<input class="form-control form-control--small js-value" type="text" placeholder="<%- @T('Display') %>"/>
<td class="settings-list-row-control">
<input class="js-selected" type="checkbox"/>
<td class="settings-list-row-control">
<div class="btn btn--text btn--create js-add">
<%- @Icon('plus-small') %> <%- @T('Add') %>
</div>
</tbody>
</table>
<table class="hidden">
<tbody>
<tr class="js-template input-data-row">
<td class="table-draggable"><%- @Icon('draggable') %></td>
<td class="settings-list-control-cell">
<input class="form-control form-control--small js-key" type="text" value="" required/>
<td class="settings-list-control-cell">
<input class="form-control form-control--small js-value" type="text" value="" required/>
<td class="settings-list-row-control">
<input class="js-selected" type="checkbox" name="data_option::default"/>
<td class="settings-list-row-control">
<div class="btn btn--text js-remove">
<%- @Icon('trash') %> <%- @T('Remove') %>
</div>
</table>
<div class="checkbox checkbox--list">
<label class="checkbox-replacement">
<input type="checkbox" name="data_option::customsort" <% if (@params.data_option && @params.data_option.customsort): %>checked<% end %>/>
<%- @Icon('checkbox', 'icon-unchecked') %>
<%- @Icon('checkbox-checked', 'icon-checked') %>
<span class="label-text"><%- @T('Use custom option sort') %></span>
<span class="help-text"><%- @T('Check this box if you want to customise how options are been sorted. If checkbox is disabled, values are sorted in alphabetical order.') %></span>
</label>
</div>
<div class="js-inputLinkTemplate"></div>
</div>

View file

@ -2,15 +2,17 @@
<table class="settings-list js-Table" style="width: 100%;"> <table class="settings-list js-Table" style="width: 100%;">
<thead> <thead>
<tr> <tr>
<th style="width: 36px" class="table-draggable"></th>
<th><%- @T('Key') %> <th><%- @T('Key') %>
<th><%- @T('Display') %> <th><%- @T('Display') %>
<th style="width: 30px"><%- @T('Default') %> <th style="width: 30px"><%- @T('Default') %>
<th style="width: 30px"><%- @T('Action') %> <th style="width: 30px"><%- @T('Action') %>
</thead> </thead>
<tbody> <tbody class="table-sortable">
<% if @params.data_option && @params.data_option.sorted: %> <% if @params.data_option && @params.data_option.mapped: %>
<% for [key, display] in @params.data_option.sorted: %> <% for [key, display] in @params.data_option.mapped: %>
<tr> <tr class="input-data-row">
<td class="table-draggable"><%- @Icon('draggable') %></td>
<td class="settings-list-control-cell"> <td class="settings-list-control-cell">
<input class="form-control form-control--small js-key" type="text" value="<%= key %>" required/> <input class="form-control form-control--small js-key" type="text" value="<%= key %>" required/>
<td class="settings-list-control-cell"> <td class="settings-list-control-cell">
@ -23,7 +25,8 @@
</div> </div>
<% end %> <% end %>
<% end %> <% end %>
<tr> <tr class="input-add-row">
<td class="settings-list-control-cell">
<td class="settings-list-control-cell"> <td class="settings-list-control-cell">
<input class="form-control form-control--small js-key" type="text" placeholder="<%- @T('Key') %>"/> <input class="form-control form-control--small js-key" type="text" placeholder="<%- @T('Key') %>"/>
<td class="settings-list-control-cell"> <td class="settings-list-control-cell">
@ -38,7 +41,8 @@
</table> </table>
<table class="hidden"> <table class="hidden">
<tbody> <tbody>
<tr class="js-template"> <tr class="js-template input-data-row">
<td class="table-draggable"><%- @Icon('draggable') %></td>
<td class="settings-list-control-cell"> <td class="settings-list-control-cell">
<input class="form-control form-control--small js-key" type="text" value="" required/> <input class="form-control form-control--small js-key" type="text" value="" required/>
<td class="settings-list-control-cell"> <td class="settings-list-control-cell">
@ -50,5 +54,14 @@
<%- @Icon('trash') %> <%- @T('Remove') %> <%- @Icon('trash') %> <%- @T('Remove') %>
</div> </div>
</table> </table>
<div class="checkbox checkbox--list">
<label class="checkbox-replacement">
<input type="checkbox" name="data_option::customsort" <% if (@params.data_option && @params.data_option.customsort): %>checked<% end %>/>
<%- @Icon('checkbox', 'icon-unchecked') %>
<%- @Icon('checkbox-checked', 'icon-checked') %>
<span class="label-text"><%- @T('Use custom option sort') %></span>
<span class="help-text"><%- @T('Check this box if you want to customise how options are been sorted. If checkbox is disabled, values are sorted in alphabetical order.') %></span>
</label>
</div>
<div class="js-inputLinkTemplate"></div> <div class="js-inputLinkTemplate"></div>
</div> </div>

View file

@ -225,6 +225,7 @@ jQuery.fn.removeAttrs = function(regex) {
// changes // changes
// - set type based on data('field-type') // - set type based on data('field-type')
// - also catch [disabled] params // - also catch [disabled] params
// - return multiselect type to make sure that the data is always array
jQuery.fn.extend( { jQuery.fn.extend( {
serializeArrayWithType: function() { serializeArrayWithType: function() {
var r20 = /%20/g, var r20 = /%20/g,
@ -248,27 +249,29 @@ jQuery.fn.extend( {
( this.checked || !rcheckableType.test( type ) ); ( this.checked || !rcheckableType.test( type ) );
} ) } )
.map( function( i, elem ) { .map( function( i, elem ) {
var $elem = jQuery( this ); var $elem = jQuery( this );
var val = $elem.val(); var val = $elem.val();
var type = $elem.data('field-type'); var type = $elem.data('field-type');
var multiple = $elem.prop('multiple');
var multiselect = multiple && $elem.hasClass('multiselect');
var result; var result;
if ( val == null ) { if ( val == null ) {
// be sure that also null values are transferred // be sure that also null values are transferred
// https://github.com/zammad/zammad/issues/944 // https://github.com/zammad/zammad/issues/944
if ($elem.prop('multiple')) { if ($elem.prop('multiple')) {
result = { name: elem.name, value: null, type: type } result = { name: elem.name, value: null, type: type, multiselect: multiselect }
} else { } else {
result = null result = null
} }
} }
else if ( jQuery.isArray( val ) ) { else if ( jQuery.isArray( val ) ) {
result = jQuery.map( val, function( 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 { 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; return result;
} ).get(); } ).get();

View file

@ -3680,6 +3680,7 @@ ol.tabs li {
.table-draggable & { .table-draggable & {
vertical-align: middle; vertical-align: middle;
cursor: move;
} }
} }

View file

@ -98,7 +98,7 @@ class ObjectManagerAttributesController < ApplicationController
if permitted[:data_option] if permitted[:data_option]
if !permitted[:data_option].key?(:default) 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
end end

View file

@ -39,7 +39,11 @@ module ChecksCoreWorkflow
end end
def restricted_value?(perform_result, key) 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 end
def check_mandatory(perform_result) def check_mandatory(perform_result)

View file

@ -234,8 +234,8 @@ class CoreWorkflow::Attributes
return values if values == [''] return values if values == ['']
saved_value = saved_attribute_value(attribute) saved_value = saved_attribute_value(attribute)
if saved_value.present? && values.exclude?(saved_value) if saved_value.present?
values |= Array(saved_value.to_s) values |= Array(saved_value).map(&:to_s)
end end
if attribute[:nulloption] && values.exclude?('') if attribute[:nulloption] && values.exclude?('')

View file

@ -25,19 +25,19 @@ class CoreWorkflow::Result::Backend
def saved_value def saved_value
# make sure we have a saved object # 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 # we only want to have the saved value in the restrictions
# if no changes happend to the form. If the users does changes # if no changes happend to the form. If the users does changes
# to the form then also the saved value should get removed # 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 # attribute can be blank e.g. in custom development
# or if attribute is only available in the frontend but not # or if attribute is only available in the frontend but not
# in the backend # 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 end
def attribute def attribute

View file

@ -10,7 +10,7 @@ class CoreWorkflow::Result::RemoveOption < CoreWorkflow::Result::BaseOption
def config_value def config_value
result = Array(@perform_config['remove_option']) result = Array(@perform_config['remove_option'])
result -= Array(saved_value) result -= saved_value
result result
end end
end end

View file

@ -13,7 +13,7 @@ class CoreWorkflow::Result::SetFixedTo < CoreWorkflow::Result::BaseOption
def config_value def config_value
result = Array(@perform_config['set_fixed_to']) result = Array(@perform_config['set_fixed_to'])
result |= Array(saved_value) result |= saved_value
result result
end end

View file

@ -9,6 +9,7 @@ class ObjectManager::Attribute < ApplicationModel
user_autocompletion user_autocompletion
checkbox checkbox
select select
multiselect
tree_select tree_select
datetime datetime
date date
@ -42,6 +43,9 @@ class ObjectManager::Attribute < ApplicationModel
before_validation :set_base_options before_validation :set_base_options
before_create :ensure_multiselect
before_update :ensure_multiselect
scope :active, -> { where(active: true) } scope :active, -> { where(active: true) }
scope :editable, -> { where(editable: true) } scope :editable, -> { where(editable: true) }
scope :for_object, lambda { |name_or_klass| scope :for_object, lambda { |name_or_klass|
@ -588,11 +592,17 @@ to send no browser reload event, pass false
data_type = nil data_type = nil
case attribute.data_type 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 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 data_type = :integer
when %r{^boolean|active$} when %r{^(boolean|active)$}
data_type = :boolean data_type = :boolean
when %r{^datetime$} when %r{^datetime$}
data_type = :datetime data_type = :datetime
@ -603,7 +613,7 @@ to send no browser reload event, pass false
# change field # change field
if model.column_names.include?(attribute.name) if model.column_names.include?(attribute.name)
case attribute.data_type 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( ActiveRecord::Migration.change_column(
model.table_name, model.table_name,
attribute.name, attribute.name,
@ -611,7 +621,21 @@ to send no browser reload event, pass false
limit: attribute.data_option[:maxlength], limit: attribute.data_option[:maxlength],
null: true 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( ActiveRecord::Migration.change_column(
model.table_name, model.table_name,
attribute.name, attribute.name,
@ -635,7 +659,7 @@ to send no browser reload event, pass false
# create field # create field
case attribute.data_type 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( ActiveRecord::Migration.add_column(
model.table_name, model.table_name,
attribute.name, attribute.name,
@ -643,7 +667,21 @@ to send no browser reload event, pass false
limit: attribute.data_option[:maxlength], limit: attribute.data_option[:maxlength],
null: true 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( ActiveRecord::Migration.add_column(
model.table_name, model.table_name,
attribute.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? local_data_option[:null] = true if local_data_option[:null].nil?
case data_type 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[:nulloption] = true if local_data_option[:nulloption].nil?
local_data_option[:maxlength] ||= 255 local_data_option[:maxlength] ||= 255
end end
@ -889,7 +927,7 @@ is certain attribute used by triggers, overviews or schedulers
end end
def data_type_must_not_change 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_changed?
return if (data_type_change - allowable_changes).empty? 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 data_option_maxlength_check
when 'integer' when 'integer'
data_option_min_max_check data_option_min_max_check
when %r{^((tree_)?select|checkbox)$} when %r{^((multi|tree_)?select|checkbox)$}
data_option_default_check + data_option_relation_check data_option_default_check + data_option_relation_check
when 'boolean' when 'boolean'
data_option_default_check + data_option_nil_check data_option_default_check + data_option_nil_check
@ -971,4 +1009,11 @@ is certain attribute used by triggers, overviews or schedulers
[] []
end end
end end
def ensure_multiselect
return if data_type != 'multiselect'
return if data_option && data_option[:multiple] == true
data_option[:multiple] = true
end
end end

View file

@ -684,7 +684,6 @@ condition example
end end
next next
end end
if selector['operator'] == 'is' if selector['operator'] == 'is'
if selector['pre_condition'] == 'not_set' if selector['pre_condition'] == 'not_set'
if attributes[1].match?(%r{^(created_by|updated_by|owner|customer|user)_id}) if attributes[1].match?(%r{^(created_by|updated_by|owner|customer|user)_id})
@ -779,65 +778,81 @@ condition example
query += "#{attribute} NOT #{like} (?)" query += "#{attribute} NOT #{like} (?)"
value = "%#{selector['value']}%" value = "%#{selector['value']}%"
bind_params.push value bind_params.push value
elsif selector['operator'] == 'contains all' && attributes[0] == 'ticket' && attributes[1] == 'tags' elsif selector['operator'] == 'contains all'
query += "? = ( if attributes[0] == 'ticket' && attributes[1] == 'tags'
SELECT query += "? = (
COUNT(*) SELECT
FROM COUNT(*)
tag_objects, FROM
tag_items, tag_objects,
tags tag_items,
WHERE tags
tickets.id = tags.o_id AND WHERE
tag_objects.id = tags.tag_object_id AND tickets.id = tags.o_id AND
tag_objects.name = 'Ticket' AND tag_objects.id = tags.tag_object_id AND
tag_items.id = tags.tag_item_id AND tag_objects.name = 'Ticket' AND
tag_items.name IN (?) tag_items.id = tags.tag_item_id AND
)" tag_items.name IN (?)
bind_params.push selector['value'].count )"
bind_params.push selector['value'] bind_params.push selector['value'].count
elsif selector['operator'] == 'contains one' && attributes[0] == 'ticket' && attributes[1] == 'tags' bind_params.push selector['value']
tables += ', tag_objects, tag_items, tags' elsif Ticket.column_names.include?(attributes[1])
query += " query += SqlHelper.new(object: Ticket).array_contains_all(attributes[1], selector['value'])
tickets.id = tags.o_id AND end
tag_objects.id = tags.tag_object_id AND elsif selector['operator'] == 'contains one' && attributes[0] == 'ticket'
tag_objects.name = 'Ticket' AND if attributes[1] == 'tags'
tag_items.id = tags.tag_item_id AND tables += ', tag_objects, tag_items, tags'
tag_items.name IN (?)" 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'] bind_params.push selector['value']
elsif selector['operator'] == 'contains all not' && attributes[0] == 'ticket' && attributes[1] == 'tags' elsif Ticket.column_names.include?(attributes[1])
query += "0 = ( query += SqlHelper.new(object: Ticket).array_contains_one(attributes[1], selector['value'])
SELECT end
COUNT(*) elsif selector['operator'] == 'contains all not' && attributes[0] == 'ticket'
FROM if attributes[1] == 'tags'
tag_objects, query += "0 = (
tag_items, SELECT
tags COUNT(*)
WHERE FROM
tickets.id = tags.o_id AND tag_objects,
tag_objects.id = tags.tag_object_id AND tag_items,
tag_objects.name = 'Ticket' AND tags
tag_items.id = tags.tag_item_id AND WHERE
tag_items.name IN (?) tickets.id = tags.o_id AND
)" tag_objects.id = tags.tag_object_id AND
bind_params.push selector['value'] tag_objects.name = 'Ticket' AND
elsif selector['operator'] == 'contains one not' && attributes[0] == 'ticket' && attributes[1] == 'tags' tag_items.id = tags.tag_item_id AND
query += "( tag_items.name IN (?)
SELECT )"
COUNT(*) bind_params.push selector['value']
FROM elsif Ticket.column_names.include?(attributes[1])
tag_objects, query += SqlHelper.new(object: Ticket).array_contains_all(attributes[1], selector['value'], negated: true)
tag_items, end
tags elsif selector['operator'] == 'contains one not' && attributes[0] == 'ticket'
WHERE if attributes[1] == 'tags'
tickets.id = tags.o_id AND query += "(
tag_objects.id = tags.tag_object_id AND SELECT
tag_objects.name = 'Ticket' AND COUNT(*)
tag_items.id = tags.tag_item_id AND FROM
tag_items.name IN (?) tag_objects,
) BETWEEN 0 AND 0" tag_items,
bind_params.push selector['value'] 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)' elsif selector['operator'] == 'before (absolute)'
query += "#{attribute} <= ?" query += "#{attribute} <= ?"
bind_params.push selector['value'] bind_params.push selector['value']

View file

@ -3,6 +3,7 @@
case ActiveRecord::Base.connection_config[:adapter] case ActiveRecord::Base.connection_config[:adapter]
when 'mysql2' when 'mysql2'
Rails.application.config.db_4bytes_utf8 = false 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_case_sensitive = false
Rails.application.config.db_like = 'LIKE' Rails.application.config.db_like = 'LIKE'
Rails.application.config.db_null_byte = true Rails.application.config.db_null_byte = true
@ -15,6 +16,7 @@ when 'mysql2'
end end
when 'postgresql' when 'postgresql'
Rails.application.config.db_4bytes_utf8 = true 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_case_sensitive = true
Rails.application.config.db_like = 'ILIKE' Rails.application.config.db_like = 'ILIKE'
Rails.application.config.db_null_byte = false Rails.application.config.db_null_byte = false

View file

@ -433,6 +433,7 @@ msgstr ""
#: app/assets/javascripts/app/views/integration/ldap_wizard.jst.eco #: 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/placetel.jst.eco
#: app/assets/javascripts/app/views/integration/sipgate.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/select.jst.eco
#: app/assets/javascripts/app/views/object_manager/attribute/tree_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 #: 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/scheduler_modal.jst.eco
#: app/assets/javascripts/app/views/layout_ref/sla_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/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/object_manager/attribute/select.jst.eco
#: app/assets/javascripts/app/views/profile/linked_accounts.jst.eco #: app/assets/javascripts/app/views/profile/linked_accounts.jst.eco
#: app/assets/javascripts/app/views/tag/index.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:" msgid "Check the response and payload for detailed information:"
msgstr "" 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 #: app/assets/javascripts/app/controllers/_integration/check_mk.coffee
msgid "Checkmk" msgid "Checkmk"
msgstr "" msgstr ""
@ -2418,6 +2425,7 @@ msgstr ""
#: app/assets/javascripts/app/views/channel/chat.jst.eco #: app/assets/javascripts/app/views/channel/chat.jst.eco
#: app/assets/javascripts/app/views/generic/multi_locales.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/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/select.jst.eco
#: db/seeds/settings.rb #: db/seeds/settings.rb
msgid "Default" msgid "Default"
@ -3220,6 +3228,7 @@ msgstr ""
#: app/assets/javascripts/app/models/object_manager_attribute.coffee #: 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/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/select.jst.eco
#: app/assets/javascripts/app/views/object_manager/index.jst.eco #: app/assets/javascripts/app/views/object_manager/index.jst.eco
msgid "Display" msgid "Display"
@ -5283,6 +5292,7 @@ msgid "Keep messages on server"
msgstr "" msgstr ""
#: app/assets/javascripts/app/views/object_manager/attribute/boolean.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 #: 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/attribute/tree_select.jst.eco
msgid "Key" msgid "Key"
@ -5981,6 +5991,10 @@ msgstr ""
msgid "Moved out" msgid "Moved out"
msgstr "" msgstr ""
#: app/assets/javascripts/app/controllers/_ui_element/object_manager_attribute.coffee
msgid "Multiselect"
msgstr ""
#: db/seeds/overviews.rb #: db/seeds/overviews.rb
msgid "My Organization Tickets" msgid "My Organization Tickets"
msgstr "" msgstr ""
@ -7498,6 +7512,7 @@ msgstr ""
#: app/assets/javascripts/app/views/integration/placetel.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/integration/sipgate.jst.eco
#: app/assets/javascripts/app/views/navigation/menu_cti_ringing.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/object_manager/attribute/select.jst.eco
#: app/assets/javascripts/app/views/profile/devices.jst.eco #: app/assets/javascripts/app/views/profile/devices.jst.eco
#: app/assets/javascripts/app/views/twitter/search_term.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." msgid "Use client storage to cache data to enhance performance of application."
msgstr "" 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 #: app/assets/javascripts/app/models/application.coffee
msgid "Use one line per URI" msgid "Use one line per URI"
msgstr "" msgstr ""
@ -10830,18 +10850,22 @@ msgid "connected"
msgstr "" msgstr ""
#: app/assets/javascripts/app/controllers/_ui_element/_application_selector.coffee #: app/assets/javascripts/app/controllers/_ui_element/_application_selector.coffee
#: app/assets/javascripts/app/controllers/_ui_element/core_workflow_condition.coffee
msgid "contains" msgid "contains"
msgstr "" msgstr ""
#: app/assets/javascripts/app/controllers/_ui_element/_application_selector.coffee #: app/assets/javascripts/app/controllers/_ui_element/_application_selector.coffee
#: app/assets/javascripts/app/controllers/_ui_element/core_workflow_condition.coffee
msgid "contains all" msgid "contains all"
msgstr "" msgstr ""
#: app/assets/javascripts/app/controllers/_ui_element/_application_selector.coffee #: 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" msgid "contains all not"
msgstr "" msgstr ""
#: app/assets/javascripts/app/controllers/_ui_element/_application_selector.coffee #: app/assets/javascripts/app/controllers/_ui_element/_application_selector.coffee
#: app/assets/javascripts/app/controllers/_ui_element/core_workflow_condition.coffee
msgid "contains not" msgid "contains not"
msgstr "" msgstr ""
@ -11015,6 +11039,7 @@ msgid "h"
msgstr "" msgstr ""
#: app/assets/javascripts/app/controllers/_ui_element/_application_selector.coffee #: 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 #: app/assets/javascripts/app/views/object_manager/index.jst.eco
msgid "has changed" msgid "has changed"
msgstr "" msgstr ""

View file

@ -199,14 +199,19 @@ examples how to use
def display_value(object, method_name, previous_method_names, key) def display_value(object, method_name, previous_method_names, key)
return key if method_name != 'value' || return key if method_name != 'value' ||
!key.instance_of?(String) (!key.instance_of?(String) && !key.instance_of?(Array))
attributes = ObjectManager::Attribute attributes = ObjectManager::Attribute
.where(object_lookup_id: ObjectLookup.by_name(object.class.to_s)) .where(object_lookup_id: ObjectLookup.by_name(object.class.to_s))
.where(name: previous_method_names.split('.').last) .where(name: previous_method_names.split('.').last)
return key if attributes.count.zero? || attributes.first.data_type != 'select' case attributes.first.data_type
when 'select'
attributes.first.data_option['options'][key] || key attributes.first.data_option['options'][key] || key
when 'multiselect'
key.map { |k| attributes.first.data_option['options'][k] || k }
else
key
end
end end
end end

View file

@ -6,6 +6,14 @@ class SqlHelper
@object = object @object = object
end 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) def get_param_key(key, params)
sort_by = [] sort_by = []
if params[key].present? && params[key].is_a?(String) if params[key].present? && params[key].is_a?(String)
@ -97,7 +105,7 @@ order_by = [
def set_sql_order_default(sql, default) def set_sql_order_default(sql, default)
if sql.blank? && default.present? 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 end
sql sql
end end
@ -128,7 +136,7 @@ sql = 'tickets.created_at, tickets.updated_at'
next if value.blank? next if value.blank?
next if order_by[index].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 end
sql = set_sql_order_default(sql, default) 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 value.blank?
next if order_by[index].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 end
sql = set_sql_order_default(sql, default) sql = set_sql_order_default(sql, default)
sql.join(', ') sql.join(', ')
end 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 end

View file

@ -32,6 +32,13 @@ RSpec.describe CheckForObjectAttributes, type: :db_migration do
expect { migrate } expect { migrate }
.not_to change { attribute.reload.data_option } .not_to change { attribute.reload.data_option }
end 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 end
context 'for #data_option key:' do context 'for #data_option key:' do

View file

@ -156,6 +156,28 @@ FactoryBot.define do
end end
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 factory :object_manager_attribute_tree_select, parent: :object_manager_attribute do
default { '' } default { '' }

View file

@ -60,84 +60,250 @@ RSpec.describe NotificationFactory::Renderer do
end end
context 'when handling ObjectManager::Attribute usage', db_strategy: :reset do context 'when handling ObjectManager::Attribute usage', db_strategy: :reset do
before do
it 'correctly renders simple select attributes' do create_object_manager_attribute
create :object_manager_attribute_select, name: 'select'
ObjectManager::Attribute.migration_execute 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 end
it 'correctly renders select attributes on chained user object' do let(:renderer) do
create :object_manager_attribute_select, build :notification_factory_renderer,
object_lookup_id: ObjectLookup.by_name('User'), objects: { ticket: ticket },
name: 'select' template: template
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'
end end
it 'correctly renders select attributes on chained group object' do shared_examples 'correctly rendering the attributes' do
create :object_manager_attribute_select, it 'correctly renders the attributes' do
object_lookup_id: ObjectLookup.by_name('Group'), expect(renderer.render).to eq expected_render
name: 'select' end
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'
end end
it 'correctly renders select attributes on chained organization object' do context 'with a simple select attribute' do
create :object_manager_attribute_select, let(:create_object_manager_attribute) do
object_lookup_id: ObjectLookup.by_name('Organization'), create :object_manager_attribute_select, name: 'select'
name: 'select' end
ObjectManager::Attribute.migration_execute 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' it_behaves_like 'correctly rendering the attributes'
@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'
end end
it 'correctly renders tree select attributes' do context 'with select attribute on chained user object' do
create :object_manager_attribute_tree_select, name: 'tree_select' let(:create_object_manager_attribute) do
ObjectManager::Attribute.migration_execute 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, let(:ticket) { create :ticket, customer: user }
objects: { ticket: ticket }, let(:template) { '#{ticket.customer.select} _SEPERATOR_ #{ticket.customer.select.value}' }
template: '#{ticket.tree_select} _SEPERATOR_ #{ticket.tree_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 end
end end

View file

@ -299,6 +299,37 @@ RSpec.describe CoreWorkflow, type: :model do
end end
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 describe '.perform - Custom - Pending Time' do
it 'does not show pending time for non pending state' do it 'does not show pending time for non pending state' do
expect(result[:visibility]['pending_time']).to eq('remove') expect(result[:visibility]['pending_time']).to eq('remove')

View file

@ -931,4 +931,220 @@ RSpec.describe Trigger, type: :model do
end end
end 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 end

View file

@ -16,4 +16,16 @@ RSpec.configure do |config|
end end
end 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 end

View file

@ -619,6 +619,206 @@ RSpec.shared_examples 'core workflow' do
end end
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 describe 'modify boolean attribute', authenticated_as: :authenticate, db_strategy: :reset do
def authenticate def authenticate
create(:object_manager_attribute_boolean, object_name: object_name, name: field_name, display: field_name, screens: screens) create(:object_manager_attribute_boolean, object_name: object_name, name: field_name, display: field_name, screens: screens)

View file

@ -38,6 +38,29 @@ RSpec.describe 'Manage > Trigger', type: :system do
expect(find('.js-value select')).to be_multiple expect(find('.js-value select')).to be_multiple
end end
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 end
it 'sets a customer email address with no @ character' do it 'sets a customer email address with no @ character' do
@ -100,4 +123,158 @@ RSpec.describe 'Manage > Trigger', type: :system do
end end
end 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 end

View file

@ -107,6 +107,10 @@ RSpec.describe 'System > Objects', type: :system do
['Text', 'Select', 'Integer', 'Datetime', 'Date', 'Boolean', 'Tree Select'].each do |data_type| ['Text', 'Select', 'Integer', 'Datetime', 'Date', 'Boolean', 'Tree Select'].each do |data_type|
include_examples 'create and remove field with migration', data_type include_examples 'create and remove field with migration', data_type
end end
context 'with Multiselect' do
include_examples 'create and remove field with migration', 'Multiselect'
end
end end
context 'when creating and modifying tree select fields', db_strategy: :reset do 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 # 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) { %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(: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 before do
attribute = create(:object_manager_attribute_select, data_option: { options: options_hash, default: 0 }, position: 999) object_attribute
ObjectManager::Attribute.migration_execute ObjectManager::Attribute.migration_execute
attribute
refresh
visit '/#system/object_manager'
click 'tbody tr:last-child td:first-child'
end end
it 'preserves the sorting correctly' do shared_examples 'sorting options correctly' do
object_attribute shared_examples 'preserving the sorting correctly' do
page.refresh it 'preserves the sorting correctly' do
visit '/#system/object_manager' sorted_dialog_values = all('table.settings-list tbody tr td input.js-key').map(&:value).reject { |x| x == '' }
click 'tbody tr:last-child' 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 == '' } visit '/#ticket/create'
expect(sorted_dialog_values).to eq(options) 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' context 'with no customsort' do
sorted_ticket_values = all("select[name=#{object_attribute.name}] option").map(&:value).reject { |x| x == '' } let(:data_option) { { options: options_hash, default: 0 } }
expect(sorted_ticket_values).to eq(options) 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
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) expect(ObjectManager::Attribute.last.data_option['options']).to eq(expected_data_options)
end 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 it 'checks smart defaults for boolean field' do
fill_in 'Name', with: 'bool1' fill_in 'Name', with: 'bool1'
find('input[name=display]').set('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) expect { page.find('.js-submit').click }.to change(ObjectManager::Attribute, :count).by(1)
end end
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 end

View file

@ -2376,4 +2376,68 @@ RSpec.describe 'Ticket zoom', type: :system do
expect(page).to have_select('state_id', selected: 'new') expect(page).to have_select('state_id', selected: 'new')
end end
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 end