Feature: Mobile - Use GraphQL query for search
This commit is contained in:
5
.gitignore
vendored
5
.gitignore
vendored
@@ -100,4 +100,7 @@ yarn-error.log
|
||||
# Cypress
|
||||
.cypress/screenshots/*
|
||||
.cypress/videos/*
|
||||
.cypress/**/__diff_output__/**
|
||||
.cypress/**/__diff_output__/**
|
||||
|
||||
# Auto Generated
|
||||
schema.graphql
|
||||
16
.graphqlrc.json
Normal file
16
.graphqlrc.json
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"name": "Zammad GraphQL Schema",
|
||||
"schemaPath": "schema.graphql",
|
||||
"documents": ["./app/frontend/**/fragments/*.graphql"],
|
||||
"extensions": {
|
||||
"endpoints": {
|
||||
"Default GraphQL Endpoint": {
|
||||
"url": "http://localhost:3000/graphql",
|
||||
"headers": {
|
||||
"SkipAuthenticityTokenCheck": "true"
|
||||
},
|
||||
"introspect": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1 +1,2 @@
|
||||
**/graphql/**/*.ts
|
||||
**/graphql/**/*.ts
|
||||
!**/graphql/**/mocks/*.ts
|
||||
@@ -12,6 +12,7 @@ const Template = createTemplate<Props>(OrganizationItem)
|
||||
|
||||
const organization = {
|
||||
id: '54321',
|
||||
internalId: 2,
|
||||
ticketsCount: 2,
|
||||
name: 'Lorem Ipsum',
|
||||
active: false,
|
||||
@@ -24,23 +25,19 @@ export const Default = Template.create({
|
||||
updatedAt: new Date(2022, 1, 2).toISOString(),
|
||||
updatedBy: {
|
||||
id: '456',
|
||||
firstname: 'Jane',
|
||||
lastname: 'Doe',
|
||||
fullname: 'Jane Doe',
|
||||
},
|
||||
members: {
|
||||
edges: [
|
||||
{
|
||||
node: { fullname: 'Erik Wise' },
|
||||
},
|
||||
{
|
||||
node: { fullname: 'Peter Smith' },
|
||||
},
|
||||
],
|
||||
totalCount: 3,
|
||||
},
|
||||
members: [
|
||||
{
|
||||
lastname: 'Wise',
|
||||
firstname: 'Erik',
|
||||
},
|
||||
{
|
||||
lastname: 'Smith',
|
||||
firstname: 'Peter',
|
||||
},
|
||||
{
|
||||
lastname: "O'Hara",
|
||||
firstname: 'Nils',
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
@@ -18,14 +18,14 @@ const users = computed(() => {
|
||||
const { members } = props.entity
|
||||
if (!members) return ''
|
||||
|
||||
let usersString = members
|
||||
const users = members.edges
|
||||
.map((edge) => edge.node.fullname)
|
||||
.filter((fullname) => fullname && fullname !== '-')
|
||||
.slice(0, 2)
|
||||
.map((user) => {
|
||||
return [user.firstname, user.lastname].filter(Boolean).join(' ')
|
||||
})
|
||||
.join(', ')
|
||||
|
||||
const length = members.length - 2
|
||||
let usersString = users.join(', ')
|
||||
|
||||
const length = members.totalCount - users.length
|
||||
|
||||
if (length > 0) {
|
||||
usersString += `, +${length}`
|
||||
@@ -36,28 +36,34 @@ const users = computed(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex">
|
||||
<div class="mt-4 w-12">
|
||||
<div class="flex ltr:pr-3 rtl:pl-3">
|
||||
<div class="mt-4 flex w-14 justify-center">
|
||||
<CommonOrganizationAvatar class="bg-gray" :entity="entity" />
|
||||
</div>
|
||||
<div
|
||||
class="flex flex-1 flex-col border-b border-white/10 py-3 text-gray-100"
|
||||
class="flex flex-1 flex-col overflow-hidden border-b border-white/10 py-3 text-gray-100"
|
||||
>
|
||||
<div class="flex">
|
||||
<div>
|
||||
{{
|
||||
entity.ticketsCount === 1
|
||||
? `1 ${$t('ticket')}`
|
||||
: $t('%s tickets', entity.ticketsCount)
|
||||
}}
|
||||
</div>
|
||||
<div v-if="users" class="px-1">·</div>
|
||||
<div>{{ users }}</div>
|
||||
</div>
|
||||
<div class="mb-1 text-lg font-bold">
|
||||
<span class="overflow-hidden text-ellipsis whitespace-nowrap">
|
||||
{{
|
||||
entity.ticketsCount === 1
|
||||
? `1 ${$t('ticket')}`
|
||||
: $t('%s tickets', entity.ticketsCount || 0)
|
||||
}}
|
||||
<template v-if="users">
|
||||
·
|
||||
{{ users }}
|
||||
</template>
|
||||
</span>
|
||||
<span
|
||||
class="mb-1 whitespace-normal text-lg font-bold leading-5 line-clamp-3"
|
||||
>
|
||||
<slot> {{ entity.name }} </slot>
|
||||
</div>
|
||||
<div v-if="stringUpdated" data-test-id="stringUpdated" class="text-gray">
|
||||
</span>
|
||||
<div
|
||||
v-if="stringUpdated"
|
||||
data-test-id="stringUpdated"
|
||||
class="overflow-hidden text-ellipsis text-gray"
|
||||
>
|
||||
{{ stringUpdated }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -19,27 +19,24 @@ describe('ticket item display', () => {
|
||||
const organization: OrganizationItemData = {
|
||||
id: '54321',
|
||||
ticketsCount: 2,
|
||||
internalId: 3,
|
||||
name: 'lorem ipsum',
|
||||
active: true,
|
||||
members: [
|
||||
{
|
||||
lastname: 'Wise',
|
||||
firstname: 'Erik',
|
||||
},
|
||||
{
|
||||
lastname: 'Smith',
|
||||
firstname: 'Peter',
|
||||
},
|
||||
{
|
||||
lastname: "O'Hara",
|
||||
firstname: 'Nils',
|
||||
},
|
||||
],
|
||||
members: {
|
||||
edges: [
|
||||
{
|
||||
node: { fullname: 'Erik Wise' },
|
||||
},
|
||||
{
|
||||
node: { fullname: 'Peter Smith' },
|
||||
},
|
||||
],
|
||||
totalCount: 3,
|
||||
},
|
||||
updatedAt: new Date(2022, 1, 1, 10, 0, 0, 0).toISOString(),
|
||||
updatedBy: {
|
||||
id: '456',
|
||||
firstname: 'Jane',
|
||||
lastname: 'Doe',
|
||||
fullname: 'Jane Doe',
|
||||
},
|
||||
}
|
||||
|
||||
@@ -51,9 +48,9 @@ describe('ticket item display', () => {
|
||||
})
|
||||
|
||||
expect(view.getByText('lorem ipsum')).toBeInTheDocument()
|
||||
expect(view.getByText('2 tickets')).toBeInTheDocument()
|
||||
expect(view.getByText('·')).toBeInTheDocument()
|
||||
expect(view.getByText('Erik Wise, Peter Smith, +1')).toBeInTheDocument()
|
||||
expect(view.getByText(/2 tickets/)).toBeInTheDocument()
|
||||
expect(view.getByText(/·/)).toBeInTheDocument()
|
||||
expect(view.getByText(/Erik Wise, Peter Smith, \+1/)).toBeInTheDocument()
|
||||
|
||||
expect(
|
||||
view.getByText('edited 10 hours ago by Jane Doe'),
|
||||
@@ -63,6 +60,7 @@ describe('ticket item display', () => {
|
||||
it('renders when something is missing', () => {
|
||||
const organization: OrganizationItemData = {
|
||||
id: '54321',
|
||||
internalId: 2,
|
||||
ticketsCount: 1,
|
||||
name: 'lorem ipsum',
|
||||
active: true,
|
||||
@@ -76,8 +74,8 @@ describe('ticket item display', () => {
|
||||
})
|
||||
|
||||
expect(view.getByText('lorem ipsum')).toBeInTheDocument()
|
||||
expect(view.getByText('1 ticket')).toBeInTheDocument()
|
||||
expect(view.queryByIconName('·')).not.toBeInTheDocument()
|
||||
expect(view.getByText(/1 ticket/)).toBeInTheDocument()
|
||||
expect(view.queryByIconName(/·/)).not.toBeInTheDocument()
|
||||
|
||||
expect(view.queryByTestId('stringUpdated')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
@@ -2,17 +2,21 @@
|
||||
|
||||
export interface OrganizationItemData {
|
||||
id: string
|
||||
ticketsCount: number
|
||||
internalId: number
|
||||
ticketsCount?: number
|
||||
members?: {
|
||||
lastname?: Maybe<string>
|
||||
firstname?: Maybe<string>
|
||||
}[]
|
||||
edges: {
|
||||
node: {
|
||||
fullname: string
|
||||
}
|
||||
}[]
|
||||
totalCount: number
|
||||
}
|
||||
active: boolean
|
||||
name: string
|
||||
updatedAt?: string
|
||||
updatedBy?: {
|
||||
id: string
|
||||
firstname?: Maybe<string>
|
||||
lastname?: Maybe<string>
|
||||
fullname?: Maybe<string>
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,8 +29,7 @@ export const Default = Template.create({
|
||||
updatedAt: new Date(2022, 1, 2).toISOString(),
|
||||
updatedBy: {
|
||||
id: '456',
|
||||
firstname: 'Jane',
|
||||
lastname: 'Doe',
|
||||
fullname: 'Jane Doe',
|
||||
},
|
||||
priority: {
|
||||
name: 'HIGH',
|
||||
|
||||
@@ -25,10 +25,7 @@ const customer = computed(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<CommonLink
|
||||
:link="`/tickets/${entity.internalId}`"
|
||||
class="flex cursor-pointer ltr:pr-3 rtl:pl-3"
|
||||
>
|
||||
<div class="flex cursor-pointer ltr:pr-3 rtl:pl-3">
|
||||
<div class="flex w-14 items-center justify-center">
|
||||
<!-- TODO label? -->
|
||||
<CommonTicketStateIndicator :status="entity.state.name" label="" />
|
||||
@@ -54,12 +51,12 @@ const customer = computed(() => {
|
||||
<div
|
||||
v-if="stringUpdated"
|
||||
data-test-id="stringUpdated"
|
||||
class="text-gray"
|
||||
class="overflow-hidden text-ellipsis text-gray"
|
||||
>
|
||||
{{ stringUpdated }}
|
||||
</div>
|
||||
</div>
|
||||
<CommonTicketPriorityIndicator :priority="entity.priority" />
|
||||
</div>
|
||||
</CommonLink>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -21,15 +21,12 @@ describe('ticket item display', () => {
|
||||
state: { name: TicketState.Open },
|
||||
title: 'test ticket',
|
||||
customer: {
|
||||
firstname: 'John',
|
||||
lastname: 'Doe',
|
||||
fullname: 'John Doe',
|
||||
},
|
||||
updatedAt: new Date(2022, 1, 1, 10, 0, 0, 0).toISOString(),
|
||||
updatedBy: {
|
||||
id: '456',
|
||||
firstname: 'Jane',
|
||||
lastname: 'Doe',
|
||||
fullname: 'Jane Doe',
|
||||
},
|
||||
priority: {
|
||||
name: 'high',
|
||||
@@ -81,7 +78,7 @@ describe('ticket item display', () => {
|
||||
})
|
||||
|
||||
expect(view.getByText('#12345')).toBeInTheDocument()
|
||||
expect(view.queryByText('·')).not.toBeInTheDocument()
|
||||
expect(view.queryByText(/·/)).not.toBeInTheDocument()
|
||||
expect(view.getByText('test ticket')).toBeInTheDocument()
|
||||
|
||||
expect(view.queryByTestId('stringUpdated')).not.toBeInTheDocument()
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
import type { TicketState } from '@shared/entities/ticket/types'
|
||||
|
||||
// TODO 2022-05-31 Sheremet V.A. base types on actual usage
|
||||
export interface TicketItemData {
|
||||
id: string
|
||||
internalId: number
|
||||
@@ -16,19 +15,12 @@ export interface TicketItemData {
|
||||
defaultCreate: boolean
|
||||
uiColor?: Maybe<string>
|
||||
}
|
||||
owner?: {
|
||||
firstname?: Maybe<string>
|
||||
lastname?: Maybe<string>
|
||||
}
|
||||
customer?: {
|
||||
firstname?: Maybe<string>
|
||||
lastname?: Maybe<string>
|
||||
fullname?: Maybe<string>
|
||||
}
|
||||
updatedAt?: string
|
||||
updatedBy?: {
|
||||
id: string
|
||||
firstname?: Maybe<string>
|
||||
lastname?: Maybe<string>
|
||||
fullname?: Maybe<string>
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,8 +26,7 @@ export const Default = Template.create({
|
||||
updatedAt: new Date(2022, 1, 2).toISOString(),
|
||||
updatedBy: {
|
||||
id: '456',
|
||||
firstname: 'Jane',
|
||||
lastname: 'Doe',
|
||||
fullname: 'Jane Doe',
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
@@ -16,28 +16,34 @@ const { stringUpdated } = useEditedBy(toRef(props, 'entity'))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex">
|
||||
<div class="mt-4 w-12">
|
||||
<div class="flex ltr:pr-3 rtl:pl-3">
|
||||
<div class="mt-4 flex w-14 justify-center">
|
||||
<CommonUserAvatar :entity="entity" />
|
||||
</div>
|
||||
<div
|
||||
class="flex flex-1 flex-col border-b border-white/10 py-3 text-gray-100"
|
||||
class="flex flex-1 flex-col overflow-hidden border-b border-white/10 py-3 text-gray-100"
|
||||
>
|
||||
<div class="flex">
|
||||
<div v-if="entity.organization">{{ entity.organization.name }}</div>
|
||||
<div v-if="entity.organization" class="px-1">·</div>
|
||||
<div>
|
||||
{{
|
||||
entity.ticketsCount === 1
|
||||
? `1 ${$t('ticket')}`
|
||||
: $t('%s tickets', entity.ticketsCount)
|
||||
}}
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-1 text-lg">
|
||||
<span class="overflow-hidden text-ellipsis whitespace-nowrap">
|
||||
{{
|
||||
entity.ticketsCount === 1
|
||||
? `1 ${$t('ticket')}`
|
||||
: $t('%s tickets', entity.ticketsCount || 0)
|
||||
}}
|
||||
<template v-if="entity.organization">
|
||||
·
|
||||
{{ entity.organization.name }}
|
||||
</template>
|
||||
</span>
|
||||
<span
|
||||
class="mb-1 whitespace-normal text-lg font-bold leading-5 line-clamp-3"
|
||||
>
|
||||
<slot> {{ entity.firstname }} {{ entity.lastname }} </slot>
|
||||
</div>
|
||||
<div v-if="stringUpdated" class="text-gray" data-test-id="stringUpdated">
|
||||
</span>
|
||||
<div
|
||||
v-if="stringUpdated"
|
||||
class="overflow-hidden text-ellipsis text-gray"
|
||||
data-test-id="stringUpdated"
|
||||
>
|
||||
{{ stringUpdated }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -21,8 +21,7 @@ describe('user item display', () => {
|
||||
updatedAt: new Date(2022, 1, 1, 10, 0, 0, 0).toISOString(),
|
||||
updatedBy: {
|
||||
id: '456',
|
||||
firstname: 'Jane',
|
||||
lastname: 'Doe',
|
||||
fullname: 'Jane Doe',
|
||||
},
|
||||
organization: {
|
||||
name: 'organization',
|
||||
@@ -37,9 +36,8 @@ describe('user item display', () => {
|
||||
})
|
||||
|
||||
expect(view.getByText('JD')).toBeInTheDocument() // avatar
|
||||
expect(view.getByText('organization')).toBeInTheDocument()
|
||||
expect(view.getByText('·')).toBeInTheDocument()
|
||||
expect(view.getByText('2 tickets')).toBeInTheDocument()
|
||||
expect(view.getByText(/organization/)).toBeInTheDocument()
|
||||
expect(view.getByText(/2 tickets/)).toBeInTheDocument()
|
||||
expect(view.getByText('John Doe')).toBeInTheDocument()
|
||||
expect(
|
||||
view.getByText('edited 10 hours ago by Jane Doe'),
|
||||
@@ -63,7 +61,7 @@ describe('user item display', () => {
|
||||
expect(view.getByText('JO')).toBeInTheDocument() // avatar
|
||||
expect(view.getByText(/^John$/)).toBeInTheDocument()
|
||||
expect(view.getByText('1 ticket')).toBeInTheDocument()
|
||||
expect(view.queryByText('·')).not.toBeInTheDocument()
|
||||
expect(view.queryByText(/·/)).not.toBeInTheDocument()
|
||||
expect(view.queryByTestId('stringUpdated')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -4,14 +4,14 @@ export interface UserItemData {
|
||||
id: string
|
||||
firstname?: Maybe<string>
|
||||
lastname?: Maybe<string>
|
||||
ticketsCount: number
|
||||
image?: Maybe<string>
|
||||
ticketsCount?: number
|
||||
organization?: {
|
||||
name: string
|
||||
}
|
||||
updatedAt?: string
|
||||
updatedBy?: {
|
||||
id: string
|
||||
firstname?: Maybe<string>
|
||||
lastname?: Maybe<string>
|
||||
fullname?: Maybe<string>
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ interface Author {
|
||||
id: string
|
||||
firstname?: Maybe<string>
|
||||
lastname?: Maybe<string>
|
||||
fullname?: Maybe<string>
|
||||
}
|
||||
|
||||
interface Entity {
|
||||
@@ -23,7 +24,8 @@ export const useEditedBy = (entity: Ref<Entity>) => {
|
||||
if (!updatedBy) return ''
|
||||
return updatedBy.id === session.user?.id
|
||||
? i18n.t('me')
|
||||
: [updatedBy.firstname, updatedBy.lastname].filter(Boolean).join(' ')
|
||||
: updatedBy.fullname ||
|
||||
[updatedBy.firstname, updatedBy.lastname].filter(Boolean).join(' ')
|
||||
})
|
||||
|
||||
const date = computed(() => {
|
||||
|
||||
@@ -2,8 +2,20 @@
|
||||
|
||||
import { getByText, getAllByRole } from '@testing-library/vue'
|
||||
import { visitView } from '@tests/support/components/visitView'
|
||||
import { nullableMock, waitUntil } from '@tests/support/utils'
|
||||
import type { MockGraphQLInstance } from '@tests/support/mock-graphql-api'
|
||||
import { clearMockClient } from '@tests/support/mock-apollo-client'
|
||||
import { mockPermissions } from '@tests/support/mock-permissions'
|
||||
import { mockSearchOverview } from '../graphql/mocks/mockSearchOverview'
|
||||
|
||||
describe('testing previous searches block', () => {
|
||||
let mockSearchApi: MockGraphQLInstance
|
||||
|
||||
beforeEach(() => {
|
||||
mockPermissions(['ticket.agent'])
|
||||
mockSearchApi = mockSearchOverview([])
|
||||
})
|
||||
|
||||
it('previous searches', async () => {
|
||||
localStorage.clear()
|
||||
const view = await visitView('/search/user')
|
||||
@@ -12,14 +24,15 @@ describe('testing previous searches block', () => {
|
||||
return getByText(view.getByTestId('lastSearches'), text)
|
||||
}
|
||||
|
||||
const typeInSearch = (text: string) => {
|
||||
return view.events.debounced(() =>
|
||||
view.events.type(view.getByPlaceholderText('Search'), text),
|
||||
const typeInSearch = async (text: string) => {
|
||||
await view.events.debounced(() =>
|
||||
view.events.type(view.getByPlaceholderText('Search…'), text),
|
||||
)
|
||||
await waitUntil(() => mockSearchApi.calls.resolve)
|
||||
}
|
||||
const clearSearch = () => {
|
||||
return view.events.debounced(() =>
|
||||
view.events.clear(view.getByPlaceholderText('Search')),
|
||||
view.events.clear(view.getByPlaceholderText('Search…')),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -78,10 +91,12 @@ describe('testing previous searches block', () => {
|
||||
localStorage.clear()
|
||||
const view = await visitView('/search/user')
|
||||
|
||||
const input = view.getByPlaceholderText('Search')
|
||||
const input = view.getByPlaceholderText('Search…')
|
||||
await view.events.debounced(() => view.events.type(input, 'search'))
|
||||
await view.events.debounced(() => view.events.type(input, '123'))
|
||||
|
||||
await waitUntil(() => mockSearchApi.calls.resolve)
|
||||
|
||||
let items = view.getAllByRole('listitem')
|
||||
expect(items).toHaveLength(2)
|
||||
expect(items[0]).toHaveTextContent(/^search123$/)
|
||||
@@ -93,6 +108,47 @@ describe('testing previous searches block', () => {
|
||||
expect(items[0]).toHaveTextContent(/^search$/)
|
||||
expect(items[1]).toHaveTextContent(/^search123$/)
|
||||
|
||||
// TODO expect api called
|
||||
expect(mockSearchApi.spies.resolve).toHaveBeenNthCalledWith(1, {
|
||||
onlyIn: 'User',
|
||||
isAgent: true,
|
||||
search: 'search',
|
||||
})
|
||||
expect(mockSearchApi.spies.resolve).toHaveBeenNthCalledWith(2, {
|
||||
onlyIn: 'User',
|
||||
isAgent: true,
|
||||
search: 'search123',
|
||||
})
|
||||
})
|
||||
|
||||
it('emptying out search shows last searches', async () => {
|
||||
localStorage.clear()
|
||||
clearMockClient()
|
||||
mockSearchApi.willResolve({
|
||||
search: [
|
||||
nullableMock({
|
||||
__typename: 'User',
|
||||
id: '1sdsada',
|
||||
internalId: 1,
|
||||
updatedAt: new Date().toISOString(),
|
||||
firstname: 'Max',
|
||||
lastname: 'Mustermann',
|
||||
}),
|
||||
],
|
||||
})
|
||||
const view = await visitView('/search/user')
|
||||
|
||||
const input = view.getByPlaceholderText('Search…')
|
||||
await view.events.debounced(() => view.events.type(input, 'search'))
|
||||
await view.events.debounced(() => view.events.type(input, '123'))
|
||||
|
||||
await waitUntil(() => mockSearchApi.calls.resolve)
|
||||
|
||||
expect(view.container).toHaveTextContent('Max Mustermann')
|
||||
|
||||
await view.events.debounced(() => view.events.clear(input))
|
||||
|
||||
expect(view.container).not.toHaveTextContent('Max Mustermann')
|
||||
|
||||
expect(view.getByTestId('lastSearches')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,46 +1,76 @@
|
||||
// Copyright (C) 2012-2022 Zammad Foundation, https://zammad-foundation.org/
|
||||
|
||||
import { getTestRouter } from '@tests/support/components/renderComponent'
|
||||
import { visitView } from '@tests/support/components/visitView'
|
||||
import type { MockGraphQLInstance } from '@tests/support/mock-graphql-api'
|
||||
import { mockPermissions } from '@tests/support/mock-permissions'
|
||||
import { waitUntil } from '@tests/support/utils'
|
||||
import { mockSearchOverview } from '../graphql/mocks/mockSearchOverview'
|
||||
|
||||
describe('visiting search page', () => {
|
||||
// TODO 2022-06-02 Sheremet V.A. remove skip when there is an actual API
|
||||
it.skip('doesnt search if no type is selected', async () => {
|
||||
let mockSearchApi: MockGraphQLInstance
|
||||
|
||||
beforeEach(() => {
|
||||
mockSearchApi = mockSearchOverview([])
|
||||
mockPermissions(['ticket.agent'])
|
||||
})
|
||||
|
||||
it('doesnt search if no type is selected', async () => {
|
||||
const view = await visitView('/search')
|
||||
|
||||
const searchInput = view.getByPlaceholderText('Search')
|
||||
const searchInput = view.getByPlaceholderText('Search…')
|
||||
await view.events.type(searchInput, 'search')
|
||||
|
||||
// TODO expect no api call
|
||||
expect(mockSearchApi.spies.resolve).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('allows searching', async () => {
|
||||
const view = await visitView('/search')
|
||||
|
||||
const searchInput = view.getByPlaceholderText('Search')
|
||||
const searchInput = view.getByPlaceholderText('Search…')
|
||||
|
||||
expect(searchInput).toHaveFocus()
|
||||
expect(view.getByTestId('selectTypesSection')).toBeInTheDocument()
|
||||
|
||||
await view.events.click(view.getByText('Users'))
|
||||
|
||||
expect(view.container).toHaveTextContent('Enter more than two characters')
|
||||
|
||||
await view.events.type(searchInput, 'search')
|
||||
|
||||
// TODO api called
|
||||
await waitUntil(() => mockSearchApi.calls.resolve)
|
||||
|
||||
expect(mockSearchApi.spies.resolve).toHaveBeenCalledWith({
|
||||
onlyIn: 'User',
|
||||
isAgent: true,
|
||||
search: 'search',
|
||||
})
|
||||
|
||||
expect(view.getByTestId('buttonPills')).toBeInTheDocument()
|
||||
expect(view.container).toHaveTextContent('No entries')
|
||||
|
||||
await view.events.click(view.getByText('Organizations'))
|
||||
|
||||
// TODO api called immidiatly
|
||||
expect(mockSearchApi.spies.resolve).toHaveBeenCalledWith({
|
||||
onlyIn: 'Organization',
|
||||
isAgent: true,
|
||||
search: 'search',
|
||||
})
|
||||
|
||||
// expect(view.getByIconName('loader')).toBeInTheDocument()
|
||||
|
||||
expect(view.getByPlaceholderText('Search')).toHaveFocus()
|
||||
expect(view.getByPlaceholderText('Search…')).toHaveFocus()
|
||||
})
|
||||
|
||||
it('renders correctly if queries are passed done', async () => {
|
||||
it('renders correctly if queries are passed down', async () => {
|
||||
const view = await visitView('/search/invalid?search=search')
|
||||
|
||||
expect(view.getByPlaceholderText('Search')).toHaveDisplayValue('search')
|
||||
expect(view.getByPlaceholderText('Search…')).toHaveDisplayValue('search')
|
||||
expect(view.getByTestId('selectTypesSection')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('opens with type, if there is only single type', async () => {
|
||||
// customer can only search for tickets
|
||||
mockPermissions(['ticket.customer'])
|
||||
await visitView('/search')
|
||||
expect(getTestRouter().currentRoute.value.fullPath).toBe('/search/ticket')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
<!-- Copyright (C) 2012-2022 Zammad Foundation, https://zammad-foundation.org/ -->
|
||||
|
||||
<script setup lang="ts">
|
||||
/* eslint-disable vue/no-v-html */
|
||||
|
||||
import { replaceTags } from '@shared/utils/formatter'
|
||||
import { computed } from 'vue'
|
||||
import { useSearchPlugins } from '../plugins'
|
||||
@@ -22,12 +20,7 @@ const plugin = computed(() => searchPlugins[props.type])
|
||||
<template>
|
||||
<template v-for="item in data" :key="item.id">
|
||||
<CommonLink :link="replaceTags(plugin.link, item, true)">
|
||||
<component :is="plugin.component" :entity="item">
|
||||
<div
|
||||
class="font-normal"
|
||||
v-html="plugin.itemTitle?.(item) || item.title"
|
||||
></div>
|
||||
</component>
|
||||
<component :is="plugin.component" :entity="item" />
|
||||
</CommonLink>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import type { OrganizationItemData } from '@mobile/components/Organization/types'
|
||||
import { renderComponent } from '@tests/support/components'
|
||||
import { mockPermissions } from '@tests/support/mock-permissions'
|
||||
|
||||
import SearchResults from '../SearchResults.vue'
|
||||
|
||||
@@ -10,12 +11,12 @@ describe('renders search result', () => {
|
||||
const org: OrganizationItemData = {
|
||||
name: 'organization',
|
||||
id: '123',
|
||||
internalId: 123,
|
||||
ticketsCount: 2,
|
||||
active: true,
|
||||
}
|
||||
|
||||
// will be needed when permissions are implemented for default plugins
|
||||
// mockPermissions(['admin.organization'])
|
||||
mockPermissions(['ticket.agent'])
|
||||
|
||||
const view = renderComponent(SearchResults, {
|
||||
props: {
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
// Copyright (C) 2012-2022 Zammad Foundation, https://zammad-foundation.org/
|
||||
|
||||
import type { SearchQuery } from '@shared/graphql/types'
|
||||
import type { ConfidentTake } from '@shared/types/utils'
|
||||
import { mockGraphQLApi } from '@tests/support/mock-graphql-api'
|
||||
import { SearchDocument } from '../searchOverview.api'
|
||||
|
||||
export const mockSearchOverview = (
|
||||
search: ConfidentTake<SearchQuery, 'search'>,
|
||||
) => {
|
||||
return mockGraphQLApi(SearchDocument).willResolve({
|
||||
search,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import * as Types from '../../../../../shared/graphql/types';
|
||||
|
||||
import gql from 'graphql-tag';
|
||||
import * as VueApolloComposable from '@vue/apollo-composable';
|
||||
import * as VueCompositionApi from 'vue';
|
||||
export type ReactiveFunction<TParam> = () => TParam;
|
||||
|
||||
export const SearchDocument = gql`
|
||||
query search($search: String!, $isAgent: Boolean!, $onlyIn: EnumSearchableModels) {
|
||||
search(search: $search, onlyIn: $onlyIn) {
|
||||
... on Ticket {
|
||||
id
|
||||
internalId
|
||||
title
|
||||
number
|
||||
state {
|
||||
name
|
||||
}
|
||||
priority @include(if: $isAgent) {
|
||||
name
|
||||
defaultCreate
|
||||
uiColor
|
||||
}
|
||||
customer {
|
||||
fullname
|
||||
}
|
||||
updatedAt
|
||||
updatedBy @include(if: $isAgent) {
|
||||
id
|
||||
fullname
|
||||
}
|
||||
}
|
||||
... on User {
|
||||
id
|
||||
internalId
|
||||
firstname
|
||||
lastname
|
||||
image
|
||||
organization {
|
||||
name
|
||||
}
|
||||
updatedAt
|
||||
}
|
||||
... on Organization {
|
||||
id
|
||||
internalId
|
||||
members(first: 2) {
|
||||
edges {
|
||||
node {
|
||||
fullname
|
||||
}
|
||||
}
|
||||
totalCount
|
||||
}
|
||||
active
|
||||
name
|
||||
updatedAt
|
||||
updatedBy @include(if: $isAgent) {
|
||||
id
|
||||
fullname
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
export function useSearchQuery(variables: Types.SearchQueryVariables | VueCompositionApi.Ref<Types.SearchQueryVariables> | ReactiveFunction<Types.SearchQueryVariables>, options: VueApolloComposable.UseQueryOptions<Types.SearchQuery, Types.SearchQueryVariables> | VueCompositionApi.Ref<VueApolloComposable.UseQueryOptions<Types.SearchQuery, Types.SearchQueryVariables>> | ReactiveFunction<VueApolloComposable.UseQueryOptions<Types.SearchQuery, Types.SearchQueryVariables>> = {}) {
|
||||
return VueApolloComposable.useQuery<Types.SearchQuery, Types.SearchQueryVariables>(SearchDocument, variables, options);
|
||||
}
|
||||
export function useSearchLazyQuery(variables: Types.SearchQueryVariables | VueCompositionApi.Ref<Types.SearchQueryVariables> | ReactiveFunction<Types.SearchQueryVariables>, options: VueApolloComposable.UseQueryOptions<Types.SearchQuery, Types.SearchQueryVariables> | VueCompositionApi.Ref<VueApolloComposable.UseQueryOptions<Types.SearchQuery, Types.SearchQueryVariables>> | ReactiveFunction<VueApolloComposable.UseQueryOptions<Types.SearchQuery, Types.SearchQueryVariables>> = {}) {
|
||||
return VueApolloComposable.useLazyQuery<Types.SearchQuery, Types.SearchQueryVariables>(SearchDocument, variables, options);
|
||||
}
|
||||
export type SearchQueryCompositionFunctionResult = VueApolloComposable.UseQueryReturn<Types.SearchQuery, Types.SearchQueryVariables>;
|
||||
@@ -0,0 +1,60 @@
|
||||
query search(
|
||||
$search: String!
|
||||
$isAgent: Boolean!
|
||||
$onlyIn: EnumSearchableModels
|
||||
) {
|
||||
search(search: $search, onlyIn: $onlyIn) {
|
||||
... on Ticket {
|
||||
id
|
||||
internalId
|
||||
title
|
||||
number
|
||||
state {
|
||||
name
|
||||
}
|
||||
priority @include(if: $isAgent) {
|
||||
name
|
||||
defaultCreate
|
||||
uiColor
|
||||
}
|
||||
customer {
|
||||
fullname
|
||||
}
|
||||
updatedAt
|
||||
updatedBy @include(if: $isAgent) {
|
||||
id
|
||||
fullname
|
||||
}
|
||||
}
|
||||
... on User {
|
||||
id
|
||||
internalId
|
||||
firstname
|
||||
lastname
|
||||
image
|
||||
organization {
|
||||
name
|
||||
}
|
||||
updatedAt
|
||||
}
|
||||
... on Organization {
|
||||
id
|
||||
internalId
|
||||
members(first: 2) {
|
||||
edges {
|
||||
node {
|
||||
fullname
|
||||
}
|
||||
}
|
||||
totalCount
|
||||
}
|
||||
active
|
||||
name
|
||||
updatedAt
|
||||
updatedBy @include(if: $isAgent) {
|
||||
id
|
||||
fullname
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -16,11 +16,17 @@ describe('plugins are returned correctly', () => {
|
||||
const plugins = useSearchPlugins()
|
||||
|
||||
expect(plugins).toEqual({
|
||||
ticket: expect.objectContaining({ order: 100, link: '/tickets/#{id}' }),
|
||||
user: expect.objectContaining({ order: 200, link: '/users/#{id}' }),
|
||||
ticket: expect.objectContaining({
|
||||
order: 100,
|
||||
link: '/tickets/#{internalId}',
|
||||
}),
|
||||
user: expect.objectContaining({
|
||||
order: 200,
|
||||
link: '/users/#{internalId}',
|
||||
}),
|
||||
organization: expect.objectContaining({
|
||||
order: 300,
|
||||
link: '/organizations/#{id}',
|
||||
link: '/organizations/#{internalId}',
|
||||
}),
|
||||
})
|
||||
})
|
||||
@@ -37,6 +43,6 @@ describe('plugins are returned correctly', () => {
|
||||
|
||||
const plugins = useSearchPlugins()
|
||||
|
||||
expect(plugins).not.toHaveProperty('ticket')
|
||||
expect(plugins).not.toHaveProperty('organization')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,15 +1,19 @@
|
||||
// Copyright (C) 2012-2022 Zammad Foundation, https://zammad-foundation.org/
|
||||
|
||||
import { useSessionStore } from '@shared/stores/session'
|
||||
import type { EnumSearchableModels } from '@shared/graphql/types'
|
||||
import type { Component } from 'vue'
|
||||
import { useSessionStore } from '@shared/stores/session'
|
||||
import type { Props as IconProps } from '@shared/components/CommonIcon/CommonIcon.vue'
|
||||
|
||||
export interface SearchPlugin<T = unknown> {
|
||||
headerTitle: string
|
||||
export interface SearchPlugin {
|
||||
model: EnumSearchableModels
|
||||
headerLabel: string
|
||||
order: number
|
||||
link: string
|
||||
permissions: string[]
|
||||
icon?: string | IconProps
|
||||
iconBg?: string
|
||||
component: Component // component needs to have a slot called "default" and prop "entity"
|
||||
itemTitle?(entity: T): string // by default "title" field
|
||||
}
|
||||
|
||||
const pluginsModules = import.meta.glob<SearchPlugin>(
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
// Copyright (C) 2012-2022 Zammad Foundation, https://zammad-foundation.org/
|
||||
|
||||
import OrganizationItem from '@mobile/components/Organization/OrganizationItem.vue'
|
||||
import type { OrganizationItemData } from '@mobile/components/Organization/types'
|
||||
import { type SearchPlugin } from './index'
|
||||
import type { SearchPlugin } from './index'
|
||||
|
||||
export default <SearchPlugin<OrganizationItemData>>{
|
||||
headerTitle: __('Organizations'),
|
||||
export default <SearchPlugin>{
|
||||
model: 'Organization',
|
||||
headerLabel: __('Organizations'),
|
||||
component: OrganizationItem,
|
||||
link: '/organizations/#{id}',
|
||||
permissions: ['*'], // TODO 2022-06-01 Sheremet V.A.
|
||||
link: '/organizations/#{internalId}',
|
||||
permissions: ['ticket.agent'],
|
||||
order: 300,
|
||||
itemTitle: (organization) => organization.name,
|
||||
icon: { name: 'organization', size: 'small' },
|
||||
iconBg: 'bg-orange',
|
||||
}
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
// Copyright (C) 2012-2022 Zammad Foundation, https://zammad-foundation.org/
|
||||
|
||||
import TicketItem from '@mobile/components/Ticket/TicketItem.vue'
|
||||
import { type TicketItemData } from '@mobile/components/Ticket/types'
|
||||
import { type SearchPlugin } from './index'
|
||||
import type { SearchPlugin } from './index'
|
||||
|
||||
export default <SearchPlugin<TicketItemData>>{
|
||||
headerTitle: __('Tickets'),
|
||||
export default <SearchPlugin>{
|
||||
model: 'Ticket',
|
||||
headerLabel: __('Tickets'),
|
||||
component: TicketItem,
|
||||
link: '/tickets/#{id}',
|
||||
link: '/tickets/#{internalId}',
|
||||
permissions: ['ticket.agent', 'ticket.customer'],
|
||||
icon: { name: 'stack', size: 'base' },
|
||||
iconBg: 'bg-blue',
|
||||
order: 100,
|
||||
}
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
// Copyright (C) 2012-2022 Zammad Foundation, https://zammad-foundation.org/
|
||||
|
||||
import type { UserItemData } from '@mobile/components/User/types'
|
||||
import UserItem from '@mobile/components/User/UserItem.vue'
|
||||
import { type SearchPlugin } from './index'
|
||||
import type { SearchPlugin } from './index'
|
||||
|
||||
export default <SearchPlugin<UserItemData>>{
|
||||
headerTitle: __('Users'),
|
||||
export default <SearchPlugin>{
|
||||
model: 'User',
|
||||
headerLabel: __('Users'),
|
||||
component: UserItem,
|
||||
order: 200,
|
||||
link: '/users/#{id}',
|
||||
permissions: ['*'], // TODO 2022-06-01 Sheremet V.A.
|
||||
itemTitle: (user) =>
|
||||
[user.firstname, user.lastname].filter(Boolean).join(' '),
|
||||
link: '/users/#{internalId}',
|
||||
permissions: ['ticket.agent'],
|
||||
icon: { name: 'user', size: 'base' },
|
||||
iconBg: 'bg-pink',
|
||||
}
|
||||
|
||||
@@ -1,20 +1,22 @@
|
||||
<!-- Copyright (C) 2012-2022 Zammad Foundation, https://zammad-foundation.org/ -->
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, reactive, ref, watch } from 'vue'
|
||||
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
||||
import type { CommonInputSearchExpose } from '@shared/components/CommonInputSearch/CommonInputSearch.vue'
|
||||
import CommonInputSearch from '@shared/components/CommonInputSearch/CommonInputSearch.vue'
|
||||
import CommonSectionMenu from '@mobile/components/CommonSectionMenu/CommonSectionMenu.vue'
|
||||
import type { MenuItem } from '@mobile/components/CommonSectionMenu'
|
||||
import { ignorableWatch } from '@vueuse/shared'
|
||||
import { useLocalStorage } from '@vueuse/core'
|
||||
import { QueryHandler } from '@shared/server/apollo/handler'
|
||||
import type { LocationQueryRaw } from 'vue-router'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { debounce } from 'lodash-es'
|
||||
import { TicketState } from '@shared/entities/ticket/types'
|
||||
import CommonButtonPills from '@mobile/components/CommonButtonPills/CommonButtonPills.vue'
|
||||
import { useSessionStore } from '@shared/stores/session'
|
||||
import SearchResults from '../components/SearchResults.vue'
|
||||
import { useSearchPlugins } from '../plugins'
|
||||
import { useSearchLazyQuery } from '../graphql/searchOverview.api'
|
||||
|
||||
interface SearchTypeItem extends MenuItem {
|
||||
value: string
|
||||
@@ -26,77 +28,41 @@ const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
const searchPlugins = useSearchPlugins()
|
||||
const { hasPermission } = useSessionStore()
|
||||
|
||||
const search = ref(String(route.query.search || ''))
|
||||
// we need a separate debounced value to not trigger query
|
||||
const filter = ref(search.value)
|
||||
|
||||
// TODO 2022-06-02 Sheremet V.A. Remove when API is implemented
|
||||
// TODO base on actual values
|
||||
const mockData = {
|
||||
ticket: [
|
||||
{
|
||||
id: '100423',
|
||||
// eslint-disable-next-line zammad/zammad-detect-translatable-string
|
||||
title: 'Client requests return',
|
||||
number: '100423',
|
||||
state: TicketState.Open,
|
||||
priority: {
|
||||
name: 'high',
|
||||
uiColor: 'high-priority',
|
||||
},
|
||||
owner: {
|
||||
firstname: 'Jerome',
|
||||
lastname: 'Miller',
|
||||
},
|
||||
updatedAt: new Date(2022, 2, 10).toISOString(),
|
||||
updatedBy: {
|
||||
id: 'hleb',
|
||||
firstname: 'Jerome',
|
||||
lastname: 'Miller',
|
||||
},
|
||||
},
|
||||
],
|
||||
customer: [],
|
||||
organization: [
|
||||
{
|
||||
id: '1004234',
|
||||
name: 'spriptsread.co',
|
||||
members: [
|
||||
{
|
||||
firstname: 'Jerome',
|
||||
lastname: 'Miller',
|
||||
},
|
||||
{
|
||||
firstname: 'John',
|
||||
lastname: 'Sky',
|
||||
},
|
||||
{
|
||||
firstname: 'John',
|
||||
lastname: 'Sky',
|
||||
},
|
||||
],
|
||||
ticketsCount: 2,
|
||||
active: true,
|
||||
updatedAt: new Date(2022, 2, 10).toISOString(),
|
||||
updatedBy: {
|
||||
id: 'hleb',
|
||||
firstname: 'Jerome',
|
||||
lastname: 'Miller',
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
const canSearch = computed(() => filter.value.length >= 3)
|
||||
|
||||
const found = reactive(
|
||||
Object.keys(searchPlugins).reduce((acc, name) => {
|
||||
// TODO 2022-06-02 Sheremet V.A. Remove when API is implemented
|
||||
// @ts-expect-error TODO remove
|
||||
acc[name] = mockData[name]
|
||||
return acc
|
||||
}, {} as Record<string, Record<string, unknown>[]>),
|
||||
)
|
||||
const loading = ref(false)
|
||||
const found = reactive({} as Record<string, Record<string, unknown>[]>)
|
||||
const lastSearches = useLocalStorage<string[]>('lastSearches', [])
|
||||
|
||||
const model = computed(() => {
|
||||
if (!props.type) return undefined
|
||||
return searchPlugins[props.type]?.model
|
||||
})
|
||||
|
||||
const searchQuery = new QueryHandler(
|
||||
useSearchLazyQuery(
|
||||
() => ({
|
||||
search: filter.value,
|
||||
onlyIn: model.value,
|
||||
isAgent: hasPermission(['ticket.agent']),
|
||||
}),
|
||||
() => ({ enabled: canSearch.value }),
|
||||
),
|
||||
)
|
||||
|
||||
const loading = searchQuery.loading()
|
||||
|
||||
searchQuery.watchOnResult((data) => {
|
||||
if (!props.type) return
|
||||
|
||||
found[props.type] = data.search
|
||||
})
|
||||
|
||||
const replaceQuery = (query: LocationQueryRaw) => {
|
||||
return router.replace({
|
||||
query: {
|
||||
@@ -118,38 +84,34 @@ onMounted(() => {
|
||||
focusSearch()
|
||||
})
|
||||
|
||||
const loadByFilter = async (filter: string) => {
|
||||
const loadByFilter = async (filterQuery: string) => {
|
||||
if (!props.type) {
|
||||
return
|
||||
}
|
||||
if (filter.length < 3) {
|
||||
// todo clear data, show nothing?
|
||||
filter.value = filterQuery
|
||||
replaceQuery({ search: filterQuery })
|
||||
if (!canSearch.value) {
|
||||
return
|
||||
}
|
||||
replaceQuery({ search: filter })
|
||||
lastSearches.value = lastSearches.value.filter((item) => item !== filter)
|
||||
lastSearches.value.push(filter)
|
||||
searchQuery.abort()
|
||||
|
||||
lastSearches.value = lastSearches.value.filter((item) => item !== filterQuery)
|
||||
lastSearches.value.push(filterQuery)
|
||||
if (lastSearches.value.length > 5) {
|
||||
lastSearches.value.shift()
|
||||
}
|
||||
// TODO call api
|
||||
loading.value = true
|
||||
try {
|
||||
await new Promise((resolve) => {
|
||||
setTimeout(resolve, 200)
|
||||
})
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
|
||||
searchQuery.load()
|
||||
}
|
||||
|
||||
// load data after a few ms to not overload the api
|
||||
const { ignoreUpdates } = ignorableWatch(search, debounce(loadByFilter, 500))
|
||||
const { ignoreUpdates } = ignorableWatch(search, debounce(loadByFilter, 400))
|
||||
|
||||
// load data immidiately when type changed or when last search selected
|
||||
// load data immidiately when type changes or when last search selected
|
||||
watch(
|
||||
() => props.type,
|
||||
() => loadByFilter(search.value),
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
const selectLastSearch = async (lastSearch: string) => {
|
||||
@@ -163,9 +125,11 @@ const selectLastSearch = async (lastSearch: string) => {
|
||||
const types: SearchTypeItem[] = Object.entries(searchPlugins).map(
|
||||
([name, plugin]) => {
|
||||
return {
|
||||
label: plugin.headerTitle,
|
||||
label: plugin.headerLabel,
|
||||
type: 'link',
|
||||
value: name,
|
||||
icon: plugin.icon,
|
||||
iconBg: plugin.iconBg,
|
||||
onClick: () => selectType(name),
|
||||
}
|
||||
},
|
||||
@@ -176,9 +140,19 @@ const types: SearchTypeItem[] = Object.entries(searchPlugins).map(
|
||||
export default {
|
||||
beforeRouteEnter(to) {
|
||||
const { type } = to.params
|
||||
if (!type) return undefined
|
||||
|
||||
const searchPlugins = useSearchPlugins()
|
||||
|
||||
if (!type) {
|
||||
const pluginsArray = Object.entries(searchPlugins)
|
||||
|
||||
// if no type is selected, and only one type is available, select it
|
||||
if (pluginsArray.length === 1) {
|
||||
return { ...to, params: { type: pluginsArray[0][0] } }
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
if (Array.isArray(type) || !searchPlugins[type as string]) {
|
||||
return { ...to, params: {} }
|
||||
}
|
||||
@@ -210,13 +184,22 @@ export default {
|
||||
@update:model-value="selectType($event as string)"
|
||||
/>
|
||||
<div v-else class="mt-8 px-4" data-test-id="selectTypesSection">
|
||||
<CommonSectionMenu :header-label="$t('Search…')" :items="types" />
|
||||
<CommonSectionMenu :header-title="__('Search for…')" :items="types" />
|
||||
</div>
|
||||
<div v-if="loading" class="flex h-14 w-full items-center justify-center">
|
||||
<CommonIcon name="loader" animation="spin" />
|
||||
</div>
|
||||
<div v-else-if="canSearch && type && found[type]?.length">
|
||||
<SearchResults :data="found[type]" :type="type" />
|
||||
</div>
|
||||
<div v-else-if="!canSearch" class="mt-4 px-4">
|
||||
{{ $t('Enter more than two characters to get any results…') }}
|
||||
</div>
|
||||
<div v-else class="mt-4 px-4">
|
||||
{{ $t('No entries') }}
|
||||
</div>
|
||||
<div
|
||||
v-else-if="type && !found[type]?.length"
|
||||
v-if="type && (!found[type]?.length || !canSearch)"
|
||||
class="mt-8 px-4"
|
||||
data-test-id="lastSearches"
|
||||
>
|
||||
@@ -225,17 +208,16 @@ export default {
|
||||
<li
|
||||
v-for="searchItem in [...lastSearches].reverse()"
|
||||
:key="searchItem"
|
||||
class="flex cursor-pointer items-center pb-4"
|
||||
class="pb-4"
|
||||
@click="selectLastSearch(searchItem)"
|
||||
>
|
||||
<CommonIcon name="clock" size="small" class="mx-2 text-white/50" />
|
||||
<span class="text-base">{{ searchItem }}</span>
|
||||
<button class="flex items-center">
|
||||
<CommonIcon name="clock" size="small" class="mx-2 text-white/50" />
|
||||
<span class="text-base">{{ searchItem }}</span>
|
||||
</button>
|
||||
</li>
|
||||
<li v-if="!lastSearches.length">{{ $t('No previous searches') }}</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div v-else-if="type && found[type]?.length" class="px-4">
|
||||
<SearchResults :data="found[type]" :type="type" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -2,11 +2,10 @@
|
||||
|
||||
import { EnumOrderDirection } from '@shared/graphql/types'
|
||||
import { waitFor } from '@testing-library/vue'
|
||||
import { getTestRouter } from '@tests/support/components/renderComponent'
|
||||
import { visitView } from '@tests/support/components/visitView'
|
||||
import { mockTicketOverviews } from '@tests/support/mocks/ticket-overviews'
|
||||
import { waitForNextTick } from '@tests/support/utils'
|
||||
import { stringifyQuery, useRoute } from 'vue-router'
|
||||
import { stringifyQuery } from 'vue-router'
|
||||
import { mockTicketsByOverview, ticketDefault } from './mocks/overview'
|
||||
|
||||
beforeEach(() => {
|
||||
|
||||
@@ -66,7 +66,13 @@ useInfiniteScroll(window, async () => {
|
||||
|
||||
<template>
|
||||
<CommonLoader :loading="!tickets.length && loading">
|
||||
<TicketItem v-for="ticket in tickets" :key="ticket.id" :entity="ticket" />
|
||||
<CommonLink
|
||||
v-for="ticket in tickets"
|
||||
:key="ticket.id"
|
||||
:link="`/tickets/${ticket.internalId}`"
|
||||
>
|
||||
<TicketItem :entity="ticket" />
|
||||
</CommonLink>
|
||||
<CommonLoader
|
||||
v-if="pagination.loadingNextPage"
|
||||
loading
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { watchOnce } from '@vueuse/shared'
|
||||
import { i18n } from '@shared/i18n'
|
||||
import CommonBackButton from '@mobile/components/CommonBackButton/CommonBackButton.vue'
|
||||
import { EnumOrderDirection } from '@shared/graphql/types'
|
||||
|
||||
@@ -58,7 +58,7 @@ export default {
|
||||
ref="filterInput"
|
||||
v-model="filter"
|
||||
v-bind="$attrs"
|
||||
:placeholder="i18n.t('Search')"
|
||||
:placeholder="i18n.t('Search…')"
|
||||
class="h-12 w-full grow rounded-xl bg-gray-500 px-9 placeholder:text-gray focus:shadow-none focus:outline-none focus:ring-0"
|
||||
:class="{
|
||||
'focus:border-white focus:ring-0': !noBorder,
|
||||
|
||||
@@ -366,6 +366,8 @@ export type Organization = Node & ObjectAttributeValueInterface & {
|
||||
domain?: Maybe<Scalars['String']>;
|
||||
domainAssignment: Scalars['Boolean'];
|
||||
id: Scalars['ID'];
|
||||
/** Internal database ID */
|
||||
internalId: Scalars['Int'];
|
||||
members: UserConnection;
|
||||
name: Scalars['String'];
|
||||
note?: Maybe<Scalars['String']>;
|
||||
@@ -882,6 +884,8 @@ export type User = Node & ObjectAttributeValueInterface & {
|
||||
id: Scalars['ID'];
|
||||
image?: Maybe<Scalars['String']>;
|
||||
imageSource?: Maybe<Scalars['String']>;
|
||||
/** Internal database ID */
|
||||
internalId: Scalars['Int'];
|
||||
lastLogin?: Maybe<Scalars['ISO8601DateTime']>;
|
||||
lastname?: Maybe<Scalars['String']>;
|
||||
login: Scalars['String'];
|
||||
@@ -975,6 +979,15 @@ export type AccountLocaleMutationVariables = Exact<{
|
||||
|
||||
export type AccountLocaleMutation = { __typename?: 'Mutations', accountLocale?: { __typename?: 'AccountLocalePayload', success: boolean, errors?: Array<{ __typename?: 'UserError', message: string, field?: string | null }> | null } | null };
|
||||
|
||||
export type SearchQueryVariables = Exact<{
|
||||
search: Scalars['String'];
|
||||
isAgent: Scalars['Boolean'];
|
||||
onlyIn?: InputMaybe<EnumSearchableModels>;
|
||||
}>;
|
||||
|
||||
|
||||
export type SearchQuery = { __typename?: 'Queries', search: Array<{ __typename?: 'Organization', id: string, internalId: number, active: boolean, name: string, updatedAt: any, members: { __typename?: 'UserConnection', totalCount: number, edges: Array<{ __typename?: 'UserEdge', node: { __typename?: 'User', fullname?: string | null } }> }, updatedBy?: { __typename?: 'User', id: string, fullname?: string | null } } | { __typename?: 'Ticket', id: string, internalId: number, title: string, number: string, updatedAt: any, state: { __typename?: 'TicketState', name: string }, priority?: { __typename?: 'TicketPriority', name: string, defaultCreate: boolean, uiColor?: string | null }, customer: { __typename?: 'User', fullname?: string | null }, updatedBy?: { __typename?: 'User', id: string, fullname?: string | null } } | { __typename?: 'User', id: string, internalId: number, firstname?: string | null, lastname?: string | null, image?: string | null, updatedAt: any, organization?: { __typename?: 'Organization', name: string } | null }> };
|
||||
|
||||
export type TicketArticleAttributesFragment = { __typename?: 'TicketArticle', id: string, internal: boolean, body: string, createdAt: any, subject?: string | null, createdBy: { __typename?: 'User', id: string, firstname?: string | null, lastname?: string | null }, sender?: { __typename?: 'TicketArticleType', name?: string | null } | null, to?: { __typename?: 'AddressesField', raw: string, parsed?: Array<{ __typename?: 'EmailAddress', name?: string | null, emailAddress?: string | null }> | null } | null };
|
||||
|
||||
export type TicketAttributesFragment = { __typename?: 'Ticket', id: string, internalId: number, number: string, title: string, createdAt: any, updatedAt: any, owner: { __typename?: 'User', id: string, firstname?: string | null, lastname?: string | null }, customer: { __typename?: 'User', id: string, firstname?: string | null, lastname?: string | null, fullname?: string | null }, organization?: { __typename?: 'Organization', name: string } | null, state: { __typename?: 'TicketState', id: string, name: string, stateType: { __typename?: 'TicketStateType', name: string } }, group: { __typename?: 'Group', id: string, name: string }, priority: { __typename?: 'TicketPriority', id: string, name: string, defaultCreate: boolean, uiColor?: string | null } };
|
||||
|
||||
@@ -13,7 +13,7 @@ export class Translator {
|
||||
let target = this.translationMap.get(source) || source
|
||||
|
||||
args.forEach((arg) => {
|
||||
if (arg) target = target.replace('%s', arg.toString())
|
||||
if (arg != null) target = target.replace('%s', arg.toString())
|
||||
})
|
||||
|
||||
return target
|
||||
|
||||
@@ -120,6 +120,11 @@ export default class QueryHandler<
|
||||
this.operationResult.stop()
|
||||
}
|
||||
|
||||
public abort() {
|
||||
this.operationResult.stop()
|
||||
this.operationResult.start()
|
||||
}
|
||||
|
||||
public async onLoaded(
|
||||
triggerPossibleRefetch = false,
|
||||
): Promise<Maybe<TResult>> {
|
||||
|
||||
@@ -70,4 +70,4 @@ export type CommonHandlerOptions<TOptions> = BaseHandlerOptions & TOptions
|
||||
export type CommonHandlerOptionsParameter<TOptions> =
|
||||
Partial<BaseHandlerOptions> & Partial<TOptions>
|
||||
|
||||
export type WatchResultCallback<TResult> = (result?: TResult) => void
|
||||
export type WatchResultCallback<TResult> = (result: TResult) => void
|
||||
|
||||
@@ -20,6 +20,10 @@ afterEach(() => {
|
||||
mockClient = null
|
||||
})
|
||||
|
||||
export const clearMockClient = () => {
|
||||
mockClient = null
|
||||
}
|
||||
|
||||
const createMockClient = (
|
||||
handlers: ClientRequestHandler[],
|
||||
cacheOptions = {},
|
||||
|
||||
@@ -39,3 +39,17 @@ export const waitUntil = async (
|
||||
}, 30)
|
||||
})
|
||||
}
|
||||
|
||||
// apollo cache always asks for a field, even if it's marked as optional
|
||||
// this function returns a proxy that will return "null" on properties not defined
|
||||
// in the initial object
|
||||
export const nullableMock = <T extends object>(obj: T): T => {
|
||||
return new Proxy(obj, {
|
||||
get(target, prop, receiver) {
|
||||
if (!Reflect.has(target, prop)) {
|
||||
return null
|
||||
}
|
||||
return Reflect.get(target, prop, receiver)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
module Gql::Types
|
||||
class OrganizationType < Gql::Types::BaseObject
|
||||
include Gql::Concern::IsModelObject
|
||||
include Gql::Concern::HasInternalId
|
||||
|
||||
def self.authorize(object, ctx)
|
||||
Pundit.authorize ctx.current_user, object, :show?
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
module Gql::Types
|
||||
class UserType < Gql::Types::BaseObject
|
||||
include Gql::Concern::IsModelObject
|
||||
include Gql::Concern::HasInternalId
|
||||
|
||||
def self.authorize(object, ctx)
|
||||
Pundit.authorize ctx.current_user, object, :show?
|
||||
|
||||
@@ -3973,6 +3973,10 @@ msgstr ""
|
||||
msgid "Enter link URL"
|
||||
msgstr ""
|
||||
|
||||
#: app/frontend/apps/mobile/modules/search/views/SearchOverview.vue
|
||||
msgid "Enter more than two characters to get any results…"
|
||||
msgstr ""
|
||||
|
||||
#: app/assets/javascripts/app/views/import/freshdesk.jst.eco
|
||||
#: app/assets/javascripts/app/views/import/kayako.jst.eco
|
||||
msgid "Enter the Subdomain of your %s system"
|
||||
@@ -6641,6 +6645,7 @@ msgid "No email!"
|
||||
msgstr ""
|
||||
|
||||
#: app/frontend/apps/mobile/modules/home/views/FavoriteTicketOverviewsEdit.vue
|
||||
#: app/frontend/apps/mobile/modules/search/views/SearchOverview.vue
|
||||
#: app/frontend/apps/mobile/modules/ticket/components/TicketList/TicketList.vue
|
||||
msgid "No entries"
|
||||
msgstr ""
|
||||
@@ -8201,7 +8206,6 @@ msgstr ""
|
||||
#: app/assets/javascripts/app/views/knowledge_base/search_field_widget.jst.eco
|
||||
#: app/assets/javascripts/app/views/widget/text_module.jst.eco
|
||||
#: app/frontend/apps/mobile/modules/search/routes.ts
|
||||
#: app/frontend/shared/components/CommonInputSearch/CommonInputSearch.vue
|
||||
msgid "Search"
|
||||
msgstr ""
|
||||
|
||||
@@ -8218,11 +8222,15 @@ msgstr ""
|
||||
msgid "Search for users"
|
||||
msgstr ""
|
||||
|
||||
#: app/frontend/apps/mobile/modules/search/views/SearchOverview.vue
|
||||
msgid "Search for…"
|
||||
msgstr ""
|
||||
|
||||
#: app/assets/javascripts/app/controllers/_ui_element/autocompletion_ajax.coffee
|
||||
#: app/assets/javascripts/app/controllers/_ui_element/autocompletion_ajax_customer_organization.coffee
|
||||
#: app/assets/javascripts/app/controllers/widget/link/kb_answer.coffee
|
||||
#: app/assets/javascripts/app/views/generic/icon_picker.jst.eco
|
||||
#: app/frontend/apps/mobile/modules/search/views/SearchOverview.vue
|
||||
#: app/frontend/shared/components/CommonInputSearch/CommonInputSearch.vue
|
||||
msgid "Search…"
|
||||
msgstr ""
|
||||
|
||||
|
||||
99
spec/system/apps/mobile/search_overview_spec.rb
Normal file
99
spec/system/apps/mobile/search_overview_spec.rb
Normal file
@@ -0,0 +1,99 @@
|
||||
# Copyright (C) 2012-2022 Zammad Foundation, https://zammad-foundation.org/
|
||||
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe 'Mobile > Search', type: :system, app: :mobile, authenticated_as: :user do
|
||||
context 'when searching as customer', authenticated_as: :customer do
|
||||
let(:organization) { create(:organization) }
|
||||
let(:customer) { create(:customer, organization: organization) }
|
||||
|
||||
it 'opens tickets search page by default, since customer doesn\'t have access to others' do
|
||||
visit '/search'
|
||||
expect_current_route('/search/ticket')
|
||||
end
|
||||
|
||||
it 'can search tickets' do
|
||||
create(:ticket, title: 'test ticket', customer_id: customer.id).tap do |ticket|
|
||||
create(:ticket_article, ticket: ticket)
|
||||
end
|
||||
create(:ticket, title: 'other ticket', customer_id: customer.id).tap do |ticket|
|
||||
create(:ticket_article, ticket: ticket)
|
||||
end
|
||||
|
||||
visit '/search/ticket'
|
||||
|
||||
fill_in placeholder: 'Search…', with: 'test'
|
||||
|
||||
wait_for_gql('apps/mobile/modules/search/graphql/searchOverview.graphql')
|
||||
|
||||
expect(page).to have_text('test ticket')
|
||||
expect(page).to have_no_text('other ticket')
|
||||
end
|
||||
|
||||
it 'can\'t search users' do
|
||||
visit '/search/user'
|
||||
|
||||
expect(page).to have_no_text('User')
|
||||
end
|
||||
|
||||
it 'can\'t search organizations' do
|
||||
visit '/search/organization'
|
||||
|
||||
expect(page).to have_no_text('Organization')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when searching as agent', authenticated_as: :agent do
|
||||
let(:organization) { create(:organization) }
|
||||
let(:ticket1) do
|
||||
create(:ticket, title: 'test ticket', organization_id: organization.id).tap do |ticket|
|
||||
create(:ticket_article, ticket: ticket)
|
||||
end
|
||||
end
|
||||
let(:ticket2) do
|
||||
create(:ticket, title: 'other ticket', organization_id: organization.id).tap do |ticket|
|
||||
create(:ticket_article, ticket: ticket)
|
||||
end
|
||||
end
|
||||
let(:agent) { create(:agent, organization: organization, groups: [ticket1.group, ticket2.group]) }
|
||||
|
||||
it 'can search tickets' do
|
||||
visit '/search/ticket'
|
||||
|
||||
fill_in placeholder: 'Search…', with: 'test'
|
||||
|
||||
wait_for_gql('apps/mobile/modules/search/graphql/searchOverview.graphql')
|
||||
|
||||
expect(page).to have_text('test ticket')
|
||||
expect(page).to have_no_text('other ticket')
|
||||
end
|
||||
|
||||
it 'can search users' do
|
||||
create(:customer, firstname: 'test', lastname: 'customer')
|
||||
create(:customer, firstname: 'other', lastname: 'customer')
|
||||
|
||||
visit '/search/user'
|
||||
|
||||
fill_in placeholder: 'Search…', with: 'test'
|
||||
|
||||
wait_for_gql('apps/mobile/modules/search/graphql/searchOverview.graphql')
|
||||
|
||||
expect(page).to have_text('test customer')
|
||||
expect(page).to have_no_text('other customer')
|
||||
end
|
||||
|
||||
it 'can search organizations' do
|
||||
create(:organization, name: 'test organization')
|
||||
create(:organization, name: 'other organization')
|
||||
|
||||
visit '/search/organization'
|
||||
|
||||
fill_in placeholder: 'Search…', with: 'test'
|
||||
|
||||
wait_for_gql('apps/mobile/modules/search/graphql/searchOverview.graphql')
|
||||
|
||||
expect(page).to have_text('test organization')
|
||||
expect(page).to have_no_text('other organization')
|
||||
end
|
||||
end
|
||||
end
|
||||
Reference in New Issue
Block a user