mirror of
https://github.com/zammad/zammad
synced 2026-05-24 09:48:36 +00:00
Fixes #5384 - Improve heavy used admin objects with pagination and search in admin interface.
Co-authored-by: Mantas Masalskis <mm@zammad.com> Co-authored-by: Dominik Klein <dk@zammad.com> Co-authored-by: Dusan Vuckovic <dv@zammad.com>
This commit is contained in:
parent
72cb9de11e
commit
0c93022abf
87 changed files with 2103 additions and 1461 deletions
|
|
@ -169,7 +169,6 @@ Metrics/BlockLength:
|
|||
- 'Gemfile'
|
||||
- 'config/initializers/mariadb_json_columns.rb'
|
||||
- 'app/models/ticket/can_selector.rb'
|
||||
- 'app/models/concerns/can_selector.rb'
|
||||
- 'app/controllers/application_controller/authenticates.rb'
|
||||
- 'app/controllers/reports_controller.rb'
|
||||
- 'app/controllers/time_accountings_controller.rb'
|
||||
|
|
@ -186,6 +185,7 @@ Metrics/BlockLength:
|
|||
- 'app/models/channel/filter/database.rb'
|
||||
- 'app/models/channel/filter/identify_sender.rb'
|
||||
- 'app/models/chat/session/search.rb'
|
||||
- 'app/models/concerns/can_search.rb'
|
||||
- 'app/models/concerns/can_be_published.rb'
|
||||
- 'app/models/concerns/can_clone_attachments.rb'
|
||||
- 'app/models/concerns/can_csv_import.rb'
|
||||
|
|
@ -193,7 +193,6 @@ Metrics/BlockLength:
|
|||
- 'app/models/concerns/has_history.rb'
|
||||
- 'app/models/concerns/has_rich_text.rb'
|
||||
- 'app/models/job.rb'
|
||||
- 'app/models/knowledge_base/search.rb'
|
||||
- 'app/models/object_manager/attribute.rb'
|
||||
- 'app/models/organization/search.rb'
|
||||
- 'app/models/package.rb'
|
||||
|
|
@ -349,7 +348,6 @@ Metrics/CyclomaticComplexity:
|
|||
Metrics/ModuleLength:
|
||||
Exclude:
|
||||
- 'app/models/ticket/can_selector.rb'
|
||||
- 'app/models/concerns/can_selector.rb'
|
||||
- 'app/controllers/application_controller/authenticates.rb'
|
||||
- 'app/controllers/application_controller/renders_models.rb'
|
||||
- 'app/models/application_model/can_assets.rb'
|
||||
|
|
@ -357,6 +355,7 @@ Metrics/ModuleLength:
|
|||
- 'app/models/application_model/can_creates_and_updates.rb'
|
||||
- 'app/models/channel/email_build.rb'
|
||||
- 'app/models/channel/filter/identify_sender.rb'
|
||||
- 'app/models/concerns/can_search.rb'
|
||||
- 'app/models/concerns/can_be_published.rb'
|
||||
- 'app/models/concerns/can_csv_import.rb'
|
||||
- 'app/models/concerns/has_groups.rb'
|
||||
|
|
|
|||
|
|
@ -1,14 +1,21 @@
|
|||
class App.ControllerGenericIndex extends App.Controller
|
||||
elements:
|
||||
'.js-search': 'searchField'
|
||||
|
||||
events:
|
||||
'click [data-type=edit]': 'edit'
|
||||
'click [data-type=new]': 'new'
|
||||
'click [data-type=payload]': 'payload'
|
||||
'click [data-type=import]': 'import'
|
||||
'click .js-description': 'description'
|
||||
'input .js-search': 'search'
|
||||
|
||||
constructor: ->
|
||||
super
|
||||
|
||||
@searchQuery ||= ''
|
||||
@dndCallbackOrignal = @dndCallback
|
||||
|
||||
# set title
|
||||
if @pageData.title
|
||||
@title @pageData.title, true
|
||||
|
|
@ -18,7 +25,9 @@ class App.ControllerGenericIndex extends App.Controller
|
|||
@navupdate @pageData.navupdate
|
||||
|
||||
# bind render after a change is done
|
||||
if !@disableRender
|
||||
if @pageData?.pagerAjax && !@disableRender
|
||||
@controllerBind("#{@genericObject}:create #{@genericObject}:update #{@genericObject}:touch #{@genericObject}:destroy", @delayedRender)
|
||||
else if !@disableRender
|
||||
@subscribeId = App[ @genericObject ].subscribe(@render)
|
||||
|
||||
App[ @genericObject ].bind 'ajaxError', (rec, msg) =>
|
||||
|
|
@ -49,12 +58,31 @@ class App.ControllerGenericIndex extends App.Controller
|
|||
if @subscribeId
|
||||
App[@genericObject].unsubscribe(@subscribeId)
|
||||
|
||||
paginate: (page) =>
|
||||
return if page is @pageData.pagerSelected
|
||||
paginate: (page, params) =>
|
||||
search_query = decodeURIComponent(params?.search_query || '')
|
||||
return if page is @pageData.pagerSelected && @searchQuery is search_query
|
||||
|
||||
@pageData.pagerSelected = page
|
||||
@searchQuery = search_query
|
||||
|
||||
if @table && @searchField.val() isnt search_query
|
||||
@searchField.val(search_query)
|
||||
|
||||
@render()
|
||||
|
||||
search: ->
|
||||
@delay(
|
||||
=>
|
||||
@navigate "#{@pageData.pagerBaseUrl}1/#{encodeURIComponent(@searchField.val())}"
|
||||
, 300, "#{@controllerId}-render")
|
||||
|
||||
delayedRender: =>
|
||||
@delay(@render, 300, "#{@controllerId}-render")
|
||||
|
||||
render: =>
|
||||
if @pageData?.objects
|
||||
@title @pageData.objects, true
|
||||
|
||||
if @pageData.pagerAjax
|
||||
sortBy = @table?.customOrderBy || @table?.orderBy || @defaultSortBy || 'id'
|
||||
orderBy = @table?.customOrderDirection || @table?.orderDirection || @defaultOrder || 'ASC'
|
||||
|
|
@ -66,18 +94,40 @@ class App.ControllerGenericIndex extends App.Controller
|
|||
fallbackOrderBy = "#{orderBy}, ASC"
|
||||
|
||||
@startLoading()
|
||||
App[@genericObject].indexFull(
|
||||
|
||||
params = {
|
||||
force: true
|
||||
refresh: false
|
||||
sort_by: fallbackSortBy
|
||||
order_by: fallbackOrderBy
|
||||
page: @pageData.pagerSelected
|
||||
per_page: @pageData.pagerPerPage
|
||||
query: @searchQuery
|
||||
}
|
||||
|
||||
active_filters = []
|
||||
@$('.tab.active').each( (i,d) ->
|
||||
active_filters.push $(d).data('id')
|
||||
)
|
||||
|
||||
if @filterCallback
|
||||
params = @filterCallback(active_filters, params)
|
||||
|
||||
method = 'indexFull'
|
||||
if @searchBar
|
||||
method = 'searchFull'
|
||||
|
||||
App[@genericObject][method](
|
||||
(collection, data) =>
|
||||
@pageData.pagerTotalCount = data.total_count
|
||||
if data.total_count > @pageData.pagerPerPage || @searchQuery
|
||||
@dndCallback = undefined
|
||||
else if @dndCallback is undefined && @dndCallbackOrignal
|
||||
@dndCallback = @dndCallbackOrignal
|
||||
@table.renderState = undefined if @table
|
||||
@stopLoading()
|
||||
@renderObjects(collection)
|
||||
{
|
||||
refresh: false
|
||||
sort_by: fallbackSortBy
|
||||
order_by: fallbackOrderBy
|
||||
page: @pageData.pagerSelected
|
||||
per_page: @pageData.pagerPerPage
|
||||
}
|
||||
params
|
||||
)
|
||||
return
|
||||
|
||||
|
|
@ -108,6 +158,18 @@ class App.ControllerGenericIndex extends App.Controller
|
|||
buttons: @pageData.buttons
|
||||
subHead: @pageData.subHead
|
||||
showDescription: showDescription
|
||||
objects: @pageData.objects
|
||||
searchBar: @searchBar
|
||||
searchQuery: @searchQuery
|
||||
filterMenu: @filterMenu
|
||||
)
|
||||
|
||||
@$('.tab').off('click').on(
|
||||
'click'
|
||||
(e) =>
|
||||
e.preventDefault()
|
||||
$(e.target).toggleClass('active')
|
||||
@delayedRender()
|
||||
)
|
||||
|
||||
# show description in content if no no content exists
|
||||
|
|
@ -144,6 +206,7 @@ class App.ControllerGenericIndex extends App.Controller
|
|||
pagerPerPage: @pageData.pagerPerPage
|
||||
pagerTotalCount: @pageData.pagerTotalCount
|
||||
sortRenderCallback: @render
|
||||
searchQuery: @searchQuery
|
||||
},
|
||||
params
|
||||
)
|
||||
|
|
@ -151,7 +214,7 @@ class App.ControllerGenericIndex extends App.Controller
|
|||
if !@table
|
||||
@table = new App.ControllerTable(params)
|
||||
else
|
||||
@table.update(objects: objects, pagerSelected: @pageData.pagerSelected, pagerTotalCount: @pageData.pagerTotalCount)
|
||||
@table.update(objects: objects, pagerSelected: @pageData.pagerSelected, pagerTotalCount: @pageData.pagerTotalCount, dndCallback: @dndCallback, searchQuery: @searchQuery)
|
||||
|
||||
if @pageData.logFacility
|
||||
new App.HttpLog(
|
||||
|
|
@ -183,6 +246,12 @@ class App.ControllerGenericIndex extends App.Controller
|
|||
handlers: @handlers
|
||||
validateOnSubmit: @validateOnSubmit
|
||||
screen: @editScreen
|
||||
callback: =>
|
||||
@resetActiveTabs()
|
||||
if @searchQuery
|
||||
@navigate "#{@pageData.pagerBaseUrl}"
|
||||
else
|
||||
@delayedRender()
|
||||
)
|
||||
|
||||
newControllerClass: ->
|
||||
|
|
@ -203,6 +272,12 @@ class App.ControllerGenericIndex extends App.Controller
|
|||
handlers: @handlers
|
||||
validateOnSubmit: @validateOnSubmit
|
||||
screen: @createScreen
|
||||
callback: =>
|
||||
@resetActiveTabs()
|
||||
if @searchQuery
|
||||
@navigate "#{@pageData.pagerBaseUrl}"
|
||||
else
|
||||
@delayedRender()
|
||||
)
|
||||
|
||||
clone: (item) =>
|
||||
|
|
@ -224,3 +299,6 @@ class App.ControllerGenericIndex extends App.Controller
|
|||
description: App[ @genericObject ].description
|
||||
container: @container
|
||||
)
|
||||
|
||||
resetActiveTabs: ->
|
||||
@$('.tab.active').removeClass('active')
|
||||
|
|
|
|||
|
|
@ -123,6 +123,7 @@ class App.ControllerTable extends App.Controller
|
|||
|
||||
currentRows: []
|
||||
|
||||
orderEnabled: true
|
||||
orderDirection: 'ASC'
|
||||
orderBy: undefined
|
||||
|
||||
|
|
@ -208,6 +209,7 @@ class App.ControllerTable extends App.Controller
|
|||
@renderPagerStatic(el, find)
|
||||
|
||||
renderPagerAjax: (el, find = false) =>
|
||||
page = parseInt(@pagerSelected) - 1
|
||||
pages = parseInt((@pagerTotalCount - 1) / @pagerPerPage)
|
||||
if pages < 1
|
||||
if find
|
||||
|
|
@ -216,7 +218,7 @@ class App.ControllerTable extends App.Controller
|
|||
el.filter('.js-pager').html('')
|
||||
return
|
||||
pager = App.view('generic/table_pager')(
|
||||
page: @pagerSelected - 1
|
||||
page: page
|
||||
pages: pages
|
||||
)
|
||||
if find
|
||||
|
|
@ -225,7 +227,8 @@ class App.ControllerTable extends App.Controller
|
|||
el.filter('.js-pager').html(pager)
|
||||
|
||||
renderPagerStatic: (el, find = false) =>
|
||||
pages = parseInt(((@objects.length - 1) / @pagerItemsPerPage))
|
||||
page = parseInt(@pagerShownPage)
|
||||
pages = parseInt(((@objects.length - 1) / @pagerItemsPerPage))
|
||||
if pages < 1
|
||||
if find
|
||||
el.find('.js-pager').html('')
|
||||
|
|
@ -233,7 +236,7 @@ class App.ControllerTable extends App.Controller
|
|||
el.filter('.js-pager').html('')
|
||||
return
|
||||
pager = App.view('generic/table_pager')(
|
||||
page: @pagerShownPage
|
||||
page: page
|
||||
pages: pages
|
||||
)
|
||||
if find
|
||||
|
|
@ -750,10 +753,13 @@ class App.ControllerTable extends App.Controller
|
|||
@objects.slice(page * @pagerItemsPerPage, (page + 1) * @pagerItemsPerPage)
|
||||
|
||||
paginate: (e) =>
|
||||
return if !@pagerEnabled
|
||||
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
page = $(e.currentTarget).attr('data-page')
|
||||
if @pagerAjax
|
||||
@navigate "#{@pagerBaseUrl}#{(parseInt(page) + 1)}"
|
||||
@navigate "#{@pagerBaseUrl}#{(parseInt(page) + 1)}/#{encodeURIComponent(@searchQuery)}"
|
||||
else
|
||||
render = =>
|
||||
@pagerShownPage = page
|
||||
|
|
@ -1176,11 +1182,13 @@ class App.ControllerTable extends App.Controller
|
|||
header.displayWidth = @resizeTargetRight.outerWidth()
|
||||
|
||||
sortByColumn: (event) =>
|
||||
return if !@orderEnabled
|
||||
|
||||
column = $(event.currentTarget).closest('[data-column-key]').attr('data-column-key')
|
||||
|
||||
# for ajax pagination we only accept valid attributes for sorting
|
||||
if @model && @pagerAjax
|
||||
return if !@attributesList[column]
|
||||
return if !@attributesList[column] || @attributesList[column]?.relation
|
||||
|
||||
orderBy = @customOrderBy || @orderBy
|
||||
orderDirection = @customOrderDirection || @orderDirection
|
||||
|
|
|
|||
|
|
@ -172,7 +172,7 @@ class Navigation extends App.Controller
|
|||
type: 'personal'
|
||||
)
|
||||
|
||||
renderResult: (result = []) =>
|
||||
renderResult: (result = {}) =>
|
||||
@removePopovers()
|
||||
|
||||
# remove result if not result exists
|
||||
|
|
|
|||
|
|
@ -9,6 +9,8 @@ class Group extends App.ControllerSubContent
|
|||
id: @id
|
||||
genericObject: 'Group'
|
||||
defaultSortBy: 'name'
|
||||
searchBar: true
|
||||
searchQuery: @search_query
|
||||
pageData:
|
||||
home: 'groups'
|
||||
object: __('Group')
|
||||
|
|
@ -16,7 +18,7 @@ class Group extends App.ControllerSubContent
|
|||
pagerAjax: true
|
||||
pagerBaseUrl: '#manage/groups/'
|
||||
pagerSelected: ( @page || 1 )
|
||||
pagerPerPage: 150
|
||||
pagerPerPage: 50
|
||||
navupdate: '#groups'
|
||||
notes: [
|
||||
__('Groups are …')
|
||||
|
|
@ -32,6 +34,6 @@ class Group extends App.ControllerSubContent
|
|||
if key isnt 'el' && key isnt 'shown' && key isnt 'match'
|
||||
@[key] = value
|
||||
|
||||
@genericController.paginate( @page || 1 )
|
||||
@genericController.paginate(@page || 1, params)
|
||||
|
||||
App.Config.set('Group', { prio: 1500, name: __('Groups'), parent: '#manage', target: '#manage/groups', controller: Group, permission: ['admin.group'] }, 'NavBarAdmin')
|
||||
|
|
|
|||
|
|
@ -9,6 +9,8 @@ class Macro extends App.ControllerSubContent
|
|||
id: @id
|
||||
genericObject: 'Macro'
|
||||
defaultSortBy: 'name'
|
||||
searchBar: true
|
||||
searchQuery: @search_query
|
||||
pageData:
|
||||
home: 'macros'
|
||||
object: __('Macro')
|
||||
|
|
@ -16,7 +18,7 @@ class Macro extends App.ControllerSubContent
|
|||
pagerAjax: true
|
||||
pagerBaseUrl: '#manage/macros/'
|
||||
pagerSelected: ( @page || 1 )
|
||||
pagerPerPage: 150
|
||||
pagerPerPage: 50
|
||||
navupdate: '#macros'
|
||||
buttons: [
|
||||
{ name: __('New Macro'), 'data-type': 'new', class: 'btn--success' }
|
||||
|
|
@ -29,6 +31,6 @@ class Macro extends App.ControllerSubContent
|
|||
if key isnt 'el' && key isnt 'shown' && key isnt 'match'
|
||||
@[key] = value
|
||||
|
||||
@genericController.paginate( @page || 1 )
|
||||
@genericController.paginate(@page || 1, params)
|
||||
|
||||
App.Config.set('Macros', { prio: 2310, name: __('Macros'), parent: '#manage', target: '#manage/macros', controller: Macro, permission: ['admin.macro'] }, 'NavBarAdmin')
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ class ManageRouter extends App.ControllerPermanent
|
|||
App.Config.set('manage', ManageRouter, 'Routes')
|
||||
App.Config.set('manage/:target', ManageRouter, 'Routes')
|
||||
App.Config.set('manage/:target/:page', ManageRouter, 'Routes')
|
||||
App.Config.set('manage/:target/:page/:search_query', ManageRouter, 'Routes')
|
||||
App.Config.set('settings/:target', ManageRouter, 'Routes')
|
||||
App.Config.set('channels/:target', ManageRouter, 'Routes')
|
||||
App.Config.set('channels/:target/error/:error_code', ManageRouter, 'Routes')
|
||||
|
|
|
|||
|
|
@ -14,6 +14,8 @@ class Organization extends App.ControllerSubContent
|
|||
container: @el.closest('.content')
|
||||
)
|
||||
defaultSortBy: 'name'
|
||||
searchBar: true
|
||||
searchQuery: @search_query
|
||||
pageData:
|
||||
home: 'organizations'
|
||||
object: __('Organization')
|
||||
|
|
@ -21,7 +23,7 @@ class Organization extends App.ControllerSubContent
|
|||
pagerAjax: true
|
||||
pagerBaseUrl: '#manage/organizations/'
|
||||
pagerSelected: ( @page || 1 )
|
||||
pagerPerPage: 150
|
||||
pagerPerPage: 50
|
||||
navupdate: '#organizations'
|
||||
buttons: [
|
||||
{ name: __('Import'), 'data-type': 'import', class: 'btn' }
|
||||
|
|
@ -35,7 +37,7 @@ class Organization extends App.ControllerSubContent
|
|||
if key isnt 'el' && key isnt 'shown' && key isnt 'match'
|
||||
@[key] = value
|
||||
|
||||
@genericController.paginate( @page || 1 )
|
||||
@genericController.paginate(@page || 1, params)
|
||||
|
||||
|
||||
App.Config.set('Organization', { prio: 2000, name: __('Organizations'), parent: '#manage', target: '#manage/organizations', controller: Organization, permission: ['admin.organization'] }, 'NavBarAdmin')
|
||||
|
|
|
|||
|
|
@ -15,10 +15,16 @@ class Overview extends App.ControllerSubContent
|
|||
genericObject: 'Overview'
|
||||
defaultSortBy: 'prio'
|
||||
#groupBy: 'role'
|
||||
searchBar: true
|
||||
searchQuery: @search_query
|
||||
pageData:
|
||||
home: 'overviews'
|
||||
object: __('Overview')
|
||||
objects: __('Overviews')
|
||||
pagerAjax: true
|
||||
pagerBaseUrl: '#manage/overviews/'
|
||||
pagerSelected: ( @page || 1 )
|
||||
pagerPerPage: 50
|
||||
navupdate: '#overviews'
|
||||
buttons: [
|
||||
{ name: __('New Overview'), 'data-type': 'new', class: 'btn--success' }
|
||||
|
|
@ -43,4 +49,11 @@ class Overview extends App.ControllerSubContent
|
|||
)
|
||||
)
|
||||
|
||||
show: (params) =>
|
||||
for key, value of params
|
||||
if key isnt 'el' && key isnt 'shown' && key isnt 'match'
|
||||
@[key] = value
|
||||
|
||||
@genericController.paginate(@page || 1, params)
|
||||
|
||||
App.Config.set('Overview', { prio: 2300, name: __('Overviews'), parent: '#manage', target: '#manage/overviews', controller: Overview, permission: ['admin.overview'] }, 'NavBarAdmin')
|
||||
|
|
|
|||
|
|
@ -12,6 +12,8 @@ class Role extends App.ControllerSubContent
|
|||
defaultSortBy: 'name'
|
||||
createScreen: 'create'
|
||||
editScreen: 'edit'
|
||||
searchBar: true
|
||||
searchQuery: @search_query
|
||||
pageData:
|
||||
home: 'roles'
|
||||
object: __('Role')
|
||||
|
|
@ -19,7 +21,7 @@ class Role extends App.ControllerSubContent
|
|||
pagerAjax: true
|
||||
pagerBaseUrl: '#manage/roles/'
|
||||
pagerSelected: ( @page || 1 )
|
||||
pagerPerPage: 150
|
||||
pagerPerPage: 50
|
||||
navupdate: '#roles'
|
||||
notes: [
|
||||
__('Roles are …')
|
||||
|
|
@ -35,6 +37,6 @@ class Role extends App.ControllerSubContent
|
|||
if key isnt 'el' && key isnt 'shown' && key isnt 'match'
|
||||
@[key] = value
|
||||
|
||||
@genericController.paginate( @page || 1 )
|
||||
@genericController.paginate(@page || 1, params)
|
||||
|
||||
App.Config.set('Role', { prio: 1600, name: __('Roles'), parent: '#manage', target: '#manage/roles', controller: Role, permission: ['admin.role'] }, 'NavBarAdmin')
|
||||
|
|
|
|||
|
|
@ -11,13 +11,17 @@ class App.Search extends App.Controller
|
|||
'keyup .js-search': 'listNavigate'
|
||||
'click .js-tab': 'showTab'
|
||||
'input .js-search': 'updateFilledClass'
|
||||
'click .js-page': 'paginate'
|
||||
'click .js-sort': 'sortByColumn'
|
||||
|
||||
@include App.ValidUsersForTicketSelectionMethods
|
||||
|
||||
constructor: ->
|
||||
super
|
||||
|
||||
@savedOrderBy = {}
|
||||
@savedOrderBy = {}
|
||||
@resultPaginated = {}
|
||||
@result = {}
|
||||
|
||||
current = App.TaskManager.get(@taskKey).state
|
||||
if current && current.query
|
||||
|
|
@ -125,6 +129,8 @@ class App.Search extends App.Controller
|
|||
@search(500, true)
|
||||
|
||||
listNavigate: (e) =>
|
||||
@resultPaginated = {}
|
||||
|
||||
if e.keyCode is 27 # close on esc
|
||||
@empty()
|
||||
return
|
||||
|
|
@ -160,25 +166,50 @@ class App.Search extends App.Controller
|
|||
@globalSearch.search(
|
||||
delay: delay
|
||||
query: @query
|
||||
force: force
|
||||
)
|
||||
|
||||
renderResult: (result = []) =>
|
||||
buildResultCacheKey: (offset, direction, column, object) -> {
|
||||
"#{object}-#{offset}-#{direction}-#{column}"
|
||||
}
|
||||
|
||||
renderResult: (result = {}, params = undefined) =>
|
||||
if !_.isUndefined(params?.offset)
|
||||
|
||||
for klassName, metadata of result
|
||||
@resultPaginated[klassName] ||= {}
|
||||
|
||||
cacheKey = @buildResultCacheKey(params?.offset, params?.orderDirection, params?.orderBy, klassName)
|
||||
@resultPaginated[klassName][cacheKey] = metadata.items
|
||||
|
||||
if @model is klassName
|
||||
@renderTab(klassName, metadata.items || [])
|
||||
|
||||
return
|
||||
|
||||
@result = result
|
||||
for tab in @tabs
|
||||
count = 0
|
||||
if result[tab.model]
|
||||
count = result[tab.model].length
|
||||
if @model is tab.model
|
||||
@renderTab(tab.model, result[tab.model] || [])
|
||||
count = result[tab.model]?.total_count || 0
|
||||
@$(".js-tab#{tab.model} .js-counter").text(count)
|
||||
|
||||
if @model is tab.model
|
||||
@renderTab(tab.model, result[tab.model]?.items || [])
|
||||
|
||||
showTab: (e) =>
|
||||
tabs = $(e.currentTarget).closest('.tabs')
|
||||
tabModel = $(e.currentTarget).data('tab-content')
|
||||
tabs.find('.js-tab').removeClass('active')
|
||||
$(e.currentTarget).addClass('active')
|
||||
@renderTab(tabModel, @result?[tabModel] || [])
|
||||
|
||||
|
||||
savedOrder = @savedOrderBy[tabModel]
|
||||
|
||||
items = if !savedOrder
|
||||
@result[tabModel]?.items
|
||||
else
|
||||
cacheKey = @buildResultCacheKey(savedOrder.page * 50, savedOrder.orderDirection, savedOrder.orderBy, tabModel)
|
||||
@resultPaginated?[tabModel]?[cacheKey]
|
||||
|
||||
@renderTab(tabModel, items || [])
|
||||
|
||||
renderTab: (model, localList) =>
|
||||
|
||||
|
|
@ -323,6 +354,72 @@ class App.Search extends App.Controller
|
|||
sortClickCallback: @saveOrderBy
|
||||
)
|
||||
|
||||
@renderPagination()
|
||||
|
||||
renderPagination: =>
|
||||
(@table.table || @table).pagerEnabled = false
|
||||
(@table.table || @table).orderEnabled = false
|
||||
|
||||
object = @el.find('.js-tab.active').data('tab-content')
|
||||
page = @getSavedOrderBy()?.page || 0
|
||||
count = @result[object]?.total_count || 0
|
||||
pages = Math.ceil(count / 50) - 1
|
||||
|
||||
if !pages
|
||||
@$('.js-pager').html('')
|
||||
return
|
||||
|
||||
pager = App.view('generic/table_pager')(
|
||||
page: page
|
||||
pages: pages
|
||||
)
|
||||
|
||||
@$('.js-pager').html(pager)
|
||||
|
||||
paginate: (e) =>
|
||||
@preventDefaultAndStopPropagation(e)
|
||||
|
||||
page = parseInt($(e.currentTarget).attr('data-page'))
|
||||
object = @el.find('.js-tab.active').data('tab-content')
|
||||
|
||||
ordering = @savedOrderBy[@model] || {}
|
||||
ordering.page = page
|
||||
|
||||
@savedOrderBy[@model] = ordering
|
||||
|
||||
@goToPaginated(object, page)
|
||||
|
||||
sortByColumn: (e) =>
|
||||
@preventDefaultAndStopPropagation(e)
|
||||
|
||||
newColumn = $(e.currentTarget).closest('[data-column-key]').attr('data-column-key')
|
||||
|
||||
config = _.find App[@model].configure_attributes, (elem) -> elem.name == newColumn
|
||||
|
||||
# There's no reliable way to sort to-many relations. Sorry.
|
||||
return if config.multiple && config.relation
|
||||
|
||||
current = @getSavedOrderBy()
|
||||
|
||||
newOrderDirection = if current?.orderBy == newColumn && current?.orderDirection == 'ASC'
|
||||
'DESC'
|
||||
else
|
||||
'ASC'
|
||||
|
||||
@savedOrderBy[@model] = { orderBy: newColumn, orderDirection: newOrderDirection }
|
||||
@goToPaginated(@model, 0)
|
||||
|
||||
goToPaginated: (object, page) =>
|
||||
savedOrder = @savedOrderBy[object]
|
||||
|
||||
@globalSearch.search(
|
||||
query: @query
|
||||
object:object
|
||||
offset: page * 50
|
||||
orderBy: savedOrder?.orderBy
|
||||
orderDirection: savedOrder?.orderDirection
|
||||
)
|
||||
|
||||
updateTask: =>
|
||||
current = App.TaskManager.get(@taskKey).state
|
||||
return if !current
|
||||
|
|
|
|||
|
|
@ -9,6 +9,8 @@ class Template extends App.ControllerSubContent
|
|||
id: @id
|
||||
genericObject: 'Template'
|
||||
defaultSortBy: 'name'
|
||||
searchBar: true
|
||||
searchQuery: @search_query
|
||||
pageData:
|
||||
home: 'templates'
|
||||
object: __('Template')
|
||||
|
|
@ -16,7 +18,7 @@ class Template extends App.ControllerSubContent
|
|||
pagerAjax: true
|
||||
pagerBaseUrl: '#manage/templates/'
|
||||
pagerSelected: ( @page || 1 )
|
||||
pagerPerPage: 150
|
||||
pagerPerPage: 50
|
||||
navupdate: '#templates'
|
||||
buttons: [
|
||||
{ name: __('New Template'), 'data-type': 'new', class: 'btn--success' }
|
||||
|
|
@ -29,6 +31,6 @@ class Template extends App.ControllerSubContent
|
|||
if key isnt 'el' && key isnt 'shown' && key isnt 'match'
|
||||
@[key] = value
|
||||
|
||||
@genericController.paginate( @page || 1 )
|
||||
@genericController.paginate(@page || 1, params)
|
||||
|
||||
App.Config.set('Templates', { prio: 2320, name: __('Templates'), parent: '#manage', target: '#manage/templates', controller: Template, permission: ['admin.template'] }, 'NavBarAdmin')
|
||||
|
|
|
|||
|
|
@ -15,6 +15,8 @@ class TextModule extends App.ControllerSubContent
|
|||
container: @el.closest('.content')
|
||||
deleteOption: true
|
||||
)
|
||||
searchBar: true
|
||||
searchQuery: @search_query
|
||||
pageData:
|
||||
home: 'text_modules'
|
||||
object: __('Text module')
|
||||
|
|
@ -22,7 +24,7 @@ class TextModule extends App.ControllerSubContent
|
|||
pagerAjax: true
|
||||
pagerBaseUrl: '#manage/text_modules/'
|
||||
pagerSelected: ( @page || 1 )
|
||||
pagerPerPage: 150
|
||||
pagerPerPage: 50
|
||||
navupdate: '#text_modules'
|
||||
notes: [
|
||||
__('Text modules are …')
|
||||
|
|
@ -39,6 +41,6 @@ class TextModule extends App.ControllerSubContent
|
|||
if key isnt 'el' && key isnt 'shown' && key isnt 'match'
|
||||
@[key] = value
|
||||
|
||||
@genericController.paginate( @page || 1 )
|
||||
@genericController.paginate(@page || 1, params)
|
||||
|
||||
App.Config.set('TextModule', { prio: 2300, name: __('Text modules'), parent: '#manage', target: '#manage/text_modules', controller: TextModule, permission: ['admin.text_module'] }, 'NavBarAdmin')
|
||||
|
|
|
|||
|
|
@ -1317,7 +1317,7 @@ class TicketZoomRouter extends App.ControllerPermanent
|
|||
|
||||
App.Ajax.request(
|
||||
type: 'POST'
|
||||
url: "#{@apiPath}/tickets/search"
|
||||
url: "#{@apiPath}/tickets/search?full=true"
|
||||
processData: true
|
||||
data: JSON.stringify(
|
||||
condition: {
|
||||
|
|
@ -1329,8 +1329,8 @@ class TicketZoomRouter extends App.ControllerPermanent
|
|||
limit: 1
|
||||
)
|
||||
success: (data, status, xhr) =>
|
||||
return @byTicketId(params) if _.isEmpty(data.tickets)
|
||||
@navigate("ticket/zoom/#{data.tickets[0]}")
|
||||
return @byTicketId(params) if _.isEmpty(data.record_ids)
|
||||
@navigate("ticket/zoom/#{data.record_ids[0]}")
|
||||
error: =>
|
||||
@byTicketId(params)
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,117 +1,12 @@
|
|||
class User extends App.ControllerSubContent
|
||||
@requiredPermission: 'admin.user'
|
||||
header: __('Users')
|
||||
elements:
|
||||
'.js-search': 'searchInput'
|
||||
events:
|
||||
'click [data-type=new]': 'new'
|
||||
'click [data-type=import]': 'import'
|
||||
|
||||
constructor: ->
|
||||
super
|
||||
|
||||
@render()
|
||||
|
||||
show: =>
|
||||
super
|
||||
return if !@table
|
||||
@table.show()
|
||||
|
||||
hide: =>
|
||||
super
|
||||
return if !@table
|
||||
@table.hide()
|
||||
|
||||
render: ->
|
||||
roles = App.Role.findAllByAttribute('active', true)
|
||||
roles = _.sortBy(roles, (role) -> role.name.toLowerCase())
|
||||
|
||||
@html App.view('user')(
|
||||
head: __('Users')
|
||||
buttons: [
|
||||
{ name: __('Import'), 'data-type': 'import', class: 'btn' }
|
||||
{ name: __('New User'), 'data-type': 'new', class: 'btn--success' }
|
||||
]
|
||||
roles: roles
|
||||
)
|
||||
|
||||
@$('.tab').on(
|
||||
'click'
|
||||
(e) =>
|
||||
e.preventDefault()
|
||||
$(e.target).toggleClass('active')
|
||||
query = @searchInput.val().trim()
|
||||
@query = query
|
||||
@delay(@search, 220, 'search')
|
||||
)
|
||||
|
||||
# start search
|
||||
@searchInput.on( 'keyup', (e) =>
|
||||
query = @searchInput.val().trim()
|
||||
return if query is @query
|
||||
@query = query
|
||||
@delay(@search, 220, 'search')
|
||||
)
|
||||
|
||||
# App.User.subscribe will clear model data so we use controllerBind (#4040)
|
||||
@controllerBind('User:create User:update User:touch User:destroy', => @delay(@search, 220, 'search'))
|
||||
|
||||
# show last 20 users
|
||||
@search()
|
||||
|
||||
renderResult: (user_ids = []) ->
|
||||
@stopLoading()
|
||||
|
||||
switchTo = (id,e) =>
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
@disconnectClient()
|
||||
$('#app').hide().attr('style', 'display: none!important')
|
||||
@delay(
|
||||
=>
|
||||
App.Auth._logout(false)
|
||||
@ajax(
|
||||
id: 'user_switch'
|
||||
type: 'GET'
|
||||
url: "#{@apiPath}/sessions/switch/#{id}"
|
||||
success: (data, status, xhr) =>
|
||||
location = "#{window.location.protocol}//#{window.location.host}#{data.location}"
|
||||
@windowReload(undefined, location)
|
||||
)
|
||||
800
|
||||
)
|
||||
|
||||
edit = (id, e) =>
|
||||
e.preventDefault()
|
||||
item = App.User.find(id)
|
||||
|
||||
rerender = =>
|
||||
App.Group.fetch()
|
||||
@renderResult(user_ids)
|
||||
|
||||
hideOrganizationHelp = (params, attribute, attributes, classname, form, ui) ->
|
||||
return if App.Config.get('ticket_organization_reassignment')
|
||||
|
||||
form.find('[name="organization_id"]').closest('.form-group').find('.help-message').addClass('hide')
|
||||
|
||||
item.secondaryOrganizations(0, 1000, =>
|
||||
new App.ControllerGenericEdit(
|
||||
id: item.id
|
||||
pageData:
|
||||
title: __('Users')
|
||||
home: 'users'
|
||||
object: __('User')
|
||||
objects: __('Users')
|
||||
navupdate: '#users'
|
||||
genericObject: 'User'
|
||||
callback: rerender
|
||||
container: @el.closest('.content')
|
||||
handlers: [hideOrganizationHelp]
|
||||
screen: 'edit'
|
||||
veryLarge: true
|
||||
)
|
||||
)
|
||||
|
||||
callbackLoginAttribute = (value, object, attribute, attributes) ->
|
||||
attribute.prefixIcon = null
|
||||
attribute.title = null
|
||||
|
|
@ -122,153 +17,149 @@ class User extends App.ControllerSubContent
|
|||
|
||||
value
|
||||
|
||||
users = []
|
||||
for user_id in user_ids
|
||||
user = App.User.find(user_id)
|
||||
users.push user
|
||||
|
||||
@$('.table-overview').html('')
|
||||
@table = new App.ControllerTable(
|
||||
tableId: 'users_admin_overview'
|
||||
el: @$('.table-overview')
|
||||
model: App.User
|
||||
objects: users
|
||||
class: 'user-list'
|
||||
customActions: [
|
||||
@genericController = new App.ControllerGenericIndexUser(
|
||||
el: @el
|
||||
id: @id
|
||||
genericObject: 'User'
|
||||
importCallback: ->
|
||||
new App.Import(
|
||||
baseUrl: '/api/v1/users'
|
||||
container: @el.closest('.content')
|
||||
)
|
||||
defaultSortBy: 'created_at'
|
||||
searchBar: true
|
||||
searchQuery: @search_query
|
||||
filterMenu: [
|
||||
{
|
||||
name: 'switchTo'
|
||||
display: __('View from user\'s perspective')
|
||||
icon: 'switchView '
|
||||
class: 'create js-switchTo'
|
||||
callback: (id) =>
|
||||
@disconnectClient()
|
||||
$('#app').hide().attr('style', 'display: none!important')
|
||||
@delay(
|
||||
=>
|
||||
App.Auth._logout(false)
|
||||
@ajax(
|
||||
id: 'user_switch'
|
||||
type: 'GET'
|
||||
url: "#{@apiPath}/sessions/switch/#{id}"
|
||||
success: (data, status, xhr) =>
|
||||
location = "#{window.location.protocol}//#{window.location.host}#{data.location}"
|
||||
@windowReload(undefined, location)
|
||||
)
|
||||
800
|
||||
)
|
||||
},
|
||||
{
|
||||
name: 'manageTwoFactor'
|
||||
display: __('Manage Two-Factor Authentication')
|
||||
icon: 'two-factor'
|
||||
class: 'create js-manageTwoFactor'
|
||||
available: (user) ->
|
||||
!!user.preferences?.two_factor_authentication?.default
|
||||
callback: (id) ->
|
||||
user = App.User.find(id)
|
||||
return if !user
|
||||
|
||||
new App.ControllerManageTwoFactor(
|
||||
user: user
|
||||
)
|
||||
},
|
||||
{
|
||||
name: 'delete'
|
||||
display: __('Delete')
|
||||
icon: 'trash'
|
||||
class: 'delete'
|
||||
callback: (id) =>
|
||||
@navigate "#system/data_privacy/#{id}"
|
||||
},
|
||||
{
|
||||
name: 'unlock'
|
||||
display: __('Unlock')
|
||||
icon: 'lock-open'
|
||||
class: 'unlock'
|
||||
available: (user) ->
|
||||
user.maxLoginFailedReached()
|
||||
callback: (id) =>
|
||||
@ajax(
|
||||
id: "user_unlock_#{id}"
|
||||
type: 'PUT'
|
||||
url: "#{@apiPath}/users/unlock/#{id}"
|
||||
success: =>
|
||||
App.User.full(id,
|
||||
=> @notify(
|
||||
type: 'success'
|
||||
msg: __('User successfully unlocked!')
|
||||
|
||||
@renderResult(user_ids)
|
||||
),
|
||||
true)
|
||||
)
|
||||
name: 'Roles',
|
||||
data: _.map(roles, (role) -> return { id: role.id, name: role.name })
|
||||
}
|
||||
]
|
||||
callbackAttributes: {
|
||||
login: [ callbackLoginAttribute ]
|
||||
}
|
||||
bindRow:
|
||||
events:
|
||||
'click': edit
|
||||
)
|
||||
|
||||
search: =>
|
||||
role_ids = []
|
||||
@$('.tab.active').each( (i,d) ->
|
||||
role_ids.push $(d).data('id')
|
||||
)
|
||||
@startLoading(@$('.table-overview'))
|
||||
App.Ajax.request(
|
||||
id: 'search'
|
||||
type: 'GET'
|
||||
url: "#{@apiPath}/users/search?sort_by=created_at"
|
||||
data:
|
||||
query: @query || '*'
|
||||
limit: 50
|
||||
role_ids: role_ids
|
||||
full: true
|
||||
processData: true,
|
||||
success: (data, status, xhr) =>
|
||||
App.Collection.loadAssets(data.assets)
|
||||
@renderResult(data.user_ids)
|
||||
@stopLoading()
|
||||
done: =>
|
||||
@stopLoading()
|
||||
)
|
||||
|
||||
new: (e) ->
|
||||
e.preventDefault()
|
||||
new App.ControllerGenericNew(
|
||||
filterCallback: (active_filters, params) ->
|
||||
if active_filters && active_filters.length > 0
|
||||
params.role_ids = active_filters
|
||||
return params
|
||||
pageData:
|
||||
title: __('Users')
|
||||
home: 'users'
|
||||
object: __('User')
|
||||
objects: __('Users')
|
||||
home: 'users'
|
||||
object: __('User')
|
||||
objects: __('Users')
|
||||
pagerAjax: true
|
||||
pagerBaseUrl: '#manage/users/'
|
||||
pagerSelected: ( @page || 1 )
|
||||
pagerPerPage: 50
|
||||
navupdate: '#users'
|
||||
genericObject: 'User'
|
||||
screen: 'create',
|
||||
buttons: [
|
||||
{ name: __('Import'), 'data-type': 'import', class: 'btn' }
|
||||
{ name: __('New User'), 'data-type': 'new', class: 'btn--success' }
|
||||
]
|
||||
tableExtend: {
|
||||
callbackAttributes: {
|
||||
login: [ callbackLoginAttribute ]
|
||||
}
|
||||
customActions: [
|
||||
{
|
||||
name: 'switchTo'
|
||||
display: __('View from user\'s perspective')
|
||||
icon: 'switchView '
|
||||
class: 'create js-switchTo'
|
||||
callback: (id) =>
|
||||
@disconnectClient()
|
||||
$('#app').hide().attr('style', 'display: none!important')
|
||||
@delay(
|
||||
=>
|
||||
App.Auth._logout(false)
|
||||
@ajax(
|
||||
id: 'user_switch'
|
||||
type: 'GET'
|
||||
url: "#{@apiPath}/sessions/switch/#{id}"
|
||||
success: (data, status, xhr) =>
|
||||
location = "#{window.location.protocol}//#{window.location.host}#{data.location}"
|
||||
@windowReload(undefined, location)
|
||||
)
|
||||
800
|
||||
)
|
||||
},
|
||||
{
|
||||
name: 'manageTwoFactor'
|
||||
display: __('Manage Two-Factor Authentication')
|
||||
icon: 'two-factor'
|
||||
class: 'create js-manageTwoFactor'
|
||||
available: (user) ->
|
||||
!!user.preferences?.two_factor_authentication?.default
|
||||
callback: (id) ->
|
||||
user = App.User.find(id)
|
||||
return if !user
|
||||
|
||||
new App.ControllerManageTwoFactor(
|
||||
user: user
|
||||
)
|
||||
},
|
||||
{
|
||||
name: 'delete'
|
||||
display: __('Delete')
|
||||
icon: 'trash'
|
||||
class: 'delete'
|
||||
callback: (id) =>
|
||||
@navigate "#system/data_privacy/#{id}"
|
||||
},
|
||||
{
|
||||
name: 'unlock'
|
||||
display: __('Unlock')
|
||||
icon: 'lock-open'
|
||||
class: 'unlock'
|
||||
available: (user) ->
|
||||
user.maxLoginFailedReached()
|
||||
callback: (id) =>
|
||||
@ajax(
|
||||
id: "user_unlock_#{id}"
|
||||
type: 'PUT'
|
||||
url: "#{@apiPath}/users/unlock/#{id}"
|
||||
success: =>
|
||||
App.User.full(id, =>
|
||||
@notify(
|
||||
type: 'success'
|
||||
msg: __('User successfully unlocked!')
|
||||
)
|
||||
, true)
|
||||
)
|
||||
}
|
||||
]
|
||||
}
|
||||
container: @el.closest('.content')
|
||||
callback: @newUserAddedCallback
|
||||
veryLarge: true
|
||||
)
|
||||
|
||||
# GitHub Issue #3050
|
||||
# resets search input value to empty after new user added
|
||||
# resets any active role tab
|
||||
newUserAddedCallback: =>
|
||||
@searchInput.val('')
|
||||
@query = ''
|
||||
@resetActiveTabs()
|
||||
@search()
|
||||
show: (params) =>
|
||||
for key, value of params
|
||||
if key isnt 'el' && key isnt 'shown' && key isnt 'match'
|
||||
@[key] = value
|
||||
|
||||
resetActiveTabs: ->
|
||||
@$('.tab.active').removeClass('active')
|
||||
@genericController.paginate(@page || 1, params)
|
||||
|
||||
import: (e) ->
|
||||
class App.ControllerGenericIndexUser extends App.ControllerGenericIndex
|
||||
edit: (id, e) =>
|
||||
e.preventDefault()
|
||||
new App.Import(
|
||||
baseUrl: '/api/v1/users'
|
||||
container: @el.closest('.content')
|
||||
item = App.User.find(id)
|
||||
|
||||
hideOrganizationHelp = (params, attribute, attributes, classname, form, ui) ->
|
||||
return if App.Config.get('ticket_organization_reassignment')
|
||||
|
||||
form.find('[name="organization_id"]').closest('.form-group').find('.help-message').addClass('hide')
|
||||
|
||||
item.secondaryOrganizations(0, 1000, =>
|
||||
new App.ControllerGenericEdit(
|
||||
id: item.id
|
||||
pageData:
|
||||
title: __('Users')
|
||||
home: 'users'
|
||||
object: __('User')
|
||||
objects: __('Users')
|
||||
navupdate: '#users'
|
||||
genericObject: 'User'
|
||||
container: @el.closest('.content')
|
||||
handlers: [hideOrganizationHelp]
|
||||
screen: 'edit'
|
||||
veryLarge: true
|
||||
)
|
||||
)
|
||||
|
||||
App.Config.set( 'User', { prio: 1000, name: __('Users'), parent: '#manage', target: '#manage/users', controller: User, permission: ['admin.user'] }, 'NavBarAdmin' )
|
||||
|
|
|
|||
|
|
@ -403,8 +403,8 @@ class App.ObjectOrganizationAutocompletion extends App.Controller
|
|||
App.Collection.loadAssets(data.assets)
|
||||
|
||||
# user search endpoint
|
||||
if data.user_ids
|
||||
for id in data.user_ids
|
||||
if data.record_ids
|
||||
for id in data.record_ids
|
||||
object = App[@objectSingle].fullLocal(id)
|
||||
@recipientList.append(@buildObjectItem(object))
|
||||
|
||||
|
|
|
|||
|
|
@ -3,19 +3,22 @@ class App.GlobalSearch extends App.Controller
|
|||
constructor: ->
|
||||
super
|
||||
@searchResultCache = {}
|
||||
@lastQuery = undefined
|
||||
@lastParams = undefined
|
||||
@apiPath = App.Config.get('api_path')
|
||||
@ajaxId = "search-#{Math.floor( Math.random() * 999999 )}"
|
||||
|
||||
search: (params) =>
|
||||
query = params.query
|
||||
|
||||
cacheKey = @searchResultCacheKey(query, params)
|
||||
|
||||
# use cache for search result
|
||||
currentTime = new Date
|
||||
if !params.force && @searchResultCache[query] && @searchResultCache[query].time > currentTime.setSeconds(currentTime.getSeconds() - 20)
|
||||
if !params.force && @searchResultCache[cacheKey] && @searchResultCache[cacheKey].time > currentTime.setSeconds(currentTime.getSeconds() - 20)
|
||||
if @ajaxRequestId
|
||||
App.Ajax.abort(@ajaxRequestId)
|
||||
@ajaxStart(params)
|
||||
@renderTry(@searchResultCache[query].result, query, params)
|
||||
@renderTry(@searchResultCache[cacheKey].result, query, params)
|
||||
delayCallback = =>
|
||||
@ajaxStop(params)
|
||||
@delay(delayCallback, 700)
|
||||
|
|
@ -31,12 +34,17 @@ class App.GlobalSearch extends App.Controller
|
|||
@delay(delayCallback, 10000, 'global-search-ajax-longer-as-expected')
|
||||
|
||||
@ajaxRequestId = App.Ajax.request(
|
||||
id: @ajaxId
|
||||
type: 'GET'
|
||||
url: "#{@apiPath}/search"
|
||||
id: @ajaxId
|
||||
type: 'GET'
|
||||
url: "#{@apiPath}/search"
|
||||
data:
|
||||
query: query
|
||||
by_object: true
|
||||
objects: params.object
|
||||
limit: @limit || 10
|
||||
offset: params.offset
|
||||
order_by: params.orderDirection
|
||||
sort_by: params.orderBy
|
||||
processData: true
|
||||
success: (data, status, xhr) =>
|
||||
@clearDelay('global-search-ajax-longer-as-expected')
|
||||
|
|
@ -46,23 +54,30 @@ class App.GlobalSearch extends App.Controller
|
|||
organizationProfileAccess = @permissionCheck(App.Config.get('organization/profile/:organization_id', 'Routes').requiredPermission)
|
||||
|
||||
result = {}
|
||||
for item in data.result
|
||||
# user and organization are allowed via API but should not show
|
||||
# up for customers because there are no profile pages for customers
|
||||
continue if item.type is 'User' && !userProfileAccess
|
||||
continue if item.type is 'Organization' && !organizationProfileAccess
|
||||
for klassName, metadata of data.result
|
||||
# user and organization are allowed via API but should not show # up for customers because there are no profile pages for customers
|
||||
continue if klassName is 'User' && !userProfileAccess
|
||||
continue if klassName is 'Organization' && !organizationProfileAccess
|
||||
|
||||
klass = App[klassName]
|
||||
|
||||
if !klass.find
|
||||
App.Log.error('_globalSearchSingleton', "No such model App.#{klassName}")
|
||||
continue
|
||||
|
||||
item_objects = []
|
||||
|
||||
for item_id in metadata.object_ids
|
||||
item_object = klass.find(item_id)
|
||||
|
||||
if !item_object.searchResultAttributes
|
||||
App.Log.error('_globalSearchSingleton', "No such model #{klassName.toLocaleLowerCase()}.searchResultAttributes()")
|
||||
continue
|
||||
|
||||
item_objects.push(item_object.searchResultAttributes())
|
||||
|
||||
result[klassName] = { items: item_objects, total_count: metadata.total_count }
|
||||
|
||||
if App[item.type] && App[item.type].find
|
||||
if !result[item.type]
|
||||
result[item.type] = []
|
||||
item_object = App[item.type].find(item.id)
|
||||
if item_object.searchResultAttributes
|
||||
item_object_search_attributes = item_object.searchResultAttributes()
|
||||
result[item.type].push item_object_search_attributes
|
||||
else
|
||||
App.Log.error('_globalSearchSingleton', "No such model #{item.type.toLocaleLowerCase()}.searchResultAttributes()")
|
||||
else
|
||||
App.Log.error('_globalSearchSingleton', "No such model App.#{item.type}")
|
||||
@ajaxStop(params)
|
||||
@renderTry(result, query, params)
|
||||
error: =>
|
||||
|
|
@ -82,6 +97,7 @@ class App.GlobalSearch extends App.Controller
|
|||
params.callbackStop()
|
||||
|
||||
renderTry: (result, query, params) =>
|
||||
cacheKey = @searchResultCacheKey(query, params)
|
||||
|
||||
if query
|
||||
if _.isEmpty(result)
|
||||
|
|
@ -92,19 +108,22 @@ class App.GlobalSearch extends App.Controller
|
|||
params.callbackMatch()
|
||||
|
||||
# if result hasn't changed, do not rerender
|
||||
if !params.force && @lastQuery is query && @searchResultCache[query]
|
||||
diff = difference(@searchResultCache[query].result, result)
|
||||
if !params.force && @lastParams is params && @searchResultCache[cacheKey]
|
||||
diff = difference(@searchResultCache[cacheKey].result, result)
|
||||
if _.isEmpty(diff)
|
||||
return
|
||||
|
||||
@lastQuery = query
|
||||
@lastParams = params
|
||||
|
||||
# cache search result
|
||||
@searchResultCache[query] =
|
||||
@searchResultCache[cacheKey] =
|
||||
result: result
|
||||
time: new Date
|
||||
|
||||
@render(result)
|
||||
@render(result, params)
|
||||
|
||||
searchResultCacheKey: (query, params) ->
|
||||
"#{query}-#{params.object}-#{params.offset}-#{params.orderDirection}-#{params.orderBy}"
|
||||
|
||||
close: =>
|
||||
@lastQuery = undefined
|
||||
@lastParams = undefined
|
||||
|
|
|
|||
|
|
@ -759,7 +759,7 @@
|
|||
textmodule.emptyResultsContainer()
|
||||
|
||||
activeSet = false
|
||||
$.each(data.user_ids, function(index, user_id) {
|
||||
$.each(data.record_ids, function(index, user_id) {
|
||||
user = App.User.find(user_id)
|
||||
if (!user) return true
|
||||
if (!user.active) return true
|
||||
|
|
|
|||
|
|
@ -662,6 +662,73 @@ set new attributes of model (remove already available attributes)
|
|||
App.Log.error('Model', statusText, error, url)
|
||||
)
|
||||
|
||||
###
|
||||
|
||||
search full collection (with assets)
|
||||
|
||||
App.Model.searchFull(@callback)
|
||||
|
||||
App.Model.searchFull(
|
||||
@callback
|
||||
query: 'search string'
|
||||
page: 1
|
||||
per_page: 10
|
||||
sort_by: 'name'
|
||||
order_by: 'ASC'
|
||||
)
|
||||
|
||||
|
||||
###
|
||||
@searchFull: (callback, params = {}) ->
|
||||
if params.full is undefined
|
||||
params.full = true
|
||||
|
||||
url = "#{@url}/search"
|
||||
App.Log.debug('Model', "searchFull collection #{@className}", url)
|
||||
|
||||
# request already active, queue callback
|
||||
queueManagerName = "#{@className}::searchFull"
|
||||
|
||||
if params.refresh is undefined
|
||||
params.refresh = true
|
||||
|
||||
App.Ajax.request(
|
||||
type: 'POST'
|
||||
url: url
|
||||
processData: true,
|
||||
data: JSON.stringify(params)
|
||||
success: (data, status, xhr) =>
|
||||
App.Log.debug('Model', "got searchFull collection #{@className}", data)
|
||||
|
||||
recordIds = data.record_ids
|
||||
|
||||
# full / load assets
|
||||
if data.assets
|
||||
App.Collection.loadAssets(data.assets, targetModel: @className)
|
||||
|
||||
# if no record_ids are found, no initial render is fired
|
||||
if data.record_ids && _.isEmpty(data.record_ids) && params.refresh
|
||||
App[@className].trigger('refresh', [])
|
||||
|
||||
# find / load object
|
||||
else if params.refresh
|
||||
App[@className].refresh(data)
|
||||
|
||||
if callback
|
||||
localCallback = =>
|
||||
collection = []
|
||||
for id in recordIds
|
||||
collection.push App[@className].find(id)
|
||||
callback(collection, data)
|
||||
App.QueueManager.add(queueManagerName, localCallback)
|
||||
|
||||
App.QueueManager.run(queueManagerName)
|
||||
|
||||
error: (xhr, statusText, error) =>
|
||||
@searchFullActive = false
|
||||
App.Log.error('Model', statusText, error, url)
|
||||
)
|
||||
|
||||
@_bindsEmpty: ->
|
||||
if @SUBSCRIPTION_ITEM
|
||||
for id, keys of @SUBSCRIPTION_ITEM
|
||||
|
|
|
|||
|
|
@ -10,7 +10,6 @@ class App.Overview extends App.Model
|
|||
{ name: 'organization_shared', display: __('Only available for users with shared organizations'), tag: 'select', options: { true: 'yes', false: 'no' }, translate: true, default: false, null: true },
|
||||
{ name: 'out_of_office', display: __('Only available for users which are absence replacements for other users.'), tag: 'select', options: { true: 'yes', false: 'no' }, translate: true, default: false, null: true },
|
||||
{ name: 'condition', display: __('Conditions for shown tickets'), tag: 'ticket_selector', null: false, out_of_office: true },
|
||||
{ name: 'prio', display: __('Prio'), readonly: 1 },
|
||||
{
|
||||
name: 'view::s'
|
||||
display: __('Attributes')
|
||||
|
|
@ -65,6 +64,7 @@ class App.Overview extends App.Model
|
|||
DESC: __('descending')
|
||||
},
|
||||
{ name: 'active', display: __('Active'), tag: 'active', default: true },
|
||||
{ name: 'prio', display: __('Position'), tag: 'integer', type: 'number', limit: 100, null: true },
|
||||
{ name: 'created_by_id', display: __('Created by'), relation: 'User', readonly: 1 },
|
||||
{ name: 'created_at', display: __('Created'), tag: 'datetime', readonly: 1 },
|
||||
{ name: 'updated_by_id', display: __('Updated by'), relation: 'User', readonly: 1 },
|
||||
|
|
@ -76,6 +76,7 @@ class App.Overview extends App.Model
|
|||
'name',
|
||||
'link',
|
||||
'role_ids',
|
||||
'prio',
|
||||
]
|
||||
|
||||
@description = __('''
|
||||
|
|
|
|||
|
|
@ -27,6 +27,25 @@
|
|||
</div>
|
||||
|
||||
<div class="page-content">
|
||||
<% if @searchBar: %>
|
||||
<div class="searchfield">
|
||||
<input class="js-search form-control" name="search" value="<%= @searchQuery %>" placeholder="<%- @Ti('Search for %s', @objects) %>" type="search">
|
||||
<%- @Icon('magnifier') %>
|
||||
</div>
|
||||
<% end %>
|
||||
<% if @filterMenu: %>
|
||||
<% for filter in @filterMenu: %>
|
||||
<div class="userSearch horizontal">
|
||||
<div class="userSearch-label"><%- @T(filter.name) %>:</div>
|
||||
<div class="tabs tabs-wide">
|
||||
<% for filter in filter.data: %>
|
||||
<div class="tab" data-id="<%= filter.id %>"><%- @Ti(filter.name) %></div>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
<% end %>
|
||||
|
||||
<div class="table-overview"></div>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,12 @@
|
|||
<div class="btn-group btn-group--full" role="group" aria-label="" style="margin-bottom: 5px;">
|
||||
<% for page in [0..@pages]: %>
|
||||
<div class="btn btn--text btn--large js-page<%- ' is-selected' if @page is page || @page is page.toString() %>" data-page="<%= page %>"><%= page + 1 %></div>
|
||||
<div class="horizontal" role="group" aria-label="<%- @T('Pagination links') %>" style="margin-bottom: 5px;">
|
||||
<a href="#" class="btn btn--split--first btn--slim btn--small btn--secondary js-page<% if @page <= 0: %> is-disabled<% end %>" data-page="<%= @page - 1 %>" aria-label="<%- @T('Previous page') %>"<% if @page <= 0: %> aria-disabled="true"<% end %>>‹</a>
|
||||
<a href="#" class="btn btn--split btn--slim btn--small js-page<% if @page is 0: %> btn--primary btn--active<% else: %> btn--secondary<% end %>" data-page="0" aria-label="<%- @T('Page %s', 1) %>">1</a>
|
||||
<% if @pages > 1: %>
|
||||
<% for page in [1..@pages - 1]: %>
|
||||
<% continue if !_.contains([@page, @page - 1, @page - 2, @page + 1, @page + 2], page) %>
|
||||
<a href="#" class="btn btn--split btn--slim btn--small js-page<% if @page is page: %> btn--primary btn--active<% else: %> btn--secondary<% end %>" aria-label="<%- @T('Page %s', page + 1) %>" aria-current="true" data-page="<%= page %>"><% if page is @page - 2 || page is @page + 2: %>…<% else: %><%= page + 1 %><% end %></a>
|
||||
<% end %>
|
||||
<% end %>
|
||||
<a href="#" class="btn btn--split btn--slim btn--small js-page<% if @page is @pages: %> btn--primary btn--active<% else: %> btn--secondary<% end %>" data-page="<%= @pages %>" aria-label="<%- @T('Page %s', @pages + 1) %>"><%= @pages + 1 %></a>
|
||||
<a href="#" class="btn btn--split--last btn--slim btn--small btn--secondary js-page<% if @page >= @pages: %> is-disabled<% end %>" data-page="<%= @page + 1 %>" aria-label="<%- @T('Next page') %>"<% if @page >= @pages: %> aria-disabled="true"<% end %>>›</a>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
<% for area, items of @result: %>
|
||||
<% if done && items.length > 0: %> <li class="divider"></li> <% end %>
|
||||
<% for area, metadata of @result: %>
|
||||
<% if done && metadata.items.length > 0: %> <li class="divider"></li> <% end %>
|
||||
<% done = true %>
|
||||
<% for item in items: %>
|
||||
|
||||
<% for item in metadata.items: %>
|
||||
<li>
|
||||
<a href="<%- item.url %>" class="nav-tab nav-tab--search <%= item.class %>" data-id="<%= item.id %>" data-popover-show-avatar="true">
|
||||
<div class="nav-tab-icon">
|
||||
|
|
|
|||
|
|
@ -9049,27 +9049,6 @@ li.list-item-none {
|
|||
display: block;
|
||||
}
|
||||
|
||||
.table.user-list {
|
||||
tr:hover .switchView {
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
td .list {
|
||||
margin-top: -4px;
|
||||
}
|
||||
|
||||
.switchView {
|
||||
visibility: hidden;
|
||||
display: block;
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.btn {
|
||||
margin: 3px 5px 0;
|
||||
}
|
||||
}
|
||||
|
||||
.switchBackToUser {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
|
|
|||
|
|
@ -6,30 +6,18 @@ module ApplicationController::HasResponseExtentions
|
|||
private
|
||||
|
||||
def response_expand?
|
||||
return true if params[:expand] == true
|
||||
return true if params[:expand] == 'true'
|
||||
return true if params[:expand] == 1
|
||||
return true if params[:expand] == '1'
|
||||
|
||||
false
|
||||
ActiveModel::Type::Boolean.new.cast params[:expand]
|
||||
end
|
||||
|
||||
def response_full?
|
||||
return true if params[:full] == true
|
||||
return true if params[:full] == 'true'
|
||||
return true if params[:full] == 1
|
||||
return true if params[:full] == '1'
|
||||
|
||||
false
|
||||
ActiveModel::Type::Boolean.new.cast params[:full]
|
||||
end
|
||||
|
||||
def response_all?
|
||||
return true if params[:all] == true
|
||||
return true if params[:all] == 'true'
|
||||
return true if params[:all] == 1
|
||||
return true if params[:all] == '1'
|
||||
|
||||
false
|
||||
ActiveModel::Type::Boolean.new.cast params[:all]
|
||||
end
|
||||
|
||||
def response_only_total_count?
|
||||
ActiveModel::Type::Boolean.new.cast params[:only_total_count]
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -158,4 +158,80 @@ module ApplicationController::RendersModels
|
|||
rescue => e
|
||||
raise Exceptions::UnprocessableEntity, e
|
||||
end
|
||||
|
||||
def model_search_render(object, params)
|
||||
paginate_with(max: 200, default: 50)
|
||||
|
||||
generic_objects = object.search(
|
||||
query: params[:query] || params[:term],
|
||||
condition: params[:condition],
|
||||
ids: params[:ids],
|
||||
role_ids: params[:role_ids],
|
||||
group_ids: params[:group_ids],
|
||||
permissions: params[:permissions],
|
||||
only_total_count: response_only_total_count?,
|
||||
sort_by: params[:sort_by],
|
||||
order_by: params[:order_by],
|
||||
offset: pagination.offset,
|
||||
limit: pagination.limit,
|
||||
current_user: current_user,
|
||||
full: true,
|
||||
with_total_count: true,
|
||||
) || { objects: [], total_count: 0 }
|
||||
|
||||
if response_only_total_count?
|
||||
model_search_render_result_only_total_count(generic_objects[:total_count])
|
||||
elsif response_full?
|
||||
model_search_render_result_full(generic_objects)
|
||||
elsif response_expand?
|
||||
model_search_render_result_expand(generic_objects)
|
||||
elsif params[:label] || params[:term]
|
||||
model_search_render_result_label(object, generic_objects)
|
||||
else
|
||||
generic_objects_with_associations = generic_objects[:objects].map(&:attributes_with_association_ids)
|
||||
model_index_render_result(generic_objects_with_associations)
|
||||
end
|
||||
end
|
||||
|
||||
def model_search_render_result_only_total_count(total)
|
||||
render json: {
|
||||
total_count: total,
|
||||
}, status: :ok
|
||||
end
|
||||
|
||||
def model_search_render_result_full(generic_objects)
|
||||
assets = {}
|
||||
item_ids = []
|
||||
generic_objects[:objects].each do |item|
|
||||
item_ids.push item.id
|
||||
assets = item.assets(assets)
|
||||
end
|
||||
render json: {
|
||||
record_ids: item_ids,
|
||||
assets: assets,
|
||||
total_count: generic_objects[:total_count],
|
||||
}, status: :ok
|
||||
end
|
||||
|
||||
def model_search_render_result_expand(generic_objects)
|
||||
list = generic_objects[:objects].map(&:attributes_with_association_names)
|
||||
|
||||
render json: list, status: :ok
|
||||
end
|
||||
|
||||
def model_search_render_result_label(object, generic_objects)
|
||||
rows = generic_objects[:objects].map do |row|
|
||||
realname = row.try(:fullname, recipient_line: true) || row.try(:fullname) || row.try(:name) || row.try(:id)
|
||||
value = row.try(:email) || realname
|
||||
|
||||
if params[:term] && object.column_names.include?('active')
|
||||
{ id: row.id, label: realname, value: value, inactive: !row.active }
|
||||
else
|
||||
{ id: row.id, label: realname, value: realname }
|
||||
end
|
||||
end
|
||||
|
||||
render json: rows
|
||||
end
|
||||
|
||||
end
|
||||
|
|
|
|||
|
|
@ -135,6 +135,10 @@ curl http://localhost/api/v1/groups -v -u #{login}:#{password} -H "Content-Type:
|
|||
model_update_render(Group, params)
|
||||
end
|
||||
|
||||
def search
|
||||
model_search_render(Group, params)
|
||||
end
|
||||
|
||||
=begin
|
||||
|
||||
Resource:
|
||||
|
|
|
|||
|
|
@ -136,6 +136,10 @@ curl http://localhost/api/v1/macros.json -v -u #{login}:#{password} -H "Content-
|
|||
model_update_render(Macro, params)
|
||||
end
|
||||
|
||||
def search
|
||||
model_search_render(Macro, params)
|
||||
end
|
||||
|
||||
=begin
|
||||
|
||||
Resource:
|
||||
|
|
|
|||
|
|
@ -178,64 +178,7 @@ curl http://localhost/api/v1/organization/{id} -v -u #{login}:#{password} -H "Co
|
|||
|
||||
# GET /api/v1/organizations/search
|
||||
def search
|
||||
query = params[:query]
|
||||
if query.respond_to?(:permit!)
|
||||
query = query.permit!.to_h
|
||||
end
|
||||
query_params = {
|
||||
query: query,
|
||||
limit: pagination.limit,
|
||||
offset: pagination.offset,
|
||||
sort_by: params[:sort_by],
|
||||
order_by: params[:order_by],
|
||||
current_user: current_user,
|
||||
}
|
||||
%i[ids role_ids].each do |key|
|
||||
next if params[key].blank?
|
||||
|
||||
query_params[key] = params[key]
|
||||
end
|
||||
|
||||
# do query
|
||||
organization_all = Organization.search(query_params)
|
||||
|
||||
if response_expand?
|
||||
list = organization_all.map(&:attributes_with_association_names)
|
||||
render json: list, status: :ok
|
||||
return
|
||||
end
|
||||
|
||||
# build result list
|
||||
if params[:label]
|
||||
organizations = []
|
||||
organization_all.each do |organization|
|
||||
a = { id: organization.id, label: organization.name, value: organization.name }
|
||||
organizations.push a
|
||||
end
|
||||
|
||||
# return result
|
||||
render json: organizations
|
||||
return
|
||||
end
|
||||
|
||||
if response_full?
|
||||
organization_ids = []
|
||||
assets = {}
|
||||
organization_all.each do |organization|
|
||||
assets = organization.assets(assets)
|
||||
organization_ids.push organization.id
|
||||
end
|
||||
|
||||
# return result
|
||||
render json: {
|
||||
assets: assets,
|
||||
organization_ids: organization_ids.uniq,
|
||||
}
|
||||
return
|
||||
end
|
||||
|
||||
list = organization_all.map(&:attributes_with_association_ids)
|
||||
render json: list, status: :ok
|
||||
model_search_render(Organization, params)
|
||||
end
|
||||
|
||||
# GET /api/v1/organizations/history/1
|
||||
|
|
|
|||
|
|
@ -144,6 +144,10 @@ curl http://localhost/api/v1/overviews -v -u #{login}:#{password} -H "Content-Ty
|
|||
model_update_render(Overview, params)
|
||||
end
|
||||
|
||||
def search
|
||||
model_search_render(Overview, params)
|
||||
end
|
||||
|
||||
=begin
|
||||
|
||||
Resource:
|
||||
|
|
|
|||
|
|
@ -126,6 +126,10 @@ curl http://localhost/api/v1/roles.json -v -u #{login}:#{password} -H "Content-T
|
|||
model_update_render(Role, params)
|
||||
end
|
||||
|
||||
def search
|
||||
model_search_render(Role, params)
|
||||
end
|
||||
|
||||
=begin
|
||||
|
||||
Resource:
|
||||
|
|
|
|||
|
|
@ -7,38 +7,80 @@ class SearchController < ApplicationController
|
|||
# GET|POST /api/v1/search/:objects
|
||||
|
||||
def search_generic
|
||||
# get params
|
||||
query = params[:query]
|
||||
if query.respond_to?(:permit!)
|
||||
query = query.permit!.to_h
|
||||
end
|
||||
limit = params[:limit] || 10
|
||||
assets = search_result
|
||||
.result
|
||||
.values
|
||||
.each_with_object({}) { |index_result, memo| ApplicationModel::CanAssets.reduce index_result[:objects], memo }
|
||||
|
||||
# convert objects string into array of class names
|
||||
# e.g. user-ticket-another_object = %w( User Ticket AnotherObject )
|
||||
objects = if params[:objects]
|
||||
params[:objects].split('-').map { |x| x.camelize.constantize }
|
||||
else
|
||||
Models.searchable
|
||||
end
|
||||
|
||||
assets = {}
|
||||
result = []
|
||||
Service::Search.new(current_user: current_user).execute(
|
||||
term: query,
|
||||
objects: objects,
|
||||
options: { limit: limit, ids: params[:ids] },
|
||||
).each do |item|
|
||||
assets = item.assets(assets)
|
||||
result << {
|
||||
type: item.class.to_app_model.to_s,
|
||||
id: item[:id],
|
||||
}
|
||||
end
|
||||
result = if param_by_object?
|
||||
result_by_object
|
||||
else
|
||||
result_flattened
|
||||
end
|
||||
|
||||
render json: {
|
||||
assets: assets,
|
||||
result: result,
|
||||
}
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def result_by_object
|
||||
search_result
|
||||
.result
|
||||
.each_with_object({}) do |(model, metadata), memo|
|
||||
memo[model.to_app_model.to_s] = {
|
||||
object_ids: metadata[:objects].pluck(:id),
|
||||
total_count: metadata[:total_count]
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
def result_flattened
|
||||
search_result
|
||||
.flattened
|
||||
.map do |item|
|
||||
{
|
||||
type: item.class.to_app_model.to_s,
|
||||
id: item[:id],
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
def search_result
|
||||
@search_result ||= begin
|
||||
# get params
|
||||
query = params[:query].try(:permit!)&.to_h || params[:query]
|
||||
|
||||
Service::Search
|
||||
.new(current_user:, query:, objects: search_result_objects, options: search_result_options)
|
||||
.execute
|
||||
end
|
||||
end
|
||||
|
||||
def search_result_options
|
||||
{
|
||||
limit: params[:limit] || 10,
|
||||
ids: params[:ids],
|
||||
offset: params[:offset],
|
||||
sort_by: Array(params[:sort_by]).compact_blank.presence,
|
||||
order_by: Array(params[:order_by]).compact_blank.presence,
|
||||
with_total_count: param_by_object?,
|
||||
}.compact
|
||||
end
|
||||
|
||||
def param_by_object?
|
||||
@param_by_object ||= ActiveModel::Type::Boolean.new.cast(params[:by_object])
|
||||
end
|
||||
|
||||
def search_result_objects
|
||||
objects = Models.searchable
|
||||
|
||||
return objects if params[:objects].blank?
|
||||
|
||||
given_objects = params[:objects].split('-').map(&:downcase)
|
||||
|
||||
objects.select { |elem| given_objects.include? elem.to_app_model.to_s.downcase }
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -149,6 +149,10 @@ curl http://localhost/api/v1/templates/1.json -v -u #{login}:#{password} -H "Con
|
|||
model_update_render(policy_scope(Template), params)
|
||||
end
|
||||
|
||||
def search
|
||||
model_search_render(Template, params)
|
||||
end
|
||||
|
||||
=begin
|
||||
|
||||
Resource:
|
||||
|
|
|
|||
|
|
@ -131,6 +131,10 @@ curl http://localhost/api/v1/text_modules.json -v -u #{login}:#{password} -H "Co
|
|||
model_update_render(TextModule, params)
|
||||
end
|
||||
|
||||
def search
|
||||
model_search_render(TextModule, params)
|
||||
end
|
||||
|
||||
=begin
|
||||
|
||||
Resource:
|
||||
|
|
|
|||
|
|
@ -466,49 +466,7 @@ class TicketsController < ApplicationController
|
|||
|
||||
# GET /api/v1/tickets/search
|
||||
def search
|
||||
|
||||
# permit nested conditions
|
||||
if params[:condition]
|
||||
params.require(:condition).permit!
|
||||
end
|
||||
|
||||
paginate_with(max: 200, default: 50)
|
||||
|
||||
query = params[:query]
|
||||
if query.respond_to?(:permit!)
|
||||
query = query.permit!.to_h
|
||||
end
|
||||
|
||||
# build result list
|
||||
tickets = Ticket.search(
|
||||
query: query,
|
||||
condition: params[:condition].to_h,
|
||||
limit: pagination.limit,
|
||||
offset: pagination.offset,
|
||||
order_by: params[:order_by],
|
||||
sort_by: params[:sort_by],
|
||||
current_user: current_user,
|
||||
)
|
||||
|
||||
if response_expand?
|
||||
list = tickets.map(&:attributes_with_association_names)
|
||||
render json: list, status: :ok
|
||||
return
|
||||
end
|
||||
|
||||
assets = {}
|
||||
ticket_result = []
|
||||
tickets.each do |ticket|
|
||||
ticket_result.push ticket.id
|
||||
assets = ticket.assets(assets)
|
||||
end
|
||||
|
||||
# return result
|
||||
render json: {
|
||||
tickets: ticket_result,
|
||||
tickets_count: tickets.count,
|
||||
assets: assets,
|
||||
}
|
||||
model_search_render(Ticket, params)
|
||||
end
|
||||
|
||||
# GET /api/v1/ticket_stats
|
||||
|
|
|
|||
|
|
@ -218,84 +218,7 @@ class UsersController < ApplicationController
|
|||
# @response_message 200 [Array<User>] A list of User records matching the search term.
|
||||
# @response_message 403 Forbidden / Invalid session.
|
||||
def search
|
||||
query = params[:query]
|
||||
if query.respond_to?(:permit!)
|
||||
query.permit!.to_h
|
||||
end
|
||||
|
||||
query = params[:query] || params[:term]
|
||||
if query.respond_to?(:permit!)
|
||||
query = query.permit!.to_h
|
||||
end
|
||||
|
||||
query_params = {
|
||||
query: query,
|
||||
limit: pagination.limit,
|
||||
offset: pagination.offset,
|
||||
sort_by: params[:sort_by],
|
||||
order_by: params[:order_by],
|
||||
current_user: current_user,
|
||||
}
|
||||
%i[ids role_ids group_ids permissions].each do |key|
|
||||
next if params[key].blank?
|
||||
|
||||
query_params[key] = params[key]
|
||||
end
|
||||
|
||||
# do query
|
||||
user_all = User.search(query_params)
|
||||
|
||||
if response_expand?
|
||||
list = user_all.map(&:attributes_with_association_names)
|
||||
render json: list, status: :ok
|
||||
return
|
||||
end
|
||||
|
||||
# build result list
|
||||
if params[:label] || params[:term]
|
||||
users = []
|
||||
user_all.each do |user|
|
||||
realname = user.fullname
|
||||
|
||||
# improve realname, if possible
|
||||
if user.email.present? && realname != user.email
|
||||
begin
|
||||
realname = Channel::EmailBuild.recipient_line(realname, user.email)
|
||||
rescue Mail::Field::IncompleteParseError
|
||||
# mute if parsing of recipient_line was not successful / #5166
|
||||
end
|
||||
end
|
||||
a = if params[:term]
|
||||
{ id: user.id, label: realname, value: user.email, inactive: !user.active }
|
||||
else
|
||||
{ id: user.id, label: realname, value: realname }
|
||||
end
|
||||
users.push a
|
||||
end
|
||||
|
||||
# return result
|
||||
render json: users
|
||||
return
|
||||
end
|
||||
|
||||
if response_full?
|
||||
user_ids = []
|
||||
assets = {}
|
||||
user_all.each do |user|
|
||||
assets = user.assets(assets)
|
||||
user_ids.push user.id
|
||||
end
|
||||
|
||||
# return result
|
||||
render json: {
|
||||
assets: assets,
|
||||
user_ids: user_ids.uniq,
|
||||
}
|
||||
return
|
||||
end
|
||||
|
||||
list = user_all.map(&:attributes_with_association_ids)
|
||||
render json: list, status: :ok
|
||||
model_search_render(User, params)
|
||||
end
|
||||
|
||||
# @path [GET] /users/history/{id}
|
||||
|
|
|
|||
|
|
@ -16,17 +16,14 @@ module Gql::Queries
|
|||
|
||||
return [] if query.blank?
|
||||
|
||||
objects = Service::Search.new(current_user: context.current_user).execute(
|
||||
term: query,
|
||||
objects: input[:only_in] || Gql::Types::SearchResultType.searchable_models,
|
||||
options: { limit: limit }
|
||||
)
|
||||
|
||||
post_process(objects, input:)
|
||||
end
|
||||
|
||||
def post_process(results, input:)
|
||||
results.map { |object| coerce_to_result(object) }
|
||||
Service::Search
|
||||
.new(current_user: context.current_user,
|
||||
query: query,
|
||||
objects: input[:only_in] || Gql::Types::SearchResultType.searchable_models,
|
||||
options: { limit: limit })
|
||||
.execute
|
||||
.flattened
|
||||
.map { |object| coerce_to_result(object) }
|
||||
end
|
||||
|
||||
def coerce_to_result(object)
|
||||
|
|
|
|||
|
|
@ -16,11 +16,16 @@ module Gql::Queries
|
|||
|
||||
return [] if query.strip.empty?
|
||||
|
||||
Service::Search.new(current_user: context.current_user).execute(
|
||||
term: query,
|
||||
objects: [::Organization],
|
||||
options: { limit: limit, ids: customer_ids(input[:customer]) },
|
||||
).map { |organization| coerce_to_result(organization) }
|
||||
Service::Search
|
||||
.new(
|
||||
current_user: context.current_user,
|
||||
query: query,
|
||||
objects: [::Organization],
|
||||
options: { limit: limit, ids: customer_ids(input[:customer]) },
|
||||
)
|
||||
.execute
|
||||
.flattened
|
||||
.map { |organization| coerce_to_result(organization) }
|
||||
end
|
||||
|
||||
def coerce_to_result(organization)
|
||||
|
|
|
|||
|
|
@ -12,11 +12,15 @@ module Gql::Queries
|
|||
type [Gql::Types::SearchResultType, { null: false }], null: false
|
||||
|
||||
def resolve(search:, only_in: nil, limit: 10)
|
||||
Service::Search.new(current_user: context.current_user).execute(
|
||||
term: search,
|
||||
objects: only_in ? [only_in] : Gql::Types::SearchResultType.searchable_models,
|
||||
options: { limit: limit }
|
||||
)
|
||||
Service::Search
|
||||
.new(
|
||||
current_user: context.current_user,
|
||||
query: search,
|
||||
objects: only_in ? [only_in] : Gql::Types::SearchResultType.searchable_models,
|
||||
options: { limit: limit }
|
||||
)
|
||||
.execute
|
||||
.flattened
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
class Chat::Session < ApplicationModel
|
||||
include HasSearchIndexBackend
|
||||
include CanSelector
|
||||
include HasTags
|
||||
|
||||
include Chat::Session::Search
|
||||
|
|
|
|||
|
|
@ -4,6 +4,8 @@ class Chat::Session
|
|||
module Search
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
include CanSearch
|
||||
|
||||
# methods defined here are going to extend the class, not the instance of it
|
||||
class_methods do
|
||||
|
||||
|
|
@ -34,56 +36,6 @@ returns if user has no permissions to search
|
|||
direct_search_index: true,
|
||||
}
|
||||
end
|
||||
|
||||
=begin
|
||||
|
||||
search organizations
|
||||
|
||||
result = Chat::Session.search(
|
||||
current_user: User.find(123),
|
||||
query: 'search something',
|
||||
limit: 15,
|
||||
offset: 100,
|
||||
)
|
||||
|
||||
returns
|
||||
|
||||
result = [organization_model1, organization_model2]
|
||||
|
||||
=end
|
||||
|
||||
def search(params)
|
||||
|
||||
# get params
|
||||
query = params[:query]
|
||||
limit = params[:limit] || 10
|
||||
offset = params[:offset] || 0
|
||||
current_user = params[:current_user]
|
||||
|
||||
# enable search only for agents and admins
|
||||
return [] if !search_preferences(current_user)
|
||||
|
||||
# try search index backend
|
||||
if SearchIndexBackend.enabled?
|
||||
items = SearchIndexBackend.search(query, 'Chat::Session', limit: limit, from: offset)
|
||||
chat_sessions = []
|
||||
items.each do |item|
|
||||
chat_session = Chat::Session.lookup(id: item[:id])
|
||||
next if !chat_session
|
||||
|
||||
chat_sessions.push chat_session
|
||||
end
|
||||
return chat_sessions
|
||||
end
|
||||
|
||||
# fallback do sql query
|
||||
# - stip out * we already search for *query* -
|
||||
query.delete! '*'
|
||||
Chat::Session.where(
|
||||
'name LIKE ?', "%#{SqlHelper.quote_like(query)}%"
|
||||
).reorder('name').offset(offset).limit(limit).to_a
|
||||
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
300
app/models/concerns/can_search.rb
Normal file
300
app/models/concerns/can_search.rb
Normal file
|
|
@ -0,0 +1,300 @@
|
|||
# Copyright (C) 2012-2024 Zammad Foundation, https://zammad-foundation.org/
|
||||
|
||||
module CanSearch
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
included do
|
||||
|
||||
=begin
|
||||
|
||||
This function provides the possibility to add model specific sql extensions
|
||||
for the searches in the DB. E.g. role or group specific conditions
|
||||
in user model.
|
||||
|
||||
see e.g. also app/models/user/search.rb
|
||||
|
||||
=end
|
||||
|
||||
scope :search_sql_extension, ->(_params) {}
|
||||
|
||||
=begin
|
||||
|
||||
This function defines the sql search query for the text fields which are searched in. By default
|
||||
it is all string columns but can be modified.
|
||||
|
||||
see e.g. also app/models/ticket/search.rb
|
||||
|
||||
=end
|
||||
|
||||
scope :search_sql_query_extension, lambda { |params|
|
||||
return if params[:query].blank?
|
||||
|
||||
search_columns = columns.select { |row| row.type == :string }.map(&:name)
|
||||
return if search_columns.blank?
|
||||
|
||||
where_or_cis(search_columns, "%#{SqlHelper.quote_like(params[:query].to_s.downcase)}%")
|
||||
}
|
||||
|
||||
# Scope to specific IDs if they're given in params.
|
||||
# Usually those IDs are pre-filled in .search_params_pre method.
|
||||
scope :search_sql_ids, lambda { |params|
|
||||
where(id: params[:ids]) if params[:ids].present?
|
||||
}
|
||||
end
|
||||
|
||||
class_methods do
|
||||
|
||||
=begin
|
||||
|
||||
This defines the default search sort by for the search function.
|
||||
|
||||
=end
|
||||
|
||||
def search_default_sort_by
|
||||
'updated_at'
|
||||
end
|
||||
|
||||
=begin
|
||||
|
||||
This defines the default search order by for the search function.
|
||||
|
||||
=end
|
||||
|
||||
def search_default_order_by
|
||||
'desc'
|
||||
end
|
||||
|
||||
=begin
|
||||
|
||||
This function can be used to fix parameters for the model
|
||||
e.g. is used to restrict the result set of organization searches
|
||||
to only return customer organizations in case of a customer user
|
||||
|
||||
see e.g. also app/models/organization/search.rb
|
||||
|
||||
=end
|
||||
|
||||
def search_params_pre(params)
|
||||
# optional
|
||||
end
|
||||
|
||||
=begin
|
||||
|
||||
This function provides the possibility to add model specific query extensions
|
||||
for the searches in the elasticsearch. E.g. role or group specific conditions
|
||||
in user model.
|
||||
|
||||
see e.g. also app/models/user/search.rb
|
||||
|
||||
=end
|
||||
|
||||
def search_query_extension(params)
|
||||
# optional
|
||||
end
|
||||
|
||||
=begin
|
||||
|
||||
search objects via search index
|
||||
|
||||
result = Model.search(
|
||||
current_user: User.find(123),
|
||||
query: 'search something',
|
||||
limit: 15,
|
||||
offset: 100,
|
||||
)
|
||||
|
||||
returns
|
||||
|
||||
result = [obj1, obj2, obj3]
|
||||
|
||||
search objects via search index with total count
|
||||
|
||||
result = Model.search(
|
||||
current_user: User.find(123),
|
||||
query: 'search something',
|
||||
limit: 15,
|
||||
offset: 100,
|
||||
with_total_count: true
|
||||
)
|
||||
|
||||
returns
|
||||
|
||||
result = {
|
||||
object_ids: [1,2,3],
|
||||
count: 3,
|
||||
}
|
||||
|
||||
search objects via search index with ONLY total count
|
||||
|
||||
result = Model.search(
|
||||
current_user: User.find(123),
|
||||
query: 'search something',
|
||||
limit: 15,
|
||||
offset: 100,
|
||||
only_total_count: true
|
||||
)
|
||||
|
||||
returns
|
||||
|
||||
result = {
|
||||
count: 3,
|
||||
}
|
||||
|
||||
search objects via search index
|
||||
|
||||
result = Model.search(
|
||||
current_user: User.find(123),
|
||||
query: 'search something',
|
||||
limit: 15,
|
||||
offset: 100,
|
||||
full: false,
|
||||
)
|
||||
|
||||
returns
|
||||
|
||||
result = [1,2,3]
|
||||
|
||||
search objects via database
|
||||
|
||||
result = Group.search(
|
||||
current_user: User.find(123),
|
||||
query: 'some query', # query or condition is required
|
||||
condition: {
|
||||
'groups.id' => {
|
||||
operator: 'is',
|
||||
value: [1,2,3],
|
||||
},
|
||||
},
|
||||
limit: 15,
|
||||
offset: 100,
|
||||
|
||||
# sort single column
|
||||
sort_by: 'created_at',
|
||||
order_by: 'asc',
|
||||
|
||||
# sort multiple columns
|
||||
sort_by: [ 'created_at', 'updated_at' ],
|
||||
order_by: [ 'asc', 'desc' ],
|
||||
|
||||
full: false,
|
||||
)
|
||||
|
||||
returns
|
||||
|
||||
result = [1,2,3]
|
||||
|
||||
=end
|
||||
|
||||
def search(params)
|
||||
# It's possible to search objects that don't have .search_preferences method.
|
||||
# However, if .search_preferences exist and return falsey value, search is not authorized in a given context!
|
||||
# Thus we need to check if method exist instead of using try()!
|
||||
return if defined?(search_preferences) && !search_preferences(params[:current_user])
|
||||
|
||||
params = search_build_params(params)
|
||||
|
||||
# try search index backend
|
||||
# we only search in elastic search when we have a query present
|
||||
# else we try to use the database result, since it is more up to date
|
||||
object_ids, object_count = if SearchIndexBackend.enabled? && included_modules.include?(HasSearchIndexBackend) && params[:query].present?
|
||||
search_es(params)
|
||||
else
|
||||
search_sql(params)
|
||||
end
|
||||
|
||||
search_result(params, object_ids, object_count)
|
||||
end
|
||||
|
||||
def search_result(params, object_ids, object_count)
|
||||
if params[:only_total_count].present?
|
||||
{
|
||||
total_count: object_count,
|
||||
}
|
||||
elsif params[:with_total_count].present?
|
||||
if params[:full].present?
|
||||
return {
|
||||
objects: object_ids.map { |id| lookup(id: id) },
|
||||
total_count: object_count
|
||||
}
|
||||
end
|
||||
|
||||
{
|
||||
object_ids: object_ids,
|
||||
total_count: object_count
|
||||
}
|
||||
elsif params[:full].present?
|
||||
object_ids.map { |id| lookup(id: id) }
|
||||
else
|
||||
object_ids
|
||||
end
|
||||
end
|
||||
|
||||
def search_build_params(params)
|
||||
search_params_pre(params)
|
||||
|
||||
sql_helper = ::SqlHelper.new(object: self)
|
||||
|
||||
params[:condition] ||= {}
|
||||
params[:limit] ||= 50
|
||||
params[:query] = params[:query]&.delete('*')
|
||||
params[:offset] = params[:offset].presence || params[:from].presence || 0
|
||||
params[:full] = !params.key?(:full) || ActiveModel::Type::Boolean.new.cast(params[:full])
|
||||
params[:sort_by] = sql_helper.get_sort_by(params, search_default_sort_by)
|
||||
params[:order_by] = sql_helper.get_order_by(params, search_default_order_by)
|
||||
|
||||
params
|
||||
end
|
||||
|
||||
def search_es(params)
|
||||
result = SearchIndexBackend.search_by_index(
|
||||
params[:query],
|
||||
to_s,
|
||||
params.merge(query_extension: search_query_extension(params), with_total_count: true)
|
||||
)
|
||||
|
||||
if params[:only_total_count].blank?
|
||||
object_ids = result&.dig(:object_metadata)&.pluck(:id) || []
|
||||
end
|
||||
|
||||
object_count = result&.dig(:total_count) || 0
|
||||
|
||||
[object_ids, object_count]
|
||||
end
|
||||
|
||||
def search_sql(params)
|
||||
scope = search_sql_base(params)
|
||||
|
||||
objects_order_sql = sql_helper.get_order(params[:sort_by], params[:order_by], "#{table_name}.updated_at DESC")
|
||||
|
||||
objects_scope = scope
|
||||
.reorder(Arel.sql(objects_order_sql))
|
||||
.offset(params[:offset])
|
||||
.limit(params[:limit])
|
||||
.group(:id)
|
||||
|
||||
if params[:only_total_count].blank?
|
||||
object_ids = objects_scope.pluck(:id)
|
||||
end
|
||||
|
||||
object_count = scope.count("DISTINCT #{table_name}.id")
|
||||
|
||||
[object_ids, object_count]
|
||||
end
|
||||
|
||||
def search_sql_base(params)
|
||||
query_condition, bind_condition, tables = selector2sql(params[:condition])
|
||||
|
||||
scope = params[:scope].present? ? params[:scope].new(params[:current_user]).resolve : all
|
||||
|
||||
scope
|
||||
.joins(tables).where(query_condition, *bind_condition)
|
||||
.search_sql_extension(params)
|
||||
.search_sql_query_extension(params)
|
||||
.search_sql_ids(params)
|
||||
end
|
||||
|
||||
def sql_helper
|
||||
@sql_helper ||= ::SqlHelper.new(object: self)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -12,8 +12,8 @@ module CanSelector
|
|||
return [] if !query
|
||||
|
||||
ActiveRecord::Base.transaction(requires_new: true) do
|
||||
tickets = distinct.where(query, *bind_params).joins(tables).reorder(options[:order_by])
|
||||
[tickets.count, tickets.limit(limit)]
|
||||
objects = distinct.where(query, *bind_params).joins(tables).reorder(options[:order_by])
|
||||
[objects.count, objects.limit(limit)]
|
||||
rescue ActiveRecord::StatementInvalid => e
|
||||
Rails.logger.error e
|
||||
raise ActiveRecord::Rollback
|
||||
|
|
|
|||
|
|
@ -11,6 +11,8 @@ class Group < ApplicationModel
|
|||
include HasObjectManagerAttributes
|
||||
include HasCollectionUpdate
|
||||
include HasSearchIndexBackend
|
||||
include CanSelector
|
||||
include CanSearch
|
||||
|
||||
include Group::Assets
|
||||
|
||||
|
|
|
|||
|
|
@ -6,8 +6,8 @@ class KnowledgeBase::Answer::Translation < ApplicationModel
|
|||
include HasAgentAllowedParams
|
||||
include HasLinks
|
||||
include HasSearchIndexBackend
|
||||
include KnowledgeBase::Search
|
||||
include KnowledgeBase::HasUniqueTitle
|
||||
include KnowledgeBase::Answer::Translation::Search
|
||||
|
||||
AGENT_ALLOWED_ATTRIBUTES = %i[title kb_locale_id].freeze
|
||||
AGENT_ALLOWED_NESTED_RELATIONS = %i[content].freeze
|
||||
|
|
@ -67,50 +67,18 @@ class KnowledgeBase::Answer::Translation < ApplicationModel
|
|||
output
|
||||
end
|
||||
|
||||
class << self
|
||||
def search_preferences(current_user)
|
||||
return false if !KnowledgeBase.exists? || !current_user.permissions?('knowledge_base.*')
|
||||
scope :search_sql_text_fallback, lambda { |query|
|
||||
fields = %w[title]
|
||||
fields << KnowledgeBase::Answer::Translation::Content.arel_table[:body]
|
||||
|
||||
{
|
||||
prio: 1209,
|
||||
direct_search_index: false,
|
||||
}
|
||||
end
|
||||
|
||||
def search_es_filter(es_response, _query, kb_locales, options)
|
||||
return es_response if options[:user]&.permissions?('knowledge_base.editor')
|
||||
|
||||
answer_translations_id = es_response.pluck(:id)
|
||||
|
||||
allowed_answer_translation_ids = KnowledgeBase::Answer
|
||||
.internal
|
||||
.joins(:translations)
|
||||
.where(knowledge_base_answer_translations: { id: answer_translations_id, kb_locale_id: kb_locales.map(&:id) })
|
||||
.pluck('knowledge_base_answer_translations.id')
|
||||
|
||||
es_response.filter { |elem| allowed_answer_translation_ids.include? elem[:id].to_i }
|
||||
end
|
||||
|
||||
def search_fallback(query, scope = nil, options: {})
|
||||
fields = %w[title]
|
||||
fields << KnowledgeBase::Answer::Translation::Content.arel_table[:body]
|
||||
|
||||
output = where_or_cis(fields, query)
|
||||
.joins(:content)
|
||||
|
||||
if !options[:user]&.permissions?('knowledge_base.editor')
|
||||
answer_ids = KnowledgeBase::Answer.internal.pluck(:id)
|
||||
|
||||
output = output.where(answer_id: answer_ids)
|
||||
end
|
||||
|
||||
if scope.present?
|
||||
output = output
|
||||
.joins(:answer)
|
||||
.where(knowledge_base_answers: { category_id: scope })
|
||||
end
|
||||
where_or_cis(fields, query).joins(:content)
|
||||
}
|
||||
|
||||
scope :apply_kb_scope, lambda { |scope|
|
||||
if scope.present?
|
||||
output
|
||||
.joins(:answer)
|
||||
.where(knowledge_base_answers: { category_id: scope })
|
||||
end
|
||||
end
|
||||
}
|
||||
end
|
||||
|
|
|
|||
51
app/models/knowledge_base/answer/translation/search.rb
Normal file
51
app/models/knowledge_base/answer/translation/search.rb
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
# Copyright (C) 2012-2024 Zammad Foundation, https://zammad-foundation.org/
|
||||
|
||||
class KnowledgeBase::Answer::Translation
|
||||
module Search
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
include CanSelector
|
||||
include CanSearch
|
||||
|
||||
included do
|
||||
scope :search_sql_extension, lambda { |params|
|
||||
return if params[:current_user]&.permissions?('knowledge_base.editor')
|
||||
|
||||
answer_ids = KnowledgeBase::Answer.internal.pluck(:id)
|
||||
|
||||
where(answer_id: answer_ids)
|
||||
}
|
||||
|
||||
scope :search_sql_query_extension, lambda { |params|
|
||||
return if params[:query].blank?
|
||||
|
||||
search_sql_text_fallback(params[:query])
|
||||
}
|
||||
end
|
||||
|
||||
class_methods do
|
||||
def search_preferences(current_user)
|
||||
return false if !KnowledgeBase.exists? || !current_user.permissions?('knowledge_base.*')
|
||||
|
||||
{
|
||||
prio: 1209,
|
||||
direct_search_index: false,
|
||||
}
|
||||
end
|
||||
|
||||
def search_query_extension(params)
|
||||
kb_locales = KnowledgeBase.active.map { |elem| KnowledgeBase::Locale.preferred(params[:current_user], elem) }
|
||||
|
||||
output = { bool: { filter: { terms: { kb_locale_id: kb_locales.map(&:id) } } } }
|
||||
|
||||
return output if params[:current_user]&.permissions?('knowledge_base.editor')
|
||||
|
||||
output[:bool][:must] = [ { terms: {
|
||||
answer_id: KnowledgeBase::Answer.internal.pluck(:id)
|
||||
} } ]
|
||||
|
||||
output
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -3,7 +3,6 @@
|
|||
class KnowledgeBase::Category::Translation < ApplicationModel
|
||||
include HasAgentAllowedParams
|
||||
include HasSearchIndexBackend
|
||||
include KnowledgeBase::Search
|
||||
include KnowledgeBase::HasUniqueTitle
|
||||
|
||||
AGENT_ALLOWED_ATTRIBUTES = %i[title kb_locale_id].freeze
|
||||
|
|
@ -37,19 +36,14 @@ class KnowledgeBase::Category::Translation < ApplicationModel
|
|||
attrs
|
||||
end
|
||||
|
||||
class << self
|
||||
def search_fallback(query, scope = nil, options: {})
|
||||
fields = %w[title]
|
||||
scope :search_sql_text_fallback, lambda { |query|
|
||||
where_or_cis(%w[title], query)
|
||||
}
|
||||
|
||||
output = where_or_cis(fields, query)
|
||||
|
||||
if scope.present?
|
||||
output = output
|
||||
.joins(:category)
|
||||
.where(knowledge_base_categories: { parent_id: scope })
|
||||
end
|
||||
|
||||
output
|
||||
scope :apply_kb_scope, lambda { |scope|
|
||||
if scope.present?
|
||||
joins(:category)
|
||||
.where(knowledge_base_categories: { parent_id: scope })
|
||||
end
|
||||
end
|
||||
}
|
||||
end
|
||||
|
|
|
|||
|
|
@ -1,60 +0,0 @@
|
|||
# Copyright (C) 2012-2024 Zammad Foundation, https://zammad-foundation.org/
|
||||
|
||||
class KnowledgeBase
|
||||
module Search
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
class_methods do
|
||||
def search(params)
|
||||
current_user = params[:current_user]
|
||||
# enable search only for agents and admins
|
||||
return [] if !search_preferences(current_user)
|
||||
|
||||
sql_helper = ::SqlHelper.new(object: self)
|
||||
|
||||
options = {
|
||||
limit: params[:limit] || 10,
|
||||
from: params[:offset] || 0,
|
||||
sort_by: sql_helper.get_sort_by(params, 'updated_at'),
|
||||
order_by: sql_helper.get_order_by(params, 'desc'),
|
||||
user: current_user
|
||||
}
|
||||
|
||||
kb_locales = KnowledgeBase.active.map { |elem| KnowledgeBase::Locale.preferred(current_user, elem) }
|
||||
|
||||
# try search index backend
|
||||
if SearchIndexBackend.enabled?
|
||||
search_es(params[:query], kb_locales, options)
|
||||
else
|
||||
# fallback do sql query
|
||||
search_sql(params[:query], kb_locales, options)
|
||||
end
|
||||
end
|
||||
|
||||
def search_es(query, kb_locales, options)
|
||||
options[:query_extension] = { bool: { filter: { terms: { kb_locale_id: kb_locales.map(&:id) } } } }
|
||||
|
||||
es_response = SearchIndexBackend.search(query, name, options)
|
||||
es_response = search_es_filter(es_response, query, kb_locales, options) if defined? :search_es_filter
|
||||
|
||||
es_response.filter_map { |item| lookup(id: item[:id]) }
|
||||
end
|
||||
|
||||
def search_sql(query, kb_locales, options)
|
||||
table_name = arel_table.name
|
||||
sql_helper = ::SqlHelper.new(object: self)
|
||||
order_sql = sql_helper.get_order(options[:sort_by], options[:order_by], "#{table_name}.updated_at ASC")
|
||||
|
||||
# - strip out * we already search for *query* -
|
||||
query = query.delete '*'
|
||||
|
||||
search_fallback("%#{query}%", options: options)
|
||||
.where(kb_locale: kb_locales)
|
||||
.reorder(Arel.sql(order_sql))
|
||||
.offset(options[:from])
|
||||
.limit(options[:limit])
|
||||
.to_a
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -3,7 +3,6 @@
|
|||
class KnowledgeBase::Translation < ApplicationModel
|
||||
include HasAgentAllowedParams
|
||||
include HasSearchIndexBackend
|
||||
include KnowledgeBase::Search
|
||||
|
||||
AGENT_ALLOWED_ATTRIBUTES = %i[title footer_note kb_locale_id].freeze
|
||||
|
||||
|
|
@ -28,17 +27,11 @@ class KnowledgeBase::Translation < ApplicationModel
|
|||
attrs
|
||||
end
|
||||
|
||||
class << self
|
||||
def search_fallback(query, scope = nil, options: {})
|
||||
fields = %w[title]
|
||||
scope :search_sql_text_fallback, lambda { |query|
|
||||
where_or_cis(%w[title], query)
|
||||
}
|
||||
|
||||
output = where_or_cis(fields, query)
|
||||
|
||||
if scope.present?
|
||||
output = output.where(id: 0) # KB cannot be in any scope
|
||||
end
|
||||
|
||||
output
|
||||
end
|
||||
end
|
||||
scope :apply_kb_scope, lambda { |scope|
|
||||
none if scope.present?
|
||||
}
|
||||
end
|
||||
|
|
|
|||
|
|
@ -5,6 +5,9 @@ class Macro < ApplicationModel
|
|||
include ChecksHtmlSanitized
|
||||
include CanSeed
|
||||
include HasCollectionUpdate
|
||||
include HasSearchIndexBackend
|
||||
include CanSelector
|
||||
include CanSearch
|
||||
include Macro::TriggersSubscriptions
|
||||
|
||||
store :perform
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ class Organization < ApplicationModel
|
|||
include ChecksClientNotification
|
||||
include HasHistory
|
||||
include HasSearchIndexBackend
|
||||
include CanSelector
|
||||
include CanCsvImport
|
||||
include ChecksHtmlSanitized
|
||||
include HasObjectManagerAttributes
|
||||
|
|
|
|||
|
|
@ -4,6 +4,8 @@ class Organization
|
|||
module Search
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
include CanSearch
|
||||
|
||||
# methods defined here are going to extend the class, not the instance of it
|
||||
class_methods do
|
||||
|
||||
|
|
@ -41,121 +43,18 @@ returns if user has no permissions to search
|
|||
false
|
||||
end
|
||||
|
||||
=begin
|
||||
def search_default_sort_by
|
||||
%w[active updated_at]
|
||||
end
|
||||
|
||||
search organizations
|
||||
def search_default_order_by
|
||||
%w[desc desc]
|
||||
end
|
||||
|
||||
result = Organization.search(
|
||||
current_user: User.find(123),
|
||||
query: 'search something',
|
||||
limit: 15,
|
||||
offset: 100,
|
||||
def search_params_pre(params)
|
||||
return if !customer_only?(params[:current_user])
|
||||
|
||||
# sort single column
|
||||
sort_by: 'created_at',
|
||||
order_by: 'asc',
|
||||
|
||||
# sort multiple columns
|
||||
sort_by: [ 'created_at', 'updated_at' ],
|
||||
order_by: [ 'asc', 'desc' ],
|
||||
)
|
||||
|
||||
returns
|
||||
|
||||
result = [organization_model1, organization_model2]
|
||||
|
||||
=end
|
||||
|
||||
def search(params)
|
||||
|
||||
# get params
|
||||
query = params[:query]
|
||||
limit = params[:limit] || 10
|
||||
offset = params[:offset] || 0
|
||||
current_user = params[:current_user]
|
||||
|
||||
sql_helper = ::SqlHelper.new(object: self)
|
||||
|
||||
# check sort - positions related to order by
|
||||
sort_by = sql_helper.get_sort_by(params, %w[active updated_at])
|
||||
|
||||
# check order - positions related to sort by
|
||||
order_by = sql_helper.get_order_by(params, %w[desc desc])
|
||||
|
||||
# enable search only for permitted users
|
||||
return [] if !search_preferences(current_user)
|
||||
|
||||
# make sure customers always only can search their own organizations
|
||||
if customer_only?(current_user)
|
||||
params[:ids] = current_user.all_organization_ids
|
||||
end
|
||||
|
||||
# try search index backend
|
||||
if SearchIndexBackend.enabled?
|
||||
items = SearchIndexBackend.search(query, 'Organization', limit: limit,
|
||||
from: offset,
|
||||
sort_by: sort_by,
|
||||
order_by: order_by,
|
||||
ids: params[:ids])
|
||||
organizations = []
|
||||
items.each do |item|
|
||||
organization = Organization.lookup(id: item[:id])
|
||||
next if !organization
|
||||
|
||||
organizations.push organization
|
||||
end
|
||||
return organizations
|
||||
end
|
||||
|
||||
order_select_sql = sql_helper.get_order_select(sort_by, order_by, 'organizations.updated_at')
|
||||
order_sql = sql_helper.get_order(sort_by, order_by, 'organizations.updated_at ASC')
|
||||
|
||||
# fallback do sql query
|
||||
# - stip out * we already search for *query* -
|
||||
query.delete! '*'
|
||||
organizations = Organization.where_or_cis(%i[name note], "%#{query}%")
|
||||
.reorder(Arel.sql(order_sql))
|
||||
.offset(offset)
|
||||
.limit(limit)
|
||||
|
||||
if params[:ids].present?
|
||||
organizations = organizations.where(id: params[:ids])
|
||||
end
|
||||
|
||||
organizations = organizations.to_a
|
||||
|
||||
# use result independent of size if an explicit offset is given
|
||||
# this is the case for e.g. paginated searches
|
||||
return organizations if params[:offset].present?
|
||||
return organizations if organizations.length > 3
|
||||
|
||||
# if only a few organizations are found, search for names of users
|
||||
organizations_by_user = Organization.select("DISTINCT(organizations.id), #{order_select_sql}")
|
||||
.joins('LEFT OUTER JOIN users ON users.organization_id = organizations.id')
|
||||
.where(User.or_cis(%i[firstname lastname email], "%#{query}%"))
|
||||
.reorder(Arel.sql(order_sql))
|
||||
.limit(limit)
|
||||
|
||||
if params[:ids].present?
|
||||
organizations_by_user = organizations_by_user.where(id: params[:ids])
|
||||
end
|
||||
|
||||
organizations_by_user.each do |organization_by_user|
|
||||
|
||||
organization_exists = false
|
||||
organizations.each do |organization|
|
||||
next if organization.id != organization_by_user.id
|
||||
|
||||
organization_exists = true
|
||||
break
|
||||
end
|
||||
|
||||
# get model with full data
|
||||
next if organization_exists
|
||||
|
||||
organizations.push Organization.find(organization_by_user.id)
|
||||
end
|
||||
organizations
|
||||
params[:ids] = params[:current_user].all_organization_ids
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -7,9 +7,13 @@ class Overview < ApplicationModel
|
|||
include ChecksConditionValidation
|
||||
include CanSeed
|
||||
include CanPriorization
|
||||
include HasSearchIndexBackend
|
||||
include CanSelector
|
||||
include CanSearch
|
||||
|
||||
include Overview::Assets
|
||||
include Overview::TriggersSubscriptions
|
||||
include Overview::SearchIndex
|
||||
|
||||
has_and_belongs_to_many :roles, after_add: :cache_update, after_remove: :cache_update, class_name: 'Role'
|
||||
has_and_belongs_to_many :users, after_add: :cache_update, after_remove: :cache_update, class_name: 'User'
|
||||
|
|
|
|||
12
app/models/overview/search_index.rb
Normal file
12
app/models/overview/search_index.rb
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
# Copyright (C) 2012-2024 Zammad Foundation, https://zammad-foundation.org/
|
||||
|
||||
module Overview::SearchIndex
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
def search_index_attribute_lookup(include_references: true)
|
||||
attributes = super
|
||||
attributes.delete('view')
|
||||
attributes.delete('order')
|
||||
attributes
|
||||
end
|
||||
end
|
||||
|
|
@ -9,6 +9,9 @@ class Role < ApplicationModel
|
|||
include ChecksHtmlSanitized
|
||||
include HasGroups
|
||||
include HasCollectionUpdate
|
||||
include HasSearchIndexBackend
|
||||
include CanSelector
|
||||
include CanSearch
|
||||
|
||||
include Role::Assets
|
||||
|
||||
|
|
|
|||
|
|
@ -2,6 +2,9 @@
|
|||
|
||||
class Template < ApplicationModel
|
||||
include ChecksClientNotification
|
||||
include HasSearchIndexBackend
|
||||
include CanSelector
|
||||
include CanSearch
|
||||
include Template::Assets
|
||||
include Template::TriggersSubscriptions
|
||||
|
||||
|
|
|
|||
|
|
@ -6,6 +6,9 @@ class TextModule < ApplicationModel
|
|||
include ChecksClientNotification
|
||||
include ChecksHtmlSanitized
|
||||
include CanCsvImport
|
||||
include HasSearchIndexBackend
|
||||
include CanSelector
|
||||
include CanSearch
|
||||
|
||||
validates :name, presence: true
|
||||
validates :content, presence: true
|
||||
|
|
|
|||
|
|
@ -28,9 +28,9 @@ class Ticket < ApplicationModel
|
|||
include ::Ticket::Subject
|
||||
include ::Ticket::Assets
|
||||
include ::Ticket::SearchIndex
|
||||
include ::Ticket::CanSelector
|
||||
include ::Ticket::Search
|
||||
include ::Ticket::MergeHistory
|
||||
include ::Ticket::CanSelector
|
||||
include ::Ticket::PerformChanges
|
||||
|
||||
store :preferences
|
||||
|
|
|
|||
|
|
@ -3,6 +3,23 @@
|
|||
module Ticket::Search
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
include CanSearch
|
||||
|
||||
included do
|
||||
scope :search_sql_query_extension, lambda { |params|
|
||||
return if params[:query].blank?
|
||||
|
||||
fields = %w[title number]
|
||||
fields << Ticket::Article.arel_table[:body]
|
||||
fields << Ticket::Article.arel_table[:from]
|
||||
fields << Ticket::Article.arel_table[:to]
|
||||
fields << Ticket::Article.arel_table[:subject]
|
||||
|
||||
where_or_cis(fields, "%#{SqlHelper.quote_like(params[:query])}%")
|
||||
.joins(:articles)
|
||||
}
|
||||
end
|
||||
|
||||
# methods defined here are going to extend the class, not the instance of it
|
||||
class_methods do
|
||||
|
||||
|
|
@ -34,190 +51,58 @@ returns if user has no permissions to search
|
|||
}
|
||||
end
|
||||
|
||||
=begin
|
||||
def search_params_pre(params)
|
||||
params[:scope] ||= TicketPolicy::ReadScope
|
||||
end
|
||||
|
||||
search tickets via search index
|
||||
|
||||
result = Ticket.search(
|
||||
current_user: User.find(123),
|
||||
query: 'search something',
|
||||
scope: TicketPolicy::ReadScope, # defaults to ReadScope
|
||||
limit: 15,
|
||||
offset: 100,
|
||||
)
|
||||
|
||||
returns
|
||||
|
||||
result = [ticket_model1, ticket_model2]
|
||||
|
||||
search tickets via search index
|
||||
|
||||
result = Ticket.search(
|
||||
current_user: User.find(123),
|
||||
query: 'search something',
|
||||
limit: 15,
|
||||
offset: 100,
|
||||
full: false,
|
||||
)
|
||||
|
||||
returns
|
||||
|
||||
result = [1,3,5,6,7]
|
||||
|
||||
search tickets via database
|
||||
|
||||
result = Ticket.search(
|
||||
current_user: User.find(123),
|
||||
query: 'some query', # query or condition is required
|
||||
scope: TicketPolicy::ReadScope, # defaults to ReadScope
|
||||
condition: {
|
||||
'tickets.owner_id' => {
|
||||
operator: 'is',
|
||||
value: user.id,
|
||||
},
|
||||
'tickets.state_id' => {
|
||||
operator: 'is',
|
||||
value: Ticket::State.where(
|
||||
state_type_id: Ticket::StateType.where(
|
||||
name: [
|
||||
'pending reminder',
|
||||
'pending action',
|
||||
],
|
||||
).map(&:id),
|
||||
),
|
||||
},
|
||||
},
|
||||
limit: 15,
|
||||
offset: 100,
|
||||
|
||||
# sort single column
|
||||
sort_by: 'created_at',
|
||||
order_by: 'asc',
|
||||
|
||||
# sort multiple columns
|
||||
sort_by: [ 'created_at', 'updated_at' ],
|
||||
order_by: [ 'asc', 'desc' ],
|
||||
|
||||
full: false,
|
||||
)
|
||||
|
||||
returns
|
||||
|
||||
result = [1,3,5,6,7]
|
||||
|
||||
=end
|
||||
|
||||
def search(params)
|
||||
|
||||
# get params
|
||||
query = params[:query]
|
||||
condition = params[:condition]
|
||||
scope = params[:scope] || TicketPolicy::ReadScope
|
||||
limit = params[:limit] || 12
|
||||
offset = params[:offset] || 0
|
||||
current_user = params[:current_user]
|
||||
full = false
|
||||
if params[:full] == true || params[:full] == 'true' || !params.key?(:full)
|
||||
full = true
|
||||
end
|
||||
|
||||
sql_helper = ::SqlHelper.new(object: self)
|
||||
|
||||
# check sort
|
||||
sort_by = sql_helper.get_sort_by(params, 'updated_at')
|
||||
|
||||
# check order
|
||||
order_by = sql_helper.get_order_by(params, 'desc')
|
||||
|
||||
# try search index backend
|
||||
if condition.blank? && SearchIndexBackend.enabled?
|
||||
|
||||
query_or = []
|
||||
if current_user.permissions?('ticket.agent')
|
||||
group_ids = current_user.group_ids_access(scope.const_get(:ACCESS_TYPE))
|
||||
if group_ids.present?
|
||||
access_condition = {
|
||||
'query_string' => { 'default_field' => 'group_id', 'query' => "\"#{group_ids.join('" OR "')}\"" }
|
||||
}
|
||||
query_or.push(access_condition)
|
||||
end
|
||||
end
|
||||
if current_user.permissions?('ticket.customer')
|
||||
organizations_query = current_user.all_organizations.where(shared: true).map { |o| "organization_id:#{o.id}" }.join(' OR ')
|
||||
access_condition = if organizations_query.present?
|
||||
{
|
||||
'query_string' => { 'query' => "customer_id:#{current_user.id} OR #{organizations_query}" }
|
||||
}
|
||||
else
|
||||
{
|
||||
'query_string' => { 'default_field' => 'customer_id', 'query' => current_user.id }
|
||||
}
|
||||
end
|
||||
def search_query_extension(params)
|
||||
query_or = []
|
||||
if params[:current_user].permissions?('ticket.agent')
|
||||
group_ids = params[:current_user].group_ids_access(params[:scope].const_get(:ACCESS_TYPE))
|
||||
if group_ids.present?
|
||||
access_condition = {
|
||||
'query_string' => { 'default_field' => 'group_id', 'query' => "\"#{group_ids.join('" OR "')}\"" }
|
||||
}
|
||||
query_or.push(access_condition)
|
||||
end
|
||||
end
|
||||
if params[:current_user].permissions?('ticket.customer')
|
||||
organizations_query = params[:current_user].all_organizations.where(shared: true).map { |row| "organization_id:#{row.id}" }.join(' OR ')
|
||||
access_condition = if organizations_query.present?
|
||||
{
|
||||
'query_string' => { 'query' => "customer_id:#{params[:current_user].id} OR #{organizations_query}" }
|
||||
}
|
||||
else
|
||||
{
|
||||
'query_string' => { 'default_field' => 'customer_id', 'query' => params[:current_user].id }
|
||||
}
|
||||
end
|
||||
query_or.push(access_condition)
|
||||
end
|
||||
|
||||
return [] if query_or.blank?
|
||||
|
||||
query_extension = {
|
||||
if query_or.blank?
|
||||
return {
|
||||
bool: {
|
||||
must: [
|
||||
{
|
||||
bool: {
|
||||
should: query_or,
|
||||
},
|
||||
'query_string' => { 'query' => 'id:0' }
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
items = SearchIndexBackend.search(query, 'Ticket', limit: limit,
|
||||
query_extension: query_extension,
|
||||
from: offset,
|
||||
sort_by: sort_by,
|
||||
order_by: order_by)
|
||||
if !full
|
||||
return items.pluck(:id)
|
||||
end
|
||||
|
||||
tickets = []
|
||||
items.each do |item|
|
||||
ticket = Ticket.lookup(id: item[:id])
|
||||
next if !ticket
|
||||
|
||||
tickets.push ticket
|
||||
end
|
||||
return tickets
|
||||
end
|
||||
|
||||
order_sql = sql_helper.get_order(sort_by, order_by, 'tickets.updated_at DESC')
|
||||
tickets_all = scope.new(current_user).resolve
|
||||
.reorder(Arel.sql(order_sql))
|
||||
.offset(offset)
|
||||
.limit(limit)
|
||||
|
||||
ticket_ids = if query
|
||||
tickets_all.joins(:articles)
|
||||
.where(<<~SQL.squish, query: "%#{SqlHelper.quote_like(query.delete('*'))}%")
|
||||
tickets.title LIKE :query
|
||||
OR tickets.number LIKE :query
|
||||
OR ticket_articles.body LIKE :query
|
||||
OR ticket_articles.from LIKE :query
|
||||
OR ticket_articles.to LIKE :query
|
||||
OR ticket_articles.subject LIKE :query
|
||||
SQL
|
||||
else
|
||||
query_condition, bind_condition, tables = selector2sql(condition)
|
||||
|
||||
tickets_all.joins(tables)
|
||||
.where(query_condition, *bind_condition)
|
||||
end.group(:id).pluck(:id)
|
||||
|
||||
if full
|
||||
ticket_ids.map { |id| Ticket.lookup(id: id) }
|
||||
else
|
||||
ticket_ids
|
||||
end
|
||||
{
|
||||
bool: {
|
||||
must: [
|
||||
{
|
||||
bool: {
|
||||
should: query_or,
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
end
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ class User < ApplicationModel
|
|||
include ChecksClientNotification
|
||||
include HasHistory
|
||||
include HasSearchIndexBackend
|
||||
include CanSelector
|
||||
include CanCsvImport
|
||||
include ChecksHtmlSanitized
|
||||
include HasGroups
|
||||
|
|
@ -139,11 +140,17 @@ returns
|
|||
|
||||
=end
|
||||
|
||||
def fullname(email_fallback: true)
|
||||
def fullname(email_fallback: true, recipient_line: false)
|
||||
name = "#{firstname} #{lastname}".strip
|
||||
|
||||
if name.blank? && email.present? && email_fallback
|
||||
return email
|
||||
elsif recipient_line
|
||||
begin
|
||||
return Channel::EmailBuild.recipient_line(name, email)
|
||||
rescue
|
||||
return email
|
||||
end
|
||||
end
|
||||
|
||||
return name if name.present?
|
||||
|
|
|
|||
|
|
@ -4,6 +4,33 @@ class User
|
|||
module Search
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
include CanSearch
|
||||
|
||||
included do
|
||||
scope :search_sql_extension, lambda { |params|
|
||||
statement = all
|
||||
|
||||
if params[:role_ids]
|
||||
statement = statement.joins(:roles).where('roles.id' => params[:role_ids])
|
||||
end
|
||||
|
||||
if params[:group_ids]
|
||||
user_ids = []
|
||||
params[:group_ids].each do |group_id, access|
|
||||
user_ids |= User.group_access(group_id.to_i, access).pluck(:id)
|
||||
end
|
||||
statement = if user_ids.present?
|
||||
statement.where(id: user_ids)
|
||||
else
|
||||
statement.none
|
||||
end
|
||||
end
|
||||
|
||||
# Fixes #3755 - User with user_id 1 is show in admin interface (which should not)
|
||||
statement.where('users.id != 1')
|
||||
}
|
||||
end
|
||||
|
||||
# methods defined here are going to extend the class, not the instance of it
|
||||
class_methods do
|
||||
|
||||
|
|
@ -35,155 +62,59 @@ returns if user has no permissions to search
|
|||
}
|
||||
end
|
||||
|
||||
=begin
|
||||
def search_default_sort_by
|
||||
%w[active updated_at]
|
||||
end
|
||||
|
||||
search user
|
||||
def search_default_order_by
|
||||
%w[desc desc]
|
||||
end
|
||||
|
||||
result = User.search(
|
||||
query: 'some search term',
|
||||
limit: 15,
|
||||
offset: 100,
|
||||
current_user: user_model,
|
||||
)
|
||||
def search_params_pre(params)
|
||||
return if params[:permissions].blank?
|
||||
|
||||
or with certain role_ids | permissions
|
||||
params[:role_ids] ||= []
|
||||
params[:role_ids] |= Role.with_permissions(params[:permissions]).pluck(:id)
|
||||
end
|
||||
|
||||
result = User.search(
|
||||
query: 'some search term',
|
||||
limit: 15,
|
||||
offset: 100,
|
||||
current_user: user_model,
|
||||
role_ids: [1,2,3],
|
||||
group_ids: [1,2,3],
|
||||
permissions: ['ticket.agent']
|
||||
|
||||
# sort single column
|
||||
sort_by: 'created_at',
|
||||
order_by: 'asc',
|
||||
|
||||
# sort multiple columns
|
||||
sort_by: [ 'created_at', 'updated_at' ],
|
||||
order_by: [ 'asc', 'desc' ],
|
||||
)
|
||||
|
||||
returns
|
||||
|
||||
result = [user_model1, user_model2, ...]
|
||||
|
||||
=end
|
||||
|
||||
def search(params)
|
||||
|
||||
# get params
|
||||
query = params[:query]
|
||||
limit = params[:limit] || 10
|
||||
offset = params[:offset] || 0
|
||||
current_user = params[:current_user]
|
||||
|
||||
sql_helper = ::SqlHelper.new(object: self)
|
||||
|
||||
# check sort - positions related to order by
|
||||
sort_by = sql_helper.get_sort_by(params, %w[active updated_at])
|
||||
|
||||
# check order - positions related to sort by
|
||||
order_by = sql_helper.get_order_by(params, %w[desc desc])
|
||||
|
||||
# enable search only for agents and admins
|
||||
return [] if !search_preferences(current_user)
|
||||
|
||||
is_query = query.present? && query != '*'
|
||||
|
||||
# lookup for roles of permission
|
||||
if params[:permissions].present?
|
||||
params[:role_ids] ||= []
|
||||
role_ids = Role.with_permissions(params[:permissions]).pluck(:id)
|
||||
params[:role_ids].concat(role_ids)
|
||||
def search_query_extension(params)
|
||||
query_extension = {}
|
||||
if params[:role_ids].present?
|
||||
query_extension['bool'] ||= {}
|
||||
query_extension['bool']['must'] ||= []
|
||||
if !params[:role_ids].is_a?(Array)
|
||||
params[:role_ids] = [params[:role_ids]]
|
||||
end
|
||||
access_condition = {
|
||||
'query_string' => { 'default_field' => 'role_ids', 'query' => "\"#{params[:role_ids].join('" OR "')}\"" }
|
||||
}
|
||||
query_extension['bool']['must'].push access_condition
|
||||
end
|
||||
|
||||
# try search index backend
|
||||
if SearchIndexBackend.enabled? && is_query
|
||||
query_extension = {}
|
||||
if params[:role_ids].present?
|
||||
query_extension['bool'] ||= {}
|
||||
query_extension['bool']['must'] ||= []
|
||||
if !params[:role_ids].is_a?(Array)
|
||||
params[:role_ids] = [params[:role_ids]]
|
||||
end
|
||||
access_condition = {
|
||||
'query_string' => { 'default_field' => 'role_ids', 'query' => "\"#{params[:role_ids].join('" OR "')}\"" }
|
||||
}
|
||||
query_extension['bool']['must'].push access_condition
|
||||
end
|
||||
|
||||
user_ids = []
|
||||
if params[:group_ids].present?
|
||||
params[:group_ids].each do |group_id, access|
|
||||
user_ids |= User.group_access(group_id.to_i, access).pluck(:id)
|
||||
end
|
||||
return [] if user_ids.blank?
|
||||
end
|
||||
if params[:ids].present?
|
||||
user_ids |= params[:ids].map(&:to_i)
|
||||
end
|
||||
if user_ids.present?
|
||||
query_extension['bool'] ||= {}
|
||||
query_extension['bool']['must'] ||= []
|
||||
query_extension['bool']['must'].push({ 'terms' => { '_id' => user_ids } })
|
||||
end
|
||||
|
||||
items = SearchIndexBackend.search(query, 'User', limit: limit,
|
||||
query_extension: query_extension,
|
||||
from: offset,
|
||||
sort_by: sort_by,
|
||||
order_by: order_by)
|
||||
users = []
|
||||
items.each do |item|
|
||||
user = User.lookup(id: item[:id])
|
||||
next if !user
|
||||
|
||||
users.push user
|
||||
end
|
||||
return users
|
||||
end
|
||||
|
||||
order_sql = sql_helper.get_order(sort_by, order_by, 'users.updated_at DESC')
|
||||
|
||||
# fallback do sql query
|
||||
# - stip out * we already search for *query* -
|
||||
query.delete! '*'
|
||||
|
||||
statement = User
|
||||
if params[:ids].present?
|
||||
statement = statement.where(id: params[:ids])
|
||||
end
|
||||
|
||||
if params[:role_ids]
|
||||
statement = statement.joins(:roles).where('roles.id' => params[:role_ids])
|
||||
end
|
||||
if params[:group_ids]
|
||||
if params[:group_ids].present?
|
||||
user_ids = []
|
||||
params[:group_ids].each do |group_id, access|
|
||||
user_ids |= User.group_access(group_id.to_i, access).pluck(:id)
|
||||
end
|
||||
statement = if user_ids.present?
|
||||
statement.where(id: user_ids)
|
||||
else
|
||||
statement.none
|
||||
end
|
||||
|
||||
if user_ids.present?
|
||||
query_extension['bool'] ||= {}
|
||||
query_extension['bool']['must'] ||= []
|
||||
query_extension['bool']['must'].push({ 'terms' => { '_id' => user_ids } })
|
||||
else
|
||||
query_extension = {
|
||||
bool: {
|
||||
must: [
|
||||
{
|
||||
'query_string' => { 'query' => 'id:0' }
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
if is_query
|
||||
statement = statement.where(
|
||||
'(users.firstname LIKE :query OR users.lastname LIKE :query OR users.email LIKE :query OR users.login LIKE :query)', query: "%#{SqlHelper.quote_like(query)}%"
|
||||
)
|
||||
end
|
||||
|
||||
# Fixes #3755 - User with user_id 1 is show in admin interface (which should not)
|
||||
statement = statement.where('users.id != 1')
|
||||
|
||||
statement.reorder(Arel.sql(order_sql))
|
||||
.offset(offset)
|
||||
.limit(limit)
|
||||
query_extension
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
# Copyright (C) 2012-2024 Zammad Foundation, https://zammad-foundation.org/
|
||||
|
||||
class Controllers::TextModulesControllerPolicy < Controllers::ApplicationControllerPolicy
|
||||
default_permit! ['admin.text_module']
|
||||
permit! %i[index show], to: ['ticket.agent', 'admin.text_module']
|
||||
permit! %i[create update destroy import_example import_start], to: 'admin.text_module'
|
||||
end
|
||||
|
|
|
|||
|
|
@ -1,63 +1,63 @@
|
|||
# Copyright (C) 2012-2024 Zammad Foundation, https://zammad-foundation.org/
|
||||
|
||||
class Service::Search < Service::BaseWithCurrentUser
|
||||
def execute(term:, objects:, options: { limit: 10 })
|
||||
options[:limit] = 10 if options[:limit].blank?
|
||||
|
||||
perform_search(term: term, objects: objects, options: options)
|
||||
end
|
||||
|
||||
def perform_search(term:, objects:, options:)
|
||||
if SearchIndexBackend.enabled?
|
||||
# Performance optimization: some models may allow combining their Elasticsearch queries into one.
|
||||
result_by_model = combined_backend_search(term: term, objects: objects, options: options)
|
||||
|
||||
# Other models require dedicated handling, e.g. for permission checks.
|
||||
result_by_model.merge!(models(objects: objects, direct_search_index: false).index_with do |model|
|
||||
model_search(model: model, term: term, options: options)
|
||||
end)
|
||||
|
||||
# Finally, sort by object priority.
|
||||
models(objects: objects).map do |model|
|
||||
result_by_model[model]
|
||||
end.flatten.compact
|
||||
else
|
||||
models(objects: objects).map do |model|
|
||||
model_search(model: model, term: term, options: options)
|
||||
end.flatten.compact
|
||||
Result = Struct.new(:result, :sorting) do
|
||||
def flattened
|
||||
result
|
||||
.in_order_of(:first, sorting)
|
||||
.flat_map { |elem| elem.last[:objects] }
|
||||
end
|
||||
end
|
||||
|
||||
# Perform a direct, cross-module Elasticsearch query and map the results by class.
|
||||
def combined_backend_search(term:, objects:, options:)
|
||||
result_by_model = {}
|
||||
models_with_direct_search_index = models(objects: objects, direct_search_index: true).map(&:to_s)
|
||||
if models_with_direct_search_index
|
||||
SearchIndexBackend.search(term, models_with_direct_search_index, options).each do |item|
|
||||
klass = "::#{item[:type]}".constantize
|
||||
record = klass.lookup(id: item[:id])
|
||||
(result_by_model[klass] ||= []).push(record) if record
|
||||
attr_reader :query, :objects, :options
|
||||
|
||||
# @param current_user [User] which runs the search
|
||||
# @param query [String] to search for
|
||||
# @param objects [Array<ActiveRecord::Base>] searchable classes with search_preferences method present
|
||||
# @param options [Hash] options to forward to CanSearch and SearchIndexBackend. E.g. offset and limit.
|
||||
def initialize(current_user:, query:, objects:, options: {})
|
||||
super(current_user:)
|
||||
|
||||
@query = query
|
||||
@objects = objects
|
||||
@options = options
|
||||
.compact_blank
|
||||
.with_defaults(limit: 10) # limit can be overriden
|
||||
.merge!(with_total_count: true, full: true) # those options are mandatory
|
||||
end
|
||||
|
||||
def execute
|
||||
result = models_sorted
|
||||
.index_with { |elem| search_single_model(elem) }
|
||||
.compact
|
||||
|
||||
Result.new(result, models_sorted)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def models
|
||||
@models ||= objects
|
||||
.index_with { |elem| elem.search_preferences(current_user) }
|
||||
.compact_blank
|
||||
end
|
||||
|
||||
def models_sorted
|
||||
@models_sorted ||= models.keys.sort_by { |elem| models.dig(elem, :prio) }.reverse
|
||||
end
|
||||
|
||||
def search_single_model(model)
|
||||
if !SearchIndexBackend.enabled? || !models.dig(model, :direct_search_index)
|
||||
return model.search(query:, current_user:, **options)
|
||||
end
|
||||
|
||||
SearchIndexBackend
|
||||
.search_by_index(query, model.name, options)
|
||||
.tap do |result|
|
||||
next if result.blank?
|
||||
|
||||
result[:objects] = result[:object_metadata]
|
||||
.map { |elem| model.lookup(id: elem[:id]) }
|
||||
end
|
||||
end
|
||||
result_by_model
|
||||
end
|
||||
|
||||
# Call the model specific search, which will query Elasticsearch if available,
|
||||
# or the Database otherwise.
|
||||
def model_search(model:, term:, options:)
|
||||
model.search({ query: term, current_user: current_user, limit: options[:limit], ids: options[:ids] })
|
||||
end
|
||||
|
||||
# Get a prioritized list of searchable models
|
||||
def models(objects:, direct_search_index: nil)
|
||||
objects.select do |model|
|
||||
prefs = model.search_preferences(current_user)
|
||||
next false if !prefs
|
||||
next false if !direct_search_index.nil? && prefs[:direct_search_index] != direct_search_index
|
||||
|
||||
true
|
||||
end.sort_by do |model|
|
||||
model.search_preferences(current_user)[:prio]
|
||||
end.reverse
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ Zammad::Application.routes.draw do
|
|||
|
||||
# groups
|
||||
match api_path + '/groups', to: 'groups#index', via: :get
|
||||
match api_path + '/groups/search', to: 'groups#search', via: %i[get post]
|
||||
match api_path + '/groups/:id', to: 'groups#show', via: :get
|
||||
match api_path + '/groups', to: 'groups#create', via: :post
|
||||
match api_path + '/groups/:id', to: 'groups#update', via: :put
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ Zammad::Application.routes.draw do
|
|||
|
||||
# macros
|
||||
match api_path + '/macros', to: 'macros#index', via: :get
|
||||
match api_path + '/macros/search', to: 'macros#search', via: %i[get post]
|
||||
match api_path + '/macros/:id', to: 'macros#show', via: :get
|
||||
match api_path + '/macros', to: 'macros#create', via: :post
|
||||
match api_path + '/macros/:id', to: 'macros#update', via: :put
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ Zammad::Application.routes.draw do
|
|||
|
||||
# overviews
|
||||
match api_path + '/overviews', to: 'overviews#index', via: :get
|
||||
match api_path + '/overviews/search', to: 'overviews#search', via: %i[get post]
|
||||
match api_path + '/overviews/:id', to: 'overviews#show', via: :get
|
||||
match api_path + '/overviews', to: 'overviews#create', via: :post
|
||||
match api_path + '/overviews/:id', to: 'overviews#update', via: :put
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ Zammad::Application.routes.draw do
|
|||
|
||||
# roles
|
||||
match api_path + '/roles', to: 'roles#index', via: :get
|
||||
match api_path + '/roles/search', to: 'roles#search', via: %i[get post]
|
||||
match api_path + '/roles/:id', to: 'roles#show', via: :get
|
||||
match api_path + '/roles', to: 'roles#create', via: :post
|
||||
match api_path + '/roles/:id', to: 'roles#update', via: :put
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ Zammad::Application.routes.draw do
|
|||
|
||||
# templates
|
||||
match api_path + '/templates', to: 'templates#index', via: :get
|
||||
match api_path + '/templates/search', to: 'templates#search', via: %i[get post]
|
||||
match api_path + '/templates/:id', to: 'templates#show', via: :get
|
||||
match api_path + '/templates', to: 'templates#create', via: :post
|
||||
match api_path + '/templates/:id', to: 'templates#update', via: :put
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ Zammad::Application.routes.draw do
|
|||
match api_path + '/text_modules/import_example', to: 'text_modules#import_example', via: :get
|
||||
match api_path + '/text_modules/import', to: 'text_modules#import_start', via: :post
|
||||
match api_path + '/text_modules', to: 'text_modules#index', via: :get
|
||||
match api_path + '/text_modules/search', to: 'text_modules#search', via: %i[get post]
|
||||
match api_path + '/text_modules/:id', to: 'text_modules#show', via: :get
|
||||
match api_path + '/text_modules', to: 'text_modules#create', via: :post
|
||||
match api_path + '/text_modules/:id', to: 'text_modules#update', via: :put
|
||||
|
|
|
|||
166
i18n/zammad.pot
166
i18n/zammad.pot
|
|
@ -313,7 +313,7 @@ msgid "%s of my tickets escalated"
|
|||
msgstr ""
|
||||
|
||||
#: app/assets/javascripts/app/views/ticket_overview/batch_overlay_user_group.jst.eco:11
|
||||
#: app/graphql/gql/queries/autocomplete_search/generic.rb:64
|
||||
#: app/graphql/gql/queries/autocomplete_search/generic.rb:61
|
||||
msgid "%s people"
|
||||
msgstr ""
|
||||
|
||||
|
|
@ -771,7 +771,7 @@ msgstr ""
|
|||
msgid "Accounts"
|
||||
msgstr ""
|
||||
|
||||
#: app/assets/javascripts/app/controllers/_application_controller/table.coffee:718
|
||||
#: app/assets/javascripts/app/controllers/_application_controller/table.coffee:721
|
||||
#: app/assets/javascripts/app/controllers/_ui_element/_application_action.coffee:115
|
||||
#: app/assets/javascripts/app/controllers/_ui_element/_application_selector.coffee:82
|
||||
#: app/assets/javascripts/app/controllers/_ui_element/object_perform_action.coffee:109
|
||||
|
|
@ -844,7 +844,7 @@ msgstr ""
|
|||
#: app/assets/javascripts/app/models/ldap_source.coffee:7
|
||||
#: app/assets/javascripts/app/models/macro.coffee:15
|
||||
#: app/assets/javascripts/app/models/object_manager_attribute.coffee:9
|
||||
#: app/assets/javascripts/app/models/overview.coffee:67
|
||||
#: app/assets/javascripts/app/models/overview.coffee:66
|
||||
#: app/assets/javascripts/app/models/postmaster_filter.coffee:13
|
||||
#: app/assets/javascripts/app/models/report_profile.js.coffee:9
|
||||
#: app/assets/javascripts/app/models/role.coffee:11
|
||||
|
|
@ -1253,8 +1253,8 @@ msgstr ""
|
|||
msgid "Agent idle timeout"
|
||||
msgstr ""
|
||||
|
||||
#: app/models/role.rb:153
|
||||
#: app/models/user.rb:698
|
||||
#: app/models/role.rb:154
|
||||
#: app/models/user.rb:704
|
||||
msgid "Agent limit exceeded, please check your account settings."
|
||||
msgstr ""
|
||||
|
||||
|
|
@ -1584,11 +1584,11 @@ msgstr ""
|
|||
msgid "Are you sure you want to reload? You have unsaved changes that will get lost"
|
||||
msgstr ""
|
||||
|
||||
#: app/assets/javascripts/app/controllers/_application_controller/table.coffee:693
|
||||
#: app/assets/javascripts/app/controllers/_application_controller/table.coffee:696
|
||||
msgid "Are you sure you want to set \"%s\" as default?"
|
||||
msgstr ""
|
||||
|
||||
#: app/assets/javascripts/app/controllers/_application_controller/table.coffee:709
|
||||
#: app/assets/javascripts/app/controllers/_application_controller/table.coffee:712
|
||||
msgid "Are you sure you want to unset \"%s\" as default?"
|
||||
msgstr ""
|
||||
|
||||
|
|
@ -1740,7 +1740,7 @@ msgstr ""
|
|||
msgid "Assignment timeout in minutes if assigned agent is not working on it. Ticket will be shown as unassigend."
|
||||
msgstr ""
|
||||
|
||||
#: app/models/user.rb:601
|
||||
#: app/models/user.rb:607
|
||||
msgid "At least one identifier (firstname, lastname, phone, mobile or email) for user is required."
|
||||
msgstr ""
|
||||
|
||||
|
|
@ -1752,8 +1752,8 @@ msgstr ""
|
|||
msgid "At least one object must be selected."
|
||||
msgstr ""
|
||||
|
||||
#: app/models/role.rb:125
|
||||
#: app/models/user.rb:672
|
||||
#: app/models/role.rb:126
|
||||
#: app/models/user.rb:678
|
||||
msgid "At least one user needs to have admin permissions."
|
||||
msgstr ""
|
||||
|
||||
|
|
@ -1836,7 +1836,7 @@ msgid "Attribute that uniquely identifies the user. If unset, the name identifie
|
|||
msgstr ""
|
||||
|
||||
#: app/assets/javascripts/app/controllers/ticket_overview/settings.coffee:31
|
||||
#: app/assets/javascripts/app/models/overview.coffee:16
|
||||
#: app/assets/javascripts/app/models/overview.coffee:15
|
||||
#: app/frontend/apps/desktop/pages/ticket/components/TicketSidebar/TicketSidebarInformation/TicketSidebarInformationContent.vue:88
|
||||
msgid "Attributes"
|
||||
msgstr ""
|
||||
|
|
@ -2604,7 +2604,7 @@ msgstr ""
|
|||
msgid "Channel Distribution"
|
||||
msgstr ""
|
||||
|
||||
#: app/assets/javascripts/app/controllers/manage.coffee:35
|
||||
#: app/assets/javascripts/app/controllers/manage.coffee:36
|
||||
msgid "Channels"
|
||||
msgstr ""
|
||||
|
||||
|
|
@ -2660,7 +2660,7 @@ msgstr ""
|
|||
msgid "Check item"
|
||||
msgstr ""
|
||||
|
||||
#: lib/search_index_backend.rb:410
|
||||
#: lib/search_index_backend.rb:437
|
||||
msgid "Check the response and payload for detailed information:"
|
||||
msgstr ""
|
||||
|
||||
|
|
@ -2886,7 +2886,7 @@ msgstr ""
|
|||
msgid "Clients"
|
||||
msgstr ""
|
||||
|
||||
#: app/assets/javascripts/app/controllers/_application_controller/table.coffee:650
|
||||
#: app/assets/javascripts/app/controllers/_application_controller/table.coffee:653
|
||||
msgid "Clone"
|
||||
msgstr ""
|
||||
|
||||
|
|
@ -4655,12 +4655,12 @@ msgid "Defines which user roles will receive a warning in case of matching ticke
|
|||
msgstr ""
|
||||
|
||||
#: app/assets/javascripts/app/controllers/_application_controller/_modal_generic_confirm_delete.coffee:4
|
||||
#: app/assets/javascripts/app/controllers/_application_controller/table.coffee:674
|
||||
#: app/assets/javascripts/app/controllers/_application_controller/table.coffee:677
|
||||
#: app/assets/javascripts/app/controllers/_manage/knowledge_base.coffee:118
|
||||
#: app/assets/javascripts/app/controllers/data_privacy.coffee:196
|
||||
#: app/assets/javascripts/app/controllers/knowledge_base/delete_action.coffee:9
|
||||
#: app/assets/javascripts/app/controllers/package.coffee:54
|
||||
#: app/assets/javascripts/app/controllers/user.coffee:177
|
||||
#: app/assets/javascripts/app/controllers/user.coffee:99
|
||||
#: app/assets/javascripts/app/controllers/user_profile/action_row.coffee:88
|
||||
#: app/assets/javascripts/app/views/calendar/index.jst.eco:80
|
||||
#: app/assets/javascripts/app/views/channel/email_account_overview.jst.eco:153
|
||||
|
|
@ -5387,7 +5387,7 @@ msgstr ""
|
|||
msgid "Elasticsearch Total Payload Size"
|
||||
msgstr ""
|
||||
|
||||
#: lib/search_index_backend.rb:406
|
||||
#: lib/search_index_backend.rb:433
|
||||
msgid "Elasticsearch is not reachable. It's possible that it's not running. Please check whether it is installed."
|
||||
msgstr ""
|
||||
|
||||
|
|
@ -5499,7 +5499,7 @@ msgstr ""
|
|||
msgid "Email address"
|
||||
msgstr ""
|
||||
|
||||
#: app/models/user.rb:611
|
||||
#: app/models/user.rb:617
|
||||
msgid "Email address '%{email}' is already used for another user."
|
||||
msgstr ""
|
||||
|
||||
|
|
@ -6119,7 +6119,7 @@ msgstr ""
|
|||
msgid "Expires at"
|
||||
msgstr ""
|
||||
|
||||
#: app/assets/javascripts/app/controllers/search.coffee:50
|
||||
#: app/assets/javascripts/app/controllers/search.coffee:54
|
||||
msgid "Extended Search"
|
||||
msgstr ""
|
||||
|
||||
|
|
@ -6127,7 +6127,7 @@ msgstr ""
|
|||
msgid "External data source field"
|
||||
msgstr ""
|
||||
|
||||
#: lib/search_index_backend.rb:674
|
||||
#: lib/search_index_backend.rb:709
|
||||
msgid "Extract zammad-attachment information from arrays"
|
||||
msgstr ""
|
||||
|
||||
|
|
@ -6926,12 +6926,12 @@ msgid "Group selection for ticket creation"
|
|||
msgstr ""
|
||||
|
||||
#: app/assets/javascripts/app/controllers/ticket_overview/settings.coffee:60
|
||||
#: app/assets/javascripts/app/models/overview.coffee:43
|
||||
#: app/assets/javascripts/app/models/overview.coffee:42
|
||||
msgid "Grouping by"
|
||||
msgstr ""
|
||||
|
||||
#: app/assets/javascripts/app/controllers/ticket_overview/settings.coffee:70
|
||||
#: app/assets/javascripts/app/models/overview.coffee:58
|
||||
#: app/assets/javascripts/app/models/overview.coffee:57
|
||||
msgid "Grouping order"
|
||||
msgstr ""
|
||||
|
||||
|
|
@ -6948,7 +6948,7 @@ msgstr ""
|
|||
msgid "Groups"
|
||||
msgstr ""
|
||||
|
||||
#: app/assets/javascripts/app/controllers/group.coffee:22
|
||||
#: app/assets/javascripts/app/controllers/group.coffee:24
|
||||
msgid "Groups are …"
|
||||
msgstr ""
|
||||
|
||||
|
|
@ -7422,9 +7422,9 @@ msgstr ""
|
|||
#: app/assets/javascripts/app/controllers/import_freshdesk.coffee:25
|
||||
#: app/assets/javascripts/app/controllers/import_otrs.coffee:20
|
||||
#: app/assets/javascripts/app/controllers/import_zendesk.coffee:26
|
||||
#: app/assets/javascripts/app/controllers/organization.coffee:27
|
||||
#: app/assets/javascripts/app/controllers/text_module.coffee:31
|
||||
#: app/assets/javascripts/app/controllers/user.coffee:32
|
||||
#: app/assets/javascripts/app/controllers/organization.coffee:29
|
||||
#: app/assets/javascripts/app/controllers/text_module.coffee:33
|
||||
#: app/assets/javascripts/app/controllers/user.coffee:52
|
||||
#: app/assets/javascripts/app/controllers/widget/import.coffee:4
|
||||
#: app/assets/javascripts/app/controllers/widget/import_result.coffee:6
|
||||
#: app/assets/javascripts/app/controllers/widget/import_try_result.coffee:6
|
||||
|
|
@ -7841,7 +7841,7 @@ msgstr ""
|
|||
msgid "Invalid client_id received!"
|
||||
msgstr ""
|
||||
|
||||
#: app/models/user.rb:556
|
||||
#: app/models/user.rb:562
|
||||
msgid "Invalid email '%{email}'"
|
||||
msgstr ""
|
||||
|
||||
|
|
@ -8675,7 +8675,7 @@ msgstr ""
|
|||
msgid "Manage Twitter channel of your system."
|
||||
msgstr ""
|
||||
|
||||
#: app/assets/javascripts/app/controllers/user.coffee:162
|
||||
#: app/assets/javascripts/app/controllers/user.coffee:84
|
||||
#: app/assets/javascripts/app/controllers/user/manage_two_factor.coffee:4
|
||||
msgid "Manage Two-Factor Authentication"
|
||||
msgstr ""
|
||||
|
|
@ -9317,7 +9317,7 @@ msgstr ""
|
|||
msgid "More information can be found here."
|
||||
msgstr ""
|
||||
|
||||
#: app/models/user.rb:624
|
||||
#: app/models/user.rb:630
|
||||
msgid "More than 250 secondary organizations are not allowed."
|
||||
msgstr ""
|
||||
|
||||
|
|
@ -9538,7 +9538,7 @@ msgstr ""
|
|||
msgid "Need session_id."
|
||||
msgstr ""
|
||||
|
||||
#: app/controllers/tickets_controller.rb:477
|
||||
#: app/controllers/tickets_controller.rb:438
|
||||
msgid "Need user_id or organization_id as param"
|
||||
msgstr ""
|
||||
|
||||
|
|
@ -9604,7 +9604,7 @@ msgstr ""
|
|||
msgid "New Deletion Task"
|
||||
msgstr ""
|
||||
|
||||
#: app/assets/javascripts/app/controllers/group.coffee:25
|
||||
#: app/assets/javascripts/app/controllers/group.coffee:27
|
||||
msgid "New Group"
|
||||
msgstr ""
|
||||
|
||||
|
|
@ -9612,15 +9612,15 @@ msgstr ""
|
|||
msgid "New Keyboard Shortcuts"
|
||||
msgstr ""
|
||||
|
||||
#: app/assets/javascripts/app/controllers/macro.coffee:22
|
||||
#: app/assets/javascripts/app/controllers/macro.coffee:24
|
||||
msgid "New Macro"
|
||||
msgstr ""
|
||||
|
||||
#: app/assets/javascripts/app/controllers/organization.coffee:28
|
||||
#: app/assets/javascripts/app/controllers/organization.coffee:30
|
||||
msgid "New Organization"
|
||||
msgstr ""
|
||||
|
||||
#: app/assets/javascripts/app/controllers/overview.coffee:24
|
||||
#: app/assets/javascripts/app/controllers/overview.coffee:30
|
||||
msgid "New Overview"
|
||||
msgstr ""
|
||||
|
||||
|
|
@ -9641,7 +9641,7 @@ msgstr ""
|
|||
msgid "New Public Link"
|
||||
msgstr ""
|
||||
|
||||
#: app/assets/javascripts/app/controllers/role.coffee:28
|
||||
#: app/assets/javascripts/app/controllers/role.coffee:30
|
||||
msgid "New Role"
|
||||
msgstr ""
|
||||
|
||||
|
|
@ -9662,7 +9662,7 @@ msgstr ""
|
|||
msgid "New Tags"
|
||||
msgstr ""
|
||||
|
||||
#: app/assets/javascripts/app/controllers/template.coffee:22
|
||||
#: app/assets/javascripts/app/controllers/template.coffee:24
|
||||
msgid "New Template"
|
||||
msgstr ""
|
||||
|
||||
|
|
@ -9696,7 +9696,7 @@ msgstr ""
|
|||
msgid "New Trigger"
|
||||
msgstr ""
|
||||
|
||||
#: app/assets/javascripts/app/controllers/user.coffee:33
|
||||
#: app/assets/javascripts/app/controllers/user.coffee:53
|
||||
#: app/assets/javascripts/app/views/cti/caller_log.jst.eco:89
|
||||
#: app/assets/javascripts/app/views/navigation/menu_cti_ringing.jst.eco:54
|
||||
msgid "New User"
|
||||
|
|
@ -9731,7 +9731,7 @@ msgstr ""
|
|||
msgid "New tab"
|
||||
msgstr ""
|
||||
|
||||
#: app/assets/javascripts/app/controllers/text_module.coffee:32
|
||||
#: app/assets/javascripts/app/controllers/text_module.coffee:34
|
||||
msgid "New text module"
|
||||
msgstr ""
|
||||
|
||||
|
|
@ -9779,6 +9779,10 @@ msgstr ""
|
|||
msgid "Next month"
|
||||
msgstr ""
|
||||
|
||||
#: app/assets/javascripts/app/views/generic/table_pager.jst.eco:11
|
||||
msgid "Next page"
|
||||
msgstr ""
|
||||
|
||||
#: app/frontend/shared/components/Form/fields/FieldDate/useDateTime.ts:90
|
||||
msgid "Next year"
|
||||
msgstr ""
|
||||
|
|
@ -9901,7 +9905,7 @@ msgstr ""
|
|||
msgid "No drafts"
|
||||
msgstr ""
|
||||
|
||||
#: app/controllers/users_controller.rb:347
|
||||
#: app/controllers/users_controller.rb:275
|
||||
msgid "No email!"
|
||||
msgstr ""
|
||||
|
||||
|
|
@ -10010,11 +10014,11 @@ msgstr ""
|
|||
msgid "No shared drafts yet"
|
||||
msgstr ""
|
||||
|
||||
#: app/controllers/organizations_controller.rb:160
|
||||
#: app/controllers/text_modules_controller.rb:71
|
||||
#: app/controllers/organizations_controller.rb:107
|
||||
#: app/controllers/text_modules_controller.rb:75
|
||||
#: app/controllers/ticket_articles_controller.rb:232
|
||||
#: app/controllers/tickets_controller.rb:609
|
||||
#: app/controllers/users_controller.rb:650
|
||||
#: app/controllers/tickets_controller.rb:570
|
||||
#: app/controllers/users_controller.rb:578
|
||||
msgid "No source data submitted!"
|
||||
msgstr ""
|
||||
|
||||
|
|
@ -10026,7 +10030,7 @@ msgstr ""
|
|||
msgid "No template created yet."
|
||||
msgstr ""
|
||||
|
||||
#: app/controllers/users_controller.rb:326
|
||||
#: app/controllers/users_controller.rb:254
|
||||
msgid "No token!"
|
||||
msgstr ""
|
||||
|
||||
|
|
@ -10854,6 +10858,10 @@ msgstr ""
|
|||
msgid "Packages"
|
||||
msgstr ""
|
||||
|
||||
#: app/assets/javascripts/app/views/generic/table_pager.jst.eco:3
|
||||
msgid "Page %s"
|
||||
msgstr ""
|
||||
|
||||
#: app/views/knowledge_base/public/not_found.html.erb:4
|
||||
msgid "Page not found"
|
||||
msgstr ""
|
||||
|
|
@ -10863,6 +10871,10 @@ msgstr ""
|
|||
msgid "Pages"
|
||||
msgstr ""
|
||||
|
||||
#: app/assets/javascripts/app/views/generic/table_pager.jst.eco:1
|
||||
msgid "Pagination links"
|
||||
msgstr ""
|
||||
|
||||
#: app/controllers/integration/smime_controller.rb:77
|
||||
msgid "Parameter 'data' or 'file' required."
|
||||
msgstr ""
|
||||
|
|
@ -11203,13 +11215,13 @@ msgstr ""
|
|||
msgid "Please provide a recipient in \"TO\" or \"CC\"."
|
||||
msgstr ""
|
||||
|
||||
#: app/controllers/users_controller.rb:458
|
||||
#: app/controllers/users_controller.rb:386
|
||||
msgid "Please provide your current password."
|
||||
msgstr ""
|
||||
|
||||
#: app/assets/javascripts/app/controllers/_profile/password.coffee:105
|
||||
#: app/assets/javascripts/app/controllers/password_reset_verify.coffee:87
|
||||
#: app/controllers/users_controller.rb:463
|
||||
#: app/controllers/users_controller.rb:391
|
||||
msgid "Please provide your new password."
|
||||
msgstr ""
|
||||
|
||||
|
|
@ -11268,6 +11280,7 @@ msgid "Port"
|
|||
msgstr ""
|
||||
|
||||
#: app/assets/javascripts/app/models/object_manager_attribute.coffee:12
|
||||
#: app/assets/javascripts/app/models/overview.coffee:67
|
||||
#: app/assets/javascripts/app/views/object_manager/index.jst.eco:58
|
||||
msgid "Position"
|
||||
msgstr ""
|
||||
|
|
@ -11376,6 +11389,10 @@ msgstr ""
|
|||
msgid "Previous month"
|
||||
msgstr ""
|
||||
|
||||
#: app/assets/javascripts/app/views/generic/table_pager.jst.eco:2
|
||||
msgid "Previous page"
|
||||
msgstr ""
|
||||
|
||||
#: app/frontend/shared/components/Form/fields/FieldDate/useDateTime.ts:91
|
||||
msgid "Previous year"
|
||||
msgstr ""
|
||||
|
|
@ -11392,7 +11409,6 @@ msgstr ""
|
|||
msgid "Print Codes"
|
||||
msgstr ""
|
||||
|
||||
#: app/assets/javascripts/app/models/overview.coffee:13
|
||||
#: app/assets/javascripts/app/models/public_link.coffee:11
|
||||
#: app/assets/javascripts/app/models/user_overview_sorting.coffee:8
|
||||
msgid "Prio"
|
||||
|
|
@ -12118,7 +12134,7 @@ msgstr ""
|
|||
msgid "Roles"
|
||||
msgstr ""
|
||||
|
||||
#: app/assets/javascripts/app/controllers/role.coffee:25
|
||||
#: app/assets/javascripts/app/controllers/role.coffee:27
|
||||
msgid "Roles are …"
|
||||
msgstr ""
|
||||
|
||||
|
|
@ -12455,6 +12471,10 @@ msgstr ""
|
|||
msgid "Search URL is missing."
|
||||
msgstr ""
|
||||
|
||||
#: app/assets/javascripts/app/views/generic/admin/index.jst.eco:32
|
||||
msgid "Search for %s"
|
||||
msgstr ""
|
||||
|
||||
#: app/assets/javascripts/app/views/user.jst.eco:15
|
||||
msgid "Search for users"
|
||||
msgstr ""
|
||||
|
|
@ -12550,7 +12570,7 @@ msgstr ""
|
|||
msgid "Secondary organizations"
|
||||
msgstr ""
|
||||
|
||||
#: app/models/user.rb:618
|
||||
#: app/models/user.rb:624
|
||||
msgid "Secondary organizations are only allowed when the primary organization is given."
|
||||
msgstr ""
|
||||
|
||||
|
|
@ -12917,7 +12937,7 @@ msgstr ""
|
|||
msgid "Set as Default"
|
||||
msgstr ""
|
||||
|
||||
#: app/assets/javascripts/app/controllers/_application_controller/table.coffee:687
|
||||
#: app/assets/javascripts/app/controllers/_application_controller/table.coffee:690
|
||||
#: app/assets/javascripts/app/views/profile/password.jst.eco:62
|
||||
#: app/frontend/apps/desktop/pages/personal-setting/views/PersonalSettingTwoFactorAuth.vue:169
|
||||
msgid "Set as default"
|
||||
|
|
@ -13008,7 +13028,7 @@ msgstr ""
|
|||
#: app/assets/javascripts/app/controllers/_channel/microsoft365.coffee:26
|
||||
#: app/assets/javascripts/app/controllers/_channel/web.coffee:11
|
||||
#: app/assets/javascripts/app/controllers/chat.coffee:869
|
||||
#: app/assets/javascripts/app/controllers/manage.coffee:36
|
||||
#: app/assets/javascripts/app/controllers/manage.coffee:37
|
||||
#: app/assets/javascripts/app/controllers/ticket_zoom/setting.coffee:20
|
||||
#: app/assets/javascripts/app/controllers/time_accounting.coffee:18
|
||||
#: app/assets/javascripts/app/views/channel/form.jst.eco:13
|
||||
|
|
@ -13448,12 +13468,12 @@ msgid "Sorry, there is currently no CTI backend enabled."
|
|||
msgstr ""
|
||||
|
||||
#: app/assets/javascripts/app/controllers/ticket_overview/settings.coffee:40
|
||||
#: app/assets/javascripts/app/models/overview.coffee:24
|
||||
#: app/assets/javascripts/app/models/overview.coffee:23
|
||||
msgid "Sorting by"
|
||||
msgstr ""
|
||||
|
||||
#: app/assets/javascripts/app/controllers/ticket_overview/settings.coffee:49
|
||||
#: app/assets/javascripts/app/models/overview.coffee:32
|
||||
#: app/assets/javascripts/app/models/overview.coffee:31
|
||||
msgid "Sorting order"
|
||||
msgstr ""
|
||||
|
||||
|
|
@ -13799,7 +13819,7 @@ msgstr ""
|
|||
|
||||
#: app/assets/javascripts/app/controllers/_manage/system.coffee:3
|
||||
#: app/assets/javascripts/app/controllers/_manage/ticket_duplicate_detection.coffee:37
|
||||
#: app/assets/javascripts/app/controllers/manage.coffee:37
|
||||
#: app/assets/javascripts/app/controllers/manage.coffee:38
|
||||
#: app/assets/javascripts/app/views/generic/history.jst.eco:15
|
||||
#: app/frontend/apps/desktop/pages/ticket/components/TicketDetailView/actions/TicketHistory/composables/useHistoryEvents.ts:32
|
||||
#: db/seeds/permissions.rb:191
|
||||
|
|
@ -14007,7 +14027,7 @@ msgstr ""
|
|||
msgid "Text modules"
|
||||
msgstr ""
|
||||
|
||||
#: app/assets/javascripts/app/controllers/text_module.coffee:28
|
||||
#: app/assets/javascripts/app/controllers/text_module.coffee:30
|
||||
msgid "Text modules are …"
|
||||
msgstr ""
|
||||
|
||||
|
|
@ -14361,7 +14381,7 @@ msgid "The connection was refused."
|
|||
msgstr ""
|
||||
|
||||
#: app/assets/javascripts/app/controllers/widget/two_factor_configuration/modal/password_check.coffee:45
|
||||
#: app/controllers/users_controller.rb:477
|
||||
#: app/controllers/users_controller.rb:405
|
||||
#: app/graphql/gql/mutations/user/current/change_password.rb:30
|
||||
msgid "The current password you provided is incorrect."
|
||||
msgstr ""
|
||||
|
|
@ -14529,7 +14549,7 @@ msgstr ""
|
|||
msgid "The installation of packages comes with security implications, because arbitrary code will be executed in the context of the Zammad application."
|
||||
msgstr ""
|
||||
|
||||
#: lib/search_index_backend.rb:408
|
||||
#: lib/search_index_backend.rb:435
|
||||
msgid "The installed attachment plugin could not handle the request payload. Ensure that the correct attachment plugin is installed (ingest-attachment)."
|
||||
msgstr ""
|
||||
|
||||
|
|
@ -14764,7 +14784,7 @@ msgstr ""
|
|||
msgid "The required PGP passphrase is missing."
|
||||
msgstr ""
|
||||
|
||||
#: app/controllers/users_controller.rb:749
|
||||
#: app/controllers/users_controller.rb:677
|
||||
#: app/services/service/user/add_first_admin.rb:12
|
||||
msgid "The required attribute 'email' is missing."
|
||||
msgstr ""
|
||||
|
|
@ -14863,11 +14883,11 @@ msgstr ""
|
|||
msgid "The required parameter 'group_id' is missing."
|
||||
msgstr ""
|
||||
|
||||
#: app/controllers/users_controller.rb:592
|
||||
#: app/controllers/users_controller.rb:520
|
||||
msgid "The required parameter 'id' is missing."
|
||||
msgstr ""
|
||||
|
||||
#: lib/search_index_backend.rb:597
|
||||
#: lib/search_index_backend.rb:632
|
||||
msgid "The required parameter 'key' is missing."
|
||||
msgstr ""
|
||||
|
||||
|
|
@ -14875,7 +14895,7 @@ msgstr ""
|
|||
msgid "The required parameter 'link_type' is missing."
|
||||
msgstr ""
|
||||
|
||||
#: app/models/text_module.rb:28
|
||||
#: app/models/text_module.rb:29
|
||||
msgid "The required parameter 'locale' is missing."
|
||||
msgstr ""
|
||||
|
||||
|
|
@ -14899,7 +14919,7 @@ msgstr ""
|
|||
msgid "The required parameter 'params' is missing."
|
||||
msgstr ""
|
||||
|
||||
#: app/controllers/users_controller.rb:485
|
||||
#: app/controllers/users_controller.rb:413
|
||||
msgid "The required parameter 'password' is missing."
|
||||
msgstr ""
|
||||
|
||||
|
|
@ -14919,7 +14939,7 @@ msgstr ""
|
|||
msgid "The required parameter 'request_token' is missing."
|
||||
msgstr ""
|
||||
|
||||
#: app/controllers/users_controller.rb:742
|
||||
#: app/controllers/users_controller.rb:670
|
||||
msgid "The required parameter 'signup' is missing."
|
||||
msgstr ""
|
||||
|
||||
|
|
@ -14931,7 +14951,7 @@ msgstr ""
|
|||
msgid "The required parameter 'user_id' is missing."
|
||||
msgstr ""
|
||||
|
||||
#: lib/search_index_backend.rb:598
|
||||
#: lib/search_index_backend.rb:633
|
||||
msgid "The required parameter 'value' is missing."
|
||||
msgstr ""
|
||||
|
||||
|
|
@ -15649,7 +15669,7 @@ msgstr ""
|
|||
msgid "This user has no access and will not receive notifications."
|
||||
msgstr ""
|
||||
|
||||
#: app/assets/javascripts/app/controllers/user.coffee:120
|
||||
#: app/assets/javascripts/app/controllers/user.coffee:15
|
||||
msgid "This user is currently blocked because of too many failed login attempts."
|
||||
msgstr ""
|
||||
|
||||
|
|
@ -16026,7 +16046,7 @@ msgid "Tickets assigned to me: %s of %s"
|
|||
msgstr ""
|
||||
|
||||
#: app/controllers/ticket_articles_controller.rb:225
|
||||
#: app/controllers/tickets_controller.rb:602
|
||||
#: app/controllers/tickets_controller.rb:563
|
||||
msgid "Tickets can only be imported if system is in import mode."
|
||||
msgstr ""
|
||||
|
||||
|
|
@ -16695,7 +16715,7 @@ msgstr ""
|
|||
msgid "Unlink object"
|
||||
msgstr ""
|
||||
|
||||
#: app/assets/javascripts/app/controllers/user.coffee:185
|
||||
#: app/assets/javascripts/app/controllers/user.coffee:107
|
||||
msgid "Unlock"
|
||||
msgstr ""
|
||||
|
||||
|
|
@ -16725,7 +16745,7 @@ msgstr ""
|
|||
msgid "Unselect Option"
|
||||
msgstr ""
|
||||
|
||||
#: app/assets/javascripts/app/controllers/_application_controller/table.coffee:703
|
||||
#: app/assets/javascripts/app/controllers/_application_controller/table.coffee:706
|
||||
msgid "Unset default"
|
||||
msgstr ""
|
||||
|
||||
|
|
@ -17150,7 +17170,7 @@ msgstr ""
|
|||
msgid "User menu"
|
||||
msgstr ""
|
||||
|
||||
#: app/assets/javascripts/app/controllers/user.coffee:199
|
||||
#: app/assets/javascripts/app/controllers/user.coffee:121
|
||||
msgid "User successfully unlocked!"
|
||||
msgstr ""
|
||||
|
||||
|
|
@ -17329,7 +17349,7 @@ msgstr ""
|
|||
msgid "View"
|
||||
msgstr ""
|
||||
|
||||
#: app/assets/javascripts/app/controllers/user.coffee:140
|
||||
#: app/assets/javascripts/app/controllers/user.coffee:62
|
||||
msgid "View from user's perspective"
|
||||
msgstr ""
|
||||
|
||||
|
|
@ -18191,7 +18211,7 @@ msgid "archived"
|
|||
msgstr ""
|
||||
|
||||
#: app/assets/javascripts/app/controllers/ticket_overview/settings.coffee:55
|
||||
#: app/assets/javascripts/app/models/overview.coffee:38
|
||||
#: app/assets/javascripts/app/models/overview.coffee:37
|
||||
#: app/frontend/apps/mobile/pages/ticket/components/TicketList/TicketOrderBySelector.vue:37
|
||||
msgid "ascending"
|
||||
msgstr ""
|
||||
|
|
@ -18426,7 +18446,7 @@ msgid "delivered to the customer"
|
|||
msgstr ""
|
||||
|
||||
#: app/assets/javascripts/app/controllers/ticket_overview/settings.coffee:56
|
||||
#: app/assets/javascripts/app/models/overview.coffee:39
|
||||
#: app/assets/javascripts/app/models/overview.coffee:38
|
||||
#: app/frontend/apps/mobile/pages/ticket/components/TicketList/TicketOrderBySelector.vue:38
|
||||
msgid "descending"
|
||||
msgstr ""
|
||||
|
|
@ -18850,7 +18870,7 @@ msgstr ""
|
|||
msgid "is the wrong length (should be 1 character)"
|
||||
msgstr ""
|
||||
|
||||
#: app/models/user.rb:822
|
||||
#: app/models/user.rb:828
|
||||
msgid "is too long"
|
||||
msgstr ""
|
||||
|
||||
|
|
|
|||
|
|
@ -317,6 +317,10 @@ remove whole data from index
|
|||
=end
|
||||
|
||||
def self.search(query, index, options = {})
|
||||
if options.key? :with_total_count
|
||||
raise 'Option "with_total_count" is not supported by multi-index search. Please use search_by_index instead.' # rubocop:disable Zammad/DetectTranslatableString
|
||||
end
|
||||
|
||||
if !index.is_a? Array
|
||||
return search_by_index(query, index, options)
|
||||
end
|
||||
|
|
@ -337,10 +341,15 @@ remove whole data from index
|
|||
=end
|
||||
|
||||
def self.search_by_index(query, index, options = {})
|
||||
return [] if query.blank?
|
||||
return if query.blank?
|
||||
|
||||
url = build_url(type: index, action: '_search', with_pipeline: false, with_document_type: false)
|
||||
return [] if url.blank?
|
||||
action = '_search'
|
||||
if options[:only_total_count].present?
|
||||
action = '_count'
|
||||
end
|
||||
|
||||
url = build_url(type: index, action: action, with_pipeline: false, with_document_type: false)
|
||||
return if url.blank?
|
||||
|
||||
# real search condition
|
||||
condition = {
|
||||
|
|
@ -358,28 +367,37 @@ remove whole data from index
|
|||
|
||||
query_data = build_query(index, condition, options)
|
||||
|
||||
if (fields = options.dig(:highlight_fields_by_indexes, index.to_sym))
|
||||
if (fields = options.dig(:highlight_fields_by_indexes, index.to_sym)) && options[:only_total_count].blank?
|
||||
fields_for_highlight = fields.index_with { |_elem| {} }
|
||||
|
||||
query_data[:highlight] = { fields: fields_for_highlight }
|
||||
end
|
||||
|
||||
if options[:only_total_count].present?
|
||||
query_data.slice!(:query)
|
||||
end
|
||||
|
||||
response = make_request(url, data: query_data, method: :post)
|
||||
|
||||
if !response.success?
|
||||
Rails.logger.error humanized_error(
|
||||
verb: 'GET',
|
||||
url: url,
|
||||
payload: query_data,
|
||||
response: response,
|
||||
)
|
||||
return []
|
||||
if options[:only_total_count].present?
|
||||
return {
|
||||
total_count: response.data&.dig('count') || 0,
|
||||
}
|
||||
end
|
||||
data = response.data&.dig('hits', 'hits')
|
||||
|
||||
return [] if !data
|
||||
data = if response.success?
|
||||
Array.wrap(response.data&.dig('hits', 'hits'))
|
||||
else
|
||||
Rails.logger.error humanized_error(
|
||||
verb: 'GET',
|
||||
url: url,
|
||||
payload: query_data,
|
||||
response: response,
|
||||
)
|
||||
[]
|
||||
end
|
||||
|
||||
data.map do |item|
|
||||
data.map! do |item|
|
||||
Rails.logger.debug { "... #{item['_type']} #{item['_id']}" }
|
||||
|
||||
output = {
|
||||
|
|
@ -393,6 +411,15 @@ remove whole data from index
|
|||
|
||||
output
|
||||
end
|
||||
|
||||
if options[:with_total_count].present?
|
||||
return {
|
||||
total_count: response.data&.dig('hits', 'total', 'value') || 0,
|
||||
object_metadata: data,
|
||||
}
|
||||
end
|
||||
|
||||
data
|
||||
end
|
||||
|
||||
def self.search_by_index_sort(index:, sort_by: nil, order_by: nil, fulltext: false)
|
||||
|
|
@ -674,7 +701,8 @@ generate url for index or document access (only for internal use)
|
|||
}.freeze
|
||||
|
||||
def self.build_query(index, condition, options = {})
|
||||
options = DEFAULT_QUERY_OPTIONS.merge(options.deep_symbolize_keys)
|
||||
options[:from] = options[:from].presence || options[:offset].presence
|
||||
options = DEFAULT_QUERY_OPTIONS.merge(options.compact_blank.deep_symbolize_keys)
|
||||
|
||||
data = {
|
||||
from: options[:from],
|
||||
|
|
@ -682,7 +710,8 @@ generate url for index or document access (only for internal use)
|
|||
sort: search_by_index_sort(index: index, sort_by: options[:sort_by], order_by: options[:order_by], fulltext: options[:fulltext]),
|
||||
query: {
|
||||
bool: {
|
||||
must: []
|
||||
must: [],
|
||||
must_not: [],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -697,6 +726,12 @@ generate url for index or document access (only for internal use)
|
|||
data[:query][:bool][:must].push({ ids: { values: options[:ids] } })
|
||||
end
|
||||
|
||||
if options[:condition].present?
|
||||
selector_query = SearchIndexBackend.selector2query(index, options[:condition], {}, nil)
|
||||
data[:query][:bool][:must] += Array.wrap(selector_query[:query][:bool][:must])
|
||||
data[:query][:bool][:must_not] += Array.wrap(selector_query[:query][:bool][:must_not])
|
||||
end
|
||||
|
||||
data
|
||||
end
|
||||
|
||||
|
|
|
|||
|
|
@ -28,7 +28,7 @@ class SearchKnowledgeBaseBackend
|
|||
@granular_permissions_handler = KnowledgeBase::InternalAssets.new(user)
|
||||
end
|
||||
|
||||
raw_results = raw_results(query, user, pagination: pagination)
|
||||
raw_results = raw_results(query, pagination: pagination)
|
||||
|
||||
filtered = filter_results raw_results, user
|
||||
|
||||
|
|
@ -41,16 +41,15 @@ class SearchKnowledgeBaseBackend
|
|||
filtered
|
||||
end
|
||||
|
||||
def search_fallback(query, indexes, options)
|
||||
indexes
|
||||
.map { |index| search_fallback_for_index(query, index, options) }
|
||||
.flatten
|
||||
def search_fallback(query, indexes)
|
||||
indexes.flat_map { |index| search_fallback_for_index(query, index) }
|
||||
end
|
||||
|
||||
def search_fallback_for_index(query, index, options)
|
||||
def search_fallback_for_index(query, index)
|
||||
index
|
||||
.constantize
|
||||
.search_fallback("%#{query}%", @cached_scope_ids, options: options)
|
||||
.search_sql_text_fallback("%#{query}%")
|
||||
.apply_kb_scope(@cached_scope_ids)
|
||||
.where(kb_locale: kb_locales)
|
||||
.reorder(**search_fallback_order)
|
||||
.pluck(:id)
|
||||
|
|
@ -61,8 +60,8 @@ class SearchKnowledgeBaseBackend
|
|||
@params[:order_by].presence || { updated_at: :desc }
|
||||
end
|
||||
|
||||
def raw_results(query, user, pagination: nil)
|
||||
return search_fallback(query, indexes, { user: user }) if !SearchIndexBackend.enabled?
|
||||
def raw_results(query, pagination: nil)
|
||||
return search_fallback(query, indexes) if !SearchIndexBackend.enabled?
|
||||
|
||||
SearchIndexBackend
|
||||
.search(query, indexes, options(pagination: pagination))
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@ QUnit.test('table new - initial list', assert => {
|
|||
objects: App.TicketPriority.search({sortBy:'name', order: 'ASC'}),
|
||||
checkbox: false,
|
||||
radio: false,
|
||||
clone: false,
|
||||
})
|
||||
//equal(el.find('table').length, 0, 'row count')
|
||||
//table.render()
|
||||
|
|
@ -349,6 +350,7 @@ QUnit.test('table new - initial list', assert => {
|
|||
checkbox: false,
|
||||
radio: false,
|
||||
groupBy: 'note',
|
||||
clone: false,
|
||||
})
|
||||
|
||||
assert.equal(el.find('table > thead > tr').length, 1, 'row count')
|
||||
|
|
@ -565,6 +567,7 @@ QUnit.test('table new - initial list', assert => {
|
|||
objects: App.TicketPriority.all(),
|
||||
checkbox: false,
|
||||
radio: false,
|
||||
clone: false,
|
||||
ttt: true
|
||||
})
|
||||
|
||||
|
|
@ -795,6 +798,7 @@ QUnit.test('table new - initial list', assert => {
|
|||
objects: App.TicketPriority.search({sortBy:'name', order: 'ASC'}),
|
||||
checkbox: false,
|
||||
radio: false,
|
||||
clone: false,
|
||||
})
|
||||
|
||||
assert.equal(el.find('table > thead > tr').length, 1, 'row count')
|
||||
|
|
@ -1073,6 +1077,7 @@ QUnit.test('table new - initial list', assert => {
|
|||
objects: App.TicketPriority.all(),
|
||||
checkbox: false,
|
||||
radio: false,
|
||||
clone: false,
|
||||
ttt: true
|
||||
})
|
||||
|
||||
|
|
@ -1095,10 +1100,10 @@ QUnit.test('table new - initial list', assert => {
|
|||
assert.equal(el.find('tbody > tr').length, 150)
|
||||
assert.equal(el.find('tbody > tr:nth-child(151) > td').length, 0)
|
||||
|
||||
assert.equal(el.find('.js-pager').first().find('.js-page').length, 2)
|
||||
assert.equal(el.find('.js-pager').first().find('.js-page.is-selected').length, 1)
|
||||
assert.equal(el.find('.js-pager').first().find('.js-page.is-selected').text(), '1')
|
||||
el.find('.js-pager').first().find('.js-page:nth-child(2)').trigger('click')
|
||||
assert.equal(el.find('.js-pager').first().find('.js-page').length, 4)
|
||||
assert.equal(el.find('.js-pager').first().find('.js-page.btn--active').length, 1)
|
||||
assert.equal(el.find('.js-pager').first().find('.js-page.btn--active').text(), '1')
|
||||
el.find('.js-pager').first().find('.js-page[data-page=1]').trigger('click')
|
||||
|
||||
assert.equal(el.find('table > thead > tr').length, 1, 'row count')
|
||||
assert.equal(el.find('table > thead > tr > th:nth-child(1)').text().trim(), 'Name', 'check header')
|
||||
|
|
@ -1209,10 +1214,10 @@ QUnit.test('table new - initial list', assert => {
|
|||
result = table.update({sync: true, objects: objects})
|
||||
assert.equal(result[0], 'fullRender.contentRemoved')
|
||||
|
||||
assert.equal(el.find('.js-pager').first().find('.js-page').length, 2)
|
||||
assert.equal(el.find('.js-pager').first().find('.js-page.is-selected').length, 1)
|
||||
assert.equal(el.find('.js-pager').first().find('.js-page.is-selected').text(), '1')
|
||||
el.find('.js-pager').first().find('.js-page:nth-child(2)').trigger('click')
|
||||
assert.equal(el.find('.js-pager').first().find('.js-page').length, 4)
|
||||
assert.equal(el.find('.js-pager').first().find('.js-page.btn--active').length, 1)
|
||||
assert.equal(el.find('.js-pager').first().find('.js-page.btn--active').text(), '1')
|
||||
el.find('.js-pager').first().find('.js-page[data-page=1]').trigger('click')
|
||||
|
||||
assert.equal(el.find('table > thead > tr').length, 1, 'row count')
|
||||
assert.equal(el.find('table > thead > tr > th:nth-child(1)').text().trim(), 'Name', 'check header')
|
||||
|
|
@ -1229,10 +1234,10 @@ QUnit.test('table new - initial list', assert => {
|
|||
assert.equal(el.find('tbody > tr').length, 2)
|
||||
assert.equal(el.find('tbody > tr:nth-child(3) > td').length, 0)
|
||||
|
||||
assert.equal(el.find('.js-pager').first().find('.js-page').length, 2)
|
||||
assert.equal(el.find('.js-pager').first().find('.js-page.is-selected').length, 1)
|
||||
assert.equal(el.find('.js-pager').first().find('.js-page.is-selected').text(), '2')
|
||||
el.find('.js-pager').first().find('.js-page:nth-child(1)').trigger('click')
|
||||
assert.equal(el.find('.js-pager').first().find('.js-page').length, 4)
|
||||
assert.equal(el.find('.js-pager').first().find('.js-page.btn--active').length, 1)
|
||||
assert.equal(el.find('.js-pager').first().find('.js-page.btn--active').text(), '2')
|
||||
el.find('.js-pager').first().find('.js-page[data-page=0]').trigger('click')
|
||||
|
||||
assert.equal(el.find('table > thead > tr').length, 1, 'row count')
|
||||
assert.equal(el.find('table > thead > tr > th:nth-child(1)').text().trim(), 'Name', 'check header')
|
||||
|
|
@ -1253,9 +1258,9 @@ QUnit.test('table new - initial list', assert => {
|
|||
assert.equal(el.find('tbody > tr').length, 150)
|
||||
assert.equal(el.find('tbody > tr:nth-child(151) > td').length, 0)
|
||||
|
||||
assert.equal(el.find('.js-pager').first().find('.js-page').length, 2)
|
||||
assert.equal(el.find('.js-pager').first().find('.js-page.is-selected').length, 1)
|
||||
assert.equal(el.find('.js-pager').first().find('.js-page.is-selected').text(), '1')
|
||||
assert.equal(el.find('.js-pager').first().find('.js-page').length, 4)
|
||||
assert.equal(el.find('.js-pager').first().find('.js-page.btn--active').length, 1)
|
||||
assert.equal(el.find('.js-pager').first().find('.js-page.btn--active').text(), '1')
|
||||
|
||||
objects.splice(2,2)
|
||||
|
||||
|
|
@ -1317,9 +1322,9 @@ QUnit.test('table new - initial list', assert => {
|
|||
assert.equal(el.find('tbody > tr').length, 150)
|
||||
assert.equal(el.find('tbody > tr:nth-child(151) > td').length, 0)
|
||||
|
||||
assert.equal(el.find('.js-pager').first().find('.js-page').length, 2)
|
||||
assert.equal(el.find('.js-pager').first().find('.js-page.is-selected').length, 1)
|
||||
assert.equal(el.find('.js-pager').first().find('.js-page.is-selected').text(), '1')
|
||||
assert.equal(el.find('.js-pager').first().find('.js-page').length, 4)
|
||||
assert.equal(el.find('.js-pager').first().find('.js-page.btn--active').length, 1)
|
||||
assert.equal(el.find('.js-pager').first().find('.js-page.btn--active').text(), '1')
|
||||
|
||||
$('#qunit').append('<hr><h1>table with data 7</h1><div id="table-new7"></div>')
|
||||
var el = $('#table-new7')
|
||||
|
|
@ -1355,6 +1360,7 @@ QUnit.test('table new - initial list', assert => {
|
|||
objects: App.TicketPriority.search({sortBy:'name', order: 'ASC'}),
|
||||
checkbox: false,
|
||||
radio: false,
|
||||
clone: false,
|
||||
})
|
||||
//equal(el.find('table').length, 0, 'row count')
|
||||
//table.render()
|
||||
|
|
@ -1454,6 +1460,7 @@ QUnit.test('table new - initial list', assert => {
|
|||
objects: App.TicketPriority.all(),
|
||||
checkbox: false,
|
||||
radio: false,
|
||||
clone: false,
|
||||
orderBy: 'not_existing',
|
||||
orderDirection: 'DESC',
|
||||
})
|
||||
|
|
@ -1521,6 +1528,7 @@ QUnit.test('table new - initial list', assert => {
|
|||
objects: App.TicketPriority.all(),
|
||||
checkbox: false,
|
||||
radio: false,
|
||||
clone: false,
|
||||
orderBy: 'external',
|
||||
orderDirection: 'DESC',
|
||||
})
|
||||
|
|
@ -1605,6 +1613,7 @@ QUnit.test('table new - initial list', assert => {
|
|||
objects: App.TicketPriority.all(),
|
||||
checkbox: false,
|
||||
radio: false,
|
||||
clone: false,
|
||||
orderBy: 'external',
|
||||
orderDirection: 'ASC',
|
||||
})
|
||||
|
|
@ -1673,6 +1682,7 @@ QUnit.test('table new - initial list', assert => {
|
|||
objects: App.TicketPriority.search({sortBy:'name', order: 'ASC'}),
|
||||
checkbox: false,
|
||||
radio: false,
|
||||
clone: false,
|
||||
frontendTimeUpdateExecute: false,
|
||||
})
|
||||
//equal(el.find('table').length, 0, 'row count')
|
||||
|
|
@ -1733,12 +1743,13 @@ QUnit.test('table new - initial list', assert => {
|
|||
objects: App.TicketPriority.all(),
|
||||
checkbox: false,
|
||||
radio: false,
|
||||
clone: false,
|
||||
pagerItemsPerPage: 10,
|
||||
ttt: true
|
||||
})
|
||||
|
||||
assert.equal(el.find('tbody > tr').length, 10)
|
||||
assert.equal(el.find('.js-pager:first-child .js-page').length, 4)
|
||||
assert.equal(el.find('.js-pager:first-child .js-page').length, 6)
|
||||
|
||||
$('#qunit').append('<hr><h1>table with large data and pager disabled</h1><div id="table-new13"></div>')
|
||||
var el = $('#table-new13')
|
||||
|
|
@ -1768,6 +1779,7 @@ QUnit.test('table new - initial list', assert => {
|
|||
objects: App.TicketPriority.all(),
|
||||
checkbox: false,
|
||||
radio: false,
|
||||
clone: false,
|
||||
pagerEnabled: false,
|
||||
ttt: true
|
||||
})
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
FactoryBot.define do
|
||||
factory :overview do
|
||||
sequence(:name) { |n| "Test Overview #{n}" }
|
||||
prio { 1 }
|
||||
sequence(:prio) { |n| n }
|
||||
role_ids { Role.where(name: %w[Customer Agent Admin]).pluck(:id) }
|
||||
out_of_office { false }
|
||||
updated_by_id { 1 }
|
||||
|
|
|
|||
|
|
@ -20,8 +20,66 @@ RSpec.describe SearchIndexBackend do
|
|||
end
|
||||
end
|
||||
|
||||
describe '.search', searchindex: true do
|
||||
describe '.search', searchindex: false do
|
||||
before do
|
||||
allow(described_class).to receive(:search_by_index) { |_query, index, _options| ["response:#{index}"] }
|
||||
end
|
||||
|
||||
let(:query) { Faker::Lorem.word }
|
||||
let(:options) { { opt1: true } }
|
||||
|
||||
it 'calls search_by_index if single index given' do
|
||||
described_class.search(query, 'Index A', options)
|
||||
|
||||
expect(described_class)
|
||||
.to have_received(:search_by_index)
|
||||
.with(query, 'Index A', options)
|
||||
.once
|
||||
end
|
||||
|
||||
it 'calls search_by_index for each given index given', aggregate_failures: true do
|
||||
described_class.search(query, %w[indexA indexB], options)
|
||||
|
||||
expect(described_class)
|
||||
.to have_received(:search_by_index)
|
||||
.with(query, 'indexA', options)
|
||||
.once
|
||||
|
||||
expect(described_class)
|
||||
.to have_received(:search_by_index)
|
||||
.with(query, 'indexB', options)
|
||||
.once
|
||||
end
|
||||
|
||||
it 'flattens results if multiple indexes are queries' do
|
||||
expect(described_class.search(query, %w[indexA indexB], options))
|
||||
.to eq %w[response:indexA response:indexB]
|
||||
end
|
||||
|
||||
context 'when one of the indexes return nil' do
|
||||
before do
|
||||
allow(described_class).to receive(:search_by_index)
|
||||
.with(anything, 'empty', anything).and_return(nil)
|
||||
end
|
||||
|
||||
it 'does not include nil in flattened return' do
|
||||
expect(described_class.search(query, %w[indexA empty indexB], options))
|
||||
.to eq %w[response:indexA response:indexB]
|
||||
end
|
||||
|
||||
it 'returns nil if single index was queried' do
|
||||
expect(described_class.search(query, 'empty', options))
|
||||
.to be_nil
|
||||
end
|
||||
end
|
||||
|
||||
it 'raises an error if with_total_count option is passed' do
|
||||
expect { described_class.search(query, %w[indexA indexB], { with_total_count: true }) }
|
||||
.to raise_error(include('with_total_count'))
|
||||
end
|
||||
end
|
||||
|
||||
describe '.search_by_index', searchindex: true do
|
||||
context 'query finds results' do
|
||||
|
||||
let(:record_type) { 'Ticket'.freeze }
|
||||
|
|
@ -33,9 +91,17 @@ RSpec.describe SearchIndexBackend do
|
|||
end
|
||||
|
||||
it 'finds added records' do
|
||||
result = described_class.search(record.number, record_type, sort_by: ['updated_at'], order_by: ['desc'])
|
||||
result = described_class.search_by_index(record.number, record_type, sort_by: ['updated_at'], order_by: ['desc'])
|
||||
expect(result).to eq([{ id: record.id.to_s, type: record_type }])
|
||||
end
|
||||
|
||||
it 'returns count and id when with_total_count option is given' do
|
||||
result = described_class.search_by_index(record.number, record_type, with_total_count: true)
|
||||
expect(result).to include(
|
||||
total_count: 1,
|
||||
object_metadata: include(include(id: record.id.to_s))
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when search for user firstname + double lastname' do
|
||||
|
|
@ -48,26 +114,27 @@ RSpec.describe SearchIndexBackend do
|
|||
end
|
||||
|
||||
it 'finds user record' do
|
||||
result = described_class.search('AnFirst ASplit Lastname', record_type, sort_by: ['updated_at'], order_by: ['desc'])
|
||||
result = described_class.search_by_index('AnFirst ASplit Lastname', record_type, sort_by: ['updated_at'], order_by: ['desc'])
|
||||
expect(result).to eq([{ id: record.id.to_s, type: record_type }])
|
||||
end
|
||||
end
|
||||
|
||||
context 'for query with no results' do
|
||||
subject(:search) { described_class.search(query, index, limit: 3000) }
|
||||
subject(:search) { described_class.search_by_index(query, index, limit: 3000, with_total_count:) }
|
||||
|
||||
let(:query) { 'preferences.notification_sound.enabled:*' }
|
||||
let(:with_total_count) { false }
|
||||
|
||||
context 'on a single index' do
|
||||
let(:index) { 'User' }
|
||||
|
||||
it { is_expected.to be_an(Array).and be_empty }
|
||||
end
|
||||
|
||||
context 'on multiple indices' do
|
||||
let(:index) { %w[User Organization] }
|
||||
context 'when with_total_count is given' do
|
||||
let(:with_total_count) { true }
|
||||
|
||||
it { is_expected.to be_an(Array).and not_include(nil).and be_empty }
|
||||
it { is_expected.to include(total_count: 0, object_metadata: be_empty) }
|
||||
end
|
||||
end
|
||||
|
||||
context 'when user has a signature detection' do
|
||||
|
|
@ -81,7 +148,7 @@ RSpec.describe SearchIndexBackend do
|
|||
end
|
||||
|
||||
it 'does not find the ticket record' do
|
||||
result = described_class.search('Hamburg', record_type, sort_by: ['updated_at'], order_by: ['desc'])
|
||||
result = described_class.search_by_index('Hamburg', record_type, sort_by: ['updated_at'], order_by: ['desc'])
|
||||
expect(result).to eq([])
|
||||
end
|
||||
end
|
||||
|
|
@ -99,19 +166,19 @@ RSpec.describe SearchIndexBackend do
|
|||
|
||||
it 'finds record in a given timezone with a range' do
|
||||
Setting.set('timezone_default', 'UTC')
|
||||
result = described_class.search('created_at: [2019-01-01 TO 2019-01-01]', record_type)
|
||||
result = described_class.search_by_index('created_at: [2019-01-01 TO 2019-01-01]', record_type)
|
||||
expect(result).to eq([{ id: record.id.to_s, type: record_type }])
|
||||
end
|
||||
|
||||
it 'finds record in a far away timezone with a date' do
|
||||
Setting.set('timezone_default', 'Europe/Vilnius')
|
||||
result = described_class.search('created_at: 2019-01-02', record_type)
|
||||
result = described_class.search_by_index('created_at: 2019-01-02', record_type)
|
||||
expect(result).to eq([{ id: record.id.to_s, type: record_type }])
|
||||
end
|
||||
|
||||
it 'finds record in UTC with date' do
|
||||
Setting.set('timezone_default', 'UTC')
|
||||
result = described_class.search('created_at: 2019-01-01', record_type)
|
||||
result = described_class.search_by_index('created_at: 2019-01-01', record_type)
|
||||
expect(result).to eq([{ id: record.id.to_s, type: record_type }])
|
||||
end
|
||||
end
|
||||
|
|
@ -132,17 +199,17 @@ RSpec.describe SearchIndexBackend do
|
|||
end
|
||||
|
||||
it 'finds added records by integer part' do
|
||||
result = described_class.search('102105', record_type, sort_by: ['updated_at'], order_by: ['desc'])
|
||||
result = described_class.search_by_index('102105', record_type, sort_by: ['updated_at'], order_by: ['desc'])
|
||||
expect(result).to eq([{ id: record.id.to_s, type: record_type }])
|
||||
end
|
||||
|
||||
it 'finds added records by integer' do
|
||||
result = described_class.search('1021052349', record_type, sort_by: ['updated_at'], order_by: ['desc'])
|
||||
result = described_class.search_by_index('1021052349', record_type, sort_by: ['updated_at'], order_by: ['desc'])
|
||||
expect(result).to eq([{ id: record.id.to_s, type: record_type }])
|
||||
end
|
||||
|
||||
it 'finds added records by quoted integer' do
|
||||
result = described_class.search('"1021052349"', record_type, sort_by: ['updated_at'], order_by: ['desc'])
|
||||
result = described_class.search_by_index('"1021052349"', record_type, sort_by: ['updated_at'], order_by: ['desc'])
|
||||
expect(result).to eq([{ id: record.id.to_s, type: record_type }])
|
||||
end
|
||||
end
|
||||
|
|
@ -160,7 +227,7 @@ RSpec.describe SearchIndexBackend do
|
|||
end
|
||||
|
||||
it 'finds added records' do
|
||||
result = described_class.search(record.number, record_type, sort_by: [field_name], order_by: ['desc'])
|
||||
result = described_class.search_by_index(record.number, record_type, sort_by: [field_name], order_by: ['desc'])
|
||||
expect(result).to eq([{ id: record.id.to_s, type: record_type }])
|
||||
end
|
||||
end
|
||||
|
|
|
|||
114
spec/models/concerns/can_search_spec.rb
Normal file
114
spec/models/concerns/can_search_spec.rb
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
# Copyright (C) 2012-2024 Zammad Foundation, https://zammad-foundation.org/
|
||||
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe 'CanSearch', searchindex: true, type: :model do
|
||||
let(:roles) { create_list(:role, 100) }
|
||||
|
||||
before do
|
||||
roles
|
||||
searchindex_model_reload([Role])
|
||||
end
|
||||
|
||||
def search(params)
|
||||
Role.search({ current_user: User.find(1), full: false, with_total_count: true }.merge(params))
|
||||
end
|
||||
|
||||
it 'does search by query', :aggregate_failures do
|
||||
expected_result = 10
|
||||
params = { query: 'TestRole*', limit: 10, with_total_count: false, full: true }
|
||||
expect(search(params).count).to eq(expected_result)
|
||||
|
||||
allow(SearchIndexBackend).to receive(:enabled?).and_return(false)
|
||||
expect(search(params).count).to eq(expected_result)
|
||||
end
|
||||
|
||||
it 'does search by query not full', :aggregate_failures do
|
||||
expected_result = 10
|
||||
params = { query: 'TestRole*', limit: 10, with_total_count: false }
|
||||
expect(search(params).count).to eq(expected_result)
|
||||
|
||||
allow(SearchIndexBackend).to receive(:enabled?).and_return(false)
|
||||
expect(search(params).count).to eq(expected_result)
|
||||
end
|
||||
|
||||
it 'does search by query with total count', :aggregate_failures do
|
||||
expected_result = 100
|
||||
params = { query: 'TestRole*', limit: 10 }
|
||||
expect(search(params)[:object_ids].count).to eq(10)
|
||||
expect(search(params)[:total_count]).to eq(expected_result)
|
||||
|
||||
allow(SearchIndexBackend).to receive(:enabled?).and_return(false)
|
||||
expect(search(params)[:object_ids].count).to eq(10)
|
||||
expect(search(params)[:total_count]).to eq(expected_result)
|
||||
end
|
||||
|
||||
it 'does search by query only total count', :aggregate_failures do
|
||||
expected_result = 100
|
||||
params = { query: 'TestRole*', limit: 10, only_total_count: true }
|
||||
expect(search(params)[:total_count]).to eq(expected_result)
|
||||
|
||||
allow(SearchIndexBackend).to receive(:enabled?).and_return(false)
|
||||
expect(search(params)[:total_count]).to eq(expected_result)
|
||||
end
|
||||
|
||||
it 'does search by query and ids', :aggregate_failures do
|
||||
expected_result = roles[0..3].map(&:id)
|
||||
params = { query: 'TestRole*', limit: 10, ids: expected_result }
|
||||
expect(search(params)[:object_ids].sort.map(&:to_i)).to eq(expected_result)
|
||||
expect(search(params)[:total_count]).to eq(4)
|
||||
|
||||
allow(SearchIndexBackend).to receive(:enabled?).and_return(false)
|
||||
expect(search(params)[:object_ids].sort.map(&:to_i)).to eq(expected_result)
|
||||
expect(search(params)[:total_count]).to eq(4)
|
||||
end
|
||||
|
||||
it 'does search by query and ids and sorted', :aggregate_failures do
|
||||
expected_result = roles[0..3].map(&:id).reverse
|
||||
params = { query: 'TestRole*', limit: 10, ids: expected_result, sort_by: 'id', order_by: 'desc' }
|
||||
expect(search(params)[:object_ids].map(&:to_i)).to eq(expected_result)
|
||||
expect(search(params)[:total_count]).to eq(4)
|
||||
|
||||
allow(SearchIndexBackend).to receive(:enabled?).and_return(false)
|
||||
expect(search(params)[:object_ids].map(&:to_i)).to eq(expected_result)
|
||||
expect(search(params)[:total_count]).to eq(4)
|
||||
end
|
||||
|
||||
it 'does search by query and ids and sorted and offset', :aggregate_failures do
|
||||
ids = roles[0..3].map(&:id)
|
||||
params = { query: 'TestRole*', limit: 1, ids: ids, sort_by: 'id', order_by: 'asc' }
|
||||
expect(search(params)[:object_ids].map(&:to_i)).to eq([ids[0]])
|
||||
expect(search(params)[:total_count]).to eq(4)
|
||||
expect(search(params.merge(offset: 1))[:object_ids].map(&:to_i)).to eq([ids[1]])
|
||||
expect(search(params.merge(offset: 1))[:total_count]).to eq(4)
|
||||
expect(search(params.merge(offset: 2))[:object_ids].map(&:to_i)).to eq([ids[2]])
|
||||
expect(search(params.merge(offset: 2))[:total_count]).to eq(4)
|
||||
|
||||
allow(SearchIndexBackend).to receive(:enabled?).and_return(false)
|
||||
expect(search(params)[:object_ids].map(&:to_i)).to eq([ids[0]])
|
||||
expect(search(params)[:total_count]).to eq(4)
|
||||
expect(search(params.merge(offset: 1))[:object_ids].map(&:to_i)).to eq([ids[1]])
|
||||
expect(search(params.merge(offset: 1))[:total_count]).to eq(4)
|
||||
expect(search(params.merge(offset: 2))[:object_ids].map(&:to_i)).to eq([ids[2]])
|
||||
expect(search(params.merge(offset: 2))[:total_count]).to eq(4)
|
||||
end
|
||||
|
||||
it 'does search by query and condition and sorted and offset', :aggregate_failures do
|
||||
ids = roles[0..3].map(&:id)
|
||||
params = { query: 'TestRole*', limit: 1, condition: { 'role.id' => { 'operator' => 'is', 'value' => ids.map(&:to_s) } }, sort_by: 'id', order_by: 'asc' }
|
||||
expect(search(params)[:object_ids].map(&:to_i)).to eq([ids[0]])
|
||||
expect(search(params)[:total_count]).to eq(4)
|
||||
expect(search(params.merge(offset: 1))[:object_ids].map(&:to_i)).to eq([ids[1]])
|
||||
expect(search(params.merge(offset: 1))[:total_count]).to eq(4)
|
||||
expect(search(params.merge(offset: 2))[:object_ids].map(&:to_i)).to eq([ids[2]])
|
||||
expect(search(params.merge(offset: 2))[:total_count]).to eq(4)
|
||||
|
||||
allow(SearchIndexBackend).to receive(:enabled?).and_return(false)
|
||||
expect(search(params)[:object_ids].map(&:to_i)).to eq([ids[0]])
|
||||
expect(search(params)[:total_count]).to eq(4)
|
||||
expect(search(params.merge(offset: 1))[:object_ids].map(&:to_i)).to eq([ids[1]])
|
||||
expect(search(params.merge(offset: 1))[:total_count]).to eq(4)
|
||||
expect(search(params.merge(offset: 2))[:object_ids].map(&:to_i)).to eq([ids[2]])
|
||||
expect(search(params.merge(offset: 2))[:total_count]).to eq(4)
|
||||
end
|
||||
end
|
||||
|
|
@ -14,7 +14,7 @@ RSpec.describe 'KnowledgeBase translation update', authenticated_as: :current_us
|
|||
{
|
||||
title: new_title,
|
||||
footer_note: 'new footer',
|
||||
id: knowledge_base.kb_locales.first.id
|
||||
id: knowledge_base.translations.first.id
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -888,11 +888,11 @@ RSpec.describe 'Ticket', type: :request do
|
|||
expect(article_json_response['type_id']).to eq(Ticket::Article::Type.lookup(name: 'note').id)
|
||||
|
||||
perform_enqueued_jobs
|
||||
get "/api/v1/tickets/search?query=#{CGI.escape(title)}", params: {}, as: :json
|
||||
get "/api/v1/tickets/search?query=#{CGI.escape(title)}&full=true", params: {}, as: :json
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(json_response).to be_a(Hash)
|
||||
expect(json_response['tickets'][0]).to eq(ticket.id)
|
||||
expect(json_response['tickets_count']).to eq(1)
|
||||
expect(json_response['record_ids'][0]).to eq(ticket.id)
|
||||
expect(json_response['record_ids'].count).to eq(1)
|
||||
|
||||
params = {
|
||||
condition: {
|
||||
|
|
@ -902,11 +902,11 @@ RSpec.describe 'Ticket', type: :request do
|
|||
},
|
||||
},
|
||||
}
|
||||
post '/api/v1/tickets/search', params: params, as: :json
|
||||
post '/api/v1/tickets/search?full=true', params: params, as: :json
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(json_response).to be_a(Hash)
|
||||
expect(json_response['tickets'][0]).to eq(ticket.id)
|
||||
expect(json_response['tickets_count']).to eq(1)
|
||||
expect(json_response['record_ids'][0]).to eq(ticket.id)
|
||||
expect(json_response['record_ids'].count).to eq(1)
|
||||
|
||||
delete "/api/v1/ticket_articles/#{article_json_response['id']}", params: {}, as: :json
|
||||
expect(response).to have_http_status(:ok)
|
||||
|
|
@ -1090,33 +1090,33 @@ RSpec.describe 'Ticket', type: :request do
|
|||
end
|
||||
|
||||
authenticated_as(admin)
|
||||
get "/api/v1/tickets/search?query=#{CGI.escape(title)}&limit=40", params: {}, as: :json
|
||||
get "/api/v1/tickets/search?query=#{CGI.escape(title)}&limit=40&full=true", params: {}, as: :json
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(json_response).to be_a(Hash)
|
||||
expect(json_response['tickets'][0]).to eq(tickets[19].id)
|
||||
expect(json_response['tickets'][19]).to eq(tickets[0].id)
|
||||
expect(json_response['tickets_count']).to eq(20)
|
||||
expect(json_response['record_ids'][0]).to eq(tickets[19].id)
|
||||
expect(json_response['record_ids'][19]).to eq(tickets[0].id)
|
||||
expect(json_response['record_ids'].count).to eq(20)
|
||||
|
||||
get "/api/v1/tickets/search?query=#{CGI.escape(title)}&limit=10", params: {}, as: :json
|
||||
get "/api/v1/tickets/search?query=#{CGI.escape(title)}&limit=10&full=true", params: {}, as: :json
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(json_response).to be_a(Hash)
|
||||
expect(json_response['tickets'][0]).to eq(tickets[19].id)
|
||||
expect(json_response['tickets'][9]).to eq(tickets[10].id)
|
||||
expect(json_response['tickets_count']).to eq(10)
|
||||
expect(json_response['record_ids'][0]).to eq(tickets[19].id)
|
||||
expect(json_response['record_ids'][9]).to eq(tickets[10].id)
|
||||
expect(json_response['record_ids'].count).to eq(10)
|
||||
|
||||
get "/api/v1/tickets/search?query=#{CGI.escape(title)}&limit=40&page=1&per_page=5", params: {}, as: :json
|
||||
get "/api/v1/tickets/search?query=#{CGI.escape(title)}&limit=40&full=true&page=1&per_page=5", params: {}, as: :json
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(json_response).to be_a(Hash)
|
||||
expect(json_response['tickets'][0]).to eq(tickets[19].id)
|
||||
expect(json_response['tickets'][4]).to eq(tickets[15].id)
|
||||
expect(json_response['tickets_count']).to eq(5)
|
||||
expect(json_response['record_ids'][0]).to eq(tickets[19].id)
|
||||
expect(json_response['record_ids'][4]).to eq(tickets[15].id)
|
||||
expect(json_response['record_ids'].count).to eq(5)
|
||||
|
||||
get "/api/v1/tickets/search?query=#{CGI.escape(title)}&limit=40&page=2&per_page=5", params: {}, as: :json
|
||||
get "/api/v1/tickets/search?query=#{CGI.escape(title)}&limit=40&full=true&page=2&per_page=5", params: {}, as: :json
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(json_response).to be_a(Hash)
|
||||
expect(json_response['tickets'][0]).to eq(tickets[14].id)
|
||||
expect(json_response['tickets'][4]).to eq(tickets[10].id)
|
||||
expect(json_response['tickets_count']).to eq(5)
|
||||
expect(json_response['record_ids'][0]).to eq(tickets[14].id)
|
||||
expect(json_response['record_ids'][4]).to eq(tickets[10].id)
|
||||
expect(json_response['record_ids'].count).to eq(5)
|
||||
|
||||
get '/api/v1/tickets?limit=40&page=1&per_page=5', params: {}, as: :json
|
||||
expect(response).to have_http_status(:ok)
|
||||
|
|
@ -1286,11 +1286,11 @@ RSpec.describe 'Ticket', type: :request do
|
|||
expect(article_json_response['type_id']).to eq(Ticket::Article::Type.lookup(name: 'note').id)
|
||||
|
||||
perform_enqueued_jobs
|
||||
get "/api/v1/tickets/search?query=#{CGI.escape(title)}", params: {}, as: :json
|
||||
get "/api/v1/tickets/search?query=#{CGI.escape(title)}&full=true", params: {}, as: :json
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(json_response).to be_a(Hash)
|
||||
expect(json_response['tickets'][0]).to eq(ticket.id)
|
||||
expect(json_response['tickets_count']).to eq(1)
|
||||
expect(json_response['record_ids'][0]).to eq(ticket.id)
|
||||
expect(json_response['record_ids'].count).to eq(1)
|
||||
|
||||
params = {
|
||||
condition: {
|
||||
|
|
@ -1300,11 +1300,11 @@ RSpec.describe 'Ticket', type: :request do
|
|||
},
|
||||
},
|
||||
}
|
||||
post '/api/v1/tickets/search', params: params, as: :json
|
||||
post '/api/v1/tickets/search?full=true', params: params, as: :json
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(json_response).to be_a(Hash)
|
||||
expect(json_response['tickets'][0]).to eq(ticket.id)
|
||||
expect(json_response['tickets_count']).to eq(1)
|
||||
expect(json_response['record_ids'][0]).to eq(ticket.id)
|
||||
expect(json_response['record_ids'].count).to eq(1)
|
||||
|
||||
delete "/api/v1/ticket_articles/#{article_json_response['id']}", params: {}, as: :json
|
||||
expect(response).to have_http_status(:forbidden)
|
||||
|
|
@ -2108,40 +2108,40 @@ RSpec.describe 'Ticket', type: :request do
|
|||
)
|
||||
|
||||
authenticated_as(admin)
|
||||
get "/api/v1/tickets/search?query=#{CGI.escape(title)}&limit=40", params: {}, as: :json
|
||||
get "/api/v1/tickets/search?query=#{CGI.escape(title)}&limit=40&full=true", params: {}, as: :json
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(json_response).to be_a(Hash)
|
||||
expect(json_response['tickets']).to eq([ticket2.id, ticket1.id])
|
||||
expect(json_response['record_ids']).to eq([ticket2.id, ticket1.id])
|
||||
|
||||
authenticated_as(admin)
|
||||
get "/api/v1/tickets/search?query=#{CGI.escape(title)}&limit=40", params: { sort_by: 'created_at', order_by: 'asc' }, as: :json
|
||||
get "/api/v1/tickets/search?query=#{CGI.escape(title)}&limit=40&full=true", params: { sort_by: 'created_at', order_by: 'asc' }, as: :json
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(json_response).to be_a(Hash)
|
||||
expect(json_response['tickets']).to eq([ticket1.id, ticket2.id])
|
||||
expect(json_response['record_ids']).to eq([ticket1.id, ticket2.id])
|
||||
|
||||
authenticated_as(admin)
|
||||
get "/api/v1/tickets/search?query=#{CGI.escape(title)}&limit=40", params: { sort_by: 'title', order_by: 'asc' }, as: :json
|
||||
get "/api/v1/tickets/search?query=#{CGI.escape(title)}&limit=40&full=true", params: { sort_by: 'title', order_by: 'asc' }, as: :json
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(json_response).to be_a(Hash)
|
||||
expect(json_response['tickets']).to eq([ticket1.id, ticket2.id])
|
||||
expect(json_response['record_ids']).to eq([ticket1.id, ticket2.id])
|
||||
|
||||
authenticated_as(admin)
|
||||
get "/api/v1/tickets/search?query=#{CGI.escape(title)}&limit=40", params: { sort_by: 'title', order_by: 'desc' }, as: :json
|
||||
get "/api/v1/tickets/search?query=#{CGI.escape(title)}&limit=40&full=true", params: { sort_by: 'title', order_by: 'desc' }, as: :json
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(json_response).to be_a(Hash)
|
||||
expect(json_response['tickets']).to eq([ticket2.id, ticket1.id])
|
||||
expect(json_response['record_ids']).to eq([ticket2.id, ticket1.id])
|
||||
|
||||
authenticated_as(admin)
|
||||
get "/api/v1/tickets/search?query=#{CGI.escape(title)}&limit=40", params: { sort_by: %w[created_at updated_at], order_by: %w[asc asc] }, as: :json
|
||||
get "/api/v1/tickets/search?query=#{CGI.escape(title)}&limit=40&full=true", params: { sort_by: %w[created_at updated_at], order_by: %w[asc asc] }, as: :json
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(json_response).to be_a(Hash)
|
||||
expect(json_response['tickets']).to eq([ticket1.id, ticket2.id])
|
||||
expect(json_response['record_ids']).to eq([ticket1.id, ticket2.id])
|
||||
|
||||
authenticated_as(admin)
|
||||
get "/api/v1/tickets/search?query=#{CGI.escape(title)}&limit=40", params: { sort_by: %w[created_at updated_at], order_by: %w[desc asc] }, as: :json
|
||||
get "/api/v1/tickets/search?query=#{CGI.escape(title)}&limit=40&full=true", params: { sort_by: %w[created_at updated_at], order_by: %w[desc asc] }, as: :json
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(json_response).to be_a(Hash)
|
||||
expect(json_response['tickets']).to eq([ticket2.id, ticket1.id])
|
||||
expect(json_response['record_ids']).to eq([ticket2.id, ticket1.id])
|
||||
end
|
||||
|
||||
it 'does ticket history' do
|
||||
|
|
|
|||
|
|
@ -1435,15 +1435,16 @@ RSpec.describe 'User', performs_jobs: true, type: :request do
|
|||
end
|
||||
end
|
||||
|
||||
describe 'GET /api/v1/users/search group ids' do
|
||||
describe 'POST /api/v1/users/search group ids and generic model_search_render tests' do
|
||||
let(:group1) { create(:group) }
|
||||
let(:group2) { create(:group) }
|
||||
let!(:agent1) { create(:agent, firstname: '9U7Z-agent1', groups: [group1]) }
|
||||
let!(:agent2) { create(:agent, firstname: '9U7Z-agent2', groups: [group2]) }
|
||||
let(:group2) { create(:group) }
|
||||
let!(:agent1) { create(:agent, firstname: '9U7Z-agent1', groups: [group1]) }
|
||||
let!(:agent2) { create(:agent, firstname: '9U7Z-agent2', groups: [group2]) }
|
||||
let!(:random_agents) { create_list(:agent, 20) }
|
||||
|
||||
def make_request(params)
|
||||
authenticated_as(agent1)
|
||||
get '/api/v1/users/search', params: params, as: :json
|
||||
post '/api/v1/users/search', params: params, as: :json
|
||||
end
|
||||
|
||||
describe 'without searchindex' do
|
||||
|
|
@ -1478,6 +1479,45 @@ RSpec.describe 'User', performs_jobs: true, type: :request do
|
|||
not_in_response = json_response.none? { |item| item['id'] == 1 }
|
||||
expect(not_in_response).to be(true)
|
||||
end
|
||||
|
||||
it 'does return data' do
|
||||
make_request(offset: 0, limit: 10)
|
||||
expect(json_response).to be_a(Array)
|
||||
expect(json_response.count).to eq(10)
|
||||
end
|
||||
|
||||
it 'does return expand data' do
|
||||
make_request(expand: true, offset: 0, limit: 10)
|
||||
expect(json_response).to be_a(Array)
|
||||
expect(json_response.count).to eq(10)
|
||||
expect(json_response[0]['groups']).to be_present
|
||||
end
|
||||
|
||||
it 'does return full data with total count' do
|
||||
make_request(query: '9U7Z', full: true, offset: 0, limit: 1)
|
||||
expect(json_response['assets']).to be_present
|
||||
expect(json_response['record_ids'].count).to eq(1)
|
||||
expect(json_response['total_count']).to eq(2)
|
||||
end
|
||||
|
||||
it 'does return label data' do
|
||||
make_request(query: '9U7Z', label: true, offset: 0, limit: 1)
|
||||
expect(json_response).to be_a(Array)
|
||||
expect(json_response[0].keys).to include('id', 'label', 'value')
|
||||
end
|
||||
|
||||
it 'does return term data' do
|
||||
make_request(term: '9U7Z', offset: 0, limit: 1)
|
||||
expect(json_response).to be_a(Array)
|
||||
expect(json_response[0].keys).to include('id', 'label', 'value', 'inactive')
|
||||
end
|
||||
|
||||
it 'does return only total count' do
|
||||
make_request(term: '9U7Z', offset: 0, limit: 1, only_total_count: true)
|
||||
expect(json_response).to be_a(Hash)
|
||||
expect(json_response.keys).to eq(['total_count'])
|
||||
expect(json_response['total_count']).to eq(2)
|
||||
end
|
||||
end
|
||||
|
||||
describe 'with searchindex', searchindex: true do
|
||||
|
|
@ -1512,6 +1552,45 @@ RSpec.describe 'User', performs_jobs: true, type: :request do
|
|||
not_in_response = json_response.none? { |item| item['id'] == 1 }
|
||||
expect(not_in_response).to be(true)
|
||||
end
|
||||
|
||||
it 'does return data' do
|
||||
make_request(offset: 0, limit: 10)
|
||||
expect(json_response).to be_a(Array)
|
||||
expect(json_response.count).to eq(10)
|
||||
end
|
||||
|
||||
it 'does return expand data' do
|
||||
make_request(expand: true, offset: 0, limit: 10)
|
||||
expect(json_response).to be_a(Array)
|
||||
expect(json_response.count).to eq(10)
|
||||
expect(json_response[0]['groups']).to be_present
|
||||
end
|
||||
|
||||
it 'does return full data with total count' do
|
||||
make_request(query: '9U7Z', full: true, offset: 0, limit: 1)
|
||||
expect(json_response['assets']).to be_present
|
||||
expect(json_response['record_ids'].count).to eq(1)
|
||||
expect(json_response['total_count']).to eq(2)
|
||||
end
|
||||
|
||||
it 'does return label data' do
|
||||
make_request(query: '9U7Z', label: true, offset: 0, limit: 1)
|
||||
expect(json_response).to be_a(Array)
|
||||
expect(json_response[0].keys).to include('id', 'label', 'value')
|
||||
end
|
||||
|
||||
it 'does return term data' do
|
||||
make_request(term: '9U7Z', offset: 0, limit: 1)
|
||||
expect(json_response).to be_a(Array)
|
||||
expect(json_response[0].keys).to include('id', 'label', 'value', 'inactive')
|
||||
end
|
||||
|
||||
it 'does return only total count' do
|
||||
make_request(term: '9U7Z', offset: 0, limit: 1, only_total_count: true)
|
||||
expect(json_response).to be_a(Hash)
|
||||
expect(json_response.keys).to eq(['total_count'])
|
||||
expect(json_response['total_count']).to eq(2)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
|
|
@ -1532,17 +1611,17 @@ RSpec.describe 'User', performs_jobs: true, type: :request do
|
|||
|
||||
it 'uses elasticsearch when query is non empty' do
|
||||
# Check if ES is used
|
||||
allow(SearchIndexBackend).to receive(:search)
|
||||
allow(SearchIndexBackend).to receive(:search_by_index)
|
||||
|
||||
make_request(query: 'Test')
|
||||
expect(SearchIndexBackend).to have_received(:search)
|
||||
expect(SearchIndexBackend).to have_received(:search_by_index)
|
||||
end
|
||||
|
||||
it 'does not uses elasticsearch when query is empty' do
|
||||
allow(SearchIndexBackend).to receive(:search)
|
||||
allow(SearchIndexBackend).to receive(:search_by_index)
|
||||
|
||||
make_request(query: '')
|
||||
expect(SearchIndexBackend).not_to have_received(:search)
|
||||
expect(SearchIndexBackend).not_to have_received(:search_by_index)
|
||||
end
|
||||
end
|
||||
|
||||
|
|
|
|||
|
|
@ -3,13 +3,113 @@
|
|||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Service::Search do
|
||||
describe '#models' do
|
||||
let(:search) { described_class.new(current_user: create(:agent)) }
|
||||
let(:models_with_direct_index) { search.models(objects: Models.searchable, direct_search_index: true) }
|
||||
let(:models_without_direct_index) { search.models(objects: Models.searchable, direct_search_index: false) }
|
||||
let(:query) { 'test_phrase' }
|
||||
let(:current_user) { create(:agent) }
|
||||
let(:objects) { [User, Organization, Ticket] }
|
||||
let(:options) { {} }
|
||||
let(:instance) { described_class.new(current_user:, query:, objects:, options:) }
|
||||
|
||||
it 'returns different models for different direct_search_index flags' do
|
||||
expect(models_with_direct_index).not_to be_intersect(models_without_direct_index)
|
||||
describe '#execute' do
|
||||
let(:customer) { create(:customer, firstname: query) }
|
||||
let(:organization) { create(:organization, name: query) }
|
||||
|
||||
before do
|
||||
customer
|
||||
organization
|
||||
end
|
||||
|
||||
it 'returns combined result with found items' do
|
||||
expect(instance.execute.result).to include(
|
||||
User => include(objects: [customer], total_count: 1),
|
||||
Organization => include(objects: [organization], total_count: 1),
|
||||
Ticket => include(objects: be_blank, total_count: 0)
|
||||
)
|
||||
end
|
||||
|
||||
it 'lists models in the result in a specific order' do
|
||||
expect(instance.execute.result.keys).to eq [Ticket, User, Organization]
|
||||
end
|
||||
|
||||
it 'lists flattened results in correct order' do
|
||||
expect(instance.execute.flattened).to eq [customer, organization]
|
||||
end
|
||||
|
||||
context 'when objects are restricted' do
|
||||
let(:objects) { [User] }
|
||||
|
||||
it 'searches given model only' do
|
||||
expect(instance.execute.result.keys).to eq [User]
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe '#search_single_model' do
|
||||
before do
|
||||
allow(SearchIndexBackend).to receive(:search_by_index)
|
||||
allow(User).to receive(:search)
|
||||
allow(Ticket).to receive(:search)
|
||||
end
|
||||
|
||||
context 'when ElasticSearch is available' do
|
||||
before { allow(SearchIndexBackend).to receive(:enabled?).and_return(true) }
|
||||
|
||||
context 'when direct index query allowed' do
|
||||
it 'uses SearchIndexBackend' do
|
||||
instance.send(:search_single_model, User)
|
||||
|
||||
expect(SearchIndexBackend).to have_received(:search_by_index)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when direct index query not allowed' do
|
||||
it 'uses model#search' do
|
||||
instance.send(:search_single_model, Ticket)
|
||||
|
||||
expect(Ticket).to have_received(:search)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'when ElasticSearch not available' do
|
||||
before { allow(SearchIndexBackend).to receive(:enabled?).and_return(false) }
|
||||
|
||||
context 'when direct index query allowed' do
|
||||
it 'uses model#search' do
|
||||
instance.send(:search_single_model, User)
|
||||
|
||||
expect(User).to have_received(:search)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when direct index query not allowed' do
|
||||
it 'uses model#search' do
|
||||
instance.send(:search_single_model, Ticket)
|
||||
|
||||
expect(Ticket).to have_received(:search)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'with given options' do
|
||||
let(:options) { { limit: 123, offset: 1024 } }
|
||||
|
||||
before { allow(SearchIndexBackend).to receive(:enabled?).and_return(true) }
|
||||
|
||||
it 'forwards limit and offset arguments to model#search' do
|
||||
instance.send(:search_single_model, User)
|
||||
|
||||
expect(SearchIndexBackend)
|
||||
.to have_received(:search_by_index)
|
||||
.with(anything, anything, include(limit: 123, offset: 1024))
|
||||
end
|
||||
|
||||
it 'forwards limit and offset arguments to SearchIndexBackend' do
|
||||
instance.send(:search_single_model, Ticket)
|
||||
|
||||
expect(Ticket)
|
||||
.to have_received(:search)
|
||||
.with(include(limit: 123, offset: 1024))
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -1,47 +1,105 @@
|
|||
# Copyright (C) 2012-2024 Zammad Foundation, https://zammad-foundation.org/
|
||||
|
||||
RSpec.shared_examples 'pagination' do |model:, klass:, path:, sort_by: :name|
|
||||
let(:model) { model }
|
||||
RSpec.shared_examples 'pagination', authenticated_as: :authenticate do |model:, klass:, path:, sort_by: :name, create_params: {}|
|
||||
let(:create_params) { create_params }
|
||||
let(:model) { model }
|
||||
let(:klass) { klass }
|
||||
let(:indexable) { Models.indexable.include?(klass) }
|
||||
|
||||
def authenticate
|
||||
create_list(model, 500)
|
||||
create_list(model, 500, **create_params)
|
||||
true
|
||||
end
|
||||
|
||||
it 'does paginate', authenticated_as: :authenticate do
|
||||
def offset_first_of_page(page, entries_per_page)
|
||||
(entries_per_page * (page - 1)) + 1
|
||||
end
|
||||
|
||||
before do
|
||||
visit path
|
||||
end
|
||||
|
||||
it 'does paginate' do
|
||||
entries_per_page = page.all('.js-tableBody tr').count
|
||||
|
||||
expect(page).to have_css('.js-pager')
|
||||
|
||||
class_page1 = klass.reorder(sort_by => :asc, id: :asc).offset(50).first
|
||||
class_page1 = klass.reorder(sort_by => :asc, id: :asc).offset(offset_first_of_page(1, entries_per_page)).first
|
||||
expect(page).to have_text(class_page1.name)
|
||||
expect(page).to have_css('.js-page.is-selected', text: '1')
|
||||
expect(page).to have_css('.js-page.btn--active', text: '1')
|
||||
expect(page).to have_no_css('.js-tableBody table-draggable')
|
||||
|
||||
page.first('.js-page', text: '2').click
|
||||
|
||||
class_page2 = klass.reorder(sort_by => :asc, id: :asc).offset(175).first
|
||||
class_page2 = klass.reorder(sort_by => :asc, id: :asc).offset(offset_first_of_page(2, entries_per_page)).first
|
||||
expect(page).to have_text(class_page2.name)
|
||||
expect(page).to have_css('.js-page.is-selected', text: '2')
|
||||
expect(page).to have_css('.js-page.btn--active', text: '2')
|
||||
expect(page).to have_no_css('.js-tableBody table-draggable')
|
||||
|
||||
page.first('.js-page', text: '3').click
|
||||
|
||||
class_page3 = klass.reorder(sort_by => :asc, id: :asc).offset(325).first
|
||||
class_page3 = klass.reorder(sort_by => :asc, id: :asc).offset(offset_first_of_page(3, entries_per_page)).first
|
||||
expect(page).to have_text(class_page3.name)
|
||||
expect(page).to have_css('.js-page.is-selected', text: '3')
|
||||
expect(page).to have_css('.js-page.btn--active', text: '3')
|
||||
expect(page).to have_no_css('.js-tableBody table-draggable')
|
||||
|
||||
page.first('.js-page', text: '4').click
|
||||
|
||||
class_page4 = klass.reorder(sort_by => :asc, id: :asc).offset(475).first
|
||||
class_page4 = klass.reorder(sort_by => :asc, id: :asc).offset(offset_first_of_page(4, entries_per_page)).first
|
||||
expect(page).to have_text(class_page4.name)
|
||||
expect(page).to have_css('.js-page.is-selected', text: '4')
|
||||
expect(page).to have_css('.js-page.btn--active', text: '4')
|
||||
expect(page).to have_no_css('.js-tableBody table-draggable')
|
||||
|
||||
page.first('.js-page', text: '1').click
|
||||
|
||||
page.first('.js-tableHead[data-column-key=name]').click
|
||||
class_page1 = klass.reorder(name: :asc, id: :asc).offset(offset_first_of_page(1, entries_per_page)).first
|
||||
expect(page).to have_text(class_page1.name)
|
||||
expect(page).to have_css('.js-page.is-selected', text: '1')
|
||||
expect(page).to have_css('.js-page.btn--active', text: '1')
|
||||
expect(page).to have_no_css('.js-tableBody table-draggable')
|
||||
|
||||
page.first('.js-tableHead[data-column-key=name]').click
|
||||
class_last = klass.reorder(sort_by => :desc).first
|
||||
class_last = klass.reorder(name: :desc, id: :asc).offset(offset_first_of_page(1, entries_per_page)).first
|
||||
expect(page).to have_text(class_last.name)
|
||||
end
|
||||
|
||||
context 'when search is enabled' do
|
||||
before do
|
||||
skip 'No search field enabled' if !indexable || !page.has_css?('.page-content .searchfield .js-search')
|
||||
end
|
||||
|
||||
it 'does filter results with the search bar' do
|
||||
page.find('.js-search').fill_in with: klass.last.try(:name) || klass.last.try(klass.columns.find { |row| row.type == :string }.name.to_sym)
|
||||
wait.until { page.all('.js-tableBody tr').count == 1 }
|
||||
|
||||
# does stay after reload
|
||||
refresh
|
||||
wait.until { page.find('.js-search').present? && page.all('.js-tableBody tr').count == 1 }
|
||||
|
||||
# remove filter
|
||||
page.find('.js-search').fill_in with: '', fill_options: { clear: :backspace }
|
||||
wait.until { page.all('.js-tableBody tr').count != 1 }
|
||||
end
|
||||
|
||||
context 'when ES is enabled', authenticated_as: :authenticate, searchindex: true do
|
||||
def authenticate
|
||||
create_list(model, 500, **create_params)
|
||||
searchindex_model_reload([klass]) if indexable
|
||||
create(:admin)
|
||||
end
|
||||
|
||||
it 'does only show 2 pages because of a search filter and paginate through it' do
|
||||
entries_per_page = page.all('.js-tableBody tr').count
|
||||
search_query = klass.limit(entries_per_page * 2).pluck(:id).map { |i| "id: #{i}" }.join(' OR ')
|
||||
page.find('.js-search').fill_in with: search_query, fill_options: { clear: :backspace }
|
||||
wait.until { page.first('.js-pager').all('.js-page').count == 4 }
|
||||
|
||||
page.first('.js-page', text: '2').click
|
||||
expect(page).to have_css('.js-page.btn--active', text: '2')
|
||||
expect(page).to have_no_css('.js-tableBody table-draggable')
|
||||
|
||||
wait.until { page.find('.js-search').present? && page.find('.js-search').value == search_query && page.first('.js-pager').all('.js-page').count == 4 }
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ require 'system/examples/pagination_examples'
|
|||
|
||||
RSpec.describe 'Manage > Groups', type: :system do
|
||||
context 'ajax pagination' do
|
||||
include_examples 'pagination', model: :group, klass: Group, path: 'manage/groups'
|
||||
include_examples 'pagination', model: :group, klass: Group, path: 'manage/groups', create_params: { email_address_id: 1 }
|
||||
end
|
||||
|
||||
describe 'with nested groups' do
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
# Copyright (C) 2012-2024 Zammad Foundation, https://zammad-foundation.org/
|
||||
|
||||
require 'rails_helper'
|
||||
require 'system/examples/pagination_examples'
|
||||
|
||||
RSpec.describe 'Manage > Overviews', type: :system do
|
||||
let(:group) { create(:group) }
|
||||
|
|
@ -43,6 +44,10 @@ RSpec.describe 'Manage > Overviews', type: :system do
|
|||
|
||||
let(:overview) { create(:overview, condition: condition) }
|
||||
|
||||
context 'ajax pagination' do
|
||||
include_examples 'pagination', model: :overview, klass: Overview, path: 'manage/overviews', sort_by: :prio
|
||||
end
|
||||
|
||||
shared_examples 'previewing the correct ticket for single selected object' do
|
||||
before do
|
||||
wait.until { page.has_css?('.js-previewLoader.hide', visible: :all) }
|
||||
|
|
|
|||
|
|
@ -76,7 +76,7 @@ RSpec.describe 'Manage > Users', type: :system do
|
|||
|
||||
click '.js-submit'
|
||||
|
||||
expect(page).to have_css('table.user-list td', text: 'NewTestUserFirstName')
|
||||
expect(page).to have_css('table td', text: 'NewTestUserFirstName')
|
||||
end
|
||||
end
|
||||
|
||||
|
|
@ -113,7 +113,7 @@ RSpec.describe 'Manage > Users', type: :system do
|
|||
|
||||
click '.js-submit'
|
||||
|
||||
expect(page).to have_css('table.user-list td', text: 'üser@äcme.corp')
|
||||
expect(page).to have_css('table td', text: 'üser@äcme.corp')
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -146,7 +146,7 @@ RSpec.describe 'Manage > Users', type: :system do
|
|||
|
||||
context 'updating a user' do
|
||||
let(:user) { create(:admin, firstname: 'Dummy') }
|
||||
let(:row) { find "table.user-list tbody tr[data-id='#{user.id}']" }
|
||||
let(:row) { find "table tbody tr[data-id='#{user.id}']" }
|
||||
let(:group) { Group.first }
|
||||
let(:group2) { Group.second }
|
||||
|
||||
|
|
|
|||
|
|
@ -720,4 +720,46 @@ RSpec.describe 'Search', authenticated_as: :authenticate, searchindex: true, typ
|
|||
expect(page).to have_no_css('.popover')
|
||||
end
|
||||
end
|
||||
|
||||
describe 'search with many results', searchindex: false do
|
||||
let(:new_customers) { create_list(:customer, 55) }
|
||||
let(:all_zammad_customers) { User.where('email LIKE ?', '%zammad%') }
|
||||
let(:all_zammad_customers_sorted) { all_zammad_customers.reorder(:login) }
|
||||
|
||||
before do
|
||||
new_customers
|
||||
visit '#search/zammad'
|
||||
end
|
||||
|
||||
it 'shows 50 on first page and remaining on second page' do
|
||||
expect(page).to have_css('.tab[data-tab-content=User] .tab-badge', text: all_zammad_customers.count)
|
||||
|
||||
click '.tab[data-tab-content=User]'
|
||||
|
||||
expect(page).to have_css('.js-tableBody tr', count: 50)
|
||||
|
||||
page.first('.js-page', text: '2').click
|
||||
|
||||
expect(page).to have_css('.js-tableBody tr', count: all_zammad_customers.count % 50)
|
||||
end
|
||||
|
||||
it 'sorts correctly across pages' do
|
||||
click '.tab[data-tab-content=User]'
|
||||
|
||||
first('.js-sort').click
|
||||
|
||||
expect(page).to have_css('.js-tableBody tr:first-child',
|
||||
text: all_zammad_customers_sorted.first.login)
|
||||
|
||||
first('.js-sort').click
|
||||
|
||||
expect(page).to have_css('.js-tableBody tr:first-child',
|
||||
text: all_zammad_customers_sorted.last.login)
|
||||
|
||||
first('.js-page', text: '2').click
|
||||
|
||||
expect(page).to have_css('.js-tableBody tr:last-child',
|
||||
text: all_zammad_customers_sorted.first.login)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -88,7 +88,7 @@ class AdminDragDropToNewGroupTest < TestCase
|
|||
def open_user_modal
|
||||
click(css: '.content.active a[href="#manage/users"]')
|
||||
|
||||
user_css = '.user-list .js-tableBody tr td'
|
||||
user_css = '.js-tableBody tr td'
|
||||
watch_for(css: user_css)
|
||||
|
||||
user_element = @browser.find_elements(css: user_css).find do |el|
|
||||
|
|
|
|||
|
|
@ -3148,11 +3148,11 @@ wait untill text in selector disabppears
|
|||
if data[:email]
|
||||
search_query = data[:email]
|
||||
search_target = data[:email]
|
||||
search_css = '.content.active .user-list .js-tableBody td:first-child'
|
||||
search_css = '.content.active .js-tableBody td:first-child'
|
||||
else
|
||||
search_query = data[:phone]
|
||||
search_target = data[:firstname]
|
||||
search_css = '.content.active .user-list .js-tableBody td:nth-child(2)'
|
||||
search_css = '.content.active .js-tableBody td:nth-child(2)'
|
||||
end
|
||||
|
||||
60.times do |i|
|
||||
|
|
@ -3209,7 +3209,7 @@ wait untill text in selector disabppears
|
|||
css: '.content.active a[href="#manage/users"]',
|
||||
mute_log: true,
|
||||
)
|
||||
instance.find_elements(css: '.content.active .user-list td:first-child').each do |element|
|
||||
instance.find_elements(css: '.content.active td:first-child').each do |element|
|
||||
next if element.text.strip != data[:login]
|
||||
|
||||
element.click
|
||||
|
|
|
|||
Loading…
Reference in a new issue