Browse Source

Fixes #4766 - Add new Object Manager Attribute: External Data Source (JSON).

Co-authored-by: Dusan Vuckovic <dv@zammad.com>
Co-authored-by: Martin Gruner <mg@zammad.com>
Co-authored-by: Vladimir Sheremet <vs@zammad.com>
Co-authored-by: Mantas Masalskis <mm@zammad.com>
Co-authored-by: Dominik Klein <dk@zammad.com>
Co-authored-by: Rolf Schmidt <rolf.schmidt@zammad.com>
Co-authored-by: Florian Liebe <fl@zammad.com>
Co-authored-by: Tobias Schäfer <ts@zammad.com>
Dominik Klein 1 year ago
parent
commit
22a903948c

+ 3 - 0
app/assets/javascripts/app/controllers/_ui_element/_application_action.coffee

@@ -91,6 +91,9 @@ class App.UiElement.ApplicationAction
             if !row.readonly
               config = _.clone(row)
 
+              config.objectName    = groupMeta.model
+              config.attributeName = config.name
+
               # disable uploads in richtext attributes
               if attribute.no_richtext_uploads
                 if config.tag is 'richtext'

+ 13 - 8
app/assets/javascripts/app/controllers/_ui_element/_application_selector.coffee

@@ -32,6 +32,7 @@ class App.UiElement.ApplicationSelector
       '^multiselect$': [__('contains all'), __('contains one'), __('contains all not'), __('contains one not')]
       '^tree_select$': [__('is'), __('is not')]
       '^multi_tree_select$': [__('contains all'), __('contains one'), __('contains all not'), __('contains one not')]
+      '^autocompletion_ajax_external_data_source$': [__('is'), __('is not')]
       '^input$': [__('contains'), __('contains not'), __('is any of'), __('is none of'), __('starts with one of'), __('ends with one of')]
       '^richtext$': [__('contains'), __('contains not')]
       '^textarea$': [__('contains'), __('contains not')]
@@ -49,6 +50,7 @@ class App.UiElement.ApplicationSelector
         '^multiselect$': [__('contains all'), __('contains one'), __('contains all not'), __('contains one not')]
         '^tree_select$': [__('is'), __('is not'), __('has changed')]
         '^multi_tree_select$': [__('contains all'), __('contains one'), __('contains all not'), __('contains one not')]
+        '^autocompletion_ajax_external_data_source$': [__('is'), __('is not'), __('has changed')]
         '^input$': [__('contains'), __('contains not'), __('has changed'), __('is any of'), __('is none of'), __('starts with one of'), __('ends with one of')]
         '^richtext$': [__('contains'), __('contains not'), __('has changed')]
         '^textarea$': [__('contains'), __('contains not'), __('has changed')]
@@ -123,12 +125,15 @@ class App.UiElement.ApplicationSelector
         attributesByObject = App.ObjectManagerAttribute.selectorAttributesByObject()
         configureAttributes = attributesByObject[groupMeta.model] || []
         for config in configureAttributes
+          config.objectName    = groupMeta.model
+          config.attributeName = config.name
+
           # ignore passwords and relations
           if config.type isnt 'password' && config.name.substr(config.name.length-4,4) isnt '_ids' && config.searchable isnt false
             config.default  = undefined
             if config.type is 'email' || config.type is 'tel' || config.type is 'url'
               config.type = 'text'
-            if config.tag is 'select'
+            if config.tag is 'select' or config.tag is 'autocompletion_ajax_external_data_source'
               config.multiple = true
             for operatorRegEx, operator of operators_type
               myRegExp = new RegExp(operatorRegEx, 'i')
@@ -238,11 +243,11 @@ class App.UiElement.ApplicationSelector
     )
 
     # remove filter
-    item.off('click.application_selector', '.js-remove').on('click.application_selector', '.js-remove', (e) =>
+    item.off('click.application_selector', '.filter-control.js-remove').on('click.application_selector', '.filter-control.js-remove', (e) =>
       return if $(e.currentTarget).hasClass('is-disabled')
 
       if @hasEmptySelectorAtStart()
-        if item.find('.js-remove').length > 1
+        if item.find('.filter-control.js-remove').length > 1
           $(e.target).closest('.js-filterElement').remove()
         else
           $(e.target).closest('.js-filterElement').find('div.horizontal-filter-body').html(@emptyBody(attribute))
@@ -400,15 +405,15 @@ class App.UiElement.ApplicationSelector
   # disable - if we only have one attribute
   @disableRemoveForOneAttribute: (elementFull) ->
     if @hasEmptySelectorAtStart()
-      if elementFull.find('div.horizontal-filter-body input.empty:hidden').length > 0 && elementFull.find('.js-remove').length < 2
-        elementFull.find('.js-remove').addClass('is-disabled')
+      if elementFull.find('div.horizontal-filter-body input.empty:hidden').length > 0 && elementFull.find('.filter-control.js-remove').length < 2
+        elementFull.find('.filter-control.js-remove').addClass('is-disabled')
       else
-        elementFull.find('.js-remove').removeClass('is-disabled')
+        elementFull.find('.filter-control.js-remove').removeClass('is-disabled')
     else
       if elementFull.find('.js-attributeSelector select').length > 1
-        elementFull.find('.js-remove').removeClass('is-disabled')
+        elementFull.find('.filter-control.js-remove').removeClass('is-disabled')
       else
-        elementFull.find('.js-remove').addClass('is-disabled')
+        elementFull.find('.filter-control.js-remove').addClass('is-disabled')
 
   @updateAttributeSelectors: (elementFull) ->
     if !@hasDuplicateSelector()

+ 22 - 0
app/assets/javascripts/app/controllers/_ui_element/autocompletion_ajax_external_data_source.coffee

@@ -0,0 +1,22 @@
+# coffeelint: disable=camel_case_classes
+class App.UiElement.autocompletion_ajax_external_data_source
+  @render: (attributeConfig, params = {}, form) ->
+    attribute = $.extend(true, {}, attributeConfig)
+
+    # selectable search
+    searchableAjaxSelectObject = new App.ExternalDataSourceAjaxSelect(
+      delegate:        form
+      attribute:
+        value:         params[attribute.name] || attribute.value
+        name:          attribute.name
+        id:            attribute.id
+        placeholder:   App.i18n.translateInline('Search…')
+        limit:         40
+        relation:      attribute.relation
+        ajax:          true
+        multiple:      attribute.multiple
+        showArrowIcon: true
+        attributeName: attribute.attributeName || attribute.name
+        objectName:    attribute.objectName || form?.model?.className
+    )
+    searchableAjaxSelectObject.element()

+ 5 - 1
app/assets/javascripts/app/controllers/_ui_element/core_workflow_condition.coffee

@@ -72,6 +72,7 @@ class App.UiElement.core_workflow_condition extends App.UiElement.ApplicationSel
       '^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')]
       '^multi_tree_select$': [__('contains'), __('contains not'), __('contains all'), __('contains all not'), __('is set'), __('not set'), __('has changed'), __('changed to')]
+      '^autocompletion_ajax_external_data_source$': [__('is'), __('is not'), __('is set'), __('not set'), __('has changed'), __('changed to')]
       '^input$': [__('is any of'), __('is none of'), __('starts with one of'), __('ends with one of'), __('matches regex'), __('does not match regex'), __('is set'), __('not set'), __('has changed'), __('changed to')]
       '^(textarea|richtext)$': [__('is'), __('is not'), __('starts with'), __('ends with'), __('matches regex'), __('does not match regex'), __('is set'), __('not set'), __('has changed'), __('changed to')]
       '^tag$': [__('contains all'), __('contains one'), __('contains all not'), __('contains one not')]
@@ -177,12 +178,15 @@ class App.UiElement.core_workflow_condition extends App.UiElement.ApplicationSel
       for config in configureAttributes
         continue if groupKey is 'group' && _.contains(['name'], config.name)
 
+        config.objectName    = groupMeta.model
+        config.attributeName = config.name
+
         # ignore passwords and relations
         if config.type isnt 'password' && config.name.substr(config.name.length-4,4) isnt '_ids' && config.searchable isnt false
           config.default  = undefined
           if config.type is 'email' || config.type is 'tel' || config.type is 'url'
             config.type = 'text'
-          if config.tag && config.tag.match(/^(tree_)?select$/)
+          if config.tag && config.tag.match(/^(tree_)?select$/) or config.tag is 'autocompletion_ajax_external_data_source'
             config.multiple = true
           for operatorRegEx, operator of operatorsType
             myRegExp = new RegExp(operatorRegEx, 'i')

+ 2 - 1
app/assets/javascripts/app/controllers/_ui_element/core_workflow_perform.coffee

@@ -43,6 +43,7 @@ class App.UiElement.core_workflow_perform extends App.UiElement.ApplicationSelec
       '^(multi)?select$': ['show', 'hide', 'remove', 'set_mandatory', 'set_optional', 'set_readonly', 'unset_readonly', 'add_option', 'remove_option', 'set_fixed_to', 'select', 'auto_select']
       '^(multi_)?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']
+      '^autocompletion_ajax_external_data_source$': ['show', 'hide', 'remove', 'set_mandatory', 'set_optional', 'set_readonly', 'unset_readonly']
 
     operatorsName =
       '_id$': ['show', 'hide', 'set_mandatory', 'set_optional', 'set_readonly', 'unset_readonly', 'add_option', 'remove_option', 'set_fixed_to', 'select', 'auto_select']
@@ -71,7 +72,7 @@ class App.UiElement.core_workflow_perform extends App.UiElement.ApplicationSelec
         configureAttributes.splice(_.findIndex(configureAttributes, (e) -> e.name is 'title') + 1, 0, { name: 'body', display: __('Text'), data_type: 'richtext', tag: 'richtext', rows: 5, limit: 100, null: false })
 
       for config in configureAttributes
-        continue if !_.contains(['input', 'textarea', 'richtext', 'select', 'multiselect', 'integer', 'boolean', 'multi_tree_select', 'tree_select', 'date', 'datetime'], config.tag)
+        continue if !_.contains(['input', 'textarea', 'richtext', 'select', 'multiselect', 'integer', 'boolean', 'multi_tree_select', 'tree_select', 'autocompletion_ajax_external_data_source', 'date', 'datetime'], config.tag)
         continue if _.contains(['created_at', 'updated_at'], config.name)
         continue if groupKey is 'ticket' && _.contains(['number', 'organization_id', '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'], config.name)
         continue if groupKey is 'group' && _.contains(['name'], config.name)

+ 335 - 3
app/assets/javascripts/app/controllers/_ui_element/object_manager_attribute.coffee

@@ -69,14 +69,27 @@ class App.UiElement.object_manager_attribute extends App.UiElement.ApplicationUi
         name: __('Multiple tree selection field'),
         value: 'multi_tree_select',
       },
+      {
+        name: __('External data source field'),
+        value: 'autocompletion_ajax_external_data_source',
+        filterCallback: -> App.Config.get('column_type_json_supported')
+      },
     ]
 
     # if attribute already exists, do not allow to change it anymore
     if params.data_type
       options = _.filter(options, (option) -> option.value is params.data_type)
+      attribute.disabled = true
+
+    # Filter out unsupported options by executing an optional callback.
+    filterOptions = (options) -> _.filter(options, (option) ->
+      return option.filterCallback() if typeof option.filterCallback is 'function'
+
+      true
+    )
 
     configureAttributes = [
-      { name: attribute.name, display: '', tag: 'select', null: false, options: options, translate: true, default: 'input', disabled: attribute.disabled },
+      { name: attribute.name, display: '', tag: 'select', null: false, options: options, translate: true, default: 'input', disabled: attribute.disabled, filter: filterOptions },
     ]
     dataType = new App.ControllerForm(
       el: item.find('.js-dataType')
@@ -251,7 +264,7 @@ class App.UiElement.object_manager_attribute extends App.UiElement.ApplicationUi
     )
     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=#{object.attribute_name} - use ticket, user or organization as object') },
+      { name: 'data_option::linktemplate', display: __('Link template'), tag: 'input', type: 'text', null: true, default: '', placeholder: __('https://example.com/?q=#{object.attribute_name} - use ticket, user or organization as object') },
       # coffeelint: enable=no_interpolation_in_single_quotes
     ]
     inputLinkTemplate = new App.ControllerForm(
@@ -422,7 +435,7 @@ class App.UiElement.object_manager_attribute extends App.UiElement.ApplicationUi
     )
     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}' },
+      { 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(
@@ -683,6 +696,325 @@ class App.UiElement.object_manager_attribute extends App.UiElement.ApplicationUi
     item.find('.js-autocompletionUrl').html(autocompletionUrl.form)
     item.find('.js-autocompletionMethod').html(autocompletionMethod.form)
 
+  @autocompletion_ajax_external_data_source: (item, localParams, params) ->
+    configureAttributes = [
+      # coffeelint: disable=no_interpolation_in_single_quotes
+      { name: 'data_option::search_url', display: __('Search URL'), tag: 'input', type: 'text', null: false, default: '', placeholder: 'https://example.com/search?query=#{search.term}' },
+      # coffeelint: disable=no_interpolation_in_single_quotes
+    ]
+    inputSearchURL = new App.ControllerForm(
+      model:
+        configure_attributes: configureAttributes
+      noFieldset: true
+      params: params
+    )
+    item.find('.js-inputSearchURL').html(inputSearchURL.form)
+
+    configureAttributes = [
+      { name: 'data_option::verify_ssl', display: __('SSL verification'), tag: 'boolean', null: true, default: true, translate: true, },
+    ]
+    inputSSLVerify = new App.ControllerForm(
+      model:
+        configure_attributes: configureAttributes
+      noFieldset: true
+      params: params
+    )
+    item.find('.js-inputSSLVerify').html(inputSSLVerify.form)
+
+    toggleSslVerifyAlert = (e) ->
+      elem           = $(e.target)
+      isAlertVisible = elem.val() != 'true'
+
+      elem.closest('.js-dataOption').find('.js-sslVerifyAlert').toggleClass('hide', !isAlertVisible)
+
+    item.find('select[name="data_option::verify_ssl"]')
+      .off('change.toggleSslVerifyAlert').on('change.toggleSslVerifyAlert', toggleSslVerifyAlert)
+
+    toggleSslVerifyAlert(target: item.find('select[name="data_option::verify_ssl"]'))
+
+    configureAttributes = [
+      { name: 'data_option::http_auth_type', display: __('HTTP Authentication'), tag: 'select', null: true, nulloption: true, default: '', options: { basic_auth: __('Basic Authentication'), bearer_token: __('Authentication Token') }, translate: true },
+    ]
+
+    inputHTTPAuthType = new App.ControllerForm(
+      model:
+        configure_attributes: configureAttributes
+      noFieldset: true
+      params: params
+    )
+    item.find('.js-inputHTTPAuthType').html(inputHTTPAuthType.form)
+
+    configureAttributes = [
+      { name: 'data_option::http_basic_auth_username', display: __('HTTP Basic Authentication username'), tag: 'input', type: 'text', null: true, default: '', placeholder: '' },
+    ]
+    inputHTTPBasicAuthUsername = new App.ControllerForm(
+      model:
+        configure_attributes: configureAttributes
+      noFieldset: true
+      params: params
+    )
+    item.find('.js-inputHTTPBasicAuthUsername').html(inputHTTPBasicAuthUsername.form)
+
+    configureAttributes = [
+      { name: 'data_option::http_basic_auth_password', display: __('HTTP Basic Authentication password'), tag: 'input', type: 'text', null: true, default: '', placeholder: '' },
+    ]
+    inputHTTPBasicAuthPassword = new App.ControllerForm(
+      model:
+        configure_attributes: configureAttributes
+      noFieldset: true
+      params: params
+    )
+    item.find('.js-inputHTTPBasicAuthPassword').html(inputHTTPBasicAuthPassword.form)
+
+    configureAttributes = [
+      { name: 'data_option::bearer_token_auth', display: __('HTTP Authentication token'), tag: 'input', type: 'text', null: true, default: '', placeholder: '' },
+    ]
+    inputBearerTokenAuth = new App.ControllerForm(
+      model:
+        configure_attributes: configureAttributes
+      noFieldset: true
+      params: params
+    )
+    item.find('.js-inputBearerTokenAuth').html(inputBearerTokenAuth.form)
+
+    toggleHTTPAuthFields = (value) ->
+      switch value
+        when 'basic_auth'
+          inputHTTPBasicAuthUsername.show('data_option::http_basic_auth_username')
+          inputHTTPBasicAuthPassword.show('data_option::http_basic_auth_password')
+          inputHTTPBasicAuthPassword.show('data_option::http_basic_auth_password_confirm')
+          inputBearerTokenAuth.hide('data_option::bearer_token_auth', undefined, true)
+        when 'bearer_token'
+          inputHTTPBasicAuthUsername.hide('data_option::http_basic_auth_username', undefined, true)
+          inputHTTPBasicAuthPassword.hide('data_option::http_basic_auth_password', undefined, true)
+          inputHTTPBasicAuthPassword.hide('data_option::http_basic_auth_password_confirm', undefined, true)
+          inputBearerTokenAuth.show('data_option::bearer_token_auth')
+        else
+          inputHTTPBasicAuthUsername.hide('data_option::http_basic_auth_username', undefined, true)
+          inputHTTPBasicAuthPassword.hide('data_option::http_basic_auth_password', undefined, true)
+          inputHTTPBasicAuthPassword.hide('data_option::http_basic_auth_password_confirm', undefined, true)
+          inputBearerTokenAuth.hide('data_option::bearer_token_auth', undefined, true)
+
+    item.find("select[name='data_option::http_auth_type']").on('change', (e) ->
+      value = $(e.target).val()
+      toggleHTTPAuthFields(value)
+    )
+
+    toggleHTTPAuthFields(params?.data_option?.http_auth_type)
+
+    configureAttributes = [
+      { name: 'data_option::search_result_list_key', display: __('Search result list key'), tag: 'input', type: 'text', null: true, default: '', placeholder: '' },
+    ]
+    inputSearchResultListKey = new App.ControllerForm(
+      model:
+        configure_attributes: configureAttributes
+      noFieldset: true
+      params: params
+    )
+    item.find('.js-inputSearchResultListKey').html(inputSearchResultListKey.form)
+
+    configureAttributes = [
+      { name: 'data_option::search_result_value_key', display: __('Search result value key'), tag: 'input', type: 'text', null: true, default: '', placeholder: '' },
+    ]
+    inputSearchResultValueKey = new App.ControllerForm(
+      model:
+        configure_attributes: configureAttributes
+      noFieldset: true
+      params: params
+    )
+    item.find('.js-inputSearchResultValueKey').html(inputSearchResultValueKey.form)
+
+    configureAttributes = [
+      { name: 'data_option::search_result_label_key', display: __('Search result label key'), tag: 'input', type: 'text', null: true, default: '', placeholder: '' },
+    ]
+    inputSearchResultLabelKey = new App.ControllerForm(
+      model:
+        configure_attributes: configureAttributes
+      noFieldset: true
+      params: params
+    )
+    item.find('.js-inputSearchResultLabelKey').html(inputSearchResultLabelKey.form)
+
+    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: disable=no_interpolation_in_single_quotes
+    ]
+    inputLinkTemplate = new App.ControllerForm(
+      model:
+        configure_attributes: configureAttributes
+      noFieldset: true
+      params: params
+    )
+    item.find('.js-inputLinkTemplate').html(inputLinkTemplate.form)
+
+    configureAttributes = [
+      { id: 'inputSearchTerm', name: 'query', display: __('Preview'), tag: 'input', type: 'text', null: true, default: '', placeholder: __('Search…') },
+    ]
+    inputSearchTerm = new App.ControllerForm(
+      model:
+        configure_attributes: configureAttributes
+      noFieldset: true
+    )
+    item.find('.js-inputSearchTerm').html(inputSearchTerm.form)
+
+    debouncedPreviewExternalDataSource = _.debounce(@previewExternalDataSource, 300)
+
+    item.off('input.previewExternalDataSource').on('input.previewExternalDataSource', (e) ->
+      debouncedPreviewExternalDataSource(e, params.object)
+    )
+
+  @previewExternalDataSource: (e, object) =>
+    item = $(e.target).closest('.js-dataMap')
+
+    @resetExternalDataSourcePreview(item)
+
+    params = App.ControllerForm.params($(e.target).closest('form'))
+
+    return if not params.query?.trim()
+
+    item.find('.js-previewInfo')
+      .addClass('hide')
+
+    item.find('.js-loading')
+      .removeClass('hide')
+
+    data = _.extend({},
+      data_option: params.data_option,
+      query: params.query,
+      limit: 10,
+    )
+
+    App.Ajax.request(
+      id:   'previewExternalDataSource'
+      type: 'POST'
+      url:  "#{App.Config.get('api_path')}/external_data_source/preview"
+      data: JSON.stringify(data)
+      success: ({ data, parsed_items, response_body, success, error }, status, xhr) ->
+        item.find('.js-loading')
+          .addClass('hide')
+
+        if response_body
+          configureAttributes = [
+            { id: 'searchResultResponse', name: 'search_result_response', display: __('Search result response'), tag: 'code_editor', null: true, disabled: true, lineNumbers: false, height: 160, value: JSON.stringify(response_body, null, 2) },
+          ]
+          searchResultResponse = new App.ControllerForm(
+            model:
+              configure_attributes: configureAttributes
+            noFieldset: true
+          )
+          item.find('.js-searchResultResponse').html(searchResultResponse.form)
+
+        if parsed_items
+          configureAttributes = [
+            { id: 'searchResultList', name: 'search_result_list', display: __('Search result list'), tag: 'code_editor', null: true, disabled: true, lineNumbers: false, height: 160, value: JSON.stringify(parsed_items, null, 2) },
+          ]
+          searchResultList = new App.ControllerForm(
+            model:
+              configure_attributes: configureAttributes
+            noFieldset: true
+          )
+          item.find('.js-searchResultList').html(searchResultList.form)
+
+        if data
+          table = $('<table class="settings-list settings-list--stretch"><thead><tr /></thead><tbody /></table>')
+
+          if _.isEmpty(data)
+            $('<th />')
+              .text(App.i18n.translatePlain('No Entries'))
+              .appendTo(table.find('thead tr'))
+
+            table.addClass('settings-list--placeholder')
+
+            item.find('.js-searchResultSample').html(table)
+
+          else
+            $('<th />')
+              .text(App.i18n.translatePlain('Value'))
+              .appendTo(table.find('thead tr'))
+
+            $('<th />')
+              .text(App.i18n.translatePlain('Label'))
+              .appendTo(table.find('thead tr'))
+
+            if params.data_option?.linktemplate
+              $('<th />')
+                .text(App.i18n.translatePlain('Link'))
+                .addClass('centered')
+                .appendTo(table.find('thead tr'))
+
+            _.each(data, (searchItem) ->
+              resultValue = $('<td />')
+                .addClass('search-result-value')
+                .text(searchItem.value)
+
+              resultLabel = $('<td />')
+                .addClass('search-result-label')
+                .text(searchItem.label)
+
+              if params.data_option?.linktemplate
+                objects =
+                  user: App.Session.get()
+                  config: App.Config.all()
+
+                  # NB: Simulate the current attribute to certain extent.
+                  "#{object.toLowerCase()}":
+                    "#{params.name}":
+                      searchItem.value
+
+                link = App.Utils.replaceTags(params.data_option?.linktemplate, objects)
+
+                resultLinkIcon = $('<svg class="icon icon-external"><use xlink:href="assets/images/icons.svg#icon-external" /></svg>')
+                resultLinkAnchor = $('<a />')
+                  .addClass('settings-list-control')
+                  .attr('href', link)
+                  .attr('target', '_blank')
+                  .append(resultLinkIcon)
+                resultLink = $('<td />')
+                  .addClass('search-result-link settings-list-controls')
+                  .append(resultLinkAnchor)
+              else
+                $('<span />')
+                  .text(searchItem.label)
+
+              $('<tr />')
+                .attr('data-id', searchItem.value)
+                .append(resultValue)
+                .append(resultLabel)
+                .append(resultLink)
+                .appendTo(table.find('tbody'))
+            )
+
+            item.find('.js-searchResultSample').html(table)
+
+        if not success
+          item.find('.js-previewError')
+            .text(App.i18n.translatePlain(error))
+            .removeClass('hide')
+
+      error: (data, status, message) ->
+        item.find('.js-previewError')
+          .text(App.i18n.translatePlain('An error occurred: %s', message))
+          .removeClass('hide')
+
+        item.find('.js-loading')
+          .addClass('hide')
+    )
+
+  @resetExternalDataSourcePreview: (item) ->
+    item.find('.js-searchResultResponse')
+      .empty()
+    item.find('.js-searchResultList')
+      .empty()
+    item.find('.js-searchResultSample')
+      .empty()
+    item.find('.js-previewError')
+      .addClass('hide')
+    item.find('.js-loading')
+      .addClass('hide')
+    item.find('.js-previewInfo')
+      .removeClass('hide')
+
   @addDragAndDrop: (item, callback) ->
     dndOptions =
         tolerance:            'pointer'

+ 5 - 5
app/assets/javascripts/app/controllers/_ui_element/ticket_selector.coffee

@@ -184,7 +184,7 @@ class App.UiElement.ticket_selector extends App.UiElement.ApplicationSelector
     )
 
     # remove filter
-    item.off('click.application_selector', '.js-remove').on('click.application_selector', '.js-remove', (e) =>
+    item.off('click.application_selector', '.filter-control.js-remove').on('click.application_selector', '.filter-control.js-remove', (e) =>
       return if $(e.currentTarget).hasClass('is-disabled')
 
       element = $(e.target).closest('.js-filterElement')
@@ -497,9 +497,9 @@ class App.UiElement.ticket_selector extends App.UiElement.ApplicationSelector
     conditions = elementFull.find('.js-filterElement').not('[data-subclause]')
 
     if conditions.length > 1
-      conditions.find('.js-remove').removeClass('is-disabled')
+      conditions.find('.filter-control.js-remove').removeClass('is-disabled')
     else
-      conditions.find('.js-remove').addClass('is-disabled')
+      conditions.find('.filter-control.js-remove').addClass('is-disabled')
 
     subclauses = elementFull.find('.js-filterElement[data-subclause][data-level]')
 
@@ -511,9 +511,9 @@ class App.UiElement.ticket_selector extends App.UiElement.ApplicationSelector
       , '.js-filterElement').not('[data-subclause]')
 
       if nestedConditions.length and conditions.length is nestedConditions.length
-        subclause.find('.js-remove').addClass('is-disabled')
+        subclause.find('.filter-control.js-remove').addClass('is-disabled')
       else
-        subclause.find('.js-remove').removeClass('is-disabled')
+        subclause.find('.filter-control.js-remove').removeClass('is-disabled')
 
   @toggleSubclauseDisableOnMaxLevels: (elementFull) ->
     return if !@maxNestedLevels()

+ 12 - 1
app/assets/javascripts/app/index.coffee

@@ -51,9 +51,18 @@ class App extends Spine.Controller
 
   # define print name helper
   @viewPrintItem: (item, attributeConfig = {}, valueRef, table, object) ->
+
+    # Show all "empty" values as a simple dash (-):
+    #   - undefined
+    #   - empty string
+    #   - null
+    #   - empty object ({})
+    #   - empty array ([] or [''])
     return '-' if item is undefined
     return '-' if item is ''
     return '-' if item is null
+    return '-' if typeof item isnt 'function' and _.isObject(item) and _.isEmpty(item)
+    return '-' if _.isArray(item) and (_.isEmpty(item) or _.isEmpty(_.filter(item, (i) -> i isnt '')))
     result = ''
     items = [item]
     if _.isArray(item)
@@ -86,8 +95,10 @@ class App extends Spine.Controller
           resultLocal = item.displayNameLong()
         else if item.displayName
           resultLocal = item.displayName()
-        else
+        else if not _.isUndefined(item.name)
           resultLocal = item.name
+        else
+          resultLocal = item.label
 
       # execute callback on content
       if attributeConfig.callback

+ 4 - 2
app/assets/javascripts/app/lib/app_post/code_editor.coffee

@@ -157,10 +157,10 @@ class App.CodeEditor extends App.Controller
   editorOptions: =>
     autoCloseBrackets: true
     autofocus: @attribute.autofocus
-    gutters: ['CodeMirror-lint-markers'],
+    gutters: if _.isUndefined(@attribute.lineNumbers) or @attribute.lineNumbers then ['CodeMirror-lint-markers'] else [],
     hintOptions: @hintOptions()
     inputStyle: 'contenteditable'
-    lineNumbers: true
+    lineNumbers: if _.isUndefined(@attribute.lineNumbers) then true else @attribute.lineNumbers
     lint:
       skipEmpty: @attribute.null
     matchBrackets: true
@@ -180,6 +180,8 @@ class App.CodeEditor extends App.Controller
 
     @editor = CodeMirror(callback, @editorOptions())
 
+    @editor.setSize(null, @attribute.height) if @attribute.height
+
     @editor.on('change', _.throttle(@update, 300))
     @editor.on('cursorActivity', => @editor.showHint())
 

+ 10 - 8
app/assets/javascripts/app/lib/app_post/searchable_select.coffee

@@ -42,11 +42,14 @@ class App.SearchableSelect extends Spine.Controller
     @render()
 
   render: ->
+    @renderElement()
+
+  renderElement: =>
     @updateAttributeOptionDisplayName(@attribute.options)
     @updateAttributeValueName()
 
     tokens = ''
-    if @attribute.multiple && @attribute.value
+    if @attribute.multiple && @attribute.value && @attribute.valueType isnt 'json'
       relation = @attribute.relation
 
       # fallback for if the value is not an array
@@ -84,7 +87,7 @@ class App.SearchableSelect extends Spine.Controller
       attribute: @attribute
       options: @renderAllOptions('', @attribute.options, 0)
       submenus: @renderSubmenus(@attribute.options)
-      tokens: tokens
+      tokens: @attribute.existingTokens or tokens
 
     @input.get(0).selectValue = @selectValue
 
@@ -363,7 +366,7 @@ class App.SearchableSelect extends Spine.Controller
 
   resetSearch: =>
     @input.val('')
-      .attr('title', '')
+      .removeAttr('title')
     @onInput(null, false)
 
   selectValue: (value, currentText, displayName) =>
@@ -375,7 +378,6 @@ class App.SearchableSelect extends Spine.Controller
     @attribute.valueName = currentText
     @attribute.value = value
 
-
   selectItem: (event) ->
     if $(event.target).hasClass('is-inactive')
       event.stopPropagation()
@@ -532,13 +534,13 @@ class App.SearchableSelect extends Spine.Controller
     if @currentItem || !@attribute.unknown
       return if @currentItem.hasClass('has-inactive')
 
+      valueName   = @currentItem.children('span.searchableSelect-option-text').text().trim()
       value       = @currentItem.attr('data-value')
-      name        = @currentItem.text().trim()
       displayName = @currentItem.data('displayName')
       if @attribute.multiple
-        @addValueToShadowInput(name, value)
+        @addValueToShadowInput(valueName, value)
       else
-        @selectValue(value, name, displayName)
+        @selectValue(value, valueName, displayName)
         @markSelected(value)
         @toggleClear()
 
@@ -561,7 +563,7 @@ class App.SearchableSelect extends Spine.Controller
 
     # Clear the input value so the user can start searching immediately (#4830).
     @input.val('')
-      .attr('title', '')
+      .removeAttr('title')
 
   # propergate focus to our visible input
   onShadowFocus: ->

Some files were not shown because too many files changed in this diff