Fixes #445 - Add bulk option to extended search
This commit is contained in:
parent
3486390843
commit
db0229e629
8 changed files with 558 additions and 332 deletions
|
@ -167,15 +167,110 @@ class App.Search extends App.Controller
|
||||||
object = App[model].fullLocal(item.id)
|
object = App[model].fullLocal(item.id)
|
||||||
list.push object
|
list.push object
|
||||||
if model is 'Ticket'
|
if model is 'Ticket'
|
||||||
|
|
||||||
|
openTicket = (id,e) =>
|
||||||
|
# open ticket via task manager to provide task with overview info
|
||||||
|
ticket = App.Ticket.findNative(id)
|
||||||
|
App.TaskManager.execute(
|
||||||
|
key: "Ticket-#{ticket.id}"
|
||||||
|
controller: 'TicketZoom'
|
||||||
|
params:
|
||||||
|
ticket_id: ticket.id
|
||||||
|
overview_id: @overview.id
|
||||||
|
show: true
|
||||||
|
)
|
||||||
|
@navigate ticket.uiUrl()
|
||||||
|
|
||||||
|
checkbox = @permissionCheck('ticket.agent') ? true : false
|
||||||
|
|
||||||
|
callbackCheckbox = (id, checked, e) =>
|
||||||
|
if @shouldShowBulkForm()
|
||||||
|
@bulkForm.render()
|
||||||
|
@bulkForm.show()
|
||||||
|
else
|
||||||
|
@bulkForm.hide()
|
||||||
|
|
||||||
|
if @lastChecked && e.shiftKey
|
||||||
|
# check items in a row
|
||||||
|
currentItem = $(e.currentTarget).parents('.item')
|
||||||
|
lastCheckedItem = $(@lastChecked).parents('.item')
|
||||||
|
items = currentItem.parent().children()
|
||||||
|
|
||||||
|
if currentItem.index() > lastCheckedItem.index()
|
||||||
|
# current item is below last checked item
|
||||||
|
startId = lastCheckedItem.index()
|
||||||
|
endId = currentItem.index()
|
||||||
|
else
|
||||||
|
# current item is above last checked item
|
||||||
|
startId = currentItem.index()
|
||||||
|
endId = lastCheckedItem.index()
|
||||||
|
|
||||||
|
items.slice(startId+1, endId).find('[name="bulk"]').prop('checked', (-> !@checked))
|
||||||
|
|
||||||
|
@lastChecked = e.currentTarget
|
||||||
|
|
||||||
ticket_ids = []
|
ticket_ids = []
|
||||||
for item in localList
|
for item in localList
|
||||||
ticket_ids.push item.id
|
ticket_ids.push item.id
|
||||||
|
localeEl = @$('.js-content')
|
||||||
@table = new App.TicketList(
|
@table = new App.TicketList(
|
||||||
tableId: "find_#{model}"
|
tableId: "find_#{model}"
|
||||||
el: @$('.js-content')
|
el: localeEl
|
||||||
columns: [ 'number', 'title', 'customer', 'group', 'owner', 'created_at' ]
|
columns: [ 'number', 'title', 'customer', 'group', 'owner', 'created_at' ]
|
||||||
ticket_ids: ticket_ids
|
ticket_ids: ticket_ids
|
||||||
radio: false
|
radio: false
|
||||||
|
checkbox: checkbox
|
||||||
|
bindRow:
|
||||||
|
events:
|
||||||
|
'click': openTicket
|
||||||
|
bindCheckbox:
|
||||||
|
events:
|
||||||
|
'click': callbackCheckbox
|
||||||
|
select_all: callbackCheckbox
|
||||||
|
)
|
||||||
|
|
||||||
|
updateSearch = =>
|
||||||
|
callback = =>
|
||||||
|
@search(true)
|
||||||
|
@delay(callback, 100)
|
||||||
|
|
||||||
|
@bulkForm = new App.TicketBulkForm(
|
||||||
|
holder: localeEl
|
||||||
|
view: @view
|
||||||
|
callback: updateSearch
|
||||||
|
noSidebar: true
|
||||||
|
)
|
||||||
|
|
||||||
|
# start bulk action observ
|
||||||
|
@el.append(@bulkForm.el)
|
||||||
|
localElement = @$('.js-content')
|
||||||
|
if localElement.find('input[name="bulk"]:checked').length isnt 0
|
||||||
|
@bulkForm.show()
|
||||||
|
|
||||||
|
# show/hide bulk action
|
||||||
|
localElement.delegate('input[name="bulk"], input[name="bulk_all"]', 'change', (e) =>
|
||||||
|
if @shouldShowBulkForm()
|
||||||
|
@bulkForm.show()
|
||||||
|
else
|
||||||
|
@bulkForm.hide()
|
||||||
|
@bulkForm.reset()
|
||||||
|
)
|
||||||
|
|
||||||
|
# deselect bulk_all if one item is uncheck observ
|
||||||
|
localElement.delegate('[name="bulk"]', 'change', (e) ->
|
||||||
|
bulkAll = localElement.find('[name="bulk_all"]')
|
||||||
|
checkedCount = localElement.find('input[name="bulk"]:checked').length
|
||||||
|
checkboxCount = localElement.find('input[name="bulk"]').length
|
||||||
|
if checkedCount is 0
|
||||||
|
bulkAll.prop('indeterminate', false)
|
||||||
|
bulkAll.prop('checked', false)
|
||||||
|
else
|
||||||
|
if checkedCount is checkboxCount
|
||||||
|
bulkAll.prop('indeterminate', false)
|
||||||
|
bulkAll.prop('checked', true)
|
||||||
|
else
|
||||||
|
bulkAll.prop('checked', false)
|
||||||
|
bulkAll.prop('indeterminate', true)
|
||||||
)
|
)
|
||||||
else
|
else
|
||||||
openObject = (id,e) =>
|
openObject = (id,e) =>
|
||||||
|
@ -202,6 +297,18 @@ class App.Search extends App.Controller
|
||||||
updateFilledClass: ->
|
updateFilledClass: ->
|
||||||
@searchInput.toggleClass 'is-empty', !@searchInput.val()
|
@searchInput.toggleClass 'is-empty', !@searchInput.val()
|
||||||
|
|
||||||
|
shouldShowBulkForm: =>
|
||||||
|
items = @$('table').find('input[name="bulk"]:checked')
|
||||||
|
return false if items.length == 0
|
||||||
|
|
||||||
|
ticket_ids = _.map(items, (el) -> $(el).val() )
|
||||||
|
ticket_group_ids = _.map(App.Ticket.findAll(ticket_ids), (ticket) -> ticket.group_id)
|
||||||
|
ticket_group_ids = _.uniq(ticket_group_ids)
|
||||||
|
allowed_group_ids = App.User.find(@Session.get('id')).allGroupIds('change')
|
||||||
|
allowed_group_ids = _.map(allowed_group_ids, (id_string) -> parseInt(id_string, 10) )
|
||||||
|
_.every(ticket_group_ids, (id) -> id in allowed_group_ids)
|
||||||
|
|
||||||
|
|
||||||
class Router extends App.ControllerPermanent
|
class Router extends App.ControllerPermanent
|
||||||
constructor: (params) ->
|
constructor: (params) ->
|
||||||
super
|
super
|
||||||
|
|
|
@ -1,47 +1,3 @@
|
||||||
ValidUsersForTicketSelectionMethods =
|
|
||||||
validUsersForTicketSelection: ->
|
|
||||||
items = $('.content.active .table-overview .table').find('[name="bulk"]:checked')
|
|
||||||
|
|
||||||
# we want to display all users for which we can assign the tickets directly
|
|
||||||
# for this we need to get the groups of all selected tickets
|
|
||||||
# after we got those we need to check which users are available in all groups
|
|
||||||
# users that are not in all groups can't get the tickets assigned
|
|
||||||
ticket_ids = _.map(items, (el) -> $(el).val() )
|
|
||||||
ticket_group_ids = _.map(App.Ticket.findAll(ticket_ids), (ticket) -> ticket.group_id)
|
|
||||||
users = @usersInGroups(ticket_group_ids)
|
|
||||||
|
|
||||||
# get the list of possible groups for the current user
|
|
||||||
# from the TicketCreateCollection
|
|
||||||
# (filled for e.g. the TicketCreation or TicketZoom assignment)
|
|
||||||
# and order them by name
|
|
||||||
group_ids = _.keys(@formMeta?.dependencies?.group_id)
|
|
||||||
groups = App.Group.findAll(group_ids)
|
|
||||||
groups_sorted = _.sortBy(groups, (group) -> group.name)
|
|
||||||
|
|
||||||
# get the number of visible users per group
|
|
||||||
# from the TicketCreateCollection
|
|
||||||
# (filled for e.g. the TicketCreation or TicketZoom assignment)
|
|
||||||
for group in groups
|
|
||||||
group.valid_users_count = @formMeta?.dependencies?.group_id?[group.id]?.owner_id.length || 0
|
|
||||||
|
|
||||||
{
|
|
||||||
users: users
|
|
||||||
groups: groups_sorted
|
|
||||||
}
|
|
||||||
|
|
||||||
usersInGroups: (group_ids) ->
|
|
||||||
ids_by_group = _.chain(@formMeta?.dependencies?.group_id)
|
|
||||||
.pick(group_ids)
|
|
||||||
.values()
|
|
||||||
.map( (e) -> e.owner_id)
|
|
||||||
.value()
|
|
||||||
|
|
||||||
# Underscore's intersection doesn't work when chained
|
|
||||||
ids_in_all_groups = _.intersection(ids_by_group...)
|
|
||||||
|
|
||||||
users = App.User.findAll(ids_in_all_groups)
|
|
||||||
_.sortBy(users, (user) -> user.firstname)
|
|
||||||
|
|
||||||
class App.TicketOverview extends App.Controller
|
class App.TicketOverview extends App.Controller
|
||||||
className: 'overviews'
|
className: 'overviews'
|
||||||
activeFocus: 'nav'
|
activeFocus: 'nav'
|
||||||
|
@ -69,7 +25,7 @@ class App.TicketOverview extends App.Controller
|
||||||
'mouseenter .js-batch-hover-target': 'highlightBatchEntry'
|
'mouseenter .js-batch-hover-target': 'highlightBatchEntry'
|
||||||
'mouseleave .js-batch-hover-target': 'unhighlightBatchEntry'
|
'mouseleave .js-batch-hover-target': 'unhighlightBatchEntry'
|
||||||
|
|
||||||
@include ValidUsersForTicketSelectionMethods
|
@include App.ValidUsersForTicketSelectionMethods
|
||||||
|
|
||||||
constructor: ->
|
constructor: ->
|
||||||
super
|
super
|
||||||
|
@ -1280,18 +1236,19 @@ class Table extends App.Controller
|
||||||
|
|
||||||
@renderPopovers()
|
@renderPopovers()
|
||||||
|
|
||||||
@bulkForm = new BulkForm(
|
@bulkForm = new App.TicketBulkForm(
|
||||||
holder: @el
|
holder: @el
|
||||||
view: @view
|
view: @view
|
||||||
)
|
)
|
||||||
|
|
||||||
# start bulk action observ
|
# start bulk action observ
|
||||||
@el.append(@bulkForm.el)
|
@el.append(@bulkForm.el)
|
||||||
if @$('.table-overview').find('input[name="bulk"]:checked').length isnt 0
|
localElement = @$('.table-overview')
|
||||||
|
if localElement.find('input[name="bulk"]:checked').length isnt 0
|
||||||
@bulkForm.show()
|
@bulkForm.show()
|
||||||
|
|
||||||
# show/hide bulk action
|
# show/hide bulk action
|
||||||
@$('.table-overview').delegate('input[name="bulk"], input[name="bulk_all"]', 'change', (e) =>
|
localElement.delegate('input[name="bulk"], input[name="bulk_all"]', 'change', (e) =>
|
||||||
if @shouldShowBulkForm()
|
if @shouldShowBulkForm()
|
||||||
@bulkForm.show()
|
@bulkForm.show()
|
||||||
else
|
else
|
||||||
|
@ -1300,10 +1257,10 @@ class Table extends App.Controller
|
||||||
)
|
)
|
||||||
|
|
||||||
# deselect bulk_all if one item is uncheck observ
|
# deselect bulk_all if one item is uncheck observ
|
||||||
@$('.table-overview').delegate('[name="bulk"]', 'change', (e) =>
|
localElement.delegate('[name="bulk"]', 'change', (e) ->
|
||||||
bulkAll = @$('.table-overview').find('[name="bulk_all"]')
|
bulkAll = localElement.find('[name="bulk_all"]')
|
||||||
checkedCount = @$('.table-overview').find('input[name="bulk"]:checked').length
|
checkedCount = localElement.find('input[name="bulk"]:checked').length
|
||||||
checkboxCount = @$('.table-overview').find('input[name="bulk"]').length
|
checkboxCount = localElement.find('input[name="bulk"]').length
|
||||||
if checkedCount is 0
|
if checkedCount is 0
|
||||||
bulkAll.prop('indeterminate', false)
|
bulkAll.prop('indeterminate', false)
|
||||||
bulkAll.prop('checked', false)
|
bulkAll.prop('checked', false)
|
||||||
|
@ -1362,281 +1319,6 @@ class Table extends App.Controller
|
||||||
onCloseCallback: @keyboardOn
|
onCloseCallback: @keyboardOn
|
||||||
)
|
)
|
||||||
|
|
||||||
class BulkForm extends App.Controller
|
|
||||||
className: 'bulkAction hide'
|
|
||||||
|
|
||||||
events:
|
|
||||||
'submit form': 'submit'
|
|
||||||
'click .js-submit': 'submit'
|
|
||||||
'click .js-confirm': 'confirm'
|
|
||||||
'click .js-cancel': 'reset'
|
|
||||||
|
|
||||||
@include ValidUsersForTicketSelectionMethods
|
|
||||||
|
|
||||||
constructor: ->
|
|
||||||
super
|
|
||||||
|
|
||||||
@configure_attributes_ticket = []
|
|
||||||
used_attributes = ['state_id', 'pending_time', 'priority_id', 'group_id', 'owner_id']
|
|
||||||
attributesClean = App.Ticket.attributesGet('edit')
|
|
||||||
for attributeName, attribute of attributesClean
|
|
||||||
if _.contains(used_attributes, attributeName)
|
|
||||||
localAttribute = clone(attribute)
|
|
||||||
localAttribute.nulloption = true
|
|
||||||
localAttribute.default = ''
|
|
||||||
localAttribute.null = true
|
|
||||||
@configure_attributes_ticket.push localAttribute
|
|
||||||
|
|
||||||
time_attribute = _.findWhere(@configure_attributes_ticket, {'name': 'pending_time'})
|
|
||||||
if time_attribute
|
|
||||||
time_attribute.orientation = 'top'
|
|
||||||
time_attribute.disableScroll = true
|
|
||||||
|
|
||||||
@holder = @options.holder
|
|
||||||
@visible = false
|
|
||||||
|
|
||||||
load = (data) =>
|
|
||||||
App.Collection.loadAssets(data.assets)
|
|
||||||
@formMeta = data.form_meta
|
|
||||||
@render()
|
|
||||||
@bindId = App.TicketCreateCollection.bind(load)
|
|
||||||
|
|
||||||
release: =>
|
|
||||||
App.TicketCreateCollection.unbind(@bindId)
|
|
||||||
|
|
||||||
render: ->
|
|
||||||
@el.css('right', App.Utils.getScrollBarWidth())
|
|
||||||
|
|
||||||
@html(App.view('agent_ticket_view/bulk')())
|
|
||||||
|
|
||||||
handlers = @Config.get('TicketZoomFormHandler')
|
|
||||||
|
|
||||||
for attribute in @configure_attributes_ticket
|
|
||||||
continue if attribute.name != 'owner_id'
|
|
||||||
{users, groups} = @validUsersForTicketSelection()
|
|
||||||
options = _.map(users, (user) -> {value: user.id, name: user.displayName()} )
|
|
||||||
attribute.possible_groups_owners = options
|
|
||||||
|
|
||||||
new App.ControllerForm(
|
|
||||||
el: @$('#form-ticket-bulk')
|
|
||||||
model:
|
|
||||||
configure_attributes: @configure_attributes_ticket
|
|
||||||
className: 'create'
|
|
||||||
labelClass: 'input-group-addon'
|
|
||||||
handlersConfig: handlers
|
|
||||||
params: {}
|
|
||||||
filter: @formMeta.filter
|
|
||||||
formMeta: @formMeta
|
|
||||||
noFieldset: true
|
|
||||||
)
|
|
||||||
|
|
||||||
new App.ControllerForm(
|
|
||||||
el: @$('#form-ticket-bulk-comment')
|
|
||||||
model:
|
|
||||||
configure_attributes: [{ name: 'body', display: 'Comment', tag: 'textarea', rows: 4, null: true, upload: false, item_class: 'flex' }]
|
|
||||||
className: 'create'
|
|
||||||
labelClass: 'input-group-addon'
|
|
||||||
noFieldset: true
|
|
||||||
)
|
|
||||||
|
|
||||||
@confirm_attributes = [
|
|
||||||
{ name: 'type_id', display: 'Type', tag: 'select', multiple: false, null: true, relation: 'TicketArticleType', filter: @articleTypeFilter, default: '9', translate: true, class: 'medium' }
|
|
||||||
{ name: 'internal', display: 'Visibility', tag: 'select', null: true, options: { true: 'internal', false: 'public' }, class: 'medium', item_class: '', default: false }
|
|
||||||
]
|
|
||||||
|
|
||||||
new App.ControllerForm(
|
|
||||||
el: @$('#form-ticket-bulk-typeVisibility')
|
|
||||||
model:
|
|
||||||
configure_attributes: @confirm_attributes
|
|
||||||
className: 'create'
|
|
||||||
labelClass: 'input-group-addon'
|
|
||||||
noFieldset: true
|
|
||||||
)
|
|
||||||
|
|
||||||
articleTypeFilter: (items) ->
|
|
||||||
for item in items
|
|
||||||
if item.name is 'note'
|
|
||||||
return [item]
|
|
||||||
items
|
|
||||||
|
|
||||||
confirm: =>
|
|
||||||
@$('.js-action-step').addClass('hide')
|
|
||||||
@$('.js-confirm-step').removeClass('hide')
|
|
||||||
|
|
||||||
@makeSpaceForTableRows()
|
|
||||||
|
|
||||||
# need a delay because of the click event
|
|
||||||
setTimeout ( => @$('.textarea.form-group textarea').focus() ), 0
|
|
||||||
|
|
||||||
reset: =>
|
|
||||||
@cancel()
|
|
||||||
|
|
||||||
if @visible
|
|
||||||
@makeSpaceForTableRows()
|
|
||||||
|
|
||||||
cancel: =>
|
|
||||||
@$('.js-action-step').removeClass('hide')
|
|
||||||
@$('.js-confirm-step').addClass('hide')
|
|
||||||
|
|
||||||
show: =>
|
|
||||||
@el.removeClass('hide')
|
|
||||||
@visible = true
|
|
||||||
@makeSpaceForTableRows()
|
|
||||||
|
|
||||||
hide: =>
|
|
||||||
@el.addClass('hide')
|
|
||||||
@visible = false
|
|
||||||
@removeSpaceForTableRows()
|
|
||||||
|
|
||||||
makeSpaceForTableRows: =>
|
|
||||||
height = @el.height()
|
|
||||||
scrollParent = @holder.scrollParent()
|
|
||||||
isScrolledToBottom = scrollParent.prop('scrollHeight') is scrollParent.scrollTop() + scrollParent.outerHeight()
|
|
||||||
|
|
||||||
@holder.css('margin-bottom', height)
|
|
||||||
|
|
||||||
if isScrolledToBottom
|
|
||||||
scrollParent.scrollTop scrollParent.prop('scrollHeight') - scrollParent.outerHeight()
|
|
||||||
|
|
||||||
removeSpaceForTableRows: =>
|
|
||||||
@holder.css('margin-bottom', 0)
|
|
||||||
|
|
||||||
ticketMergeParams: (params) ->
|
|
||||||
ticketUpdate = {}
|
|
||||||
for item of params
|
|
||||||
if params[item] != '' && params[item] != null
|
|
||||||
ticketUpdate[item] = params[item]
|
|
||||||
|
|
||||||
# in case if a group is selected, set also the selected owner (maybe nobody)
|
|
||||||
if params.group_id != '' && params.group_id != null
|
|
||||||
ticketUpdate.owner_id = params.owner_id
|
|
||||||
ticketUpdate
|
|
||||||
|
|
||||||
submit: (e) =>
|
|
||||||
e.preventDefault()
|
|
||||||
|
|
||||||
@bulkCount = @holder.find('.table-overview').find('[name="bulk"]:checked').length
|
|
||||||
|
|
||||||
if @bulkCount is 0
|
|
||||||
App.Event.trigger('notify', {
|
|
||||||
type: 'error'
|
|
||||||
msg: App.i18n.translateContent('At least one object must be selected.')
|
|
||||||
})
|
|
||||||
return
|
|
||||||
|
|
||||||
ticket_ids = []
|
|
||||||
@holder.find('.table-overview').find('[name="bulk"]:checked').each( (index, element) ->
|
|
||||||
ticket_id = $(element).val()
|
|
||||||
ticket_ids.push ticket_id
|
|
||||||
)
|
|
||||||
|
|
||||||
params = @formParam(e.target)
|
|
||||||
|
|
||||||
for ticket_id in ticket_ids
|
|
||||||
ticket = App.Ticket.find(ticket_id)
|
|
||||||
|
|
||||||
ticketUpdate = @ticketMergeParams(params)
|
|
||||||
ticket.load(ticketUpdate)
|
|
||||||
|
|
||||||
# if title is empty - ticket can't processed, set ?
|
|
||||||
if _.isEmpty(ticket.title)
|
|
||||||
ticket.title = '-'
|
|
||||||
|
|
||||||
# validate ticket
|
|
||||||
errors = ticket.validate(
|
|
||||||
screen: 'edit'
|
|
||||||
)
|
|
||||||
if errors
|
|
||||||
@log 'error', 'update', errors
|
|
||||||
errorString = ''
|
|
||||||
for key, error of errors
|
|
||||||
errorString += "#{key}: #{error}"
|
|
||||||
|
|
||||||
@formValidate(
|
|
||||||
form: e.target
|
|
||||||
errors: errors
|
|
||||||
screen: 'edit'
|
|
||||||
)
|
|
||||||
|
|
||||||
App.Event.trigger('notify', {
|
|
||||||
type: 'error'
|
|
||||||
msg: App.i18n.translateContent('Bulk action stopped %s!', errorString)
|
|
||||||
})
|
|
||||||
@cancel()
|
|
||||||
return
|
|
||||||
|
|
||||||
@bulkCountIndex = 0
|
|
||||||
for ticket_id in ticket_ids
|
|
||||||
ticket = App.Ticket.find(ticket_id)
|
|
||||||
|
|
||||||
# update ticket
|
|
||||||
ticketUpdate = @ticketMergeParams(params)
|
|
||||||
|
|
||||||
# validate article
|
|
||||||
if params['body']
|
|
||||||
article = new App.TicketArticle
|
|
||||||
params.from = @Session.get().displayName()
|
|
||||||
params.ticket_id = ticket.id
|
|
||||||
params.form_id = @form_id
|
|
||||||
|
|
||||||
sender = App.TicketArticleSender.findByAttribute('name', 'Agent')
|
|
||||||
type = App.TicketArticleType.find(params['type_id'])
|
|
||||||
params.sender_id = sender.id
|
|
||||||
|
|
||||||
if !params['internal']
|
|
||||||
params['internal'] = false
|
|
||||||
|
|
||||||
@log 'notice', 'update article', params, sender
|
|
||||||
article.load(params)
|
|
||||||
errors = article.validate()
|
|
||||||
if errors
|
|
||||||
@log 'error', 'update article', errors
|
|
||||||
@formEnable(e)
|
|
||||||
return
|
|
||||||
|
|
||||||
ticket.load(ticketUpdate)
|
|
||||||
|
|
||||||
# if title is empty - ticket can't processed, set ?
|
|
||||||
if _.isEmpty(ticket.title)
|
|
||||||
ticket.title = '-'
|
|
||||||
|
|
||||||
@saveTicketArticle(ticket, article)
|
|
||||||
|
|
||||||
@holder.find('.table-overview').find('[name="bulk"]:checked').prop('checked', false)
|
|
||||||
App.Event.trigger('notify', {
|
|
||||||
type: 'success'
|
|
||||||
msg: App.i18n.translateContent('Bulk action executed!')
|
|
||||||
})
|
|
||||||
|
|
||||||
saveTicketArticle: (ticket, article) =>
|
|
||||||
ticket.save(
|
|
||||||
done: (r) =>
|
|
||||||
@bulkCountIndex++
|
|
||||||
|
|
||||||
# reset form after save
|
|
||||||
if article
|
|
||||||
article.save(
|
|
||||||
fail: (r) =>
|
|
||||||
@log 'error', 'update article', r
|
|
||||||
)
|
|
||||||
|
|
||||||
# refresh view after all tickets are proceeded
|
|
||||||
if @bulkCountIndex == @bulkCount
|
|
||||||
@render()
|
|
||||||
@hide()
|
|
||||||
|
|
||||||
# fetch overview data again
|
|
||||||
App.Event.trigger('overview:fetch')
|
|
||||||
|
|
||||||
fail: (r) =>
|
|
||||||
@bulkCountIndex++
|
|
||||||
@log 'error', 'update ticket', r
|
|
||||||
App.Event.trigger 'notify', {
|
|
||||||
type: 'error'
|
|
||||||
msg: App.i18n.translateContent('Can\'t update Ticket %s!', ticket.number)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class App.OverviewSettings extends App.ControllerModal
|
class App.OverviewSettings extends App.ControllerModal
|
||||||
buttonClose: true
|
buttonClose: true
|
||||||
|
|
|
@ -0,0 +1,278 @@
|
||||||
|
class App.TicketBulkForm extends App.Controller
|
||||||
|
className: 'bulkAction hide'
|
||||||
|
|
||||||
|
events:
|
||||||
|
'submit form': 'submit'
|
||||||
|
'click .js-submit': 'submit'
|
||||||
|
'click .js-confirm': 'confirm'
|
||||||
|
'click .js-cancel': 'reset'
|
||||||
|
|
||||||
|
@include App.ValidUsersForTicketSelectionMethods
|
||||||
|
|
||||||
|
constructor: ->
|
||||||
|
super
|
||||||
|
|
||||||
|
@configure_attributes_ticket = []
|
||||||
|
|
||||||
|
used_attributes = ['state_id', 'pending_time', 'priority_id', 'group_id', 'owner_id']
|
||||||
|
attributesClean = App.Ticket.attributesGet('edit')
|
||||||
|
for attributeName, attribute of attributesClean
|
||||||
|
if _.contains(used_attributes, attributeName)
|
||||||
|
localAttribute = clone(attribute)
|
||||||
|
localAttribute.nulloption = true
|
||||||
|
localAttribute.default = ''
|
||||||
|
localAttribute.null = true
|
||||||
|
@configure_attributes_ticket.push localAttribute
|
||||||
|
|
||||||
|
|
||||||
|
time_attribute = _.findWhere(@configure_attributes_ticket, {'name': 'pending_time'})
|
||||||
|
if time_attribute
|
||||||
|
time_attribute.orientation = 'top'
|
||||||
|
time_attribute.disableScroll = true
|
||||||
|
|
||||||
|
@holder = @options.holder
|
||||||
|
@visible = false
|
||||||
|
|
||||||
|
load = (data) =>
|
||||||
|
App.Collection.loadAssets(data.assets)
|
||||||
|
@formMeta = data.form_meta
|
||||||
|
@render()
|
||||||
|
@bindId = App.TicketCreateCollection.bind(load)
|
||||||
|
|
||||||
|
release: =>
|
||||||
|
App.TicketCreateCollection.unbind(@bindId)
|
||||||
|
|
||||||
|
render: ->
|
||||||
|
@el.css('right', App.Utils.getScrollBarWidth())
|
||||||
|
@el.addClass('no-sidebar') if @noSidebar
|
||||||
|
|
||||||
|
@html(App.view('agent_ticket_view/bulk')())
|
||||||
|
|
||||||
|
handlers = @Config.get('TicketZoomFormHandler')
|
||||||
|
|
||||||
|
for attribute in @configure_attributes_ticket
|
||||||
|
continue if attribute.name != 'owner_id'
|
||||||
|
{users, groups} = @validUsersForTicketSelection()
|
||||||
|
options = _.map(users, (user) -> {value: user.id, name: user.displayName()} )
|
||||||
|
attribute.possible_groups_owners = options
|
||||||
|
|
||||||
|
new App.ControllerForm(
|
||||||
|
el: @$('#form-ticket-bulk')
|
||||||
|
model:
|
||||||
|
configure_attributes: @configure_attributes_ticket
|
||||||
|
className: 'create'
|
||||||
|
labelClass: 'input-group-addon'
|
||||||
|
handlersConfig: handlers
|
||||||
|
params: {}
|
||||||
|
filter: @formMeta.filter
|
||||||
|
formMeta: @formMeta
|
||||||
|
noFieldset: true
|
||||||
|
)
|
||||||
|
|
||||||
|
new App.ControllerForm(
|
||||||
|
el: @$('#form-ticket-bulk-comment')
|
||||||
|
model:
|
||||||
|
configure_attributes: [{ name: 'body', display: 'Comment', tag: 'textarea', rows: 4, null: true, upload: false, item_class: 'flex' }]
|
||||||
|
className: 'create'
|
||||||
|
labelClass: 'input-group-addon'
|
||||||
|
noFieldset: true
|
||||||
|
)
|
||||||
|
|
||||||
|
@confirm_attributes = [
|
||||||
|
{ name: 'type_id', display: 'Type', tag: 'select', multiple: false, null: true, relation: 'TicketArticleType', filter: @articleTypeFilter, default: '9', translate: true, class: 'medium' }
|
||||||
|
{ name: 'internal', display: 'Visibility', tag: 'select', null: true, options: { true: 'internal', false: 'public' }, class: 'medium', item_class: '', default: false }
|
||||||
|
]
|
||||||
|
|
||||||
|
new App.ControllerForm(
|
||||||
|
el: @$('#form-ticket-bulk-typeVisibility')
|
||||||
|
model:
|
||||||
|
configure_attributes: @confirm_attributes
|
||||||
|
className: 'create'
|
||||||
|
labelClass: 'input-group-addon'
|
||||||
|
noFieldset: true
|
||||||
|
)
|
||||||
|
|
||||||
|
articleTypeFilter: (items) ->
|
||||||
|
for item in items
|
||||||
|
if item.name is 'note'
|
||||||
|
return [item]
|
||||||
|
items
|
||||||
|
|
||||||
|
confirm: =>
|
||||||
|
@$('.js-action-step').addClass('hide')
|
||||||
|
@$('.js-confirm-step').removeClass('hide')
|
||||||
|
|
||||||
|
@makeSpaceForTableRows()
|
||||||
|
|
||||||
|
# need a delay because of the click event
|
||||||
|
setTimeout ( => @$('.textarea.form-group textarea').focus() ), 0
|
||||||
|
|
||||||
|
reset: =>
|
||||||
|
@cancel()
|
||||||
|
|
||||||
|
if @visible
|
||||||
|
@makeSpaceForTableRows()
|
||||||
|
|
||||||
|
cancel: =>
|
||||||
|
@$('.js-action-step').removeClass('hide')
|
||||||
|
@$('.js-confirm-step').addClass('hide')
|
||||||
|
|
||||||
|
show: =>
|
||||||
|
@el.removeClass('hide')
|
||||||
|
@visible = true
|
||||||
|
@makeSpaceForTableRows()
|
||||||
|
|
||||||
|
hide: =>
|
||||||
|
@el.addClass('hide')
|
||||||
|
@visible = false
|
||||||
|
@removeSpaceForTableRows()
|
||||||
|
|
||||||
|
makeSpaceForTableRows: =>
|
||||||
|
height = @el.height()
|
||||||
|
scrollParent = @holder.scrollParent()
|
||||||
|
isScrolledToBottom = scrollParent.prop('scrollHeight') is scrollParent.scrollTop() + scrollParent.outerHeight()
|
||||||
|
|
||||||
|
@holder.css('margin-bottom', height)
|
||||||
|
|
||||||
|
if isScrolledToBottom
|
||||||
|
scrollParent.scrollTop scrollParent.prop('scrollHeight') - scrollParent.outerHeight()
|
||||||
|
|
||||||
|
removeSpaceForTableRows: =>
|
||||||
|
@holder.css('margin-bottom', 0)
|
||||||
|
|
||||||
|
ticketMergeParams: (params) ->
|
||||||
|
ticketUpdate = {}
|
||||||
|
for item of params
|
||||||
|
if params[item] != '' && params[item] != null
|
||||||
|
ticketUpdate[item] = params[item]
|
||||||
|
|
||||||
|
# in case if a group is selected, set also the selected owner (maybe nobody)
|
||||||
|
if params.group_id != '' && params.group_id != null
|
||||||
|
ticketUpdate.owner_id = params.owner_id
|
||||||
|
ticketUpdate
|
||||||
|
|
||||||
|
submit: (e) =>
|
||||||
|
e.preventDefault()
|
||||||
|
|
||||||
|
@bulkCount = @holder.find('.table').find('[name="bulk"]:checked').length
|
||||||
|
|
||||||
|
if @bulkCount is 0
|
||||||
|
App.Event.trigger('notify', {
|
||||||
|
type: 'error'
|
||||||
|
msg: App.i18n.translateContent('At least one object must be selected.')
|
||||||
|
})
|
||||||
|
return
|
||||||
|
|
||||||
|
ticket_ids = []
|
||||||
|
@holder.find('.table').find('[name="bulk"]:checked').each( (index, element) ->
|
||||||
|
ticket_id = $(element).val()
|
||||||
|
ticket_ids.push ticket_id
|
||||||
|
)
|
||||||
|
|
||||||
|
params = @formParam(e.target)
|
||||||
|
|
||||||
|
for ticket_id in ticket_ids
|
||||||
|
ticket = App.Ticket.find(ticket_id)
|
||||||
|
|
||||||
|
ticketUpdate = @ticketMergeParams(params)
|
||||||
|
ticket.load(ticketUpdate)
|
||||||
|
|
||||||
|
# if title is empty - ticket can't processed, set ?
|
||||||
|
if _.isEmpty(ticket.title)
|
||||||
|
ticket.title = '-'
|
||||||
|
|
||||||
|
# validate ticket
|
||||||
|
errors = ticket.validate(
|
||||||
|
screen: 'edit'
|
||||||
|
)
|
||||||
|
if errors
|
||||||
|
@log 'error', 'update', errors
|
||||||
|
errorString = ''
|
||||||
|
for key, error of errors
|
||||||
|
errorString += "#{key}: #{error}"
|
||||||
|
|
||||||
|
@formValidate(
|
||||||
|
form: e.target
|
||||||
|
errors: errors
|
||||||
|
screen: 'edit'
|
||||||
|
)
|
||||||
|
|
||||||
|
App.Event.trigger('notify', {
|
||||||
|
type: 'error'
|
||||||
|
msg: App.i18n.translateContent('Bulk action stopped %s!', errorString)
|
||||||
|
})
|
||||||
|
@cancel()
|
||||||
|
return
|
||||||
|
|
||||||
|
@bulkCountIndex = 0
|
||||||
|
for ticket_id in ticket_ids
|
||||||
|
ticket = App.Ticket.find(ticket_id)
|
||||||
|
|
||||||
|
# update ticket
|
||||||
|
ticketUpdate = @ticketMergeParams(params)
|
||||||
|
|
||||||
|
# validate article
|
||||||
|
if params['body']
|
||||||
|
article = new App.TicketArticle
|
||||||
|
params.from = @Session.get().displayName()
|
||||||
|
params.ticket_id = ticket.id
|
||||||
|
params.form_id = @form_id
|
||||||
|
|
||||||
|
sender = App.TicketArticleSender.findByAttribute('name', 'Agent')
|
||||||
|
type = App.TicketArticleType.find(params['type_id'])
|
||||||
|
params.sender_id = sender.id
|
||||||
|
|
||||||
|
if !params['internal']
|
||||||
|
params['internal'] = false
|
||||||
|
|
||||||
|
@log 'notice', 'update article', params, sender
|
||||||
|
article.load(params)
|
||||||
|
errors = article.validate()
|
||||||
|
if errors
|
||||||
|
@log 'error', 'update article', errors
|
||||||
|
@formEnable(e)
|
||||||
|
return
|
||||||
|
|
||||||
|
ticket.load(ticketUpdate)
|
||||||
|
|
||||||
|
# if title is empty - ticket can't processed, set ?
|
||||||
|
if _.isEmpty(ticket.title)
|
||||||
|
ticket.title = '-'
|
||||||
|
|
||||||
|
@saveTicketArticle(ticket, article)
|
||||||
|
|
||||||
|
@holder.find('.table').find('[name="bulk"]:checked').prop('checked', false)
|
||||||
|
App.Event.trigger('notify', {
|
||||||
|
type: 'success'
|
||||||
|
msg: App.i18n.translateContent('Bulk action executed!')
|
||||||
|
})
|
||||||
|
|
||||||
|
saveTicketArticle: (ticket, article) =>
|
||||||
|
ticket.save(
|
||||||
|
done: (r) =>
|
||||||
|
@bulkCountIndex++
|
||||||
|
|
||||||
|
# reset form after save
|
||||||
|
if article
|
||||||
|
article.save(
|
||||||
|
fail: (r) =>
|
||||||
|
@log 'error', 'update article', r
|
||||||
|
)
|
||||||
|
|
||||||
|
# refresh view after all tickets are proceeded
|
||||||
|
if @bulkCountIndex == @bulkCount
|
||||||
|
@render()
|
||||||
|
@hide()
|
||||||
|
|
||||||
|
# fetch overview data again
|
||||||
|
App.Event.trigger('overview:fetch')
|
||||||
|
|
||||||
|
fail: (r) =>
|
||||||
|
@bulkCountIndex++
|
||||||
|
@log 'error', 'update ticket', r
|
||||||
|
App.Event.trigger 'notify', {
|
||||||
|
type: 'error'
|
||||||
|
msg: App.i18n.translateContent('Can\'t update Ticket %s!', ticket.number)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
|
@ -117,11 +117,13 @@ class App.TicketList extends App.Controller
|
||||||
overview: @columns || [ 'number', 'title', 'customer', 'group', 'created_at' ]
|
overview: @columns || [ 'number', 'title', 'customer', 'group', 'created_at' ]
|
||||||
model: App.Ticket
|
model: App.Ticket
|
||||||
objects: list
|
objects: list
|
||||||
|
checkbox: @checkbox
|
||||||
#bindRow:
|
#bindRow:
|
||||||
# events:
|
# events:
|
||||||
# 'click': openTicket
|
# 'click': openTicket
|
||||||
callbackHeader: callbackHeader
|
callbackHeader: callbackHeader
|
||||||
callbackAttributes: callbackAttributes
|
callbackAttributes: callbackAttributes
|
||||||
|
bindCheckbox: @bindCheckbox
|
||||||
radio: @radio
|
radio: @radio
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
@ -11,7 +11,7 @@ class App.GlobalSearch extends App.Controller
|
||||||
query = params.query
|
query = params.query
|
||||||
# use cache for search result
|
# use cache for search result
|
||||||
currentTime = new Date
|
currentTime = new Date
|
||||||
if @searchResultCache[query] && @searchResultCache[query].time > currentTime.setSeconds(currentTime.getSeconds() - 20)
|
if !params.force && @searchResultCache[query] && @searchResultCache[query].time > currentTime.setSeconds(currentTime.getSeconds() - 20)
|
||||||
if @ajaxRequestId
|
if @ajaxRequestId
|
||||||
App.Ajax.abort(@ajaxRequestId)
|
App.Ajax.abort(@ajaxRequestId)
|
||||||
@ajaxStart(params)
|
@ajaxStart(params)
|
||||||
|
|
|
@ -0,0 +1,43 @@
|
||||||
|
App.ValidUsersForTicketSelectionMethods =
|
||||||
|
validUsersForTicketSelection: ->
|
||||||
|
items = $('.content.active .table-overview .table').find('[name="bulk"]:checked')
|
||||||
|
|
||||||
|
# we want to display all users for which we can assign the tickets directly
|
||||||
|
# for this we need to get the groups of all selected tickets
|
||||||
|
# after we got those we need to check which users are available in all groups
|
||||||
|
# users that are not in all groups can't get the tickets assigned
|
||||||
|
ticket_ids = _.map(items, (el) -> $(el).val() )
|
||||||
|
ticket_group_ids = _.map(App.Ticket.findAll(ticket_ids), (ticket) -> ticket.group_id)
|
||||||
|
users = @usersInGroups(ticket_group_ids)
|
||||||
|
|
||||||
|
# get the list of possible groups for the current user
|
||||||
|
# from the TicketCreateCollection
|
||||||
|
# (filled for e.g. the TicketCreation or TicketZoom assignment)
|
||||||
|
# and order them by name
|
||||||
|
group_ids = _.keys(@formMeta?.dependencies?.group_id)
|
||||||
|
groups = App.Group.findAll(group_ids)
|
||||||
|
groups_sorted = _.sortBy(groups, (group) -> group.name)
|
||||||
|
|
||||||
|
# get the number of visible users per group
|
||||||
|
# from the TicketCreateCollection
|
||||||
|
# (filled for e.g. the TicketCreation or TicketZoom assignment)
|
||||||
|
for group in groups
|
||||||
|
group.valid_users_count = @formMeta?.dependencies?.group_id?[group.id]?.owner_id.length || 0
|
||||||
|
|
||||||
|
{
|
||||||
|
users: users
|
||||||
|
groups: groups_sorted
|
||||||
|
}
|
||||||
|
|
||||||
|
usersInGroups: (group_ids) ->
|
||||||
|
ids_by_group = _.chain(@formMeta?.dependencies?.group_id)
|
||||||
|
.pick(group_ids)
|
||||||
|
.values()
|
||||||
|
.map( (e) -> e.owner_id)
|
||||||
|
.value()
|
||||||
|
|
||||||
|
# Underscore's intersection doesn't work when chained
|
||||||
|
ids_in_all_groups = _.intersection(ids_by_group...)
|
||||||
|
|
||||||
|
users = App.User.findAll(ids_in_all_groups)
|
||||||
|
_.sortBy(users, (user) -> user.firstname)
|
|
@ -3835,6 +3835,15 @@ footer {
|
||||||
min-width: $minWidth - $sidebarWidth;
|
min-width: $minWidth - $sidebarWidth;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&.no-sidebar {
|
||||||
|
@include bidi-style(left, $navigationWidth, right, 0);
|
||||||
|
min-width: $minWidth - $navigationWidth;
|
||||||
|
|
||||||
|
@include small-desktop {
|
||||||
|
min-width: $minWidth;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@include phone {
|
@include phone {
|
||||||
@include bidi-style(left, $mobileNavigationWidth, right, 0);
|
@include bidi-style(left, $mobileNavigationWidth, right, 0);
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
|
|
@ -2,7 +2,12 @@
|
||||||
|
|
||||||
require 'rails_helper'
|
require 'rails_helper'
|
||||||
|
|
||||||
RSpec.describe 'Search', type: :system, searchindex: true do
|
RSpec.describe 'Search', type: :system, authenticated: true, searchindex: true do
|
||||||
|
let(:users_group) { Group.find_by(name: 'Users') }
|
||||||
|
let(:ticket_1) { create(:ticket, title: 'Testing Ticket 1', group: users_group) }
|
||||||
|
let(:ticket_2) { create(:ticket, title: 'Testing Ticket 2', group: users_group) }
|
||||||
|
let(:note) { 'Test note' }
|
||||||
|
|
||||||
before do
|
before do
|
||||||
configure_elasticsearch(required: true, rebuild: true)
|
configure_elasticsearch(required: true, rebuild: true)
|
||||||
end
|
end
|
||||||
|
@ -13,7 +18,107 @@ RSpec.describe 'Search', type: :system, searchindex: true do
|
||||||
click_on 'Show Search Details'
|
click_on 'Show Search Details'
|
||||||
|
|
||||||
within '#navigation .tasks a[data-key=Search]' do
|
within '#navigation .tasks a[data-key=Search]' do
|
||||||
expect(page).to have_text '"Welcome"'
|
expect(page).to have_content '"Welcome"'
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'with ticket search result' do
|
||||||
|
before do
|
||||||
|
ticket_1 && ticket_2 && rebuild_searchindex
|
||||||
|
|
||||||
|
fill_in id: 'global-search', with: 'Testing'
|
||||||
|
click_on 'Show Search Details'
|
||||||
|
|
||||||
|
find('[data-tab-content=Ticket]').click
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'checkbox' do
|
||||||
|
it 'has checkbox for each ticket records' do
|
||||||
|
within '.detail-search table.table' do
|
||||||
|
expect(page).to have_xpath(".//td[contains(@class, 'js-checkbox-field')]//input[@type='checkbox']", visible: :all, minimum: 2)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'has select all checkbox' do
|
||||||
|
within '.detail-search table.table' do
|
||||||
|
expect(page).to have_xpath(".//th//input[@type='checkbox' and @name='bulk_all']", visible: :all, count: 1)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'shows bulkform when checkbox is checked' do
|
||||||
|
within '.detail-search table.table' do
|
||||||
|
find("tr[data-id='#{ticket_1.id}']").check('bulk', allow_label_click: true)
|
||||||
|
end
|
||||||
|
|
||||||
|
expect(page).to have_selector('.bulkAction.no-sidebar')
|
||||||
|
.and have_no_selector('.bulkAction.no-sidebar.hide', visible: :all)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'shows bulkform when all checkbox is checked' do
|
||||||
|
within '.detail-search table.table' do
|
||||||
|
find('th.table-checkbox').check('bulk_all', allow_label_click: true)
|
||||||
|
end
|
||||||
|
|
||||||
|
expect(page).to have_selector('.bulkAction.no-sidebar')
|
||||||
|
.and have_no_selector('.bulkAction.no-sidebar.hide', visible: :all)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'hides bulkform when checkbox is unchecked' do
|
||||||
|
within '.detail-search table.table' do
|
||||||
|
find('th.table-checkbox').check('bulk_all', allow_label_click: true)
|
||||||
|
|
||||||
|
all('.js-tableBody tr.item').each { |row| row.uncheck('bulk', allow_label_click: true) }
|
||||||
|
end
|
||||||
|
|
||||||
|
expect(page).to have_selector('.bulkAction.no-sidebar.hide', visible: :hide)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'with bulkform activated' do
|
||||||
|
before do
|
||||||
|
find('th.table-checkbox').check('bulk_all', allow_label_click: true)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'has group label' do
|
||||||
|
within '.bulkAction .bulkAction-form' do
|
||||||
|
expect(page).to have_content 'GROUP'
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'has owner label' do
|
||||||
|
within '.bulkAction .bulkAction-form' do
|
||||||
|
expect(page).to have_content 'OWNER'
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'has state label' do
|
||||||
|
within '.bulkAction .bulkAction-form' do
|
||||||
|
expect(page).to have_content 'STATE'
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'has priority label' do
|
||||||
|
within '.bulkAction .bulkAction-form' do
|
||||||
|
expect(page).to have_content 'PRIORITY'
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'bulk note' do
|
||||||
|
before { current_window.resize_to(1300, 1040) }
|
||||||
|
|
||||||
|
it 'adds note to selected ticket' do
|
||||||
|
within :active_content do
|
||||||
|
find("tr[data-id='#{ticket_1.id}']").check('bulk', allow_label_click: true)
|
||||||
|
click '.js-confirm'
|
||||||
|
find('.js-confirm-step textarea').fill_in with: note
|
||||||
|
click '.js-submit'
|
||||||
|
end
|
||||||
|
|
||||||
|
expect do
|
||||||
|
wait(10, interval: 0.1).until { ticket_1.articles.last&.body == note }
|
||||||
|
end.not_to raise_error
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
Loading…
Reference in a new issue