From bf3067d90850e3c719c37402f56fc87d0c46bccd Mon Sep 17 00:00:00 2001 From: Bola Ahmed Buari Date: Wed, 12 Jan 2022 14:24:11 +0100 Subject: [PATCH] Fixes #3900 - Be able to select more than one owner or organization in condition for overviews/triggers/schedulers like you can do it for state, priority or group --- .../_ui_element/autocompletion_ajax.coffee | 3 +- .../autocompletion_ajax_search.coffee | 6 + .../user_autocompletion_search.coffee | 1 + .../app/controllers/overview.coffee | 2 +- ..._object_organization_autocompletion.coffee | 87 ++-- .../app/lib/app_post/searchable_select.coffee | 99 ++++- .../views/generic/object_search/input.jst.eco | 14 +- .../views/generic/searchable_select.jst.eco | 60 ++- public/assets/tests/qunit/form_extended.js | 191 ++++++++- .../tests/qunit/form_ticket_perform_action.js | 4 +- spec/system/manage/overviews_spec.rb | 370 ++++++++++++++++++ 11 files changed, 746 insertions(+), 91 deletions(-) create mode 100644 app/assets/javascripts/app/controllers/_ui_element/autocompletion_ajax_search.coffee create mode 100644 spec/system/manage/overviews_spec.rb diff --git a/app/assets/javascripts/app/controllers/_ui_element/autocompletion_ajax.coffee b/app/assets/javascripts/app/controllers/_ui_element/autocompletion_ajax.coffee index a43d55579..31d3cf9a0 100644 --- a/app/assets/javascripts/app/controllers/_ui_element/autocompletion_ajax.coffee +++ b/app/assets/javascripts/app/controllers/_ui_element/autocompletion_ajax.coffee @@ -3,7 +3,7 @@ class App.UiElement.autocompletion_ajax @render: (attribute, params = {}, form) -> if params[attribute.name] || attribute.value object = App[attribute.relation].find(params[attribute.name] || attribute.value) - valueName = object.displayName() + valueName = object.displayName() if object # selectable search searchableAjaxSelectObject = new App.SearchableAjaxSelect( @@ -17,5 +17,6 @@ class App.UiElement.autocompletion_ajax limit: 40 object: attribute.relation ajax: true + multiple: attribute.multiple ) searchableAjaxSelectObject.element() diff --git a/app/assets/javascripts/app/controllers/_ui_element/autocompletion_ajax_search.coffee b/app/assets/javascripts/app/controllers/_ui_element/autocompletion_ajax_search.coffee new file mode 100644 index 000000000..cd588e9b7 --- /dev/null +++ b/app/assets/javascripts/app/controllers/_ui_element/autocompletion_ajax_search.coffee @@ -0,0 +1,6 @@ +# coffeelint: disable=camel_case_classes +class App.UiElement.autocompletion_ajax_search extends App.UiElement.autocompletion_ajax + @render: (attributeOrig, params = {}, form) -> + attribute = _.clone(attributeOrig) + attribute.multiple = true + super(attribute, params = {}, form) diff --git a/app/assets/javascripts/app/controllers/_ui_element/user_autocompletion_search.coffee b/app/assets/javascripts/app/controllers/_ui_element/user_autocompletion_search.coffee index f6dfae836..404f51c7e 100644 --- a/app/assets/javascripts/app/controllers/_ui_element/user_autocompletion_search.coffee +++ b/app/assets/javascripts/app/controllers/_ui_element/user_autocompletion_search.coffee @@ -3,4 +3,5 @@ class App.UiElement.user_autocompletion_search @render: (attributeOrig, params = {}) -> attribute = _.clone(attributeOrig) attribute.disableCreateObject = true + attribute.multiple = true new App.UserOrganizationAutocompletion(attribute: attribute, params: params).element() diff --git a/app/assets/javascripts/app/controllers/overview.coffee b/app/assets/javascripts/app/controllers/overview.coffee index cd0c773fc..ec3e3f07d 100644 --- a/app/assets/javascripts/app/controllers/overview.coffee +++ b/app/assets/javascripts/app/controllers/overview.coffee @@ -27,7 +27,7 @@ class Overview extends App.ControllerSubContent { name: __('New Overview'), 'data-type': 'new', class: 'btn--success' } ] container: @el.closest('.content') - large: true + veryLarge: true dndCallback: (e, item) => items = @el.find('table > tbody > tr') prios = [] diff --git a/app/assets/javascripts/app/lib/app_post/_object_organization_autocompletion.coffee b/app/assets/javascripts/app/lib/app_post/_object_organization_autocompletion.coffee index 83b053959..e7d424c52 100644 --- a/app/assets/javascripts/app/lib/app_post/_object_organization_autocompletion.coffee +++ b/app/assets/javascripts/app/lib/app_post/_object_organization_autocompletion.coffee @@ -80,10 +80,9 @@ class App.ObjectOrganizationAutocompletion extends App.Controller onBlur: => selectObject = @objectSelect.val() - if _.isEmpty(selectObject) + if _.isEmpty(selectObject) && !@attribute.multiple @objectId.val('') - return - if @attribute.guess is true + else if @attribute.guess is true currentObjectId = @objectId.val() if _.isEmpty(currentObjectId) || currentObjectId.match(/^guess:/) if !_.isEmpty(selectObject) @@ -95,31 +94,28 @@ class App.ObjectOrganizationAutocompletion extends App.Controller onObjectClick: (e) => objectId = $(e.currentTarget).data('object-id') - @selectObject(objectId) + objectName = $(e.currentTarget).find('.recipientList-name').text().trim() + @selectObject(objectId, objectName) @close() - selectObject: (objectId) => - if @attribute.multiple and @objectId.val() - # add objectId to end of comma separated list - objectId = _.chain( @objectId.val().split(',') ).push(objectId).join(',').value() - - @objectSelect.val('') - @objectId.val(objectId).trigger('change') + selectObject: (objectId, objectName) => + if @attribute.multiple + @addValueToObjectInput(objectName, objectId) + else + @objectSelect.val('') + @objectId.val(objectId).trigger('change') executeCallback: => - # with @attribute.multiple this can be several objects ids. - # Only work with the last one since its the newest one - objectId = @objectId.val().split(',').pop() + if @attribute.multiple + # create token + @createToken(@currentObject) if @currentObject + @currentObject = null - if objectId && App[@objectSingle].exists(objectId) - object = App[@objectSingle].find(objectId) - name = object.displayName() - - if @attribute.multiple - - # create token - @createToken(name, objectId) - else + else + objectId = @objectId.val() + if objectId && App[@objectSingle].exists(objectId) + object = App[@objectSingle].find(objectId) + name = object.displayName() if object.email # quote name for special character @@ -132,10 +128,10 @@ class App.ObjectOrganizationAutocompletion extends App.Controller if @callback @callback(objectId) - createToken: (name, objectId) => + createToken: ({name, value}) => @objectSelect.before App.view('generic/token')( name: name - value: objectId + value: value ) removeThisToken: (e) => @@ -149,12 +145,9 @@ class App.ObjectOrganizationAutocompletion extends App.Controller else token = which - # remove objectId from input - index = @$('.token').index(token) - ids = @objectId.val().split(',') - ids.splice(index, 1) - @objectId.val ids.join(',') - + id = token.data('value') + @objectId.find("[value=#{id}]").remove() + @objectId.trigger('change') token.remove() navigateByKeyboard: (e) => @@ -170,7 +163,7 @@ class App.ObjectOrganizationAutocompletion extends App.Controller @objectSelect.val('').trigger('change') # remove last token on backspace when 8 - if @objectSelect.val() is '' + if @objectSelect.val() is '' && @objectSelect.is(e.target) @removeToken('last') # close on tab when 9 then @close() @@ -223,7 +216,8 @@ class App.ObjectOrganizationAutocompletion extends App.Controller return objectId = recipientListOrganizationMembers.find('li.is-active').data('object-id') return if !objectId - @selectObject(objectId) + objectName = recipientListOrganizationMembers.find('li.is-active .recipientList-name').text().trim() + @selectObject(objectId, objectName) @close() if !@attribute.multiple return @@ -233,7 +227,8 @@ class App.ObjectOrganizationAutocompletion extends App.Controller if objectId is 'new' @newObject() else - @selectObject(objectId) + objectName = @recipientList.find('li.is-active .recipientList-name').text().trim() + @selectObject(objectId, objectName) @close() if !@attribute.multiple return @@ -242,6 +237,14 @@ class App.ObjectOrganizationAutocompletion extends App.Controller @showOrganizationMembers(undefined, @recipientList.find('li.is-active')) + addValueToObjectInput: (objectName, objectId) -> + @objectSelect.val('') + @currentObject = {name: objectName, value: objectId} + if @objectId.val() + return if @objectId.val().includes("#{objectId}") # cast objectId to string before check + @objectId.append("") + @objectId.trigger('change') + buildOrganizationItem: (organization) -> objectCount = 0 if organization[@referenceAttribute] @@ -315,17 +318,23 @@ class App.ObjectOrganizationAutocompletion extends App.Controller # fallback for if the value is not an array if typeof @attribute.value isnt 'object' @attribute.value = [@attribute.value] - value = @attribute.value.join ',' - # create tokens + # create tokens and attribute values + values = [] for objectId in @attribute.value if App[@objectSingle].exists objectId + objectName = App[@objectSingle].find(objectId).displayName() + objectValue = objectId + values.push({name: objectName, value: objectValue}) tokens += App.view('generic/token')( - name: App[@objectSingle].find(objectId).displayName() - value: objectId + name: objectName + value: objectValue ) else @log 'objectId doesn\'t exist', objectId + + @attribute.value = values + else value = @attribute.value if value @@ -369,7 +378,7 @@ class App.ObjectOrganizationAutocompletion extends App.Controller @recipientList.append(@buildObjectNew()) # reset object selection - @resetObjectSelection() + @resetObjectSelection() if !@attribute.multiple return # show dropdown diff --git a/app/assets/javascripts/app/lib/app_post/searchable_select.coffee b/app/assets/javascripts/app/lib/app_post/searchable_select.coffee index d3cb7225d..d4d54fa92 100644 --- a/app/assets/javascripts/app/lib/app_post/searchable_select.coffee +++ b/app/assets/javascripts/app/lib/app_post/searchable_select.coffee @@ -15,6 +15,7 @@ class App.SearchableSelect extends Spine.Controller 'shown.bs.dropdown': 'onDropdownShown' 'hidden.bs.dropdown': 'onDropdownHidden' 'keyup .js-input': 'onKeyUp' + 'click .js-remove': 'removeThisToken' elements: '.js-dropdown': 'dropdown' @@ -38,10 +39,33 @@ class App.SearchableSelect extends Spine.Controller render: -> @updateAttributeValueName() + tokens = '' + if @attribute.multiple && @attribute.value + object = @attribute.object + + # fallback for if the value is not an array + if typeof @attribute.value isnt 'object' + @attribute.value = [@attribute.value] + + # create tokens and attribute values + values = [] + for dataId in @attribute.value + if App[object].exists dataId + name = App[object].find(dataId).displayName() + value = dataId + values.push({name: name, value: value}) + tokens += App.view('generic/token')( + name: name + value: value + ) + + @attribute.value = values + @html App.view('generic/searchable_select') attribute: @attribute options: @renderAllOptions('', @attribute.options, 0) submenus: @renderSubmenus(@attribute.options) + tokens: tokens # initial data @currentMenu = @findMenuContainingValue(@attribute.value) @@ -133,12 +157,12 @@ class App.SearchableSelect extends Spine.Controller @unhighlightCurrentItem() @isOpen = false - if !@input.val() + if !@input.val() && !@attribute.multiple @updateAttributeValueName() @input.val(@attribute.valueName) onKeyUp: => - return if @input.val().trim() isnt '' + return if @input.val().trim() isnt '' || @attribute.multiple @shadowInput.val('') toggle: => @@ -157,6 +181,9 @@ class App.SearchableSelect extends Spine.Controller when 13 then @onEnter(event) when 27 then @onEscape(event) when 9 then @onTab(event) + when 8 # remove last token on backspace + if @input.val() is '' && @input.is(event.target) && @attribute.multiple + @removeToken('last') onEscape: -> if @isOpen @@ -192,7 +219,7 @@ class App.SearchableSelect extends Spine.Controller @clearAutocomplete() autocompleteOrNavigateIn: (event) -> - if @currentItem.hasClass('js-enter') + if @currentItem && @currentItem.hasClass('js-enter') @navigateIn(event) else @fillWithAutocompleteSuggestion(event) @@ -215,8 +242,11 @@ class App.SearchableSelect extends Spine.Controller # current position caretPosition = @invisiblePart.text().length + 1 - @input.val(@suggestion) - @shadowInput.val(@suggestionValue) + if @attribute.multiple + @addValueToShadowInput(@suggestion, @suggestionValue) + else + @input.val(@suggestion) + @shadowInput.val(@suggestionValue) @clearAutocomplete() @toggle() @@ -242,10 +272,15 @@ class App.SearchableSelect extends Spine.Controller selectItem: (event) -> currentText = event.currentTarget.querySelector('span.searchableSelect-option-text').textContent.trim() return if !currentText - @input.val currentText - @input.trigger('change') - @shadowInput.val event.currentTarget.getAttribute('data-value') - @shadowInput.trigger('change') + + dataId = event.currentTarget.getAttribute('data-value') + if @attribute.multiple + @addValueToShadowInput(currentText, dataId) + else + @input.val currentText + @input.trigger('change') + @shadowInput.val dataId + @shadowInput.trigger('change') navigateIn: (event) -> event.stopPropagation() @@ -354,11 +389,14 @@ class App.SearchableSelect extends Spine.Controller if @currentItem || !@attribute.unknown valueName = @currentItem.children('span.searchableSelect-option-text').text().trim() value = @currentItem.attr('data-value') - @input.val valueName - @shadowInput.val value + if @attribute.multiple + @addValueToShadowInput(valueName, value) + else + @input.val valueName + @shadowInput.val value + @shadowInput.trigger('change') @input.trigger('change') - @shadowInput.trigger('change') if @currentItem if @currentItem.hasClass('js-enter') @@ -386,17 +424,44 @@ class App.SearchableSelect extends Spine.Controller onShadowChange: -> value = @shadowInput.val() + if @attribute.multiple and @currentData + # create token + @createToken(@currentData) + @currentData = null + if Array.isArray(@attribute.options) for option in @attribute.options option.selected = (option.value + '') == value # makes sure option value is always a string + createToken: ({name, value}) => + @input.before App.view('generic/token')( + name: name + value: value + ) + + removeThisToken: (e) => + @removeToken $(e.currentTarget).parents('.token') + + removeToken: (which) => + switch which + when 'last' + token = @$('.token').last() + return if not token.size() + else + token = which + + id = token.data('value') + @shadowInput.find("[value=#{id}]").remove() + @shadowInput.trigger('change') + token.remove() + onInput: (event) => @toggle() if not @isOpen @query = @input.val() @filterByQuery @query - if @attribute.unknown + if @attribute.unknown && !@attribute.multiple @shadowInput.val @query filterByQuery: (query) -> @@ -422,6 +487,14 @@ class App.SearchableSelect extends Spine.Controller else @highlightFirst(true) + addValueToShadowInput: (currentText, dataId) -> + @input.val('') + @currentData = {name: currentText, value: dataId} + if @shadowInput.val() + return if @shadowInput.val().includes("#{dataId}") # cast dataId to string before check + @shadowInput.append($('