From fb8130da18d2936217cdd126b4e4667464f4d82a Mon Sep 17 00:00:00 2001 From: Martin Edenhofer Date: Tue, 20 Jun 2017 17:08:59 +0200 Subject: [PATCH] Added tree selection attribute for object manager. --- .../object_manager_attribute.coffee | 66 ++++- .../_ui_element/searchable_select.coffee | 16 +- .../_ui_element/tree_select.coffee | 41 +++ .../app/controllers/object_manager.coffee | 46 +++ ..._object_organization_autocompletion.coffee | 8 +- .../app/lib/app_post/searchable_select.coffee | 267 +++++++++++++++--- .../app_post/z_searchable_ajax_select.coffee | 3 +- .../views/generic/searchable_select.jst.eco | 29 +- .../generic/searchable_select_option.jst.eco | 8 + .../generic/searchable_select_options.jst.eco | 5 - .../generic/searchable_select_submenu.jst.eco | 11 + .../attribute/tree_select.jst.eco | 29 ++ .../app/views/object_manager/edit.jst.eco | 1 + app/assets/stylesheets/zammad.scss | 67 ++++- .../object_manager_attributes_controller.rb | 2 +- app/models/object_manager/attribute.rb | 61 +++- app/views/tests/form_tree_select.html.erb | 22 ++ config/routes/test.rb | 1 + db/migrate/20120101000001_create_base.rb | 4 +- db/migrate/20170619000001_tree_select.rb | 6 + public/assets/tests/form_searchable_select.js | 16 +- public/assets/tests/form_tree_select.js | 194 +++++++++++++ test/browser/aab_unit_test.rb | 7 + 23 files changed, 819 insertions(+), 91 deletions(-) create mode 100644 app/assets/javascripts/app/controllers/_ui_element/tree_select.coffee create mode 100644 app/assets/javascripts/app/views/generic/searchable_select_option.jst.eco delete mode 100644 app/assets/javascripts/app/views/generic/searchable_select_options.jst.eco create mode 100644 app/assets/javascripts/app/views/generic/searchable_select_submenu.jst.eco create mode 100644 app/assets/javascripts/app/views/object_manager/attribute/tree_select.jst.eco create mode 100644 app/views/tests/form_tree_select.html.erb create mode 100644 db/migrate/20170619000001_tree_select.rb create mode 100644 public/assets/tests/form_tree_select.js diff --git a/app/assets/javascripts/app/controllers/_ui_element/object_manager_attribute.coffee b/app/assets/javascripts/app/controllers/_ui_element/object_manager_attribute.coffee index 050a0fa8e..0ac160b3c 100644 --- a/app/assets/javascripts/app/controllers/_ui_element/object_manager_attribute.coffee +++ b/app/assets/javascripts/app/controllers/_ui_element/object_manager_attribute.coffee @@ -15,7 +15,7 @@ class App.UiElement.object_manager_attribute extends App.UiElement.ApplicationUi attribute: attribute params: params )) - @[localParams.data_type](element, localParams, params) + @[localParams.data_type](element, localParams, params, attribute) localItem.find('.js-dataMap').html(element) localItem.find('.js-dataScreens').html(@dataScreens(attribute, localParams, params)) @@ -24,6 +24,7 @@ class App.UiElement.object_manager_attribute extends App.UiElement.ApplicationUi date: 'Date' input: 'Text' select: 'Select' + tree_select: 'Tree Select' boolean: 'Boolean' integer: 'Integer' @@ -308,6 +309,69 @@ class App.UiElement.object_manager_attribute extends App.UiElement.ApplicationUi lastSelected = value ) + @buildRow: (element, child, level = 0, parentElement) -> + newRow = element.find('.js-template').clone().removeClass('js-template') + newRow.find('.js-key').attr('level', level) + newRow.find('.js-key').val(child.name) + newRow.find('td').first().css('padding-left', "#{(level * 20) + 10}px") + if level is 5 + newRow.find('.js-addChild').addClass('hide') + + if parentElement + parentElement.after(newRow) + return + + element.find('.js-treeTable').append(newRow) + if child.children + for subChild in child.children + @buildRow(element, subChild, level + 1) + + @tree_select: (item, localParams, params, attribute) -> + params.data_option ||= {} + params.data_option.options ||= [] + if _.isEmpty(params.data_option.options) + @buildRow(item, {}) + else + for child in params.data_option.options + @buildRow(item, child) + + item.on('click', '.js-addRow', (e) => + e.stopPropagation() + e.preventDefault() + addRow = $(e.currentTarget).closest('tr') + level = parseInt(addRow.find('.js-key').attr('level')) + @buildRow(item, {}, level, addRow) + ) + + item.on('click', '.js-addChild', (e) => + e.stopPropagation() + e.preventDefault() + addRow = $(e.currentTarget).closest('tr') + level = parseInt(addRow.find('.js-key').attr('level')) + 1 + @buildRow(item, {}, level, addRow) + ) + + item.on('click', '.js-remove', (e) -> + e.stopPropagation() + e.preventDefault() + e.stopPro + element = $(e.target).closest('tr') + level = parseInt(element.find('.js-key').attr('level')) + subElements = 0 + nextElement = element + elementsToDelete = [element] + loop + nextElement = nextElement.next() + break if !nextElement.get(0) + nextLevel = parseInt(nextElement.find('.js-key').attr('level')) + break if nextLevel <= level + subElements += 1 + elementsToDelete.push nextElement + return if subElements isnt 0 && !confirm("Delete #{subElements} sub elements?") + for element in elementsToDelete + element.remove() + ) + @boolean: (item, localParams, params) -> lastSelected = undefined item.on('click', '.js-selected', (e) -> diff --git a/app/assets/javascripts/app/controllers/_ui_element/searchable_select.coffee b/app/assets/javascripts/app/controllers/_ui_element/searchable_select.coffee index df950af65..e4d528a0e 100644 --- a/app/assets/javascripts/app/controllers/_ui_element/searchable_select.coffee +++ b/app/assets/javascripts/app/controllers/_ui_element/searchable_select.coffee @@ -9,24 +9,24 @@ class App.UiElement.searchable_select extends App.UiElement.ApplicationUiElement attribute.multiple = '' # build options list based on config - @getConfigOptionList( attribute, params ) + @getConfigOptionList(attribute, params) # build options list based on relation - @getRelationOptionList( attribute, params ) + @getRelationOptionList(attribute, params) # add null selection if needed - @addNullOption( attribute, params ) + @addNullOption(attribute, params) # sort attribute.options - @sortOptions( attribute, params ) + @sortOptions(attribute, params) # finde selected/checked item of list - @selectedOptions( attribute, params ) + @selectedOptions(attribute, params) # disable item of list - @disabledOptions( attribute, params ) + @disabledOptions(attribute, params) # filter attributes - @filterOption( attribute, params ) + @filterOption(attribute, params) - new App.SearchableSelect( attribute: attribute ).element() + new App.SearchableSelect(attribute: attribute).element() diff --git a/app/assets/javascripts/app/controllers/_ui_element/tree_select.coffee b/app/assets/javascripts/app/controllers/_ui_element/tree_select.coffee new file mode 100644 index 000000000..1962ab644 --- /dev/null +++ b/app/assets/javascripts/app/controllers/_ui_element/tree_select.coffee @@ -0,0 +1,41 @@ +# coffeelint: disable=camel_case_classes +class App.UiElement.tree_select extends App.UiElement.ApplicationUiElement + @optionsSelect: (children, value) -> + return if !children + for child in children + if child.value is value + child.selected = true + if child.children + @optionsSelect(child.children, value) + + @render: (attribute, params) -> + + # set multiple option + if attribute.multiple + attribute.multiple = 'multiple' + else + attribute.multiple = '' + + # build options list based on config + @getConfigOptionList(attribute, params) + + # build options list based on relation + @getRelationOptionList(attribute, params) + + # add null selection if needed + @addNullOption(attribute, params) + + # sort attribute.options + @sortOptions(attribute, params) + + # finde selected/checked item of list + if attribute.options + @optionsSelect(attribute.options, attribute.value) + + # disable item of list + @disabledOptions(attribute, params) + + # filter attributes + @filterOption(attribute, params) + + new App.SearchableSelect(attribute: attribute).element() diff --git a/app/assets/javascripts/app/controllers/object_manager.coffee b/app/assets/javascripts/app/controllers/object_manager.coffee index 5311380cf..2a6f6cd71 100644 --- a/app/assets/javascripts/app/controllers/object_manager.coffee +++ b/app/assets/javascripts/app/controllers/object_manager.coffee @@ -1,4 +1,46 @@ # coffeelint: disable=duplicate_key +treeParams = (e, params) -> + tree = [] + lastLevel = 0 + lastLevels = [] + valueLevels = [] + + $(e.target).closest('.modal').find('.js-treeTable .js-key').each( -> + $element = $(@) + level = parseInt($element.attr('level')) + name = $element.val() + item = + name: name + + if level is 0 + tree.push item + else if lastLevels[level-1] + lastLevels[level-1].children ||= [] + lastLevels[level-1].children.push item + else + console.log('ERROR', item) + if level is 0 + valueLevels = [] + else if lastLevel is level + valueLevels.pop() + else if lastLevel > level + down = lastLevel - level + for count in [1..down] + valueLevels.pop() + if lastLevel <= level + valueLevels.push name + + item.value = valueLevels.join('::') + lastLevels[level] = item + lastLevel = level + + ) + if tree[0] + if !params.data_option + params.data_option = {} + params.data_option.options = tree + params + class Index extends App.ControllerTabs requiredPermission: 'admin.object' constructor: -> @@ -135,6 +177,7 @@ class New extends App.ControllerGenericNew onSubmit: (e) => params = @formParam(e.target) + params = treeParams(e, params) # show attributes for create_middle in two column style if params.screens && params.screens.create_middle @@ -184,6 +227,8 @@ class Edit extends App.ControllerGenericEdit #if attribute.name is 'data_type' # attribute.disabled = true + console.log('configure_attributes', configure_attributes) + @controller = new App.ControllerForm( model: configure_attributes: configure_attributes @@ -195,6 +240,7 @@ class Edit extends App.ControllerGenericEdit onSubmit: (e) => params = @formParam(e.target) + params = treeParams(e, params) # show attributes for create_middle in two column style if params.screens && params.screens.create_middle diff --git a/app/assets/javascripts/app/lib/app_post/_object_organization_autocompletion.coffee b/app/assets/javascripts/app/lib/app_post/_object_organization_autocompletion.coffee index ac222a436..04f4482b5 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 @@ -390,14 +390,14 @@ class App.ObjectOrganizationAutocompletion extends App.Controller properties: translateX: 0 options: - speed: 300 + duration: 240 # fade out list @recipientList.velocity properties: translateX: '-100%' options: - speed: 300 + duration: 240 complete: => @recipientList.height(@organizationList.height()) hideOrganizationMembers: (e) => @@ -413,7 +413,7 @@ class App.ObjectOrganizationAutocompletion extends App.Controller properties: translateX: 0 options: - speed: 300 + duration: 240 # reset list height @recipientList.height('') @@ -423,7 +423,7 @@ class App.ObjectOrganizationAutocompletion extends App.Controller properties: translateX: '100%' options: - speed: 300 + duration: 240 complete: => @organizationList.addClass('hide') newObject: (e) -> 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 a072a057f..010bbddcf 100644 --- a/app/assets/javascripts/app/lib/app_post/searchable_select.coffee +++ b/app/assets/javascripts/app/lib/app_post/searchable_select.coffee @@ -1,19 +1,25 @@ class App.SearchableSelect extends Spine.Controller events: - 'input .js-input': 'onInput' - 'blur .js-input': 'onBlur' - 'focus .js-input': 'onFocus' - 'click .js-option': 'selectItem' - 'mouseenter .js-option': 'highlightItem' - 'shown.bs.dropdown': 'onDropdownShown' - 'hidden.bs.dropdown': 'onDropdownHidden' + 'input .js-input': 'onInput' + 'blur .js-input': 'onBlur' + 'focus .js-input': 'onFocus' + 'click .js-option': 'selectItem' + 'click .js-enter': 'navigateIn' + 'click .js-back': 'navigateOut' + 'mouseenter .js-option': 'highlightItem' + 'mouseenter .js-enter': 'highlightItem' + 'mouseenter .js-back': 'highlightItem' + 'shown.bs.dropdown': 'onDropdownShown' + 'hidden.bs.dropdown': 'onDropdownHidden' elements: - '.js-option': 'option_items' + '.js-dropdown': 'dropdown' + '.js-option, .js-enter': 'optionItems' '.js-input': 'input' '.js-shadow': 'shadowInput' '.js-optionsList': 'optionsList' + '.js-optionsSubmenu': 'optionsSubmenu' '.js-autocomplete-invisible': 'invisiblePart' '.js-autocomplete-visible': 'visiblePart' @@ -27,32 +33,95 @@ class App.SearchableSelect extends Spine.Controller @render() render: -> - firstSelected = _.find @options.attribute.options, (option) -> option.selected + firstSelected = _.find @attribute.options, (option) -> option.selected if firstSelected - @options.attribute.valueName = firstSelected.name - @options.attribute.value = firstSelected.value - else if @options.attribute.unknown && @options.attribute.value - @options.attribute.valueName = @options.attribute.value + @attribute.valueName = firstSelected.name + @attribute.value = firstSelected.value + else if @attribute.unknown && @attribute.value + @attribute.valueName = @attribute.value + else if @hasSubmenu @attribute.options + @attribute.valueName = @getName @attribute.value, @attribute.options - @options.attribute.renderedOptions = App.view('generic/searchable_select_options') - options: @options.attribute.options + @html App.view('generic/searchable_select') + attribute: @attribute + options: @renderAllOptions '', @attribute.options, 0 + submenus: @renderSubmenus @attribute.options - @html App.view('generic/searchable_select')( @options.attribute ) + # initial data + @currentMenu = @findMenuContainingValue @attribute.value + @level = @getIndex @currentMenu - @input.on 'keydown', @navigate + renderSubmenus: (options) -> + html = '' + if options + for option in options + if option.children + html += App.view('generic/searchable_select_submenu') + options: @renderOptions(option.children) + parentValue: option.value + title: option.name + + if @hasSubmenu(option.children) + html += @renderSubmenus option.children + html + + hasSubmenu: (options) -> + return false if !options + for option in options + return true if option.children + return false + + getName: (value, options) -> + for option in options + if option.value is value + return option.name + if option.children + name = @getName value, option.children + return name if name isnt undefined + undefined + + renderOptions: (options) -> + html = '' + for option in options + html += App.view('generic/searchable_select_option') + option: option + class: if option.children then 'js-enter' else 'js-option' + html + + renderAllOptions: (parentName, options, level) -> + html = '' + if options + for option in options + className = if option.children then 'js-enter' else 'js-option' + if level && level > 0 + className += ' is-hidden is-child' + + html += App.view('generic/searchable_select_option') + option: option + class: className + detail: parentName + + if option.children + html += @renderAllOptions "#{parentName} — #{option.name}", option.children, level+1 + html onDropdownShown: => @input.on 'click', @stopPropagation @highlightFirst() + $(document).on 'keydown.searchable_select', @navigate + if @level > 0 + @showSubmenu(@currentMenu) @isOpen = true onDropdownHidden: => @input.off 'click', @stopPropagation - @option_items.removeClass '.is-active' + @unhighlightCurrentItem() + $(document).off 'keydown.searchable_select' @isOpen = false toggle: => + @currentItem = null @$('[data-toggle="dropdown"]').dropdown('toggle') stopPropagation: (event) -> @@ -62,8 +131,8 @@ class App.SearchableSelect extends Spine.Controller switch event.keyCode when 40 then @nudge event, 1 # down when 38 then @nudge event, -1 # up - when 39 then @fillWithAutocompleteSuggestion event # right - when 37 then @fillWithAutocompleteSuggestion event # left + when 39 then @autocompleteOrNavigateIn event # right + when 37 then @autocompleteOrNavigateOut event # left when 13 then @onEnter event when 27 then @onEscape() when 9 then @onTab event @@ -71,12 +140,20 @@ class App.SearchableSelect extends Spine.Controller onEscape: -> @toggle() if @isOpen + getCurrentOptions: -> + @currentMenu.find('.js-option, .js-enter, .js-back') + + getOptionIndex: (menu, value) -> + menu.find('.js-option, .js-enter').filter("[data-value=\"#{value}\"]").index() + nudge: (event, direction) -> return @toggle() if not @isOpen + options = @getCurrentOptions() + event.preventDefault() - visibleOptions = @option_items.not('.is-hidden') - highlightedItem = @option_items.filter('.is-active') + visibleOptions = options.not('.is-hidden') + highlightedItem = options.filter('.is-active') currentPosition = visibleOptions.index(highlightedItem) currentPosition += direction @@ -84,10 +161,24 @@ class App.SearchableSelect extends Spine.Controller return if currentPosition < 0 return if currentPosition > visibleOptions.size() - 1 - @option_items.removeClass('is-active') - visibleOptions.eq(currentPosition).addClass('is-active') + @unhighlightCurrentItem() + @currentItem = visibleOptions.eq(currentPosition) + @currentItem.addClass('is-active') @clearAutocomplete() + autocompleteOrNavigateIn: (event) -> + if @currentItem.hasClass('js-enter') + @navigateIn(event) + else + @fillWithAutocompleteSuggestion(event) + + autocompleteOrNavigateOut: (event) -> + # if we're in a depth then navigateOut + if @level != 0 + @navigateOut(event) + else + @fillWithAutocompleteSuggestion(event) + fillWithAutocompleteSuggestion: (event) -> if !@suggestion return @@ -129,11 +220,96 @@ class App.SearchableSelect extends Spine.Controller @shadowInput.val event.currentTarget.getAttribute('data-value') @shadowInput.trigger('change') + navigateIn: (event) -> + event.stopPropagation() + @navigateDepth(1) + + navigateOut: (event) -> + event.stopPropagation() + @navigateDepth(-1) + + navigateDepth: (dir) -> + return if @animating + if dir > 0 + target = @currentItem.attr('data-value') + target_menu = @optionsSubmenu.filter("[data-parent-value=\"#{target}\"]") + else + target_menu = @findMenuContainingValue @currentMenu.attr('data-parent-value') + + @animateToSubmenu(target_menu, dir) + + @level+=dir + + animateToSubmenu: (target_menu, direction) -> + @animating = true + target_menu.prop('hidden', false) + @dropdown.height(Math.max(target_menu.height(), @currentMenu.height())) + oldCurrentItem = @currentItem + + @currentMenu.data('current_item_index', @currentItem.index()) + # default: 1 (first item after the back button) + target_item_index = target_menu.data('current_item_index') || 1 + # if the direction is out then we know the target item -> its the parent item + if direction is -1 + value = @currentMenu.attr('data-parent-value') + target_item_index = @getOptionIndex(target_menu, value) + + @currentItem = target_menu.children().eq(target_item_index) + @currentItem.addClass('is-active') + + target_menu.velocity + properties: + translateX: [0, direction*100+'%'] + options: + duration: 240 + + @currentMenu.velocity + properties: + translateX: [direction*-100+'%', 0] + options: + duration: 240 + complete: => + oldCurrentItem.removeClass('is-active') + $.Velocity.hook(@currentMenu, 'translateX', '') + @currentMenu.prop('hidden', true) + @dropdown.height(target_menu.height()) + @currentMenu = target_menu + @animating = false + + showSubmenu: (menu) -> + @currentMenu.prop('hidden', true) + menu.prop('hidden', false) + @dropdown.height(menu.height()) + + findMenuContainingValue: (value) -> + return @optionsList if !value + + # in case of numbers + if !value.split && value.toString + value = value.toString() + path = value.split('::') + if path.length == 1 + return @optionsList + else + path.pop() + return @optionsSubmenu.filter("[data-parent-value=\"#{path.join('::')}\"]") + + getIndex: (menu) -> + parentValue = menu.attr('data-parent-value') + return 0 if !parentValue + return parentValue.split('::').length + onTab: (event) -> return if not @isOpen event.preventDefault() onEnter: (event) -> + if @currentItem + if @currentItem.hasClass('js-enter') + return @navigateIn(event) + else if @currentItem.hasClass('js-back') + return @navigateOut(event) + @clearAutocomplete() if not @isOpen @@ -144,13 +320,14 @@ class App.SearchableSelect extends Spine.Controller event.preventDefault() - selected = @option_items.filter('.is-active') - if selected.length || !@options.attribute.unknown - valueName = selected.text().trim() - value = selected.attr('data-value') + if @currentItem || !@attribute.unknown + valueName = @currentItem.text().trim() + value = @currentItem.attr('data-value') @input.val valueName @shadowInput.val value + @currentItem = null + @input.trigger('change') @shadowInput.trigger('change') @toggle() @@ -169,32 +346,46 @@ class App.SearchableSelect extends Spine.Controller @query = @input.val() @filterByQuery @query - if @options.attribute.unknown + if @attribute.unknown @shadowInput.val @query filterByQuery: (query) -> query = escapeRegExp(query) regex = new RegExp(query.split(' ').join('.*'), 'i') - @option_items + @optionsList.addClass 'is-filtered' + + @optionItems .addClass 'is-hidden' .filter -> @textContent.match(regex) .removeClass 'is-hidden' - if @options.attribute.unknown && @option_items.length == @option_items.filter('.is-hidden').length - @option_items.removeClass 'is-hidden' - @option_items.removeClass 'is-active' + if !query + @optionItems.filter('.is-child').addClass 'is-hidden' + + # if all are hidden + if @attribute.unknown && @optionItems.length == @optionItems.filter('.is-hidden').length + @optionItems.not('.is-child').removeClass 'is-hidden' + @unhighlightCurrentItem() + @optionsList.removeClass 'is-filtered' else @highlightFirst(true) highlightFirst: (autocomplete) -> - first = @option_items.removeClass('is-active').not('.is-hidden').first() - first.addClass 'is-active' + @unhighlightCurrentItem() + @currentItem = @getCurrentOptions().not('.is-hidden').first() + @currentItem.addClass 'is-active' if autocomplete - @autocomplete first.attr('data-value'), first.text().trim() + @autocomplete @currentItem.attr('data-value'), @currentItem.text().trim() highlightItem: (event) => - @option_items.removeClass('is-active') - $(event.currentTarget).addClass('is-active') \ No newline at end of file + @unhighlightCurrentItem() + @currentItem = $(event.currentTarget) + @currentItem.addClass('is-active') + + unhighlightCurrentItem: -> + return if !@currentItem + @currentItem.removeClass('is-active') + @currentItem = null diff --git a/app/assets/javascripts/app/lib/app_post/z_searchable_ajax_select.coffee b/app/assets/javascripts/app/lib/app_post/z_searchable_ajax_select.coffee index a23409d6a..9c3da1044 100644 --- a/app/assets/javascripts/app/lib/app_post/z_searchable_ajax_select.coffee +++ b/app/assets/javascripts/app/lib/app_post/z_searchable_ajax_select.coffee @@ -70,8 +70,7 @@ class App.SearchableAjaxSelect extends App.SearchableSelect options.push data # fill template with gathered options - @optionsList.html App.view('generic/searchable_select_options') - options: options + @optionsList.html @renderOptions options # refresh elements @refreshElements() diff --git a/app/assets/javascripts/app/views/generic/searchable_select.jst.eco b/app/assets/javascripts/app/views/generic/searchable_select.jst.eco index 74ae5805b..70139147e 100644 --- a/app/assets/javascripts/app/views/generic/searchable_select.jst.eco +++ b/app/assets/javascripts/app/views/generic/searchable_select.jst.eco @@ -1,26 +1,29 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/app/assets/javascripts/app/views/generic/searchable_select_option.jst.eco b/app/assets/javascripts/app/views/generic/searchable_select_option.jst.eco new file mode 100644 index 000000000..bbefe2890 --- /dev/null +++ b/app/assets/javascripts/app/views/generic/searchable_select_option.jst.eco @@ -0,0 +1,8 @@ + \ No newline at end of file diff --git a/app/assets/javascripts/app/views/generic/searchable_select_options.jst.eco b/app/assets/javascripts/app/views/generic/searchable_select_options.jst.eco deleted file mode 100644 index bf6cf3fc2..000000000 --- a/app/assets/javascripts/app/views/generic/searchable_select_options.jst.eco +++ /dev/null @@ -1,5 +0,0 @@ -<% if @options: %> - <% for option in @options: %> -