Added tree selection attribute for object manager.

This commit is contained in:
Martin Edenhofer 2017-06-20 17:08:59 +02:00
parent 284dbb3de8
commit fb8130da18
23 changed files with 819 additions and 91 deletions

View file

@ -15,7 +15,7 @@ class App.UiElement.object_manager_attribute extends App.UiElement.ApplicationUi
attribute: attribute attribute: attribute
params: params params: params
)) ))
@[localParams.data_type](element, localParams, params) @[localParams.data_type](element, localParams, params, attribute)
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))
@ -24,6 +24,7 @@ class App.UiElement.object_manager_attribute extends App.UiElement.ApplicationUi
date: 'Date' date: 'Date'
input: 'Text' input: 'Text'
select: 'Select' select: 'Select'
tree_select: 'Tree Select'
boolean: 'Boolean' boolean: 'Boolean'
integer: 'Integer' integer: 'Integer'
@ -308,6 +309,69 @@ class App.UiElement.object_manager_attribute extends App.UiElement.ApplicationUi
lastSelected = value 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) -> @boolean: (item, localParams, params) ->
lastSelected = undefined lastSelected = undefined
item.on('click', '.js-selected', (e) -> item.on('click', '.js-selected', (e) ->

View file

@ -9,24 +9,24 @@ class App.UiElement.searchable_select extends App.UiElement.ApplicationUiElement
attribute.multiple = '' attribute.multiple = ''
# build options list based on config # build options list based on config
@getConfigOptionList( attribute, params ) @getConfigOptionList(attribute, params)
# build options list based on relation # build options list based on relation
@getRelationOptionList( attribute, params ) @getRelationOptionList(attribute, params)
# add null selection if needed # add null selection if needed
@addNullOption( attribute, params ) @addNullOption(attribute, params)
# sort attribute.options # sort attribute.options
@sortOptions( attribute, params ) @sortOptions(attribute, params)
# finde selected/checked item of list # finde selected/checked item of list
@selectedOptions( attribute, params ) @selectedOptions(attribute, params)
# disable item of list # disable item of list
@disabledOptions( attribute, params ) @disabledOptions(attribute, params)
# filter attributes # filter attributes
@filterOption( attribute, params ) @filterOption(attribute, params)
new App.SearchableSelect( attribute: attribute ).element() new App.SearchableSelect(attribute: attribute).element()

View file

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

View file

@ -1,4 +1,46 @@
# coffeelint: disable=duplicate_key # 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 class Index extends App.ControllerTabs
requiredPermission: 'admin.object' requiredPermission: 'admin.object'
constructor: -> constructor: ->
@ -135,6 +177,7 @@ class New extends App.ControllerGenericNew
onSubmit: (e) => onSubmit: (e) =>
params = @formParam(e.target) params = @formParam(e.target)
params = treeParams(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
@ -184,6 +227,8 @@ class Edit extends App.ControllerGenericEdit
#if attribute.name is 'data_type' #if attribute.name is 'data_type'
# attribute.disabled = true # attribute.disabled = true
console.log('configure_attributes', configure_attributes)
@controller = new App.ControllerForm( @controller = new App.ControllerForm(
model: model:
configure_attributes: configure_attributes configure_attributes: configure_attributes
@ -195,6 +240,7 @@ class Edit extends App.ControllerGenericEdit
onSubmit: (e) => onSubmit: (e) =>
params = @formParam(e.target) params = @formParam(e.target)
params = treeParams(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

@ -390,14 +390,14 @@ class App.ObjectOrganizationAutocompletion extends App.Controller
properties: properties:
translateX: 0 translateX: 0
options: options:
speed: 300 duration: 240
# fade out list # fade out list
@recipientList.velocity @recipientList.velocity
properties: properties:
translateX: '-100%' translateX: '-100%'
options: options:
speed: 300 duration: 240
complete: => @recipientList.height(@organizationList.height()) complete: => @recipientList.height(@organizationList.height())
hideOrganizationMembers: (e) => hideOrganizationMembers: (e) =>
@ -413,7 +413,7 @@ class App.ObjectOrganizationAutocompletion extends App.Controller
properties: properties:
translateX: 0 translateX: 0
options: options:
speed: 300 duration: 240
# reset list height # reset list height
@recipientList.height('') @recipientList.height('')
@ -423,7 +423,7 @@ class App.ObjectOrganizationAutocompletion extends App.Controller
properties: properties:
translateX: '100%' translateX: '100%'
options: options:
speed: 300 duration: 240
complete: => @organizationList.addClass('hide') complete: => @organizationList.addClass('hide')
newObject: (e) -> newObject: (e) ->

View file

@ -5,15 +5,21 @@ class App.SearchableSelect extends Spine.Controller
'blur .js-input': 'onBlur' 'blur .js-input': 'onBlur'
'focus .js-input': 'onFocus' 'focus .js-input': 'onFocus'
'click .js-option': 'selectItem' 'click .js-option': 'selectItem'
'click .js-enter': 'navigateIn'
'click .js-back': 'navigateOut'
'mouseenter .js-option': 'highlightItem' 'mouseenter .js-option': 'highlightItem'
'mouseenter .js-enter': 'highlightItem'
'mouseenter .js-back': 'highlightItem'
'shown.bs.dropdown': 'onDropdownShown' 'shown.bs.dropdown': 'onDropdownShown'
'hidden.bs.dropdown': 'onDropdownHidden' 'hidden.bs.dropdown': 'onDropdownHidden'
elements: elements:
'.js-option': 'option_items' '.js-dropdown': 'dropdown'
'.js-option, .js-enter': 'optionItems'
'.js-input': 'input' '.js-input': 'input'
'.js-shadow': 'shadowInput' '.js-shadow': 'shadowInput'
'.js-optionsList': 'optionsList' '.js-optionsList': 'optionsList'
'.js-optionsSubmenu': 'optionsSubmenu'
'.js-autocomplete-invisible': 'invisiblePart' '.js-autocomplete-invisible': 'invisiblePart'
'.js-autocomplete-visible': 'visiblePart' '.js-autocomplete-visible': 'visiblePart'
@ -27,32 +33,95 @@ class App.SearchableSelect extends Spine.Controller
@render() @render()
render: -> render: ->
firstSelected = _.find @options.attribute.options, (option) -> option.selected firstSelected = _.find @attribute.options, (option) -> option.selected
if firstSelected if firstSelected
@options.attribute.valueName = firstSelected.name @attribute.valueName = firstSelected.name
@options.attribute.value = firstSelected.value @attribute.value = firstSelected.value
else if @options.attribute.unknown && @options.attribute.value else if @attribute.unknown && @attribute.value
@options.attribute.valueName = @options.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') @html App.view('generic/searchable_select')
options: @options.attribute.options 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: => onDropdownShown: =>
@input.on 'click', @stopPropagation @input.on 'click', @stopPropagation
@highlightFirst() @highlightFirst()
$(document).on 'keydown.searchable_select', @navigate
if @level > 0
@showSubmenu(@currentMenu)
@isOpen = true @isOpen = true
onDropdownHidden: => onDropdownHidden: =>
@input.off 'click', @stopPropagation @input.off 'click', @stopPropagation
@option_items.removeClass '.is-active' @unhighlightCurrentItem()
$(document).off 'keydown.searchable_select'
@isOpen = false @isOpen = false
toggle: => toggle: =>
@currentItem = null
@$('[data-toggle="dropdown"]').dropdown('toggle') @$('[data-toggle="dropdown"]').dropdown('toggle')
stopPropagation: (event) -> stopPropagation: (event) ->
@ -62,8 +131,8 @@ class App.SearchableSelect extends Spine.Controller
switch event.keyCode switch event.keyCode
when 40 then @nudge event, 1 # down when 40 then @nudge event, 1 # down
when 38 then @nudge event, -1 # up when 38 then @nudge event, -1 # up
when 39 then @fillWithAutocompleteSuggestion event # right when 39 then @autocompleteOrNavigateIn event # right
when 37 then @fillWithAutocompleteSuggestion event # left when 37 then @autocompleteOrNavigateOut event # left
when 13 then @onEnter event when 13 then @onEnter event
when 27 then @onEscape() when 27 then @onEscape()
when 9 then @onTab event when 9 then @onTab event
@ -71,12 +140,20 @@ class App.SearchableSelect extends Spine.Controller
onEscape: -> onEscape: ->
@toggle() if @isOpen @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) -> nudge: (event, direction) ->
return @toggle() if not @isOpen return @toggle() if not @isOpen
options = @getCurrentOptions()
event.preventDefault() event.preventDefault()
visibleOptions = @option_items.not('.is-hidden') visibleOptions = options.not('.is-hidden')
highlightedItem = @option_items.filter('.is-active') highlightedItem = options.filter('.is-active')
currentPosition = visibleOptions.index(highlightedItem) currentPosition = visibleOptions.index(highlightedItem)
currentPosition += direction currentPosition += direction
@ -84,10 +161,24 @@ class App.SearchableSelect extends Spine.Controller
return if currentPosition < 0 return if currentPosition < 0
return if currentPosition > visibleOptions.size() - 1 return if currentPosition > visibleOptions.size() - 1
@option_items.removeClass('is-active') @unhighlightCurrentItem()
visibleOptions.eq(currentPosition).addClass('is-active') @currentItem = visibleOptions.eq(currentPosition)
@currentItem.addClass('is-active')
@clearAutocomplete() @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) -> fillWithAutocompleteSuggestion: (event) ->
if !@suggestion if !@suggestion
return return
@ -129,11 +220,96 @@ class App.SearchableSelect extends Spine.Controller
@shadowInput.val event.currentTarget.getAttribute('data-value') @shadowInput.val event.currentTarget.getAttribute('data-value')
@shadowInput.trigger('change') @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) -> onTab: (event) ->
return if not @isOpen return if not @isOpen
event.preventDefault() event.preventDefault()
onEnter: (event) -> onEnter: (event) ->
if @currentItem
if @currentItem.hasClass('js-enter')
return @navigateIn(event)
else if @currentItem.hasClass('js-back')
return @navigateOut(event)
@clearAutocomplete() @clearAutocomplete()
if not @isOpen if not @isOpen
@ -144,13 +320,14 @@ class App.SearchableSelect extends Spine.Controller
event.preventDefault() event.preventDefault()
selected = @option_items.filter('.is-active') if @currentItem || !@attribute.unknown
if selected.length || !@options.attribute.unknown valueName = @currentItem.text().trim()
valueName = selected.text().trim() value = @currentItem.attr('data-value')
value = selected.attr('data-value')
@input.val valueName @input.val valueName
@shadowInput.val value @shadowInput.val value
@currentItem = null
@input.trigger('change') @input.trigger('change')
@shadowInput.trigger('change') @shadowInput.trigger('change')
@toggle() @toggle()
@ -169,32 +346,46 @@ class App.SearchableSelect extends Spine.Controller
@query = @input.val() @query = @input.val()
@filterByQuery @query @filterByQuery @query
if @options.attribute.unknown if @attribute.unknown
@shadowInput.val @query @shadowInput.val @query
filterByQuery: (query) -> filterByQuery: (query) ->
query = escapeRegExp(query) query = escapeRegExp(query)
regex = new RegExp(query.split(' ').join('.*'), 'i') regex = new RegExp(query.split(' ').join('.*'), 'i')
@option_items @optionsList.addClass 'is-filtered'
@optionItems
.addClass 'is-hidden' .addClass 'is-hidden'
.filter -> .filter ->
@textContent.match(regex) @textContent.match(regex)
.removeClass 'is-hidden' .removeClass 'is-hidden'
if @options.attribute.unknown && @option_items.length == @option_items.filter('.is-hidden').length if !query
@option_items.removeClass 'is-hidden' @optionItems.filter('.is-child').addClass 'is-hidden'
@option_items.removeClass 'is-active'
# 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 else
@highlightFirst(true) @highlightFirst(true)
highlightFirst: (autocomplete) -> highlightFirst: (autocomplete) ->
first = @option_items.removeClass('is-active').not('.is-hidden').first() @unhighlightCurrentItem()
first.addClass 'is-active' @currentItem = @getCurrentOptions().not('.is-hidden').first()
@currentItem.addClass 'is-active'
if autocomplete if autocomplete
@autocomplete first.attr('data-value'), first.text().trim() @autocomplete @currentItem.attr('data-value'), @currentItem.text().trim()
highlightItem: (event) => highlightItem: (event) =>
@option_items.removeClass('is-active') @unhighlightCurrentItem()
$(event.currentTarget).addClass('is-active') @currentItem = $(event.currentTarget)
@currentItem.addClass('is-active')
unhighlightCurrentItem: ->
return if !@currentItem
@currentItem.removeClass('is-active')
@currentItem = null

View file

@ -70,8 +70,7 @@ class App.SearchableAjaxSelect extends App.SearchableSelect
options.push data options.push data
# fill template with gathered options # fill template with gathered options
@optionsList.html App.view('generic/searchable_select_options') @optionsList.html @renderOptions options
options: options
# refresh elements # refresh elements
@refreshElements() @refreshElements()

View file

@ -1,26 +1,29 @@
<div class="dropdown-toggle" data-toggle="dropdown"> <div class="dropdown-toggle" data-toggle="dropdown">
<input <input
class="searchableSelect-shadow form-control js-shadow" class="searchableSelect-shadow form-control js-shadow"
id="<%= @id %>" id="<%= @attribute.id %>"
name="<%= @name %>" name="<%= @attribute.name %>"
<%= @required %> <%= @attribute.required %>
<%= @autofocus %> <%= @attribute.autofocus %>
value="<%= @value %>" value="<%= @attribute.value %>"
> >
<input <input
class="searchableSelect-main form-control js-input<%= " #{ @class }" if @class %>" class="searchableSelect-main form-control js-input<%= " #{ @attribute.class }" if @attribute.class %>"
placeholder="<%= @placeholder %>" placeholder="<%= @attribute.placeholder %>"
value="<%= @valueName %>" value="<%= @attribute.valueName %>"
autocomplete="off" autocomplete="off"
<%= @required %> <%= @attribute.required %>
> >
<div class="searchableSelect-autocomplete"> <div class="searchableSelect-autocomplete">
<span class="searchableSelect-autocomplete-invisible js-autocomplete-invisible"></span> <span class="searchableSelect-autocomplete-invisible js-autocomplete-invisible"></span>
<span class="searchableSelect-autocomplete-visible js-autocomplete-visible"></span> <span class="searchableSelect-autocomplete-visible js-autocomplete-visible"></span>
</div> </div>
<% if !@ajax: %><%- @Icon('arrow-down', 'dropdown-arrow') %><% end %> <% if !@attribute.ajax: %><%- @Icon('arrow-down', 'dropdown-arrow') %><% end %>
<div class="small loading icon"></div> <div class="small loading icon"></div>
</div> </div>
<ul class="dropdown-menu dropdown-menu-left js-optionsList" role="menu"> <div class="dropdown-menu dropdown-menu-left dropdown-menu--has-submenu js-dropdown">
<%- @renderedOptions %> <ul class="js-optionsList" role="menu">
</ul> <%- @options %>
</ul>
<%- @submenus %>
</div>

View file

@ -0,0 +1,8 @@
<li role="presentation" class="<%= @class %>" data-value="<%= @option.value %>" title="<%= @option.name %><% if @detail: %><%= @detail %><% end %>">
<span class="searchableSelect-option-text">
<%= @option.name %><% if @detail: %><span class="dropdown-detail"><%= @detail %></span><% end %>
</span>
<% if @option.children: %>
<%- @Icon('arrow-right', 'recipientList-arrow') %>
<% end %>
</li>

View file

@ -1,5 +0,0 @@
<% if @options: %>
<% for option in @options: %>
<li role="presentation" class="js-option" data-value="<%= option.value %>"><%= option.name %>
<% end %>
<% end %>

View file

@ -0,0 +1,11 @@
<ul class="dropdown-submenu js-optionsSubmenu" role="menu" data-parent-value="<%- @parentValue %>" hidden>
<% if @title: %>
<li class="dropdown-controls js-back">
<%- @Icon('arrow-left') %>
<div class="dropdown-title">
<%- @title %>
</div>
</li>
<% end %>
<%- @options %>
</ul>

View file

@ -0,0 +1,29 @@
<div>
<table class="settings-list js-Table" style="width: 100%;">
<thead>
<tr>
<th><%- @T('Key') %>
<th style="width: 180px"><%- @T('Action') %>
</tr>
</thead>
<tbody class="js-treeTable"></tbody>
</table>
<table class="hidden">
<tbody>
<tr class="js-template">
<td class="settings-list-control-cell">
<input class="form-control form-control--small js-key" type="text" value="" data-level="" required/>
<td class="settings-list-row-control">
<div class="btn btn--text js-remove" style="margin-left: -10px;">
<%- @Icon('trash') %>
</div>
<div class="btn btn--text btn--create js-addChild" style="margin-left: -10px;">
<%- @Icon('plus-small') %> <%- @T('children') %>
</div>
<div class="btn btn--text btn--create js-addRow" style="margin-left: -10px;">
<%- @Icon('plus-small') %> <%- @T('row') %>
</div>
</tr>
</tbody>
</table>
</div>

View file

@ -6,6 +6,7 @@
<div class="js-datetime">date time settings</div> <div class="js-datetime">date time settings</div>
<div class="js-date">date settings</div> <div class="js-date">date settings</div>
<div class="js-select">select settings</div> <div class="js-select">select settings</div>
<div class="js-tree_selection">tree selection settings</div>
<div class="js-checkbox">checkbox settings</div> <div class="js-checkbox">checkbox settings</div>
<div class="js-boolean">boolean settings</div> <div class="js-boolean">boolean settings</div>
<div class="js-richtext">richtext settings</div> <div class="js-richtext">richtext settings</div>

View file

@ -1396,6 +1396,7 @@ fieldset > .form-group {
.merge-target, .merge-target,
.merge-source { .merge-source {
flex: 1; flex: 1;
width: 33%;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
justify-content: flex-end; justify-content: flex-end;
@ -6130,6 +6131,48 @@ footer {
height: 30px; height: 30px;
} }
.dropdown-menu--has-submenu {
overflow: hidden;
background: none;
ul {
background: hsl(234,10%,19%);
}
}
.dropdown-submenu {
position: absolute;
top: 0;
left: 0;
width: 100%;
}
.dropdown.dropdown--actions .dropdown-controls {
@extend .u-clickable;
display: flex;
&:not(:hover):not(.is-active) {
background: hsl(206,7%,28%);
}
.icon {
fill: white;
margin-right: 10px;
flex-shrink: 0;
}
}
.dropdown-title {
flex-shrink: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.dropdown-detail {
opacity: 0.5;
}
.recipientList, .recipientList,
.recipientList-organizationMembers { .recipientList-organizationMembers {
list-style: none; list-style: none;
@ -7493,15 +7536,31 @@ output {
.dropdown-menu { .dropdown-menu {
margin-top: -3px; margin-top: -3px;
max-width: 100%;
} }
&.dropdown li:hover:not(.is-active) { &-option-text {
flex: 1 1 0%;
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
display: block;
& + .icon {
margin-left: 10px;
}
}
&.dropdown li {
&:hover:not(.is-active) {
background: none; background: none;
} }
&.dropdown li.is-hidden { &.is-hidden {
display: none; display: none;
} }
}
li:not(.is-active):hover + li { li:not(.is-active):hover + li {
box-shadow: 0 1px rgba(255,255,255,.13) inset; box-shadow: 0 1px rgba(255,255,255,.13) inset;

View file

@ -108,7 +108,7 @@ class ObjectManagerAttributesController < ApplicationController
end end
end end
if params[:data_option] && !params[:data_option].key?(:default) if params[:data_option] && !params[:data_option].key?(:default)
params[:data_option][:default] = if params[:data_type] =~ /^(input|select)$/ params[:data_option][:default] = if params[:data_type] =~ /^(input|select|tree_select)$/
'' ''
end end
end end

View file

@ -115,6 +115,57 @@ possible types
note: 'some additional comment', # optional note: 'some additional comment', # optional
}, },
# tree_select
data_type: 'tree_select',
data_option: {
default: 'aa',
options: [
{
'value' => 'aa',
'name' => 'aa (comment)',
'children' => [
{
'value' => 'aaa',
'name' => 'aaa (comment)',
},
{
'value' => 'aab',
'name' => 'aab (comment)',
},
{
'value' => 'aac',
'name' => 'aac (comment)',
},
]
},
{
'value' => 'bb',
'name' => 'bb (comment)',
'children' => [
{
'value' => 'bba',
'name' => 'aaa (comment)',
},
{
'value' => 'bbb',
'name' => 'bbb (comment)',
},
{
'value' => 'bbc',
'name' => 'bbc (comment)',
},
]
},
],
maxlength: 200,
nulloption: true,
null: false,
multiple: false, # currently only "false" supported
translate: true, # optional
note: 'some additional comment', # optional
},
# checkbox # checkbox
data_type: 'checkbox', data_type: 'checkbox',
@ -550,7 +601,7 @@ to send no browser reload event, pass false
end end
data_type = nil data_type = nil
if attribute.data_type =~ /^input|select|richtext|textarea|checkbox$/ if attribute.data_type =~ /^input|select|tree_select|richtext|textarea|checkbox$/
data_type = :string data_type = :string
elsif attribute.data_type =~ /^integer|user_autocompletion$/ elsif attribute.data_type =~ /^integer|user_autocompletion$/
data_type = :integer data_type = :integer
@ -564,7 +615,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)
if attribute.data_type =~ /^input|select|richtext|textarea|checkbox$/ if attribute.data_type =~ /^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,
@ -603,7 +654,7 @@ to send no browser reload event, pass false
end end
# create field # create field
if attribute.data_type =~ /^input|select|richtext|textarea|checkbox$/ if attribute.data_type =~ /^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,
@ -704,7 +755,7 @@ to send no browser reload event, pass false
if !data_type if !data_type
raise 'Need data_type param' raise 'Need data_type param'
end end
if data_type !~ /^(input|user_autocompletion|checkbox|select|datetime|date|tag|richtext|textarea|integer|autocompletion_ajax|boolean|user_permission|active)$/ if data_type !~ /^(input|user_autocompletion|checkbox|select|tree_select|datetime|date|tag|richtext|textarea|integer|autocompletion_ajax|boolean|user_permission|active)$/
raise "Invalid data_type param '#{data_type}'" raise "Invalid data_type param '#{data_type}'"
end end
@ -735,7 +786,7 @@ to send no browser reload event, pass false
} }
end end
if data_type == 'select' || data_type == 'checkbox' if data_type == 'select' || data_type == 'tree_select' || data_type == 'checkbox'
raise 'Need data_option[:default] param' if !data_option.key?(:default) raise 'Need data_option[:default] param' if !data_option.key?(:default)
raise 'Invalid data_option[:options] or data_option[:relation] param' if data_option[:options].nil? && data_option[:relation].nil? raise 'Invalid data_option[:options] or data_option[:relation] param' if data_option[:options].nil? && data_option[:relation].nil?
if !data_option.key?(:maxlength) if !data_option.key?(:maxlength)

View file

@ -0,0 +1,22 @@
<link rel="stylesheet" href="/assets/tests/qunit-1.21.0.css">
<script src="/assets/tests/qunit-1.21.0.js"></script>
<script src="/assets/tests/form_tree_select.js"></script>
<style type="text/css">
body {
padding-top: 0px;
}
</style>
<script type="text/javascript">
</script>
<div id="qunit" class="u-dontfold"></div>
<div>
<form class="form-stacked pull-left">
<div id="forms"></div>
<button type="submit" class="btn btn-primary submit">Submit</button>
</form>
</div>

View file

@ -5,6 +5,7 @@ Zammad::Application.routes.draw do
match '/tests_model', to: 'tests#model', via: :get match '/tests_model', to: 'tests#model', via: :get
match '/tests_model_ui', to: 'tests#model_ui', via: :get match '/tests_model_ui', to: 'tests#model_ui', via: :get
match '/tests_form', to: 'tests#form', via: :get match '/tests_form', to: 'tests#form', via: :get
match '/tests_form_tree_select', to: 'tests#form_tree_select', via: :get
match '/tests_form_find', to: 'tests#form_find', via: :get match '/tests_form_find', to: 'tests#form_find', via: :get
match '/tests_form_trim', to: 'tests#form_trim', via: :get match '/tests_form_trim', to: 'tests#form_trim', via: :get
match '/tests_form_extended', to: 'tests#form_extended', via: :get match '/tests_form_extended', to: 'tests#form_extended', via: :get

View file

@ -563,8 +563,8 @@ class CreateBase < ActiveRecord::Migration
t.string :name, limit: 200, null: false t.string :name, limit: 200, null: false
t.string :display, limit: 200, null: false t.string :display, limit: 200, null: false
t.string :data_type, limit: 100, null: false t.string :data_type, limit: 100, null: false
t.string :data_option, limit: 8000, null: true t.text :data_option, limit: 800.kilobytes + 1, null: true
t.string :data_option_new, limit: 8000, null: true t.text :data_option_new, limit: 800.kilobytes + 1, null: true
t.boolean :editable, null: false, default: true t.boolean :editable, null: false, default: true
t.boolean :active, null: false, default: true t.boolean :active, null: false, default: true
t.string :screens, limit: 2000, null: true t.string :screens, limit: 2000, null: true

View file

@ -0,0 +1,6 @@
class TreeSelect < ActiveRecord::Migration
def up
change_column :object_manager_attributes, :data_option, :text, limit: 800.kilobytes + 1, null: true
change_column :object_manager_attributes, :data_option_new, :text, limit: 800.kilobytes + 1, null: true
end
end

View file

@ -45,13 +45,13 @@ test( "searchable_select check", function() {
autofocus: true autofocus: true
}) })
var params = App.ControllerForm.params( el ) var params = App.ControllerForm.params(el)
var test_params = { var test_params = {
searchable_select1: '', searchable_select1: '',
searchable_select2: 'bbb', searchable_select2: 'bbb',
searchable_select3: '', searchable_select3: '',
} }
deepEqual( params, test_params, 'form param check' ) deepEqual(params, test_params, 'form param check')
// change selection // change selection
$('[name="searchable_select1"].js-shadow + .js-input').focus().val('').trigger('input') $('[name="searchable_select1"].js-shadow + .js-input').focus().val('').trigger('input')
@ -62,13 +62,13 @@ test( "searchable_select check", function() {
var entries = $element.find('li:not(.is-hidden)').length var entries = $element.find('li:not(.is-hidden)').length
equal(entries, 1, 'dropdown count') equal(entries, 1, 'dropdown count')
$element.find('li:not(.is-hidden)').first().click() $element.find('li:not(.is-hidden)').first().click()
params = App.ControllerForm.params( el ) params = App.ControllerForm.params(el)
test_params = { test_params = {
searchable_select1: 'ccc', searchable_select1: 'ccc',
searchable_select2: 'bbb', searchable_select2: 'bbb',
searchable_select3: '', searchable_select3: '',
} }
deepEqual( params, test_params, 'form param check' ) deepEqual(params, test_params, 'form param check')
$('[name="searchable_select2"].js-shadow + .js-input').focus().val('').trigger('input') $('[name="searchable_select2"].js-shadow + .js-input').focus().val('').trigger('input')
var $element = $('[name="searchable_select2"]').closest('.searchableSelect').find('.js-optionsList') var $element = $('[name="searchable_select2"]').closest('.searchableSelect').find('.js-optionsList')
@ -79,13 +79,13 @@ test( "searchable_select check", function() {
equal(entries, 1, 'dropdown count') equal(entries, 1, 'dropdown count')
$element.find('li:not(.is-hidden)').first().click() $element.find('li:not(.is-hidden)').first().click()
params = App.ControllerForm.params( el ) params = App.ControllerForm.params(el)
test_params = { test_params = {
searchable_select1: 'ccc', searchable_select1: 'ccc',
searchable_select2: 'ccc', searchable_select2: 'ccc',
searchable_select3: '', searchable_select3: '',
} }
deepEqual( params, test_params, 'form param check' ) deepEqual(params, test_params, 'form param check')
$('[name="searchable_select3"].js-shadow + .js-input').focus().val('').trigger('input') $('[name="searchable_select3"].js-shadow + .js-input').focus().val('').trigger('input')
var $element = $('[name="searchable_select3"]').closest('.searchableSelect').find('.js-optionsList') var $element = $('[name="searchable_select3"]').closest('.searchableSelect').find('.js-optionsList')
@ -105,12 +105,12 @@ test( "searchable_select check", function() {
e.keyCode = 13 e.keyCode = 13
$('[name="searchable_select3"].js-shadow + .js-input').trigger(e) $('[name="searchable_select3"].js-shadow + .js-input').trigger(e)
params = App.ControllerForm.params( el ) params = App.ControllerForm.params(el)
test_params = { test_params = {
searchable_select1: 'ccc', searchable_select1: 'ccc',
searchable_select2: 'ccc', searchable_select2: 'ccc',
searchable_select3: 'unknown value', searchable_select3: 'unknown value',
} }
deepEqual( params, test_params, 'form param check' ) deepEqual(params, test_params, 'form param check')
}); });

View file

@ -0,0 +1,194 @@
test("form elements check", function() {
$('#forms').append('<hr><h1>form elements check</h1><form id="form1"></form>')
var el = $('#form1')
new App.ControllerForm({
el: el,
model: {
"configure_attributes": [
{
"name": "tree_select",
"display": "tree_select",
"tag": "tree_select",
"null": true,
"translate": true,
"options": [
{
"value": "aa",
"name": "yes",
"children": [
{
"value": "aa::aaa",
"name": "yes1",
},
{
"value": "aa::aab",
"name": "yes2",
},
{
"value": "aa::aac",
"name": "yes3",
},
]
},
{
"value": "bb",
"name": "bb (comment)",
"children": [
{
"value": "bb::bba",
"name": "yes11",
},
{
"value": "bb::bbb",
"name": "yes22",
},
{
"value": "bb::bbc",
"name": "yes33",
},
]
},
],
}
]
},
autofocus: true
});
equal(el.find('[name="tree_select"]').val(), '', 'check tree_select value');
equal(el.find('[name="tree_select"]').closest('.searchableSelect').find('.js-input').val(), '', 'check tree_select .js-input value');
var params = App.ControllerForm.params(el)
var test_params = {
tree_select: ''
}
deepEqual(params, test_params, 'form param check')
$('#forms').append('<hr><h1>form elements check</h1><form id="form2"></form>')
var el = $('#form2')
new App.ControllerForm({
el: el,
model: {
"configure_attributes": [
{
"name": "tree_select",
"display": "tree_select",
"tag": "tree_select",
"null": true,
"translate": true,
"value": "aa",
"options": [
{
"value": "aa",
"name": "yes",
"children": [
{
"value": "aa::aaa",
"name": "yes1",
},
{
"value": "aa::aab",
"name": "yes2",
},
{
"value": "aa::aac",
"name": "yes3",
},
]
},
{
"value": "bb",
"name": "bb (comment)",
"children": [
{
"value": "bb::bba",
"name": "yes11",
},
{
"value": "bb::bbb",
"name": "yes22",
},
{
"value": "bb::bbc",
"name": "yes33",
},
]
},
],
}
]
},
autofocus: true
});
equal(el.find('[name="tree_select"]').val(), 'aa', 'check tree_select value');
equal(el.find('[name="tree_select"]').closest('.searchableSelect').find('.js-input').val(), 'yes', 'check tree_select .js-input value');
var params = App.ControllerForm.params(el)
var test_params = {
tree_select: 'aa'
}
deepEqual(params, test_params, 'form param check')
$('#forms').append('<hr><h1>form elements check</h1><form id="form3"></form>')
var el = $('#form3')
new App.ControllerForm({
el: el,
model: {
"configure_attributes": [
{
"name": "tree_select",
"display": "tree_select",
"tag": "tree_select",
"null": true,
"translate": true,
"value": "aa::aab",
"options": [
{
"value": "aa",
"name": "yes",
"children": [
{
"value": "aa::aaa",
"name": "yes1",
},
{
"value": "aa::aab",
"name": "yes2",
},
{
"value": "aa::aac",
"name": "yes3",
},
]
},
{
"value": "bb",
"name": "bb (comment)",
"children": [
{
"value": "bb::bba",
"name": "yes11",
},
{
"value": "bb::bbb",
"name": "yes22",
},
{
"value": "bb::bbc",
"name": "yes33",
},
]
},
],
}
]
},
autofocus: true
});
equal(el.find('[name="tree_select"]').val(), 'aa::aab', 'check tree_select value');
equal(el.find('[name="tree_select"]').closest('.searchableSelect').find('.js-input').val(), 'yes2', 'check tree_select .js-input value');
var params = App.ControllerForm.params(el)
var test_params = {
tree_select: 'aa::aab'
}
deepEqual(params, test_params, 'form param check')
});

View file

@ -87,6 +87,13 @@ class AAbUnitTest < TestCase
value: '0', value: '0',
) )
location(url: browser_url + '/tests_form_tree_select')
sleep 2
match(
css: '.result .failed',
value: '0',
)
location(url: browser_url + '/tests_form_column_select') location(url: browser_url + '/tests_form_column_select')
sleep 2 sleep 2
match( match(