Feature: Mobile - Use GraphQL query for search

This commit is contained in:
Vladimir Sheremet
2022-08-12 14:18:22 +02:00
parent 5ecce31b1b
commit 147c90ae70
43 changed files with 664 additions and 279 deletions

5
.gitignore vendored
View File

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

View File

@@ -1 +1,2 @@
**/graphql/**/*.ts
**/graphql/**/*.ts
!**/graphql/**/mocks/*.ts

View File

@@ -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',
},
],
},
})

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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(() => {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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(() => {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -20,6 +20,10 @@ afterEach(() => {
mockClient = null
})
export const clearMockClient = () => {
mockClient = null
}
const createMockClient = (
handlers: ClientRequestHandler[],
cacheOptions = {},

View File

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

View File

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

View File

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

View File

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

View 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