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:
Rolf Schmidt 2024-11-28 14:09:42 +01:00
parent 72cb9de11e
commit 0c93022abf
87 changed files with 2103 additions and 1461 deletions

View file

@ -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'

View file

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

View file

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

View file

@ -172,7 +172,7 @@ class Navigation extends App.Controller
type: 'personal'
)
renderResult: (result = []) =>
renderResult: (result = {}) =>
@removePopovers()
# remove result if not result exists

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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 = __('''

View file

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

View file

@ -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 %>>&lsaquo;</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: %>&hellip;<% 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 %>>&rsaquo;</a>
</div>

View file

@ -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">

View file

@ -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;

View file

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

View file

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

View file

@ -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:

View file

@ -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:

View file

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

View file

@ -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:

View file

@ -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:

View file

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

View file

@ -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:

View file

@ -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:

View file

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

View file

@ -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}

View file

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

View file

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

View file

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

View file

@ -2,6 +2,7 @@
class Chat::Session < ApplicationModel
include HasSearchIndexBackend
include CanSelector
include HasTags
include Chat::Session::Search

View file

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

View 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

View file

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

View file

@ -11,6 +11,8 @@ class Group < ApplicationModel
include HasObjectManagerAttributes
include HasCollectionUpdate
include HasSearchIndexBackend
include CanSelector
include CanSearch
include Group::Assets

View file

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

View 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

View file

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

View file

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

View file

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

View file

@ -5,6 +5,9 @@ class Macro < ApplicationModel
include ChecksHtmlSanitized
include CanSeed
include HasCollectionUpdate
include HasSearchIndexBackend
include CanSelector
include CanSearch
include Macro::TriggersSubscriptions
store :perform

View file

@ -7,6 +7,7 @@ class Organization < ApplicationModel
include ChecksClientNotification
include HasHistory
include HasSearchIndexBackend
include CanSelector
include CanCsvImport
include ChecksHtmlSanitized
include HasObjectManagerAttributes

View file

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

View file

@ -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'

View 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

View file

@ -9,6 +9,9 @@ class Role < ApplicationModel
include ChecksHtmlSanitized
include HasGroups
include HasCollectionUpdate
include HasSearchIndexBackend
include CanSelector
include CanSearch
include Role::Assets

View file

@ -2,6 +2,9 @@
class Template < ApplicationModel
include ChecksClientNotification
include HasSearchIndexBackend
include CanSelector
include CanSearch
include Template::Assets
include Template::TriggersSubscriptions

View file

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

View file

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

View file

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

View file

@ -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?

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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 ""

View file

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

View file

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

View file

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

View file

@ -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 }

View file

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

View 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

View file

@ -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
}
]
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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 }

View file

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

View file

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

View file

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