Merge branch 'develop' of github.com:martini/zammad into develop

This commit is contained in:
Martin Edenhofer 2015-08-05 12:42:46 +02:00
commit f57e8a5ca7
9 changed files with 188 additions and 115 deletions

View file

@ -375,7 +375,7 @@ class LayoutRefCommunicationReply extends App.ControllerContent
for i in [0..100] for i in [0..100]
setTimeout @updateUploadProgress, i*duration/100 , i setTimeout @updateUploadProgress, i*duration/100 , i
setTimeout (=> setTimeout (=>
callback() callback()
@renderAttachment(fileName, fileSize) @renderAttachment(fileName, fileSize)
), duration ), duration
@ -584,7 +584,7 @@ class ImportWizard extends App.ControllerWizard
@render() @render()
# wait 500 ms after the last user input before we check the link # wait 500 ms after the last user input before we check the link
@otrsLink.on 'input', _.debounce(@checkOtrsLink, 600) @otrsLink.on 'input', _.debounce(@checkOtrsLink, 600)
checkOtrsLink: (e) => checkOtrsLink: (e) =>
if @otrsLink.val() is "" if @otrsLink.val() is ""
@ -852,14 +852,14 @@ class highlightRef extends App.ControllerContent
# containing the offsets and the highlight classes # containing the offsets and the highlight classes
# #
# we have to check how it works with having open several tickets it might break # we have to check how it works with having open several tickets it might break
# #
# if classes can be changed in the admin interface # if classes can be changed in the admin interface
# we have to watch out to not end up with empty highlight classes # we have to watch out to not end up with empty highlight classes
storeHighlights: -> storeHighlights: ->
localStorage['highlights'] = @highlighter.serialize() localStorage['highlights'] = @highlighter.serialize()
# the colors is set via css classes (can't do it inline with rangy) # the colors is set via css classes (can't do it inline with rangy)
# thus we have to create a stylesheet if the colors # thus we have to create a stylesheet if the colors
# can be changed in the admin interface # can be changed in the admin interface
addClassApplier: (entry) -> addClassApplier: (entry) ->
@highlighter.addClassApplier rangy.createCssClassApplier(@highlightClassPrefix + entry.name) @highlighter.addClassApplier rangy.createCssClassApplier(@highlightClassPrefix + entry.name)
@ -884,7 +884,7 @@ class highlightRef extends App.ControllerContent
activate: -> activate: ->
selection = rangy.getSelection() selection = rangy.getSelection()
# if there's already something selected, # if there's already something selected,
# don't go into highlight mode # don't go into highlight mode
# just toggle the selected # just toggle the selected
if !selection.isCollapsed if !selection.isCollapsed
@ -914,10 +914,10 @@ class highlightRef extends App.ControllerContent
onMouseUp: (e) => onMouseUp: (e) =>
@toggleHighlightAtSelection $(e.currentTarget).closest @articles.selector @toggleHighlightAtSelection $(e.currentTarget).closest @articles.selector
# #
# toggle Highlight # toggle Highlight
# ================ # ================
# #
# - only works when the selection starts and ends inside an article # - only works when the selection starts and ends inside an article
# - clears highlights in selection # - clears highlights in selection
# - or highlights the selection # - or highlights the selection
@ -931,7 +931,7 @@ class highlightRef extends App.ControllerContent
else else
@highlighter.highlightSelection @highlightClass, @highlighter.highlightSelection @highlightClass,
selection: selection selection: selection
containerElementId: article.get(0).id containerElementId: article.get(0).id
# remove selection # remove selection
selection.removeAllRanges() selection.removeAllRanges()
@ -1096,7 +1096,7 @@ class cluesRef extends App.ControllerContent
showWindow: => showWindow: =>
@modalWindow.velocity @modalWindow.velocity
properties: properties:
scale: [1, 0.2] scale: [1, 0.2]
opacity: [1, 0] opacity: [1, 0]
options: options:
@ -1105,7 +1105,7 @@ class cluesRef extends App.ControllerContent
hideWindow: (callback) => hideWindow: (callback) =>
@modalWindow.velocity @modalWindow.velocity
properties: properties:
scale: [0.2, 1] scale: [0.2, 1]
opacity: 0 opacity: 0
options: options:
@ -1131,7 +1131,7 @@ class cluesRef extends App.ControllerContent
if target.right + modal.width <= maxWidth if target.right + modal.width <= maxWidth
left = target.right left = target.right
position = 'right' position = 'right'
else else
# place left # place left
left = target.left - modal.width left = target.left - modal.width
position = 'left' position = 'left'
@ -1208,7 +1208,7 @@ class cluesRef extends App.ControllerContent
getVisibleBoundingBox: (el) -> getVisibleBoundingBox: (el) ->
### ###
getBoundingClientRect doesn't take getBoundingClientRect doesn't take
absolute-positioned child nodes into account absolute-positioned child nodes into account
### ###
@ -1412,7 +1412,7 @@ class schedulersRef extends App.ControllerContent
switch items.length switch items.length
when 1 then return items[0] when 1 then return items[0]
when 2 then return "#{ items[0] } and #{ items[1] }" when 2 then return "#{ items[0] } and #{ items[1] }"
else else
return "#{ items.slice(0, -1).join(', ') } and #{ items[items.length-1] }" return "#{ items.slice(0, -1).join(', ') } and #{ items[items.length-1] }"
App.Config.set( 'layout_ref/schedulers', schedulersRef, 'Routes' ) App.Config.set( 'layout_ref/schedulers', schedulersRef, 'Routes' )
@ -1431,11 +1431,22 @@ class InputsRef extends App.ControllerContent
# selectable search # selectable search
searchableSelectObject = new App.SearchableSelect searchableSelectObject = new App.SearchableSelect
attribute: attribute:
name: 'project-name' name: 'project-name'
id: 'project-name-123' id: 'project-name-123'
placeholder: 'Enter Project Name' placeholder: 'Enter Project Name'
options: [{"value":0,"name":"Apple"},{"value":1,"name":"Microsoft","selected":true},{"value":2,"name":"Google"},{"value":3,"name":"Deutsche Bahn"},{"value":4,"name":"Sparkasse"},{"value":5,"name":"Deutsche Post"},{"value":6,"name":"Mitfahrzentrale"},{"value":7,"name":"Starbucks"},{"value":8,"name":"Mac Donalds"},{"value":9,"name":"Flixbus"},{"value":10,"name":"Betahaus"},{"value":11,"name":"Bruno Banani"},{"value":12,"name":"Alpina"},{"value":13,"name":"Samsung"},{"value":14,"name":"ChariTea"},{"value":15,"name":"fritz-kola"},{"value":16,"name":"Vitamin Water"},{"value":17,"name":"Znuny"},{"value":18,"name":"Max & Moritz"}] options: [{"value":0,"name":"Apple"},{"value":1,"name":"Microsoft","selected":true},{"value":2,"name":"Google"},{"value":3,"name":"Deutsche Bahn"},{"value":4,"name":"Sparkasse"},{"value":5,"name":"Deutsche Post"},{"value":6,"name":"Mitfahrzentrale"},{"value":7,"name":"Starbucks"},{"value":8,"name":"Mac Donalds"},{"value":9,"name":"Flixbus"},{"value":10,"name":"Betahaus"},{"value":11,"name":"Bruno Banani"},{"value":12,"name":"Alpina"},{"value":13,"name":"Samsung"},{"value":14,"name":"ChariTea"},{"value":15,"name":"fritz-kola"},{"value":16,"name":"Vitamin Water"},{"value":17,"name":"Znuny"},{"value":18,"name":"Max & Moritz"}]
@$('.searchableSelectPlaceholder').replaceWith( searchableSelectObject.el ) @$('.searchableSelectPlaceholder').replaceWith( searchableSelectObject.element() )
# selectable search
searchableAjaxSelectObject = new App.SearchableAjaxSelect
attribute:
name: 'user'
id: 'user-123'
placeholder: 'Enter User'
limt: 10
object: 'User'
@$('.searchableAjaxSelectPlaceholder').replaceWith( searchableAjaxSelectObject.element() )
# time and timeframe # time and timeframe
@$('.time').timepicker() @$('.time').timepicker()
@ -1515,7 +1526,7 @@ class calendarSubscriptionsRef extends App.ControllerContent
switch items.length switch items.length
when 1 then return items[0] when 1 then return items[0]
when 2 then return "#{ items[0] } and #{ items[1] }" when 2 then return "#{ items[0] } and #{ items[1] }"
else else
return "#{ items.slice(0, -1).join(', ') } and #{ items[items.length-1] }" return "#{ items.slice(0, -1).join(', ') } and #{ items[items.length-1] }"

View file

@ -26,7 +26,6 @@ class App.SearchableSelect extends Spine.Controller
@render() @render()
render: -> render: ->
console.log "options", @options
firstSelected = _.find @options.attribute.options, (option) -> option.selected firstSelected = _.find @options.attribute.options, (option) -> option.selected
if firstSelected if firstSelected

View file

@ -14,7 +14,7 @@ class App.UserOrganizationAutocompletion extends App.Controller
@key = Math.floor( Math.random() * 999999 ).toString() @key = Math.floor( Math.random() * 999999 ).toString()
if !@attribute.source if !@attribute.source
@attribute.source = @apiPath + '/search_user_org' @attribute.source = @apiPath + '/search/user-organization'
@build() @build()
element: => element: =>

View file

@ -3,12 +3,81 @@ class App.SearchableAjaxSelect extends App.SearchableSelect
onInput: (event) => onInput: (event) =>
super super
# send ajax request @query # convert requested object
# e.g. Ticket to ticket or AnotherObject to another_object
objectString = underscored( @options.attribute.object )
onAjaxResponse: (data) => # create common accessors
@apiPath = App.Config.get('api_path')
# create cache and cache key
@searchResultCache = @searchResultCache || {}
@cacheKey = "#{objectString}+#{@query}"
# use cache for search result
if @searchResultCache[@cacheKey]
return @onAjaxResponse( @searchResultCache[@cacheKey] )
# add timout for loader icon
clearTimeout @loaderTimeoutId
@loaderTimeoutId = setTimeout @showLoader, 1000
# start search request and update options
App.Ajax.request(
id: @options.attribute.id
type: 'GET'
url: "#{@apiPath}/search/#{objectString}"
data:
query: @query
limit: @options.attribute.limit
processData: true
success: @onAjaxResponse
)
onAjaxResponse: (data, status, xhr) =>
# clear timout and remove loader icon
clearTimeout @loaderTimeoutId
@el.removeClass('is-loading')
# cache search result
@searchResultCache[@cacheKey] = data
# load assets
App.Collection.loadAssets( data.assets )
# get options from search result
options = []
for object in data.result
if object.type is 'Ticket'
ticket = App.Ticket.find( object.id )
data =
name: "##{ticket.number} - #{ticket.title}"
value: ticket.id
options.push data
else if object.type is 'User'
user = App.User.find( object.id )
data =
name: "#{user.displayName()}"
value: user.id
options.push data
else if object.type is 'Organization'
organization = App.Organization.find( object.id )
data =
name: "#{organization.displayName()}"
value: organization.id
options.push data
# fill template with gathered options
@optionsList.html App.view('generic/searchable_select_options') @optionsList.html App.view('generic/searchable_select_options')
options: data options: options
# refresh elements
@refreshElements() @refreshElements()
@filterByQuery @query # execute filter
@filterByQuery @query
showLoader: =>
@el.addClass('is-loading')

View file

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

View file

@ -66,6 +66,10 @@
<div class="searchableSelectPlaceholder"></div> <div class="searchableSelectPlaceholder"></div>
</div> </div>
<div class="select form-group">
<label for="b">Users (searchable ajax)</label>
<div class="searchableAjaxSelectPlaceholder"></div>
</div>
<h2>Checkbox</h2> <h2>Checkbox</h2>
<div class="checkbox form-group"> <div class="checkbox form-group">

View file

@ -155,7 +155,7 @@ function clone(item, full) {
function clone2(item) { function clone2(item) {
if (!item) { return item; } // null, undefined values check if (!item) { return item; } // null, undefined values check
var types = [ Number, String, Boolean ], var types = [ Number, String, Boolean ],
result; result;
// normalizing primitives if someone did new String('aaa'), or new Number('444'); // normalizing primitives if someone did new String('aaa'), or new Number('444');
@ -168,13 +168,13 @@ function clone2(item) {
if (typeof result == "undefined") { if (typeof result == "undefined") {
if (Object.prototype.toString.call( item ) === "[object Array]") { if (Object.prototype.toString.call( item ) === "[object Array]") {
result = []; result = [];
item.forEach(function(child, index, array) { item.forEach(function(child, index, array) {
result[index] = clone( child ); result[index] = clone( child );
}); });
} else if (typeof item == "object") { } else if (typeof item == "object") {
// testing that this is DOM // testing that this is DOM
if (item.nodeType && typeof item.cloneNode == "function") { if (item.nodeType && typeof item.cloneNode == "function") {
var result = item.cloneNode( true ); var result = item.cloneNode( true );
} else if (!item.prototype) { // check that this is a literal } else if (!item.prototype) { // check that this is a literal
if (item instanceof Date) { if (item instanceof Date) {
result = new Date(item); result = new Date(item);
@ -203,6 +203,11 @@ function clone2(item) {
return result; return result;
} }
// taken from https://github.com/epeli/underscore.string/blob/master/underscored.js
function underscored (str) {
return str.trim().replace(/([a-z\d])([A-Z]+)/g, '$1_$2').replace(/[-\s]+/g, '_').toLowerCase();
}
jQuery.event.special.remove = { jQuery.event.special.remove = {
remove: function(e) { remove: function(e) {
if (e.handler) e.handler(); if (e.handler) e.handler();

View file

@ -3,8 +3,9 @@
class SearchController < ApplicationController class SearchController < ApplicationController
before_action :authentication_check before_action :authentication_check
# GET /api/v1/search_user_org # GET|POST /api/v1/search/:objects
def search_user_org
def search_generic
# enable search only for agents and admins # enable search only for agents and admins
if !current_user.role?(Z_ROLENAME_AGENT) && !current_user.role?(Z_ROLENAME_ADMIN) if !current_user.role?(Z_ROLENAME_AGENT) && !current_user.role?(Z_ROLENAME_ADMIN)
@ -16,11 +17,15 @@ class SearchController < ApplicationController
query = params[:query] query = params[:query]
limit = params[:limit] || 10 limit = params[:limit] || 10
# convert objects string into array of class names
# e.g. user-ticket-another_object = %w( User Ticket AnotherObject )
objects = params[:objects].split('-').map(&:camelize)
# try search index backend # try search index backend
assets = {} assets = {}
result = [] result = []
if SearchIndexBackend.enabled? if SearchIndexBackend.enabled?
items = SearchIndexBackend.search( query, limit, %w(User Organization) ) items = SearchIndexBackend.search( query, limit, objects )
items.each { |item| items.each { |item|
require item[:type].to_filename require item[:type].to_filename
record = Kernel.const_get( item[:type] ).find( item[:id] ) record = Kernel.const_get( item[:type] ).find( item[:id] )
@ -29,34 +34,23 @@ class SearchController < ApplicationController
} }
else else
# do query # do query
users = User.search( objects.each { |object|
query: query,
limit: limit,
current_user: current_user,
)
users.each do |user|
item = {
id: user.id,
type: user.class.to_s
}
result.push item
assets = user.assets(assets)
end
organizations = Organization.search( found_objects = object.constantize.search(
query: query, query: query,
limit: limit, limit: limit,
current_user: current_user, current_user: current_user,
) )
organizations.each do |organization| found_objects.each do |found_object|
item = { item = {
id: organization.id, id: found_object.id,
type: organization.class.to_s type: found_object.class.to_s
} }
result.push item result.push item
assets = organization.assets(assets) assets = found_object.assets(assets)
end end
}
end end
render json: { render json: {
@ -68,64 +62,54 @@ class SearchController < ApplicationController
# GET /api/v1/search # GET /api/v1/search
def search def search
# build result list assets = {}
tickets = Ticket.search( result = []
limit: params[:limit], objects = %w( Ticket User Organization )
query: params[:term], if SearchIndexBackend.enabled?
current_user: current_user,
)
assets = {}
ticket_result = []
tickets.each do |ticket|
assets = ticket.assets(assets)
ticket_result.push ticket.id
end
# do query found_objects = {}
users = User.search( items = SearchIndexBackend.search( params[:term], params[:limit], objects )
query: params[:term], items.each { |item|
limit: params[:limit], require item[:type].to_filename
current_user: current_user, record = Kernel.const_get( item[:type] ).find( item[:id] )
) assets = record.assets(assets)
user_result = []
users.each do |user|
user_result.push user.id
assets = user.assets(assets)
end
organizations = Organization.search( found_objects[ item[:type] ] ||= []
query: params[:term], found_objects[ item[:type] ].push item[:id]
limit: params[:limit],
current_user: current_user,
)
organization_result = []
organizations.each do |organization|
organization_result.push organization.id
assets = organization.assets(assets)
end
result = []
if ticket_result[0]
data = {
name: 'Ticket',
ids: ticket_result,
} }
result.push data
end found_objects.each { |object, object_ids|
if user_result[0]
data = { data = {
name: 'User', name: object,
ids: user_result, ids: object_ids,
}
result.push data
} }
result.push data else
end
if organization_result[0] objects.each { |object|
data = {
name: 'Organization', found_objects = object.constantize.search(
ids: organization_result, query: params[:term],
limit: params[:limit],
current_user: current_user,
)
object_ids = []
found_objects.each do |found_object|
object_ids.push found_object.id
assets = found_object.assets(assets)
end
next if object_ids.empty?
data = {
name: object,
ids: object_ids,
}
result.push data
} }
result.push data
end end
# return result # return result

View file

@ -2,9 +2,8 @@ Zammad::Application.routes.draw do
api_path = Rails.configuration.api_path api_path = Rails.configuration.api_path
# search # search
match api_path + '/search', to: 'search#search', via: [:get, :post] match api_path + '/search', to: 'search#search', via: [:get, :post]
# search_user_org
match api_path + '/search_user_org', to: 'search#search_user_org', via: [:get, :post]
# search_generic
match api_path + '/search/:objects', to: 'search#search_generic', via: [:get, :post]
end end