mirror of
https://github.com/hcengineering/platform.git
synced 2025-01-03 08:57:14 +03:00
UBER-911: Mentions without second input and tabs (#3798)
Signed-off-by: Maxim Karmatskikh <mkarmatskih@gmail.com>
This commit is contained in:
parent
f0ba202f28
commit
af5c79c928
@ -18881,7 +18881,7 @@ packages:
|
||||
dev: false
|
||||
|
||||
file:projects/middleware.tgz(@types/node@16.11.68)(esbuild@0.16.17)(ts-node@10.9.1):
|
||||
resolution: {integrity: sha512-TT4uLU1UvoXXmVb3OHUhCemx34agLPzeNhwbHmAyPXREt7phuCSwYy2TkRJl/9F0QryvgBR+C2n99jqSqSAaXg==, tarball: file:projects/middleware.tgz}
|
||||
resolution: {integrity: sha512-gSuvnAq/78PHhS/K116OxI+McFyaXSly7OUaNxgGbYIXtwPJlNHLPqFXxGjATRzBr4M3dplOeEs+4odQGn8AhQ==, tarball: file:projects/middleware.tgz}
|
||||
id: file:projects/middleware.tgz
|
||||
name: '@rush-temp/middleware'
|
||||
version: 0.0.0
|
||||
@ -18896,6 +18896,7 @@ packages:
|
||||
eslint-plugin-promise: 6.1.1(eslint@8.51.0)
|
||||
fast-equals: 2.0.4
|
||||
jest: 29.7.0(@types/node@16.11.68)(ts-node@10.9.1)
|
||||
just-clone: 6.2.0
|
||||
prettier: 2.8.8
|
||||
ts-jest: 29.1.1(esbuild@0.16.17)(jest@29.7.0)(typescript@5.2.2)
|
||||
typescript: 5.2.2
|
||||
|
@ -31,7 +31,10 @@ import core, {
|
||||
Timestamp,
|
||||
Tx,
|
||||
TxHandler,
|
||||
TxResult
|
||||
TxResult,
|
||||
SearchQuery,
|
||||
SearchOptions,
|
||||
SearchResult
|
||||
} from '@hcengineering/core'
|
||||
import { createInMemoryTxAdapter } from '@hcengineering/dev-storage'
|
||||
import devmodel from '@hcengineering/devmodel'
|
||||
@ -59,6 +62,10 @@ class ServerStorageWrapper implements ClientConnection {
|
||||
return this.storage.findAll(this.measureCtx, c, q, o)
|
||||
}
|
||||
|
||||
async searchFulltext (query: SearchQuery, options: SearchOptions): Promise<SearchResult> {
|
||||
return { docs: [] }
|
||||
}
|
||||
|
||||
async loadModel (lastModelTx: Timestamp): Promise<Tx[]> {
|
||||
return await this.storage.findAll(this.measureCtx, core.class.Tx, {
|
||||
space: core.space.Model,
|
||||
|
@ -48,6 +48,7 @@ import {
|
||||
TypeRef,
|
||||
TypeString,
|
||||
TypeTimestamp,
|
||||
TypeAttachment,
|
||||
UX
|
||||
} from '@hcengineering/model'
|
||||
import attachment from '@hcengineering/model-attachment'
|
||||
@ -92,7 +93,10 @@ export class TContact extends TDoc implements Contact {
|
||||
@Index(IndexKind.FullText)
|
||||
name!: string
|
||||
|
||||
avatar?: string | null
|
||||
@Prop(TypeAttachment(), contact.string.Avatar)
|
||||
@Index(IndexKind.FullText)
|
||||
@Hidden()
|
||||
avatar?: string | null
|
||||
|
||||
@Prop(Collection(contact.class.Channel), contact.string.ContactInfo)
|
||||
channels?: number
|
||||
@ -678,7 +682,8 @@ export function createModel (builder: Builder): void {
|
||||
})
|
||||
|
||||
builder.mixin(contact.class.Contact, core.class.Class, view.mixin.ClassFilters, {
|
||||
filters: []
|
||||
filters: [],
|
||||
ignoreKeys: ['avatar']
|
||||
})
|
||||
|
||||
builder.mixin(contact.class.Person, core.class.Class, view.mixin.ClassFilters, {
|
||||
@ -713,7 +718,9 @@ export function createModel (builder: Builder): void {
|
||||
{
|
||||
icon: contact.icon.Person,
|
||||
label: contact.string.SearchEmployee,
|
||||
query: contact.completion.EmployeeQuery
|
||||
title: contact.string.Employees,
|
||||
query: contact.completion.EmployeeQuery,
|
||||
context: ['search']
|
||||
},
|
||||
contact.completion.EmployeeCategory
|
||||
)
|
||||
@ -724,7 +731,10 @@ export function createModel (builder: Builder): void {
|
||||
{
|
||||
icon: contact.icon.Persona,
|
||||
label: contact.string.SearchPerson,
|
||||
query: contact.completion.PersonQuery
|
||||
title: contact.string.People,
|
||||
query: contact.completion.PersonQuery,
|
||||
context: ['search', 'mention'],
|
||||
classToSearch: contact.class.Person
|
||||
},
|
||||
contact.completion.PersonCategory
|
||||
)
|
||||
@ -735,7 +745,10 @@ export function createModel (builder: Builder): void {
|
||||
{
|
||||
icon: contact.icon.Company,
|
||||
label: contact.string.SearchOrganization,
|
||||
query: contact.completion.OrganizationQuery
|
||||
title: contact.string.Organizations,
|
||||
query: contact.completion.OrganizationQuery,
|
||||
context: ['search', 'mention'],
|
||||
classToSearch: contact.class.Organization
|
||||
},
|
||||
contact.completion.OrganizationCategory
|
||||
)
|
||||
|
@ -98,7 +98,9 @@ export default mergeIds(contactId, contact, {
|
||||
CurrentEmployee: '' as IntlString,
|
||||
|
||||
ConfigLabel: '' as IntlString,
|
||||
ConfigDescription: '' as IntlString
|
||||
ConfigDescription: '' as IntlString,
|
||||
Employees: '' as IntlString,
|
||||
People: '' as IntlString
|
||||
},
|
||||
completion: {
|
||||
PersonQuery: '' as Resource<ObjectSearchFactory>,
|
||||
|
@ -26,6 +26,7 @@ import {
|
||||
DocRules,
|
||||
DocCreateExtension,
|
||||
DocCreateFunction,
|
||||
ObjectSearchContext,
|
||||
ObjectSearchCategory,
|
||||
ObjectSearchFactory
|
||||
} from '@hcengineering/presentation/src/types'
|
||||
@ -40,9 +41,12 @@ export { CreateExtensionKind, DocCreateExtension, DocCreateFunction, ObjectSearc
|
||||
export class TObjectSearchCategory extends TDoc implements ObjectSearchCategory {
|
||||
label!: IntlString
|
||||
icon!: Asset
|
||||
title!: IntlString
|
||||
context!: ObjectSearchContext[]
|
||||
|
||||
// Query for documents with pattern
|
||||
query!: Resource<ObjectSearchFactory>
|
||||
classToSearch!: Ref<Class<Doc>>
|
||||
}
|
||||
|
||||
@Model(presentation.class.PresentationMiddlewareFactory, core.class.Doc, DOMAIN_MODEL)
|
||||
|
@ -1198,7 +1198,10 @@ export function createModel (builder: Builder): void {
|
||||
{
|
||||
icon: recruit.icon.Application,
|
||||
label: recruit.string.SearchApplication,
|
||||
query: recruit.completion.ApplicationQuery
|
||||
title: recruit.string.Applications,
|
||||
query: recruit.completion.ApplicationQuery,
|
||||
context: ['search', 'mention'],
|
||||
classToSearch: recruit.class.Applicant
|
||||
},
|
||||
recruit.completion.ApplicationCategory
|
||||
)
|
||||
@ -1209,7 +1212,10 @@ export function createModel (builder: Builder): void {
|
||||
{
|
||||
icon: recruit.icon.Vacancy,
|
||||
label: recruit.string.SearchVacancy,
|
||||
query: recruit.completion.VacancyQuery
|
||||
title: recruit.string.Vacancies,
|
||||
query: recruit.completion.VacancyQuery,
|
||||
context: ['search', 'mention'],
|
||||
classToSearch: recruit.class.Vacancy
|
||||
},
|
||||
recruit.completion.VacancyCategory
|
||||
)
|
||||
|
@ -40,6 +40,17 @@ export function createModel (builder: Builder): void {
|
||||
presenter: serverContact.function.OrganizationTextPresenter
|
||||
})
|
||||
|
||||
builder.mixin(contact.class.Contact, core.class.Class, serverCore.mixin.SearchPresenter, {
|
||||
searchConfig: {
|
||||
iconConfig: {
|
||||
component: contact.component.Avatar,
|
||||
props: ['avatar', 'name']
|
||||
},
|
||||
title: { props: ['name'] }
|
||||
},
|
||||
getSearchTitle: serverContact.function.ContactNameProvider
|
||||
})
|
||||
|
||||
builder.createDoc(serverCore.class.Trigger, core.space.Model, {
|
||||
trigger: serverContact.trigger.OnContactDelete,
|
||||
txMatch: {
|
||||
|
@ -14,7 +14,7 @@
|
||||
// limitations under the License.
|
||||
//
|
||||
|
||||
import { Builder, Model } from '@hcengineering/model'
|
||||
import { Builder, Model, Mixin } from '@hcengineering/model'
|
||||
import { TClass, TDoc } from '@hcengineering/model-core'
|
||||
import type { Resource } from '@hcengineering/platform'
|
||||
|
||||
@ -28,7 +28,14 @@ import core, {
|
||||
Hierarchy,
|
||||
Ref
|
||||
} from '@hcengineering/core'
|
||||
import type { ObjectDDParticipant, Trigger, TriggerFunc } from '@hcengineering/server-core'
|
||||
import type {
|
||||
ObjectDDParticipant,
|
||||
Trigger,
|
||||
TriggerFunc,
|
||||
SearchPresenter,
|
||||
SearchPresenterFunc,
|
||||
ClassSearchConfig
|
||||
} from '@hcengineering/server-core'
|
||||
import serverCore from '@hcengineering/server-core'
|
||||
|
||||
export { serverCoreId } from '@hcengineering/server-core'
|
||||
@ -53,6 +60,13 @@ export class TObjectDDParticipant extends TClass implements ObjectDDParticipant
|
||||
>
|
||||
}
|
||||
|
||||
export function createModel (builder: Builder): void {
|
||||
builder.createModel(TTrigger, TObjectDDParticipant)
|
||||
@Mixin(serverCore.mixin.SearchPresenter, core.class.Class)
|
||||
export class TSearchPresenter extends TClass implements SearchPresenter {
|
||||
searchConfig!: ClassSearchConfig
|
||||
getSearchObjectId!: Resource<SearchPresenterFunc>
|
||||
getSearchTitle!: Resource<SearchPresenterFunc>
|
||||
}
|
||||
|
||||
export function createModel (builder: Builder): void {
|
||||
builder.createModel(TTrigger, TObjectDDParticipant, TSearchPresenter)
|
||||
}
|
||||
|
@ -28,6 +28,8 @@
|
||||
"@hcengineering/model": "^0.6.6",
|
||||
"@hcengineering/platform": "^0.6.9",
|
||||
"@hcengineering/server-recruit": "^0.6.0",
|
||||
"@hcengineering/server-contact": "^0.6.1",
|
||||
"@hcengineering/contact": "^0.6.19",
|
||||
"@hcengineering/server-core": "^0.6.1",
|
||||
"@hcengineering/model-recruit": "^0.6.0",
|
||||
"@hcengineering/notification": "^0.6.15",
|
||||
|
@ -21,6 +21,8 @@ import notification from '@hcengineering/notification'
|
||||
import serverCore from '@hcengineering/server-core'
|
||||
import serverNotification from '@hcengineering/server-notification'
|
||||
import serverRecruit from '@hcengineering/server-recruit'
|
||||
import serverContact from '@hcengineering/server-contact'
|
||||
import contact from '@hcengineering/contact'
|
||||
|
||||
export { serverRecruitId } from '@hcengineering/server-recruit'
|
||||
|
||||
@ -45,6 +47,30 @@ export function createModel (builder: Builder): void {
|
||||
trigger: serverRecruit.trigger.OnRecruitUpdate
|
||||
})
|
||||
|
||||
builder.mixin(recruit.class.Vacancy, core.class.Class, serverCore.mixin.SearchPresenter, {
|
||||
searchConfig: {
|
||||
icon: recruit.icon.Vacancy,
|
||||
title: 'name'
|
||||
}
|
||||
})
|
||||
|
||||
builder.mixin(recruit.class.Applicant, core.class.Class, serverCore.mixin.SearchPresenter, {
|
||||
searchConfig: {
|
||||
iconConfig: {
|
||||
component: contact.component.Avatar,
|
||||
props: [{ avatar: ['attachedTo', 'avatar'] }, { name: ['attachedTo', 'name'] }]
|
||||
},
|
||||
shortTitle: {
|
||||
tmpl: 'APP-{number}',
|
||||
props: ['number']
|
||||
},
|
||||
title: {
|
||||
props: [{ _class: ['attachedTo', '_class'] }, { name: ['attachedTo', 'name'] }]
|
||||
}
|
||||
},
|
||||
getSearchTitle: serverContact.function.ContactNameProvider
|
||||
})
|
||||
|
||||
builder.mixin(
|
||||
recruit.ids.AssigneeNotification,
|
||||
notification.class.NotificationType,
|
||||
|
@ -36,6 +36,20 @@ export function createModel (builder: Builder): void {
|
||||
presenter: serverTracker.function.IssueNotificationContentProvider
|
||||
})
|
||||
|
||||
builder.mixin(tracker.class.Issue, core.class.Class, serverCore.mixin.SearchPresenter, {
|
||||
searchConfig: {
|
||||
iconConfig: {
|
||||
component: tracker.component.IssueSearchIcon,
|
||||
props: ['status', 'space']
|
||||
},
|
||||
shortTitle: {
|
||||
tmpl: '{identifier}-{number}',
|
||||
props: [{ identifier: ['space', 'identifier'] }, 'number']
|
||||
},
|
||||
title: 'title'
|
||||
}
|
||||
})
|
||||
|
||||
builder.createDoc(serverCore.class.Trigger, core.space.Model, {
|
||||
trigger: serverTracker.trigger.OnIssueUpdate
|
||||
})
|
||||
|
@ -505,7 +505,10 @@ export function createModel (builder: Builder): void {
|
||||
{
|
||||
icon: tracker.icon.TrackerApplication,
|
||||
label: tracker.string.SearchIssue,
|
||||
query: tracker.completion.IssueQuery
|
||||
title: tracker.string.Issues,
|
||||
query: tracker.completion.IssueQuery,
|
||||
context: ['search', 'mention'],
|
||||
classToSearch: tracker.class.Issue
|
||||
},
|
||||
tracker.completion.IssueCategory
|
||||
)
|
||||
|
@ -57,7 +57,8 @@ export default mergeIds(trackerId, tracker, {
|
||||
NotificationIssuePresenter: '' as AnyComponent,
|
||||
MilestoneFilter: '' as AnyComponent,
|
||||
EditRelatedTargets: '' as AnyComponent,
|
||||
EditRelatedTargetsPopup: '' as AnyComponent
|
||||
EditRelatedTargetsPopup: '' as AnyComponent,
|
||||
IssueSearchIcon: '' as AnyComponent
|
||||
},
|
||||
app: {
|
||||
Tracker: '' as Ref<Application>
|
||||
|
@ -21,7 +21,7 @@ import core from '../component'
|
||||
import { Hierarchy } from '../hierarchy'
|
||||
import { ModelDb, TxDb } from '../memdb'
|
||||
import { TxOperations } from '../operations'
|
||||
import type { DocumentQuery, FindResult, TxResult } from '../storage'
|
||||
import type { DocumentQuery, FindResult, TxResult, SearchQuery, SearchOptions, SearchResult } from '../storage'
|
||||
import { Tx, TxFactory, TxProcessor } from '../tx'
|
||||
import { connect } from './connection'
|
||||
import { genMinModel } from './minmodel'
|
||||
@ -93,6 +93,11 @@ describe('client', () => {
|
||||
|
||||
return {
|
||||
findAll,
|
||||
|
||||
searchFulltext: async (query: SearchQuery, options: SearchOptions): Promise<SearchResult> => {
|
||||
return { docs: [] }
|
||||
},
|
||||
|
||||
tx: async (tx: Tx): Promise<TxResult> => {
|
||||
if (tx.objectSpace === core.space.Model) {
|
||||
hierarchy.tx(tx)
|
||||
|
@ -18,7 +18,7 @@ import { ClientConnection } from '../client'
|
||||
import core from '../component'
|
||||
import { Hierarchy } from '../hierarchy'
|
||||
import { ModelDb, TxDb } from '../memdb'
|
||||
import type { DocumentQuery, FindResult, TxResult } from '../storage'
|
||||
import type { DocumentQuery, FindResult, TxResult, SearchQuery, SearchOptions, SearchResult } from '../storage'
|
||||
import type { Tx } from '../tx'
|
||||
import { DOMAIN_TX } from '../tx'
|
||||
import { genMinModel } from './minmodel'
|
||||
@ -44,6 +44,11 @@ export async function connect (handler: (tx: Tx) => void): Promise<ClientConnect
|
||||
|
||||
return {
|
||||
findAll,
|
||||
|
||||
searchFulltext: async (query: SearchQuery, options: SearchOptions): Promise<SearchResult> => {
|
||||
return { docs: [] }
|
||||
},
|
||||
|
||||
tx: async (tx: Tx): Promise<TxResult> => {
|
||||
if (tx.objectSpace === core.space.Model) {
|
||||
hierarchy.tx(tx)
|
||||
|
@ -19,7 +19,15 @@ import core from '../component'
|
||||
import { Hierarchy } from '../hierarchy'
|
||||
import { ModelDb, TxDb } from '../memdb'
|
||||
import { TxOperations } from '../operations'
|
||||
import { DocumentQuery, FindOptions, SortingOrder, WithLookup } from '../storage'
|
||||
import {
|
||||
DocumentQuery,
|
||||
FindOptions,
|
||||
SortingOrder,
|
||||
WithLookup,
|
||||
SearchQuery,
|
||||
SearchOptions,
|
||||
SearchResult
|
||||
} from '../storage'
|
||||
import { Tx } from '../tx'
|
||||
import { genMinModel, test, TestMixin } from './minmodel'
|
||||
|
||||
@ -44,6 +52,10 @@ class ClientModel extends ModelDb implements Client {
|
||||
return (await this.findAll(_class, query, options)).shift()
|
||||
}
|
||||
|
||||
async searchFulltext (query: SearchQuery, options: SearchOptions): Promise<SearchResult> {
|
||||
return { docs: [] }
|
||||
}
|
||||
|
||||
async close (): Promise<void> {}
|
||||
}
|
||||
|
||||
|
@ -19,8 +19,8 @@ import { Account, AttachedDoc, Class, DOMAIN_MODEL, Doc, Domain, PluginConfigura
|
||||
import core from './component'
|
||||
import { Hierarchy } from './hierarchy'
|
||||
import { ModelDb } from './memdb'
|
||||
import type { DocumentQuery, FindOptions, FindResult, Storage, TxResult, WithLookup } from './storage'
|
||||
import { SortingOrder } from './storage'
|
||||
import type { DocumentQuery, FindOptions, FindResult, Storage, FulltextStorage, TxResult, WithLookup } from './storage'
|
||||
import { SortingOrder, SearchQuery, SearchOptions, SearchResult } from './storage'
|
||||
import { Tx, TxCUD, TxCollectionCUD, TxCreateDoc, TxProcessor, TxUpdateDoc } from './tx'
|
||||
import { toFindResult } from './utils'
|
||||
|
||||
@ -34,7 +34,7 @@ export type TxHandler = (tx: Tx) => void
|
||||
/**
|
||||
* @public
|
||||
*/
|
||||
export interface Client extends Storage {
|
||||
export interface Client extends Storage, FulltextStorage {
|
||||
notify?: (tx: Tx) => void
|
||||
getHierarchy: () => Hierarchy
|
||||
getModel: () => ModelDb
|
||||
@ -79,7 +79,7 @@ export enum ClientConnectEvent {
|
||||
/**
|
||||
* @public
|
||||
*/
|
||||
export interface ClientConnection extends Storage, BackupClient {
|
||||
export interface ClientConnection extends Storage, FulltextStorage, BackupClient {
|
||||
close: () => Promise<void>
|
||||
onConnect?: (event: ClientConnectEvent) => Promise<void>
|
||||
|
||||
@ -127,6 +127,10 @@ class ClientImpl implements AccountClient, BackupClient {
|
||||
return toFindResult(result, data.total)
|
||||
}
|
||||
|
||||
async searchFulltext (query: SearchQuery, options: SearchOptions): Promise<SearchResult> {
|
||||
return await this.conn.searchFulltext(query, options)
|
||||
}
|
||||
|
||||
async findOne<T extends Doc>(
|
||||
_class: Ref<Class<T>>,
|
||||
query: DocumentQuery<T>,
|
||||
|
@ -15,7 +15,16 @@ import type {
|
||||
} from './classes'
|
||||
import { Client } from './client'
|
||||
import core from './component'
|
||||
import type { DocumentQuery, FindOptions, FindResult, TxResult, WithLookup } from './storage'
|
||||
import type {
|
||||
DocumentQuery,
|
||||
FindOptions,
|
||||
FindResult,
|
||||
SearchQuery,
|
||||
SearchOptions,
|
||||
SearchResult,
|
||||
TxResult,
|
||||
WithLookup
|
||||
} from './storage'
|
||||
import { DocumentClassQuery, Tx, TxCUD, TxFactory, TxProcessor } from './tx'
|
||||
|
||||
/**
|
||||
@ -60,6 +69,10 @@ export class TxOperations implements Omit<Client, 'notify'> {
|
||||
return this.client.findOne(_class, query, options)
|
||||
}
|
||||
|
||||
searchFulltext (query: SearchQuery, options: SearchOptions): Promise<SearchResult> {
|
||||
return this.client.searchFulltext(query, options)
|
||||
}
|
||||
|
||||
tx (tx: Tx): Promise<TxResult> {
|
||||
return this.client.tx(tx)
|
||||
}
|
||||
@ -421,6 +434,7 @@ export class ApplyOperations extends TxOperations {
|
||||
close: () => ops.client.close(),
|
||||
findOne: (_class, query, options?) => ops.client.findOne(_class, query, options),
|
||||
findAll: (_class, query, options?) => ops.client.findAll(_class, query, options),
|
||||
searchFulltext: (query, options) => ops.client.searchFulltext(query, options),
|
||||
tx: async (tx): Promise<TxResult> => {
|
||||
if (ops.getHierarchy().isDerived(tx._class, core.class.TxCUD)) {
|
||||
this.txes.push(tx as TxCUD<Doc>)
|
||||
@ -474,6 +488,7 @@ export class TxBuilder extends TxOperations {
|
||||
close: async () => {},
|
||||
findOne: async (_class, query, options?) => undefined,
|
||||
findAll: async (_class, query, options?) => toFindResult([]),
|
||||
searchFulltext: async (query, options) => ({ docs: [] }),
|
||||
tx: async (tx): Promise<TxResult> => {
|
||||
if (this.hierarchy.isDerived(tx._class, core.class.TxCUD)) {
|
||||
this.txes.push(tx as TxCUD<Doc>)
|
||||
|
@ -17,7 +17,15 @@ import { MeasureContext } from './measurements'
|
||||
import type { Doc, Class, Ref, Domain, Timestamp } from './classes'
|
||||
import { Hierarchy } from './hierarchy'
|
||||
import { ModelDb } from './memdb'
|
||||
import type { DocumentQuery, FindOptions, FindResult, TxResult } from './storage'
|
||||
import type {
|
||||
DocumentQuery,
|
||||
FindOptions,
|
||||
FindResult,
|
||||
TxResult,
|
||||
SearchQuery,
|
||||
SearchOptions,
|
||||
SearchResult
|
||||
} from './storage'
|
||||
import type { Tx } from './tx'
|
||||
import { LoadModelResponse } from '.'
|
||||
|
||||
@ -66,6 +74,7 @@ export interface ServerStorage extends LowLevelStorage {
|
||||
query: DocumentQuery<T>,
|
||||
options?: FindOptions<T>
|
||||
) => Promise<FindResult<T>>
|
||||
searchFulltext: (ctx: MeasureContext, query: SearchQuery, options: SearchOptions) => Promise<SearchResult>
|
||||
tx: (ctx: MeasureContext, tx: Tx) => Promise<[TxResult, Tx[]]>
|
||||
apply: (ctx: MeasureContext, tx: Tx[], broadcast: boolean) => Promise<Tx[]>
|
||||
close: () => Promise<void>
|
||||
|
@ -13,8 +13,10 @@
|
||||
// limitations under the License.
|
||||
//
|
||||
|
||||
import type { Asset } from '@hcengineering/platform'
|
||||
|
||||
import type { KeysByType } from 'simplytyped'
|
||||
import type { AttachedDoc, Class, Doc, Ref } from './classes'
|
||||
import type { AttachedDoc, Class, Doc, Ref, Space } from './classes'
|
||||
import type { Tx } from './tx'
|
||||
|
||||
/**
|
||||
@ -208,6 +210,43 @@ export type FindResult<T extends Doc> = WithLookup<T>[] & {
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-interface
|
||||
export interface TxResult {}
|
||||
|
||||
/**
|
||||
* @public
|
||||
*/
|
||||
export interface SearchQuery {
|
||||
query: string
|
||||
classes?: Ref<Class<Doc>>[]
|
||||
spaces?: Ref<Space>[]
|
||||
}
|
||||
|
||||
/**
|
||||
* @public
|
||||
*/
|
||||
export interface SearchOptions {
|
||||
limit?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* @public
|
||||
*/
|
||||
export interface SearchResultDoc {
|
||||
id: Ref<Doc>
|
||||
icon?: Asset
|
||||
iconComponent?: string
|
||||
iconProps?: { [key: string]: string }
|
||||
shortTitle?: string
|
||||
title?: string
|
||||
doc: Pick<Doc, '_id' | '_class'>
|
||||
}
|
||||
|
||||
/**
|
||||
* @public
|
||||
*/
|
||||
export interface SearchResult {
|
||||
docs: SearchResultDoc[]
|
||||
total?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* @public
|
||||
*/
|
||||
@ -217,5 +256,13 @@ export interface Storage {
|
||||
query: DocumentQuery<T>,
|
||||
options?: FindOptions<T>
|
||||
) => Promise<FindResult<T>>
|
||||
|
||||
tx: (tx: Tx) => Promise<TxResult>
|
||||
}
|
||||
|
||||
/**
|
||||
* @public
|
||||
*/
|
||||
export interface FulltextStorage {
|
||||
searchFulltext: (query: SearchQuery, options: SearchOptions) => Promise<SearchResult>
|
||||
}
|
||||
|
@ -16,8 +16,8 @@
|
||||
import { Account, AnyAttribute, Class, Doc, DocData, DocIndexState, IndexKind, Obj, Ref, Space } from './classes'
|
||||
import core from './component'
|
||||
import { Hierarchy } from './hierarchy'
|
||||
import { DocumentQuery, FindResult } from './storage'
|
||||
import { isPredicate } from './predicate'
|
||||
import { FindResult, DocumentQuery } from './storage'
|
||||
|
||||
function toHex (value: number, chars: number): string {
|
||||
const result = value.toString(16)
|
||||
@ -116,6 +116,8 @@ export interface IndexKeyOptions {
|
||||
_class?: Ref<Class<Obj>>
|
||||
docId?: Ref<DocIndexState>
|
||||
extra?: string[]
|
||||
relative?: boolean
|
||||
refAttribute?: string
|
||||
}
|
||||
/**
|
||||
* @public
|
||||
@ -129,10 +131,16 @@ export function docUpdKey (name: string, opt?: IndexKeyOptions): string {
|
||||
*/
|
||||
export function docKey (name: string, opt?: IndexKeyOptions): string {
|
||||
const extra = opt?.extra !== undefined && opt?.extra?.length > 0 ? `#${opt.extra?.join('#') ?? ''}` : ''
|
||||
return (
|
||||
let key =
|
||||
(opt?.docId !== undefined ? opt.docId.split('.').join('_') + '|' : '') +
|
||||
(opt?._class === undefined ? name : `${opt?._class}%${name}${extra}`)
|
||||
)
|
||||
if (opt?.refAttribute !== undefined) {
|
||||
key = `${opt?.refAttribute}->${key}`
|
||||
}
|
||||
if (opt?.refAttribute !== undefined || (opt?.relative !== undefined && opt?.relative)) {
|
||||
key = '|' + key
|
||||
}
|
||||
return key
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -27,6 +27,7 @@
|
||||
"DocumentPreview": "Preview",
|
||||
"MakePrivate": "Make private",
|
||||
"MakePrivateDescription": "Only members can see it",
|
||||
"Created": "Created"
|
||||
"Created": "Created",
|
||||
"NoResults": "No results to show"
|
||||
}
|
||||
}
|
||||
|
@ -27,6 +27,7 @@
|
||||
"DocumentPreview": "Предпросмотр",
|
||||
"MakePrivate": "Сделать личным",
|
||||
"MakePrivateDescription": "Только пользователи могут видеть это",
|
||||
"Created": "Созданные"
|
||||
"Created": "Созданные",
|
||||
"NoResults": "Нет результатов"
|
||||
}
|
||||
}
|
||||
|
@ -14,7 +14,7 @@
|
||||
// limitations under the License.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { Ref, RelatedDocument } from '@hcengineering/core'
|
||||
import { Ref, RelatedDocument, DocumentQuery } from '@hcengineering/core'
|
||||
|
||||
import { getResource, IntlString } from '@hcengineering/platform'
|
||||
import ui, {
|
||||
@ -49,15 +49,17 @@
|
||||
const client = getClient()
|
||||
|
||||
let category: ObjectSearchCategory | undefined
|
||||
client
|
||||
.findAll(
|
||||
presentation.class.ObjectSearchCategory,
|
||||
allowCategory !== undefined ? { _id: { $in: allowCategory } } : {}
|
||||
)
|
||||
.then((r) => {
|
||||
categories = r.filter((it) => hasResource(it.query))
|
||||
category = categories[0]
|
||||
})
|
||||
const categoryQuery: DocumentQuery<ObjectSearchCategory> = {
|
||||
context: 'search'
|
||||
}
|
||||
if (allowCategory !== undefined) {
|
||||
categoryQuery._id = { $in: allowCategory }
|
||||
}
|
||||
|
||||
client.findAll(presentation.class.ObjectSearchCategory, categoryQuery).then((r) => {
|
||||
categories = r.filter((it) => hasResource(it.query))
|
||||
category = categories[0]
|
||||
})
|
||||
|
||||
const dispatch = createEventDispatcher()
|
||||
|
||||
|
@ -11,7 +11,10 @@ import {
|
||||
Tx,
|
||||
TxResult,
|
||||
WithLookup,
|
||||
toFindResult
|
||||
toFindResult,
|
||||
SearchQuery,
|
||||
SearchOptions,
|
||||
SearchResult
|
||||
} from '@hcengineering/core'
|
||||
import { Resource } from '@hcengineering/platform'
|
||||
|
||||
@ -108,6 +111,10 @@ export class PresentationPipelineImpl implements PresentationPipeline {
|
||||
: await this.client.findAll(_class, query, options)
|
||||
}
|
||||
|
||||
async searchFulltext (query: SearchQuery, options: SearchOptions): Promise<SearchResult> {
|
||||
return await this.client.searchFulltext(query, options)
|
||||
}
|
||||
|
||||
async findOne<T extends Doc>(
|
||||
_class: Ref<Class<T>>,
|
||||
query: DocumentQuery<T>,
|
||||
|
@ -63,7 +63,8 @@ export default plugin(presentationId, {
|
||||
MakePrivate: '' as IntlString,
|
||||
MakePrivateDescription: '' as IntlString,
|
||||
OpenInANewTab: '' as IntlString,
|
||||
Created: '' as IntlString
|
||||
Created: '' as IntlString,
|
||||
NoResults: '' as IntlString
|
||||
},
|
||||
metadata: {
|
||||
RequiredVersion: '' as Metadata<string>,
|
||||
|
@ -45,18 +45,31 @@ export interface ObjectCreate {
|
||||
export type ObjectSearchFactory = (
|
||||
client: Client,
|
||||
query: string,
|
||||
filter?: { in?: RelatedDocument[], nin?: RelatedDocument[] }
|
||||
options?: {
|
||||
in?: RelatedDocument[]
|
||||
nin?: RelatedDocument[]
|
||||
}
|
||||
) => Promise<ObjectSearchResult[]>
|
||||
|
||||
/**
|
||||
* @public
|
||||
* search - show in search popup
|
||||
* mention - show in mentions
|
||||
*/
|
||||
export type ObjectSearchContext = 'search' | 'mention'
|
||||
|
||||
/**
|
||||
* @public
|
||||
*/
|
||||
export interface ObjectSearchCategory extends Doc {
|
||||
label: IntlString
|
||||
icon: Asset
|
||||
title: IntlString
|
||||
context: ObjectSearchContext[]
|
||||
|
||||
// Query for documents with pattern
|
||||
query: Resource<ObjectSearchFactory>
|
||||
classToSearch?: Ref<Class<Doc>>
|
||||
}
|
||||
|
||||
export interface ComponentExt {
|
||||
|
@ -34,7 +34,10 @@ import core, {
|
||||
Tx,
|
||||
TxOperations,
|
||||
TxResult,
|
||||
WithLookup
|
||||
WithLookup,
|
||||
SearchQuery,
|
||||
SearchOptions,
|
||||
SearchResult
|
||||
} from '@hcengineering/core'
|
||||
import { getMetadata, getResource } from '@hcengineering/platform'
|
||||
import { LiveQuery as LQ } from '@hcengineering/query'
|
||||
@ -93,6 +96,10 @@ class UIClient extends TxOperations implements Client {
|
||||
override async tx (tx: Tx): Promise<TxResult> {
|
||||
return await this.client.tx(tx)
|
||||
}
|
||||
|
||||
async searchFulltext (query: SearchQuery, options: SearchOptions): Promise<SearchResult> {
|
||||
return await this.client.searchFulltext(query, options)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -26,14 +26,19 @@ import type {
|
||||
Ref,
|
||||
Timestamp,
|
||||
Tx,
|
||||
TxResult
|
||||
TxResult,
|
||||
FulltextStorage,
|
||||
SearchQuery,
|
||||
SearchOptions,
|
||||
SearchResult
|
||||
} from '@hcengineering/core'
|
||||
import core, { DOMAIN_TX, Hierarchy, ModelDb, TxDb } from '@hcengineering/core'
|
||||
import { genMinModel } from './minmodel'
|
||||
|
||||
export async function connect (handler: (tx: Tx) => void): Promise<
|
||||
AccountClient &
|
||||
BackupClient & {
|
||||
BackupClient &
|
||||
FulltextStorage & {
|
||||
loadModel: (last: Timestamp, hash?: string) => Promise<Tx[] | LoadModelResponse>
|
||||
}
|
||||
> {
|
||||
@ -86,6 +91,10 @@ BackupClient & {
|
||||
closeChunk: async (idx: number) => {},
|
||||
loadDocs: async (domain: Domain, docs: Ref<Doc>[]) => [],
|
||||
upload: async (domain: Domain, docs: Doc[]) => {},
|
||||
clean: async (domain: Domain, docs: Ref<Doc>[]) => {}
|
||||
clean: async (domain: Domain, docs: Ref<Doc>[]) => {},
|
||||
|
||||
searchFulltext: async (query: SearchQuery, options: SearchOptions): Promise<SearchResult> => {
|
||||
return { docs: [] }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -48,7 +48,10 @@ import core, {
|
||||
TxUpdateDoc,
|
||||
TxWorkspaceEvent,
|
||||
WithLookup,
|
||||
WorkspaceEvent
|
||||
WorkspaceEvent,
|
||||
SearchQuery,
|
||||
SearchOptions,
|
||||
SearchResult
|
||||
} from '@hcengineering/core'
|
||||
import { deepEqual } from 'fast-equals'
|
||||
|
||||
@ -178,6 +181,10 @@ export class LiveQuery extends TxProcessor implements Client {
|
||||
return toFindResult(this.clone(q.result), q.total) as FindResult<T>
|
||||
}
|
||||
|
||||
searchFulltext (query: SearchQuery, options: SearchOptions): Promise<SearchResult> {
|
||||
return this.client.searchFulltext(query, options)
|
||||
}
|
||||
|
||||
async findOne<T extends Doc>(
|
||||
_class: Ref<Class<T>>,
|
||||
query: DocumentQuery<T>,
|
||||
|
@ -1,9 +1,9 @@
|
||||
import { Node, mergeAttributes } from '@tiptap/core'
|
||||
import Suggestion, { SuggestionOptions } from '@tiptap/suggestion'
|
||||
|
||||
import { Plugin, PluginKey } from '@tiptap/pm/state'
|
||||
import { getDataAttribute } from './utils'
|
||||
|
||||
import Suggestion, { SuggestionOptions } from './components/extension/suggestion'
|
||||
|
||||
export interface CompletionOptions {
|
||||
HTMLAttributes: Record<string, any>
|
||||
renderLabel: (props: { options: CompletionOptions, node: any }) => string
|
||||
@ -47,6 +47,7 @@ export const Completion = Node.create<CompletionOptions>({
|
||||
},
|
||||
suggestion: {
|
||||
char: '@',
|
||||
allowSpaces: true,
|
||||
// pluginKey: CompletionPluginKey,
|
||||
command: ({ editor, range, props }) => {
|
||||
// increase range.to by one when the next node is of type "text"
|
||||
@ -59,20 +60,22 @@ export const Completion = Node.create<CompletionOptions>({
|
||||
range.to += 1
|
||||
}
|
||||
|
||||
editor
|
||||
.chain()
|
||||
.focus()
|
||||
.insertContentAt(range, [
|
||||
{
|
||||
type: this.name,
|
||||
attrs: props
|
||||
},
|
||||
{
|
||||
type: 'text',
|
||||
text: ' '
|
||||
}
|
||||
])
|
||||
.run()
|
||||
if (props !== null) {
|
||||
editor
|
||||
.chain()
|
||||
.focus()
|
||||
.insertContentAt(range, [
|
||||
{
|
||||
type: this.name,
|
||||
attrs: props
|
||||
},
|
||||
{
|
||||
type: 'text',
|
||||
text: ' '
|
||||
}
|
||||
])
|
||||
.run()
|
||||
}
|
||||
},
|
||||
allow: ({ editor, range }) => {
|
||||
if (range.from > editor.state.doc.content.size) return false
|
||||
|
@ -14,9 +14,9 @@
|
||||
// limitations under the License.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { ObjectSearchPopup, ObjectSearchResult } from '@hcengineering/presentation'
|
||||
import { showPopup, resizeObserver, deviceOptionsStore as deviceInfo, PopupResult } from '@hcengineering/ui'
|
||||
import { onDestroy, onMount } from 'svelte'
|
||||
import MentionPopup from './MentionPopup.svelte'
|
||||
import DummyPopup from './DummyPopup.svelte'
|
||||
|
||||
export let query: string = ''
|
||||
@ -32,7 +32,10 @@
|
||||
DummyPopup,
|
||||
{},
|
||||
undefined,
|
||||
() => close(),
|
||||
() => {
|
||||
close()
|
||||
command(null)
|
||||
},
|
||||
() => {},
|
||||
{ overlay: false, category: '' }
|
||||
)
|
||||
@ -42,15 +45,15 @@
|
||||
dummyPopup.close()
|
||||
})
|
||||
|
||||
function dispatchItem (item: ObjectSearchResult): void {
|
||||
function dispatchItem (item: { id: string; label: string; objectclass: string }): void {
|
||||
if (item == null) {
|
||||
close()
|
||||
} else {
|
||||
command({ id: item.doc._id, label: item.title, objectclass: item.doc._class })
|
||||
command(item)
|
||||
}
|
||||
}
|
||||
|
||||
let searchPopup: ObjectSearchPopup
|
||||
let searchPopup: MentionPopup
|
||||
|
||||
export function onKeyDown (ev: KeyboardEvent): boolean {
|
||||
return searchPopup?.onKeyDown(ev)
|
||||
@ -97,7 +100,7 @@
|
||||
updateStyle()
|
||||
}}
|
||||
>
|
||||
<ObjectSearchPopup bind:this={searchPopup} {query} on:close={(evt) => dispatchItem(evt.detail)} />
|
||||
<MentionPopup bind:this={searchPopup} {query} on:close={(evt) => dispatchItem(evt.detail)} />
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
|
217
packages/text-editor/src/components/MentionPopup.svelte
Normal file
217
packages/text-editor/src/components/MentionPopup.svelte
Normal file
@ -0,0 +1,217 @@
|
||||
<!--
|
||||
// Copyright © 2020, 2021 Anticrm Platform Contributors.
|
||||
// Copyright © 2021 Hardcore Engineering Inc.
|
||||
//
|
||||
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License. You may
|
||||
// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
//
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { createFocusManager, FocusHandler, Label, ListView, resizeObserver } from '@hcengineering/ui'
|
||||
import { createEventDispatcher } from 'svelte'
|
||||
import presentation, { getClient, ObjectSearchCategory } from '@hcengineering/presentation'
|
||||
|
||||
import { Class, Ref, Doc, SearchResultDoc } from '@hcengineering/core'
|
||||
import MentionResult from './MentionResult.svelte'
|
||||
|
||||
export let query: string = ''
|
||||
|
||||
type SearchSection = { category: ObjectSearchCategory; items: SearchResultDoc[] }
|
||||
type SearchItem = {
|
||||
num: number
|
||||
item: SearchResultDoc
|
||||
category: ObjectSearchCategory
|
||||
}
|
||||
|
||||
let items: SearchItem[] = []
|
||||
let categories: ObjectSearchCategory[] = []
|
||||
|
||||
const client = getClient()
|
||||
|
||||
client.findAll(presentation.class.ObjectSearchCategory, { context: 'mention' }).then(async (results) => {
|
||||
categories = results
|
||||
updateItems(query)
|
||||
})
|
||||
|
||||
const dispatch = createEventDispatcher()
|
||||
|
||||
let list: ListView
|
||||
let scrollContainer: HTMLElement
|
||||
let selection = 0
|
||||
|
||||
function dispatchItem (item: SearchResultDoc): void {
|
||||
dispatch('close', {
|
||||
id: item.id,
|
||||
label: item.shortTitle ?? item.title,
|
||||
objectclass: item.doc._class
|
||||
})
|
||||
}
|
||||
|
||||
export function onKeyDown (key: KeyboardEvent): boolean {
|
||||
if (key.key === 'ArrowDown') {
|
||||
key.stopPropagation()
|
||||
key.preventDefault()
|
||||
list?.select(selection + 1)
|
||||
return true
|
||||
}
|
||||
if (key.key === 'ArrowUp') {
|
||||
key.stopPropagation()
|
||||
key.preventDefault()
|
||||
if (selection === 0 && scrollContainer !== undefined) {
|
||||
scrollContainer.scrollTop = 0
|
||||
}
|
||||
list?.select(selection - 1)
|
||||
return true
|
||||
}
|
||||
if (key.key === 'Enter') {
|
||||
key.preventDefault()
|
||||
key.stopPropagation()
|
||||
const searchItem = items[selection]
|
||||
if (searchItem) {
|
||||
dispatchItem(searchItem.item)
|
||||
return true
|
||||
} else {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
export function done () {}
|
||||
|
||||
function packSearchResultsForListView (sections: SearchSection[]): SearchItem[] {
|
||||
let results: SearchItem[] = []
|
||||
for (const section of sections) {
|
||||
const category = section.category
|
||||
const items = section.items
|
||||
|
||||
if (category.classToSearch !== undefined) {
|
||||
results = results.concat(
|
||||
items.map((item, num) => {
|
||||
return { num, category, item }
|
||||
})
|
||||
)
|
||||
}
|
||||
}
|
||||
return results
|
||||
}
|
||||
|
||||
function findCategoryByClass (
|
||||
categories: ObjectSearchCategory[],
|
||||
_class: Ref<Class<Doc>>
|
||||
): ObjectSearchCategory | undefined {
|
||||
for (const category of categories) {
|
||||
if (category.classToSearch === _class) {
|
||||
return category
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
async function doFulltextSearch (classes: Ref<Class<Doc>>[], query: string): Promise<SearchSection[]> {
|
||||
const result = await client.searchFulltext(
|
||||
{
|
||||
query: `${query}*`,
|
||||
classes
|
||||
},
|
||||
{
|
||||
limit: 10
|
||||
}
|
||||
)
|
||||
|
||||
const itemsByClass = new Map<Ref<Class<Doc>>, SearchResultDoc[]>()
|
||||
for (const item of result.docs) {
|
||||
const list = itemsByClass.get(item.doc._class)
|
||||
if (list === undefined) {
|
||||
itemsByClass.set(item.doc._class, [item])
|
||||
} else {
|
||||
list.push(item)
|
||||
}
|
||||
}
|
||||
|
||||
const sections: SearchSection[] = []
|
||||
for (const [_class, items] of itemsByClass.entries()) {
|
||||
const category = findCategoryByClass(categories, _class)
|
||||
if (category !== undefined) {
|
||||
sections.push({ category, items })
|
||||
}
|
||||
}
|
||||
|
||||
return sections
|
||||
}
|
||||
|
||||
async function updateItems (query: string): Promise<void> {
|
||||
const classesToSearch: Ref<Class<Doc>>[] = []
|
||||
for (const cat of categories) {
|
||||
if (cat.classToSearch !== undefined) {
|
||||
classesToSearch.push(cat.classToSearch)
|
||||
}
|
||||
}
|
||||
|
||||
const sections = await doFulltextSearch(classesToSearch, query)
|
||||
items = packSearchResultsForListView(sections)
|
||||
}
|
||||
$: updateItems(query)
|
||||
|
||||
const manager = createFocusManager()
|
||||
</script>
|
||||
|
||||
<FocusHandler {manager} />
|
||||
|
||||
<form class="antiPopup mentionPoup" on:keydown={onKeyDown} use:resizeObserver={() => dispatch('changeSize')}>
|
||||
<div class="ap-scroll" bind:this={scrollContainer}>
|
||||
<div class="ap-box">
|
||||
{#if items.length === 0 && query !== ''}
|
||||
<div class="noResults"><Label label={presentation.string.NoResults} /></div>
|
||||
{/if}
|
||||
<!-- svelte-ignore a11y-click-events-have-key-events -->
|
||||
<ListView bind:this={list} bind:selection count={items.length}>
|
||||
<svelte:fragment slot="category" let:item={num}>
|
||||
{@const item = items[num]}
|
||||
{#if item.num === 0}
|
||||
<div class="mentonCategory">
|
||||
<Label label={item.category.title} />
|
||||
</div>
|
||||
{/if}
|
||||
</svelte:fragment>
|
||||
<svelte:fragment slot="item" let:item={num}>
|
||||
{@const item = items[num]}
|
||||
{@const doc = item.item}
|
||||
<div class="ap-menuItem withComp" on:click={() => dispatchItem(doc)}>
|
||||
<MentionResult value={doc} />
|
||||
</div>
|
||||
</svelte:fragment>
|
||||
</ListView>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ap-space x2" />
|
||||
</form>
|
||||
|
||||
<style lang="scss">
|
||||
.noResults {
|
||||
display: flex;
|
||||
padding: 0.25rem 1rem;
|
||||
align-items: center;
|
||||
align-self: stretch;
|
||||
}
|
||||
|
||||
.mentionPoup {
|
||||
padding-top: 0.5rem;
|
||||
}
|
||||
|
||||
.mentonCategory {
|
||||
padding: 0.5rem 1rem;
|
||||
font-size: 0.625rem;
|
||||
letter-spacing: 0.0625rem;
|
||||
color: var(--theme-dark-color);
|
||||
text-transform: uppercase;
|
||||
line-height: 1rem;
|
||||
}
|
||||
</style>
|
61
packages/text-editor/src/components/MentionResult.svelte
Normal file
61
packages/text-editor/src/components/MentionResult.svelte
Normal file
@ -0,0 +1,61 @@
|
||||
<!--
|
||||
// Copyright © 2020, 2021 Anticrm Platform Contributors.
|
||||
// Copyright © 2021 Hardcore Engineering Inc.
|
||||
//
|
||||
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License. You may
|
||||
// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
//
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import type { SearchResultDoc } from '@hcengineering/core'
|
||||
import { Asset, getResource } from '@hcengineering/platform'
|
||||
import { AnyComponent, Icon } from '@hcengineering/ui'
|
||||
|
||||
export let value: SearchResultDoc
|
||||
|
||||
$: iconComponent = value.iconComponent ? (value.iconComponent as AnyComponent) : undefined
|
||||
$: icon = value.icon !== undefined ? (value.icon as Asset) : undefined
|
||||
</script>
|
||||
|
||||
<div class="flex-row-center h-8">
|
||||
<div class="flex-center p-1 content-dark-color flex-no-shrink">
|
||||
{#if icon !== undefined}
|
||||
<Icon {icon} size={'medium'} />
|
||||
{/if}
|
||||
{#if iconComponent}
|
||||
{#await getResource(iconComponent) then component}
|
||||
<svelte:component this={component} size={'smaller'} {...value.iconProps} />
|
||||
{/await}
|
||||
{/if}
|
||||
</div>
|
||||
<span class="ml-2 max-w-120 overflow-label searchResult">
|
||||
{#if value.shortTitle !== undefined}
|
||||
<span class="shortTitle">{value.shortTitle}</span>
|
||||
{/if}
|
||||
<span class="name">{value.title}</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
.searchResult {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
|
||||
.shortTitle {
|
||||
display: flex;
|
||||
padding-right: 0.5rem;
|
||||
color: var(--theme-darker-color);
|
||||
}
|
||||
.name {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
</style>
|
430
packages/text-editor/src/components/extension/suggestion.ts
Normal file
430
packages/text-editor/src/components/extension/suggestion.ts
Normal file
@ -0,0 +1,430 @@
|
||||
import { Editor, Range, escapeForRegEx } from '@tiptap/core'
|
||||
import { EditorState, Plugin, PluginKey, Transaction } from '@tiptap/pm/state'
|
||||
import { ReplaceStep } from '@tiptap/pm/transform'
|
||||
import { Decoration, DecorationSet, EditorView } from '@tiptap/pm/view'
|
||||
|
||||
import { ResolvedPos } from '@tiptap/pm/model'
|
||||
|
||||
export interface Trigger {
|
||||
char: string
|
||||
allowSpaces: boolean
|
||||
allowedPrefixes: string[] | null
|
||||
startOfLine: boolean
|
||||
$position: ResolvedPos
|
||||
}
|
||||
|
||||
export type SuggestionMatch = {
|
||||
range: Range
|
||||
query: string
|
||||
text: string
|
||||
} | null
|
||||
|
||||
function hasChar (tr: Transaction, char = ''): boolean {
|
||||
let isHardStop = false
|
||||
let isChar = false
|
||||
for (const step of tr.steps) {
|
||||
if (step instanceof ReplaceStep) {
|
||||
const slice = step.slice
|
||||
|
||||
slice.content.descendants((node, _pos): boolean => {
|
||||
if (isHardStop) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (node.type.isText && node.text !== undefined && node.text !== '') {
|
||||
if (char !== '') {
|
||||
if (node.text?.includes(char)) {
|
||||
isChar = true
|
||||
isHardStop = true
|
||||
return false
|
||||
}
|
||||
} else {
|
||||
isChar = true
|
||||
isHardStop = true
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return isChar
|
||||
}
|
||||
|
||||
export function findSuggestionMatch (config: Trigger): SuggestionMatch {
|
||||
const { char, allowSpaces, allowedPrefixes, startOfLine, $position } = config
|
||||
|
||||
const escapedChar = escapeForRegEx(char)
|
||||
|
||||
const suffix = new RegExp(`\\s${escapedChar}$`)
|
||||
const prefix = startOfLine ? '^' : ''
|
||||
|
||||
// If allowSpaces: true terminates on at least 2 whitespaces
|
||||
const regexp = allowSpaces
|
||||
? new RegExp(`${prefix}${escapedChar}.*?(?=\\s{2}|$)`, 'gm')
|
||||
: new RegExp(`${prefix}(?:^)?${escapedChar}[^\\s${escapedChar}]*`, 'gm')
|
||||
|
||||
let text
|
||||
if ($position.nodeBefore?.isText !== undefined && $position.nodeBefore?.isText) {
|
||||
text = $position.nodeBefore.text
|
||||
}
|
||||
|
||||
if (text === undefined || text === '') {
|
||||
return null
|
||||
}
|
||||
|
||||
const textFrom = $position.pos - text.length
|
||||
|
||||
const match: any = Array.from(text.matchAll(regexp)).pop()
|
||||
|
||||
if (match === undefined || match === null || match.input === undefined || match.index === undefined) {
|
||||
return null
|
||||
}
|
||||
|
||||
// JavaScript doesn't have lookbehinds. This hacks a check that first character
|
||||
// is a space or the start of the line
|
||||
const matchPrefix = match.input.slice(Math.max(0, match.index - 1), match.index)
|
||||
|
||||
if (allowedPrefixes !== null) {
|
||||
const matchPrefixIsAllowed = new RegExp(`^[${allowedPrefixes.join('')}\0]?$`).test(matchPrefix)
|
||||
if (!matchPrefixIsAllowed) {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/* eslint-disable @typescript-eslint/restrict-plus-operands */
|
||||
// The absolute position of the match in the document
|
||||
const from: number = textFrom + match.index
|
||||
let to: number = from + match[0].length
|
||||
|
||||
// Edge case handling; if spaces are allowed and we're directly in between
|
||||
// two triggers
|
||||
if (allowSpaces && suffix.test(text.slice(to - 1, to + 1))) {
|
||||
match[0] += ' '
|
||||
to += 1
|
||||
}
|
||||
|
||||
// If the $position is located within the matched substring, return that range
|
||||
if (from < $position.pos && to >= $position.pos) {
|
||||
return {
|
||||
range: {
|
||||
from,
|
||||
to
|
||||
},
|
||||
query: match[0].slice(char.length),
|
||||
text: match[0]
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export interface SuggestionOptions<I = any> {
|
||||
pluginKey?: PluginKey
|
||||
editor: Editor
|
||||
char?: string
|
||||
allowSpaces?: boolean
|
||||
allowedPrefixes?: string[] | null
|
||||
startOfLine?: boolean
|
||||
decorationTag?: string
|
||||
decorationClass?: string
|
||||
command?: (props: { editor: Editor, range: Range, props: I }) => void
|
||||
items?: (props: { query: string, editor: Editor }) => I[] | Promise<I[]>
|
||||
render?: () => {
|
||||
onBeforeStart?: (props: SuggestionProps<I>) => void
|
||||
onStart?: (props: SuggestionProps<I>) => void
|
||||
onBeforeUpdate?: (props: SuggestionProps<I>) => void
|
||||
onUpdate?: (props: SuggestionProps<I>) => void
|
||||
onExit?: (props: SuggestionProps<I>) => void
|
||||
onKeyDown?: (props: SuggestionKeyDownProps) => boolean
|
||||
}
|
||||
allow?: (props: { editor: Editor, state: EditorState, range: Range }) => boolean
|
||||
}
|
||||
|
||||
export interface SuggestionProps<I = any> {
|
||||
editor: Editor
|
||||
range: Range
|
||||
query: string
|
||||
text: string
|
||||
items: I[]
|
||||
command: (props: I) => void
|
||||
decorationNode: Element | null
|
||||
clientRect?: (() => DOMRect | null) | null
|
||||
}
|
||||
|
||||
export interface SuggestionKeyDownProps {
|
||||
view: EditorView
|
||||
event: KeyboardEvent
|
||||
range: Range
|
||||
}
|
||||
|
||||
export const SuggestionPluginKey = new PluginKey('suggestion')
|
||||
|
||||
export default function Suggestion<I = any> ({
|
||||
pluginKey = SuggestionPluginKey,
|
||||
editor,
|
||||
char = '@',
|
||||
allowSpaces = false,
|
||||
allowedPrefixes = [' '],
|
||||
startOfLine = false,
|
||||
decorationTag = 'span',
|
||||
decorationClass = 'suggestion',
|
||||
command = () => null,
|
||||
items = () => [],
|
||||
render = () => ({}),
|
||||
allow = () => true
|
||||
}: SuggestionOptions<I>): Plugin<any> {
|
||||
let props: SuggestionProps<I> | undefined
|
||||
const renderer = render?.()
|
||||
|
||||
const plugin: Plugin<any> = new Plugin({
|
||||
key: pluginKey,
|
||||
|
||||
view () {
|
||||
return {
|
||||
// eslint-disable-next-line @typescript-eslint/no-misused-promises
|
||||
update: async (view, prevState) => {
|
||||
const prev = this.key?.getState(prevState)
|
||||
const next = this.key?.getState(view.state)
|
||||
|
||||
// See how the state changed
|
||||
/* eslint-disable @typescript-eslint/strict-boolean-expressions */
|
||||
const moved = prev.active && next.active && prev.range.from !== next.range.from
|
||||
const started = !prev.active && next.active
|
||||
const stopped = prev.active && !next.active
|
||||
const changed = !started && !stopped && prev.query !== next.query
|
||||
const handleStart = started || moved
|
||||
const handleChange = changed && !moved
|
||||
const handleExit = stopped || moved
|
||||
|
||||
// Cancel when suggestion isn't active
|
||||
if (!handleStart && !handleChange && !handleExit) {
|
||||
return
|
||||
}
|
||||
|
||||
const state = handleExit && !handleStart ? prev : next
|
||||
/* eslint-disable @typescript-eslint/restrict-template-expressions */
|
||||
const decorationNode = view.dom.querySelector(`[data-decoration-id="${state.decorationId}"]`)
|
||||
let clientRect
|
||||
if (decorationNode !== null) {
|
||||
clientRect = () => {
|
||||
// because of `items` can be asynchrounous we’ll search for the current decoration node
|
||||
const { decorationId } = this.key?.getState(editor.state) // eslint-disable-line
|
||||
const currentDecorationNode = view.dom.querySelector(`[data-decoration-id="${decorationId}"]`)
|
||||
if (currentDecorationNode !== null) {
|
||||
return currentDecorationNode?.getBoundingClientRect()
|
||||
}
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
props = {
|
||||
editor,
|
||||
range: state.range,
|
||||
query: state.query,
|
||||
text: state.text,
|
||||
items: [],
|
||||
command: (commandProps) => {
|
||||
command({
|
||||
editor,
|
||||
range: state.range,
|
||||
props: commandProps
|
||||
})
|
||||
},
|
||||
decorationNode,
|
||||
// virtual node for popper.js or tippy.js
|
||||
// this can be used for building popups without a DOM node
|
||||
clientRect
|
||||
}
|
||||
|
||||
if (handleStart) {
|
||||
renderer?.onBeforeStart?.(props)
|
||||
}
|
||||
|
||||
if (handleChange) {
|
||||
renderer?.onBeforeUpdate?.(props)
|
||||
}
|
||||
|
||||
if (handleChange || handleStart) {
|
||||
props.items = await items({
|
||||
editor,
|
||||
query: state.query
|
||||
})
|
||||
}
|
||||
|
||||
if (handleExit) {
|
||||
renderer?.onExit?.(props)
|
||||
}
|
||||
|
||||
if (handleChange) {
|
||||
renderer?.onUpdate?.(props)
|
||||
}
|
||||
|
||||
if (handleStart) {
|
||||
renderer?.onStart?.(props)
|
||||
}
|
||||
},
|
||||
|
||||
destroy: () => {
|
||||
if (props == null) {
|
||||
return
|
||||
}
|
||||
|
||||
renderer?.onExit?.(props)
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
state: {
|
||||
// Initialize the plugin's internal state.
|
||||
init () {
|
||||
const state: {
|
||||
active: boolean
|
||||
range: Range
|
||||
query: null | string
|
||||
text: null | string
|
||||
composing: boolean
|
||||
decorationId?: string | null
|
||||
specialCharInserted: boolean
|
||||
maxRangeTo: number
|
||||
} = {
|
||||
active: false,
|
||||
range: {
|
||||
from: 0,
|
||||
to: 0
|
||||
},
|
||||
query: null,
|
||||
text: null,
|
||||
composing: false,
|
||||
specialCharInserted: false,
|
||||
maxRangeTo: 0
|
||||
}
|
||||
|
||||
return state
|
||||
},
|
||||
|
||||
// Apply changes to the plugin state from a view transaction.
|
||||
apply (transaction, prev, oldState, state) {
|
||||
const { isEditable } = editor
|
||||
const { composing } = editor.view
|
||||
const { selection } = transaction
|
||||
const { empty, from } = selection
|
||||
const next = { ...prev }
|
||||
const trPluginState = transaction.getMeta(pluginKey)
|
||||
|
||||
if (trPluginState?.forceCancelSuggestion) {
|
||||
next.specialCharInserted = false
|
||||
}
|
||||
|
||||
next.composing = composing
|
||||
|
||||
// We can only be suggesting if the view is editable, and:
|
||||
// * there is no selection, or
|
||||
// * a composition is active (see: https://github.com/ueberdosis/tiptap/issues/1449)
|
||||
if (isEditable && (empty || editor.view.composing)) {
|
||||
// Reset active state if we just left the previous suggestion range
|
||||
if ((from < prev.range.from || from > prev.range.to) && !composing && !prev.composing) {
|
||||
next.active = false
|
||||
}
|
||||
|
||||
if (!prev.specialCharInserted) {
|
||||
next.specialCharInserted = hasChar(transaction, char)
|
||||
}
|
||||
|
||||
if (selection.$from.pos > next.maxRangeTo && transaction.steps.length === 0) {
|
||||
next.specialCharInserted = false
|
||||
}
|
||||
|
||||
// Make sure special char was inserted by user
|
||||
// Before try to make any match
|
||||
if (prev.specialCharInserted || next.specialCharInserted) {
|
||||
// Try to match against where our cursor currently is
|
||||
const match = findSuggestionMatch({
|
||||
char,
|
||||
allowSpaces,
|
||||
allowedPrefixes,
|
||||
startOfLine,
|
||||
$position: selection.$from
|
||||
})
|
||||
const decorationId = `id_${Math.floor(Math.random() * 0xffffffff)}`
|
||||
|
||||
// If we found a match, update the current state to show it
|
||||
if (match != null && allow({ editor, state, range: match.range })) {
|
||||
next.active = true
|
||||
next.decorationId = prev.decorationId ? prev.decorationId : decorationId
|
||||
next.range = match.range
|
||||
|
||||
if (next.range.to > next.maxRangeTo || transaction.steps.length !== 0) {
|
||||
next.maxRangeTo = next.range.to
|
||||
}
|
||||
|
||||
next.query = match.query
|
||||
next.text = match.text
|
||||
} else {
|
||||
next.active = false
|
||||
}
|
||||
} else {
|
||||
next.active = false
|
||||
}
|
||||
} else {
|
||||
next.active = false
|
||||
}
|
||||
|
||||
// Make sure to empty the range if suggestion is inactive
|
||||
if (!next.active) {
|
||||
next.decorationId = null
|
||||
next.range = { from: 0, to: 0 }
|
||||
next.maxRangeTo = 0
|
||||
next.query = null
|
||||
next.text = null
|
||||
next.specialCharInserted = false
|
||||
}
|
||||
|
||||
return next
|
||||
}
|
||||
},
|
||||
|
||||
props: {
|
||||
// Call the keydown hook if suggestion is active.
|
||||
handleKeyDown (view, event) {
|
||||
const { active, range } = plugin.getState(view.state)
|
||||
|
||||
if (!active) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (event.key === 'Escape') {
|
||||
const flag = { forceCancelSuggestion: true }
|
||||
|
||||
// It's important to dispatch this state twice
|
||||
// Just one state change is not enough to handle all
|
||||
// decorators
|
||||
view.dispatch(view.state.tr.setMeta(pluginKey, flag))
|
||||
view.dispatch(view.state.tr.setMeta(pluginKey, flag))
|
||||
}
|
||||
|
||||
return renderer?.onKeyDown?.({ view, event, range }) ?? false
|
||||
},
|
||||
|
||||
// Setup decorator on the currently active suggestion.
|
||||
decorations (state) {
|
||||
const { active, range, decorationId } = plugin.getState(state)
|
||||
|
||||
if (!active) {
|
||||
return null
|
||||
}
|
||||
|
||||
return DecorationSet.create(state.doc, [
|
||||
Decoration.inline(range.from, range.to, {
|
||||
nodeName: decorationTag,
|
||||
class: decorationClass,
|
||||
'data-decoration-id': decorationId
|
||||
})
|
||||
])
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return plugin
|
||||
}
|
@ -11,6 +11,14 @@
|
||||
// overflow-y: auto;
|
||||
color: var(--theme-text-primary-color);
|
||||
|
||||
.suggestion {
|
||||
display: inline-flex;
|
||||
padding: 0 .25rem;
|
||||
color: var(--theme-link-color);
|
||||
background-color: var(--theme-mention-bg-color);
|
||||
border-radius: .25rem;
|
||||
}
|
||||
|
||||
.title,
|
||||
h1,
|
||||
h2,
|
||||
|
@ -35,7 +35,10 @@ import core, {
|
||||
TxResult,
|
||||
TxWorkspaceEvent,
|
||||
WorkspaceEvent,
|
||||
generateId
|
||||
generateId,
|
||||
SearchQuery,
|
||||
SearchOptions,
|
||||
SearchResult
|
||||
} from '@hcengineering/core'
|
||||
import { PlatformError, UNAUTHORIZED, broadcastEvent, getMetadata, unknownError } from '@hcengineering/platform'
|
||||
|
||||
@ -407,6 +410,10 @@ class Connection implements ClientConnection {
|
||||
clean (domain: Domain, docs: Ref<Doc>[]): Promise<void> {
|
||||
return this.sendRequest({ method: 'clean', params: [domain, docs] })
|
||||
}
|
||||
|
||||
searchFulltext (query: SearchQuery, options: SearchOptions): Promise<SearchResult> {
|
||||
return this.sendRequest({ method: 'searchFulltext', params: [query, options] })
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -84,6 +84,7 @@
|
||||
"MergePersonsFrom": "Source contact",
|
||||
"MergePersonsTo": "Final contact",
|
||||
"SelectAvatar": "Select avatar",
|
||||
"Avatar": "Avatar",
|
||||
"AvatarProvider": "Avatar provider",
|
||||
"GravatarsManaged": "Gravatars are managed",
|
||||
"Through": "through",
|
||||
@ -97,6 +98,8 @@
|
||||
"ConfigLabel": "Contacts",
|
||||
"ConfigDescription": "Extension to hold information about all Employees and other Person/Organization contacts.",
|
||||
"HasMessagesIn": "has messages in",
|
||||
"HasNewMessagesIn": "has new messages in"
|
||||
"HasNewMessagesIn": "has new messages in",
|
||||
"Employees": "Employees",
|
||||
"People": "People"
|
||||
}
|
||||
}
|
||||
|
@ -85,6 +85,7 @@
|
||||
"MergePersonsFrom": "Исходный контакт",
|
||||
"MergePersonsTo": "Финальный контакт",
|
||||
"SelectAvatar": "Выбрать аватар",
|
||||
"Avatar": "Аватар",
|
||||
"GravatarsManaged": "Граватары управляются",
|
||||
"Through": "через",
|
||||
"AddMembersHeader": "Добавить пользователей в {value}:",
|
||||
@ -97,6 +98,8 @@
|
||||
"ConfigLabel": "Контакты",
|
||||
"ConfigDescription": "Расширение по работе с сотрудниками и другими контактами.",
|
||||
"HasMessagesIn": "имеет сообщения в",
|
||||
"HasNewMessagesIn": "имеет новые сообщения в"
|
||||
"HasNewMessagesIn": "имеет новые сообщения в",
|
||||
"Employees": "Сотрудники",
|
||||
"People": "Люди"
|
||||
}
|
||||
}
|
||||
|
@ -66,6 +66,7 @@ export default mergeIds(contactId, contact, {
|
||||
MergePersonsFrom: '' as IntlString,
|
||||
MergePersonsTo: '' as IntlString,
|
||||
SelectAvatar: '' as IntlString,
|
||||
Avatar: '' as IntlString,
|
||||
GravatarsManaged: '' as IntlString,
|
||||
Through: '' as IntlString,
|
||||
AvatarProvider: '' as IntlString,
|
||||
|
@ -234,5 +234,19 @@ export function getName (hierarchy: Hierarchy, value: Contact): string {
|
||||
}
|
||||
|
||||
function isPerson (hierarchy: Hierarchy, value: Contact): value is Person {
|
||||
return hierarchy.isDerived(value._class, contactPlugin.class.Person)
|
||||
return isPersonClass(hierarchy, value._class)
|
||||
}
|
||||
|
||||
function isPersonClass (hierarchy: Hierarchy, _class: Ref<Class<Doc>>): boolean {
|
||||
return hierarchy.isDerived(_class, contactPlugin.class.Person)
|
||||
}
|
||||
|
||||
/**
|
||||
* @public
|
||||
*/
|
||||
export function formatContactName (hierarchy: Hierarchy, _class: Ref<Class<Doc>>, name: string): string {
|
||||
if (isPersonClass(hierarchy, _class)) {
|
||||
return formatName(name)
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
@ -27,7 +27,10 @@ import core, {
|
||||
Ref,
|
||||
Tx,
|
||||
TxResult,
|
||||
WithLookup
|
||||
WithLookup,
|
||||
SearchQuery,
|
||||
SearchOptions,
|
||||
SearchResult
|
||||
} from '@hcengineering/core'
|
||||
import { devModelId } from '@hcengineering/devmodel'
|
||||
import { Builder } from '@hcengineering/model'
|
||||
@ -123,6 +126,14 @@ class ModelClient implements AccountClient {
|
||||
return result
|
||||
}
|
||||
|
||||
async searchFulltext (query: SearchQuery, options: SearchOptions): Promise<SearchResult> {
|
||||
const result = await this.client.searchFulltext(query, options)
|
||||
if (this.notifyEnabled) {
|
||||
console.debug('devmodel# searchFulltext=>', query, options, 'result => ', result)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
async tx (tx: Tx): Promise<TxResult> {
|
||||
const result = await this.client.tx(tx)
|
||||
if (this.notifyEnabled) {
|
||||
|
@ -0,0 +1,32 @@
|
||||
<!--
|
||||
// Copyright © 2020, 2021 Anticrm Platform Contributors.
|
||||
// Copyright © 2021 Hardcore Engineering Inc.
|
||||
//
|
||||
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License. You may
|
||||
// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
//
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { Ref, Space, Status } from '@hcengineering/core'
|
||||
import { statusStore } from '@hcengineering/view-resources'
|
||||
import { Project } from '@hcengineering/tracker'
|
||||
|
||||
import IssueStatusIcon from './IssueStatusIcon.svelte'
|
||||
|
||||
export let status: string
|
||||
export let space: Ref<Space>
|
||||
|
||||
$: st = $statusStore.byId.get(status as Ref<Status>)
|
||||
$: spaceProject = space as Ref<Project>
|
||||
</script>
|
||||
|
||||
{#if st}
|
||||
<IssueStatusIcon value={st} size={'small'} space={spaceProject} />
|
||||
{/if}
|
@ -49,6 +49,7 @@ import AssigneeEditor from './components/issues/AssigneeEditor.svelte'
|
||||
import DueDatePresenter from './components/issues/DueDatePresenter.svelte'
|
||||
import EditIssue from './components/issues/edit/EditIssue.svelte'
|
||||
import IssueItem from './components/issues/IssueItem.svelte'
|
||||
import IssueSearchIcon from './components/issues/IssueSearchIcon.svelte'
|
||||
import IssuePresenter from './components/issues/IssuePresenter.svelte'
|
||||
import IssuePreview from './components/issues/IssuePreview.svelte'
|
||||
import Issues from './components/issues/Issues.svelte'
|
||||
@ -490,7 +491,8 @@ export default async (): Promise<Resources> => ({
|
||||
EditRelatedTargets,
|
||||
EditRelatedTargetsPopup,
|
||||
TimePresenter,
|
||||
EstimationValueEditor
|
||||
EstimationValueEditor,
|
||||
IssueSearchIcon
|
||||
},
|
||||
completion: {
|
||||
IssueQuery: async (client: Client, query: string, filter?: { in?: RelatedDocument[], nin?: RelatedDocument[] }) =>
|
||||
|
@ -14,8 +14,16 @@
|
||||
// limitations under the License.
|
||||
//
|
||||
|
||||
import contact, { Channel, Contact, Organization, Person, contactId, getName } from '@hcengineering/contact'
|
||||
import { Doc, Tx, TxRemoveDoc, TxUpdateDoc, concatLink } from '@hcengineering/core'
|
||||
import contact, {
|
||||
Channel,
|
||||
Contact,
|
||||
Organization,
|
||||
Person,
|
||||
contactId,
|
||||
getName,
|
||||
formatContactName
|
||||
} from '@hcengineering/contact'
|
||||
import { Ref, Class, Doc, Tx, TxRemoveDoc, TxUpdateDoc, concatLink, Hierarchy } from '@hcengineering/core'
|
||||
import notification, { Collaborators } from '@hcengineering/notification'
|
||||
import { getMetadata } from '@hcengineering/platform'
|
||||
import serverCore, { TriggerControl } from '@hcengineering/server-core'
|
||||
@ -158,6 +166,14 @@ export function organizationTextPresenter (doc: Doc): string {
|
||||
return `${organization.name}`
|
||||
}
|
||||
|
||||
/**
|
||||
* @public
|
||||
*/
|
||||
export function contactNameProvider (hierarchy: Hierarchy, props: { [key: string]: string }): string {
|
||||
const _class = props._class !== undefined ? (props._class as Ref<Class<Doc>>) : contact.class.Contact
|
||||
return formatContactName(hierarchy, _class, props.name ?? '')
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/explicit-function-return-type
|
||||
export default async () => ({
|
||||
trigger: {
|
||||
@ -168,6 +184,7 @@ export default async () => ({
|
||||
PersonHTMLPresenter: personHTMLPresenter,
|
||||
PersonTextPresenter: personTextPresenter,
|
||||
OrganizationHTMLPresenter: organizationHTMLPresenter,
|
||||
OrganizationTextPresenter: organizationTextPresenter
|
||||
OrganizationTextPresenter: organizationTextPresenter,
|
||||
ContactNameProvider: contactNameProvider
|
||||
}
|
||||
})
|
||||
|
@ -16,7 +16,7 @@
|
||||
|
||||
import type { Plugin, Resource } from '@hcengineering/platform'
|
||||
import { plugin } from '@hcengineering/platform'
|
||||
import type { TriggerFunc } from '@hcengineering/server-core'
|
||||
import type { TriggerFunc, SearchPresenterFunc } from '@hcengineering/server-core'
|
||||
import { Presenter } from '@hcengineering/server-notification'
|
||||
|
||||
/**
|
||||
@ -36,6 +36,8 @@ export default plugin(serverContactId, {
|
||||
PersonHTMLPresenter: '' as Resource<Presenter>,
|
||||
PersonTextPresenter: '' as Resource<Presenter>,
|
||||
OrganizationHTMLPresenter: '' as Resource<Presenter>,
|
||||
OrganizationTextPresenter: '' as Resource<Presenter>
|
||||
OrganizationTextPresenter: '' as Resource<Presenter>,
|
||||
|
||||
ContactNameProvider: '' as Resource<SearchPresenterFunc>
|
||||
}
|
||||
})
|
||||
|
@ -36,12 +36,16 @@ import core, {
|
||||
TxCUD,
|
||||
TxFactory,
|
||||
TxResult,
|
||||
WorkspaceId
|
||||
WorkspaceId,
|
||||
SearchQuery,
|
||||
SearchOptions,
|
||||
SearchResult
|
||||
} from '@hcengineering/core'
|
||||
import { MinioService } from '@hcengineering/minio'
|
||||
import { FullTextIndexPipeline } from './indexer'
|
||||
import { createStateDoc, isClassIndexable } from './indexer/utils'
|
||||
import type { FullTextAdapter, IndexedDoc, WithFind } from './types'
|
||||
import { mapSearchResultDoc } from './mapper'
|
||||
import type { FullTextAdapter, WithFind, IndexedDoc } from './types'
|
||||
|
||||
/**
|
||||
* @public
|
||||
@ -241,6 +245,18 @@ export class FullTextIndex implements WithFind {
|
||||
return result
|
||||
}
|
||||
|
||||
async searchFulltext (ctx: MeasureContext, query: SearchQuery, options: SearchOptions): Promise<SearchResult> {
|
||||
const resultRaw = await this.adapter.searchString(query, options)
|
||||
|
||||
const result: SearchResult = {
|
||||
...resultRaw,
|
||||
docs: resultRaw.docs.map((raw) => {
|
||||
return mapSearchResultDoc(this.hierarchy, raw)
|
||||
})
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
submitting: Promise<void> | undefined
|
||||
|
||||
timeout: any
|
||||
|
@ -40,7 +40,8 @@ import {
|
||||
FullTextPipelineStage,
|
||||
fullTextPushStageId
|
||||
} from './types'
|
||||
import { collectPropagate, collectPropagateClasses, docKey, getFullTextContext } from './utils'
|
||||
import { collectPropagate, collectPropagateClasses, docKey, getFullTextContext, IndexKeyOptions } from './utils'
|
||||
import { updateDocWithPresenter } from '../mapper'
|
||||
|
||||
/**
|
||||
* @public
|
||||
@ -128,7 +129,7 @@ export class FullTextPushStage implements FullTextPipelineStage {
|
||||
)
|
||||
if (refDocs.length > 0) {
|
||||
refDocs.forEach((c) => {
|
||||
updateDoc2Elastic(c.attributes, elasticDoc, c._id)
|
||||
updateDoc2Elastic(c.attributes, elasticDoc, c._id, attribute)
|
||||
})
|
||||
}
|
||||
}
|
||||
@ -208,6 +209,8 @@ export class FullTextPushStage implements FullTextPipelineStage {
|
||||
// Include child ref attributes
|
||||
await this.indexRefAttributes(allAttributes, doc, elasticDoc, metrics)
|
||||
|
||||
await updateDocWithPresenter(pipeline.hierarchy, elasticDoc)
|
||||
|
||||
this.checkIntegrity(elasticDoc)
|
||||
bulk.push(elasticDoc)
|
||||
} catch (err: any) {
|
||||
@ -258,7 +261,12 @@ export function createElasticDoc (upd: DocIndexState): IndexedDoc {
|
||||
}
|
||||
return doc
|
||||
}
|
||||
function updateDoc2Elastic (attributes: Record<string, any>, doc: IndexedDoc, docIdOverride?: Ref<DocIndexState>): void {
|
||||
function updateDoc2Elastic (
|
||||
attributes: Record<string, any>,
|
||||
doc: IndexedDoc,
|
||||
docIdOverride?: Ref<DocIndexState>,
|
||||
refAttribute?: string
|
||||
): void {
|
||||
for (const [k, v] of Object.entries(attributes)) {
|
||||
if (v == null) {
|
||||
continue
|
||||
@ -280,7 +288,11 @@ function updateDoc2Elastic (attributes: Record<string, any>, doc: IndexedDoc, do
|
||||
}
|
||||
continue
|
||||
}
|
||||
const docIdAttr = '|' + docKey(attr, { _class, extra: extra.filter((it) => it !== 'base64') })
|
||||
const docKeyOpts: IndexKeyOptions = { _class, relative: true, extra: extra.filter((it) => it !== 'base64') }
|
||||
if (refAttribute !== undefined) {
|
||||
docKeyOpts.refAttribute = refAttribute
|
||||
}
|
||||
const docIdAttr = docKey(attr, docKeyOpts)
|
||||
if (vv !== null) {
|
||||
// Since we replace array of values, we could ignore null
|
||||
doc[docIdAttr] = [...(doc[docIdAttr] ?? [])]
|
||||
|
@ -102,7 +102,7 @@ export const contentStageId = 'cnt-v2b'
|
||||
/**
|
||||
* @public
|
||||
*/
|
||||
export const fieldStateId = 'fld-v6'
|
||||
export const fieldStateId = 'fld-v7'
|
||||
|
||||
/**
|
||||
* @public
|
||||
|
140
server/core/src/mapper.ts
Normal file
140
server/core/src/mapper.ts
Normal file
@ -0,0 +1,140 @@
|
||||
import { Hierarchy, Ref, RefTo, Class, Doc, SearchResultDoc, docKey } from '@hcengineering/core'
|
||||
import { getResource } from '@hcengineering/platform'
|
||||
|
||||
import plugin from './plugin'
|
||||
import { IndexedDoc, SearchPresenter, ClassSearchConfigProps } from './types'
|
||||
|
||||
interface IndexedReader {
|
||||
get: (attribute: string) => any
|
||||
getDoc: (attribute: string) => IndexedReader | undefined
|
||||
}
|
||||
|
||||
function createIndexedReader (
|
||||
_class: Ref<Class<Doc>>,
|
||||
hierarchy: Hierarchy,
|
||||
doc: IndexedDoc,
|
||||
refAttribute?: string
|
||||
): IndexedReader {
|
||||
return {
|
||||
get: (attr: string) => {
|
||||
const realAttr = hierarchy.findAttribute(_class, attr)
|
||||
if (realAttr !== undefined) {
|
||||
return doc[docKey(attr, { refAttribute, _class: realAttr.attributeOf })]
|
||||
}
|
||||
return undefined
|
||||
},
|
||||
getDoc: (attr: string) => {
|
||||
const realAttr = hierarchy.findAttribute(_class, attr)
|
||||
if (realAttr !== undefined) {
|
||||
const refAtrr = realAttr.type as RefTo<Doc>
|
||||
return createIndexedReader(refAtrr.to, hierarchy, doc, docKey(attr, { _class }))
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function readAndMapProps (reader: IndexedReader, props: ClassSearchConfigProps[]): { [key: string]: string } {
|
||||
const res: { [key: string]: string } = {}
|
||||
for (const prop of props) {
|
||||
if (typeof prop === 'string') {
|
||||
res[prop] = reader.get(prop)
|
||||
} else {
|
||||
for (const [propName, rest] of Object.entries(prop)) {
|
||||
if (rest.length > 1) {
|
||||
const val = reader.getDoc(rest[0])?.get(rest[1]) ?? ''
|
||||
res[propName] = Array.isArray(val) ? val[0] : val
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
function findSearchPresenter (hierarchy: Hierarchy, _class: Ref<Class<Doc>>): SearchPresenter | undefined {
|
||||
const ancestors = hierarchy.getAncestors(_class).reverse()
|
||||
for (const _class of ancestors) {
|
||||
const searchMixin = hierarchy.classHierarchyMixin(_class, plugin.mixin.SearchPresenter)
|
||||
if (searchMixin !== undefined) {
|
||||
return searchMixin
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* @public
|
||||
*/
|
||||
export async function updateDocWithPresenter (hierarchy: Hierarchy, doc: IndexedDoc): Promise<void> {
|
||||
const searchPresenter = findSearchPresenter(hierarchy, doc._class)
|
||||
if (searchPresenter === undefined) {
|
||||
return
|
||||
}
|
||||
|
||||
const reader = createIndexedReader(doc._class, hierarchy, doc)
|
||||
|
||||
const props = [
|
||||
{
|
||||
name: 'searchTitle',
|
||||
config: searchPresenter.searchConfig.title,
|
||||
provider: searchPresenter.getSearchTitle
|
||||
}
|
||||
]
|
||||
|
||||
if (searchPresenter.searchConfig.shortTitle !== undefined) {
|
||||
props.push({
|
||||
name: 'searchShortTitle',
|
||||
config: searchPresenter.searchConfig.shortTitle,
|
||||
provider: searchPresenter.getSearchObjectId
|
||||
})
|
||||
}
|
||||
|
||||
for (const prop of props) {
|
||||
let value
|
||||
if (typeof prop.config === 'string') {
|
||||
value = reader.get(prop.config)
|
||||
} else if (prop.config.tmpl !== undefined) {
|
||||
const tmpl = prop.config.tmpl
|
||||
const renderProps = readAndMapProps(reader, prop.config.props)
|
||||
value = fillTemplate(tmpl, renderProps)
|
||||
} else if (prop.provider !== undefined) {
|
||||
const func = await getResource(prop.provider)
|
||||
const renderProps = readAndMapProps(reader, prop.config.props)
|
||||
value = func(hierarchy, { _class: doc._class, ...renderProps })
|
||||
}
|
||||
doc[prop.name] = value
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @public
|
||||
*/
|
||||
export function mapSearchResultDoc (hierarchy: Hierarchy, raw: IndexedDoc): SearchResultDoc {
|
||||
const doc: SearchResultDoc = {
|
||||
id: raw.id,
|
||||
title: raw.searchTitle,
|
||||
shortTitle: raw.searchShortTitle,
|
||||
doc: {
|
||||
_id: raw.id,
|
||||
_class: raw._class
|
||||
}
|
||||
}
|
||||
|
||||
const searchPresenter = findSearchPresenter(hierarchy, doc.doc._class)
|
||||
if (searchPresenter?.searchConfig.icon !== undefined) {
|
||||
doc.icon = searchPresenter.searchConfig.icon
|
||||
}
|
||||
if (searchPresenter?.searchConfig.iconConfig !== undefined) {
|
||||
doc.iconComponent = searchPresenter.searchConfig.iconConfig.component
|
||||
doc.iconProps = readAndMapProps(
|
||||
createIndexedReader(raw._class, hierarchy, raw),
|
||||
searchPresenter.searchConfig.iconConfig.props
|
||||
)
|
||||
}
|
||||
|
||||
return doc
|
||||
}
|
||||
|
||||
function fillTemplate (tmpl: string, props: { [key: string]: string }): string {
|
||||
return tmpl.replace(/{(.*?)}/g, (_, key: string) => props[key])
|
||||
}
|
@ -26,7 +26,10 @@ import {
|
||||
ServerStorage,
|
||||
StorageIterator,
|
||||
Tx,
|
||||
TxResult
|
||||
TxResult,
|
||||
SearchQuery,
|
||||
SearchOptions,
|
||||
SearchResult
|
||||
} from '@hcengineering/core'
|
||||
import { DbConfiguration, createServerStorage } from './storage'
|
||||
import { BroadcastFunc, Middleware, MiddlewareCreator, Pipeline, SessionContext } from './types'
|
||||
@ -91,6 +94,12 @@ class PipelineImpl implements Pipeline {
|
||||
: await this.storage.findAll(ctx, _class, query, options)
|
||||
}
|
||||
|
||||
async searchFulltext (ctx: SessionContext, query: SearchQuery, options: SearchOptions): Promise<SearchResult> {
|
||||
return this.head !== undefined
|
||||
? await this.head.searchFulltext(ctx, query, options)
|
||||
: await this.storage.searchFulltext(ctx, query, options)
|
||||
}
|
||||
|
||||
async tx (ctx: SessionContext, tx: Tx): Promise<[TxResult, Tx[], string[] | undefined]> {
|
||||
if (this.head === undefined) {
|
||||
const res = await this.storage.tx(ctx, tx)
|
||||
|
@ -16,8 +16,8 @@
|
||||
|
||||
import { Metadata, Plugin, plugin } from '@hcengineering/platform'
|
||||
|
||||
import type { Class, Ref, Space } from '@hcengineering/core'
|
||||
import type { ObjectDDParticipant, Trigger } from './types'
|
||||
import type { Class, Ref, Space, Mixin } from '@hcengineering/core'
|
||||
import type { ObjectDDParticipant, SearchPresenter, Trigger } from './types'
|
||||
|
||||
/**
|
||||
* @public
|
||||
@ -32,7 +32,8 @@ const serverCore = plugin(serverCoreId, {
|
||||
Trigger: '' as Ref<Class<Trigger>>
|
||||
},
|
||||
mixin: {
|
||||
ObjectDDParticipant: '' as Ref<ObjectDDParticipant>
|
||||
ObjectDDParticipant: '' as Ref<ObjectDDParticipant>,
|
||||
SearchPresenter: '' as Ref<Mixin<SearchPresenter>>
|
||||
},
|
||||
space: {
|
||||
DocIndexState: '' as Ref<Space>,
|
||||
|
@ -51,7 +51,10 @@ import core, {
|
||||
TxUpdateDoc,
|
||||
TxWorkspaceEvent,
|
||||
WorkspaceEvent,
|
||||
WorkspaceId
|
||||
WorkspaceId,
|
||||
SearchQuery,
|
||||
SearchOptions,
|
||||
SearchResult
|
||||
} from '@hcengineering/core'
|
||||
import { MinioService } from '@hcengineering/minio'
|
||||
import { getResource } from '@hcengineering/platform'
|
||||
@ -376,6 +379,12 @@ class TServerStorage implements ServerStorage {
|
||||
})
|
||||
}
|
||||
|
||||
async searchFulltext (ctx: MeasureContext, query: SearchQuery, options: SearchOptions): Promise<SearchResult> {
|
||||
return await ctx.with('full-text-search', {}, (ctx) => {
|
||||
return this.fulltext.searchFulltext(ctx, query, options)
|
||||
})
|
||||
}
|
||||
|
||||
private getParentClass (_class: Ref<Class<Doc>>): Ref<Class<Doc>> {
|
||||
const baseDomain = this.hierarchy.getDomain(_class)
|
||||
const ancestors = this.hierarchy.getAncestors(_class)
|
||||
|
@ -34,10 +34,13 @@ import {
|
||||
Tx,
|
||||
TxFactory,
|
||||
TxResult,
|
||||
WorkspaceId
|
||||
WorkspaceId,
|
||||
SearchQuery,
|
||||
SearchOptions,
|
||||
SearchResult
|
||||
} from '@hcengineering/core'
|
||||
import { MinioService } from '@hcengineering/minio'
|
||||
import type { Resource } from '@hcengineering/platform'
|
||||
import type { Resource, Asset } from '@hcengineering/platform'
|
||||
import { Readable } from 'stream'
|
||||
|
||||
/**
|
||||
@ -59,6 +62,7 @@ export interface Middleware {
|
||||
query: DocumentQuery<T>,
|
||||
options?: FindOptions<T>
|
||||
) => Promise<FindResult<T>>
|
||||
searchFulltext: (ctx: SessionContext, query: SearchQuery, options: SearchOptions) => Promise<SearchResult>
|
||||
}
|
||||
|
||||
/**
|
||||
@ -93,6 +97,7 @@ export interface Pipeline extends LowLevelStorage {
|
||||
query: DocumentQuery<T>,
|
||||
options?: FindOptions<T>
|
||||
) => Promise<FindResult<T>>
|
||||
searchFulltext: (ctx: SessionContext, query: SearchQuery, options: SearchOptions) => Promise<SearchResult>
|
||||
tx: (ctx: SessionContext, tx: Tx) => Promise<[TxResult, Tx[], string[] | undefined]>
|
||||
close: () => Promise<void>
|
||||
}
|
||||
@ -133,20 +138,6 @@ export interface Trigger extends Doc {
|
||||
txMatch?: DocumentQuery<Tx>
|
||||
}
|
||||
|
||||
/**
|
||||
* @public
|
||||
*/
|
||||
export interface IndexedDoc {
|
||||
id: Ref<Doc>
|
||||
_class: Ref<Class<Doc>>
|
||||
space: Ref<Space>
|
||||
modifiedOn: Timestamp
|
||||
modifiedBy: Ref<Account>
|
||||
attachedTo?: Ref<Doc>
|
||||
attachedToClass?: Ref<Class<Doc>>
|
||||
[key: string]: any
|
||||
}
|
||||
|
||||
/**
|
||||
* @public
|
||||
*/
|
||||
@ -160,6 +151,30 @@ export interface EmbeddingSearchOption {
|
||||
minScore?: number // 75 for example.
|
||||
}
|
||||
|
||||
/**
|
||||
* @public
|
||||
*/
|
||||
export interface IndexedDoc {
|
||||
id: Ref<Doc>
|
||||
_class: Ref<Class<Doc>>
|
||||
space: Ref<Space>
|
||||
modifiedOn: Timestamp
|
||||
modifiedBy: Ref<Account>
|
||||
attachedTo?: Ref<Doc>
|
||||
attachedToClass?: Ref<Class<Doc>>
|
||||
searchTitle?: string
|
||||
searchShortTitle?: string
|
||||
[key: string]: any
|
||||
}
|
||||
|
||||
/**
|
||||
* @public
|
||||
*/
|
||||
export interface SearchStringResult {
|
||||
docs: IndexedDoc[]
|
||||
total?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* @public
|
||||
*/
|
||||
@ -173,6 +188,9 @@ export interface FullTextAdapter {
|
||||
update: (id: Ref<Doc>, update: Record<string, any>) => Promise<TxResult>
|
||||
remove: (id: Ref<Doc>[]) => Promise<void>
|
||||
updateMany: (docs: IndexedDoc[]) => Promise<TxResult[]>
|
||||
|
||||
searchString: (query: SearchQuery, options: SearchOptions) => Promise<SearchStringResult>
|
||||
|
||||
search: (
|
||||
_classes: Ref<Class<Doc>>[],
|
||||
search: DocumentQuery<Doc>,
|
||||
@ -220,6 +238,10 @@ export class DummyFullTextAdapter implements FullTextAdapter {
|
||||
return []
|
||||
}
|
||||
|
||||
async searchString (query: SearchQuery, options: SearchOptions): Promise<SearchStringResult> {
|
||||
return { docs: [] }
|
||||
}
|
||||
|
||||
async search (query: any): Promise<IndexedDoc[]> {
|
||||
return []
|
||||
}
|
||||
@ -307,3 +329,44 @@ export interface ObjectDDParticipant extends Class<Obj> {
|
||||
) => Promise<Doc[]>
|
||||
>
|
||||
}
|
||||
|
||||
/**
|
||||
* @public
|
||||
*/
|
||||
export interface SearchProps {
|
||||
[key: string]: string
|
||||
}
|
||||
|
||||
/**
|
||||
* @public
|
||||
*/
|
||||
export type SearchPresenterFunc = (hierarchy: Hierarchy, props: SearchProps) => string
|
||||
|
||||
/**
|
||||
* @public
|
||||
*/
|
||||
export type ClassSearchConfigProps = string | { [key: string]: string[] }
|
||||
|
||||
/**
|
||||
* @public
|
||||
*/
|
||||
export type ClassSearchConfigProperty = string | { tmpl?: string, props: ClassSearchConfigProps[] }
|
||||
|
||||
/**
|
||||
* @public
|
||||
*/
|
||||
export interface ClassSearchConfig {
|
||||
icon?: Asset
|
||||
iconConfig?: { component: any, props: ClassSearchConfigProps[] }
|
||||
title: ClassSearchConfigProperty
|
||||
shortTitle?: ClassSearchConfigProperty
|
||||
}
|
||||
|
||||
/**
|
||||
* @public
|
||||
*/
|
||||
export interface SearchPresenter extends Class<Doc> {
|
||||
searchConfig: ClassSearchConfig
|
||||
getSearchObjectId?: Resource<SearchPresenterFunc>
|
||||
getSearchTitle?: Resource<SearchPresenterFunc>
|
||||
}
|
||||
|
@ -16,6 +16,7 @@
|
||||
|
||||
import { Account, Class, Doc, getWorkspaceId, MeasureMetricsContext, Ref, Space } from '@hcengineering/core'
|
||||
import type { IndexedDoc } from '@hcengineering/server-core'
|
||||
|
||||
import { createElasticAdapter } from '../adapter'
|
||||
|
||||
describe('client', () => {
|
||||
@ -38,7 +39,18 @@ describe('client', () => {
|
||||
console.log(hits)
|
||||
})
|
||||
|
||||
// it('should find document', async () => {
|
||||
// const adapter = await createElasticAdapter('http://localhost:9200/', 'ws1')
|
||||
// })
|
||||
it('should find document with raw search', async () => {
|
||||
const adapter = await createElasticAdapter(
|
||||
'http://localhost:9200/',
|
||||
getWorkspaceId('ws1', ''),
|
||||
new MeasureMetricsContext('-', {})
|
||||
)
|
||||
const result = await adapter.searchString(
|
||||
{
|
||||
query: 'hey'
|
||||
},
|
||||
{}
|
||||
)
|
||||
console.log(result)
|
||||
})
|
||||
})
|
||||
|
@ -23,12 +23,17 @@ import {
|
||||
Ref,
|
||||
toWorkspaceString,
|
||||
TxResult,
|
||||
WorkspaceId
|
||||
WorkspaceId,
|
||||
SearchQuery,
|
||||
SearchOptions
|
||||
} from '@hcengineering/core'
|
||||
import type { EmbeddingSearchOption, FullTextAdapter, IndexedDoc } from '@hcengineering/server-core'
|
||||
import type { EmbeddingSearchOption, FullTextAdapter, SearchStringResult, IndexedDoc } from '@hcengineering/server-core'
|
||||
|
||||
import { Client, errors as esErr } from '@elastic/elasticsearch'
|
||||
import { Domain } from 'node:domain'
|
||||
|
||||
const DEFAULT_LIMIT = 200
|
||||
|
||||
class ElasticAdapter implements FullTextAdapter {
|
||||
constructor (
|
||||
private readonly client: Client,
|
||||
@ -100,6 +105,65 @@ class ElasticAdapter implements FullTextAdapter {
|
||||
return this._metrics
|
||||
}
|
||||
|
||||
async searchString (query: SearchQuery, options: SearchOptions): Promise<SearchStringResult> {
|
||||
try {
|
||||
const elasticQuery: any = {
|
||||
query: {
|
||||
bool: {
|
||||
must: {
|
||||
simple_query_string: {
|
||||
query: query.query,
|
||||
analyze_wildcard: true,
|
||||
flags: 'OR|PREFIX|PHRASE|FUZZY|NOT|ESCAPE',
|
||||
default_operator: 'and',
|
||||
fields: [
|
||||
'searchTitle^5', // Boost matches in searchTitle by a factor of 5
|
||||
'searchShortTitle^5',
|
||||
'*' // Search in all other fields without a boost
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
size: options.limit ?? DEFAULT_LIMIT
|
||||
}
|
||||
|
||||
const filter = []
|
||||
if (query.spaces !== undefined) {
|
||||
filter.push({
|
||||
terms: { 'space.keyword': query.spaces }
|
||||
})
|
||||
}
|
||||
if (query.classes !== undefined) {
|
||||
filter.push({
|
||||
terms: { '_class.keyword': query.classes }
|
||||
})
|
||||
}
|
||||
|
||||
if (filter.length > 0) {
|
||||
elasticQuery.query.bool.filter = filter
|
||||
}
|
||||
|
||||
const result = await this.client.search({
|
||||
index: toWorkspaceString(this.workspaceId),
|
||||
body: elasticQuery
|
||||
})
|
||||
|
||||
const resp: SearchStringResult = { docs: [] }
|
||||
if (result.body.hits !== undefined) {
|
||||
if (result.body.hits.total?.value !== undefined) {
|
||||
resp.total = result.body.hits.total?.value
|
||||
}
|
||||
resp.docs = result.body.hits.hits.map((hit: any) => ({ ...hit._source, _score: hit._score }))
|
||||
}
|
||||
|
||||
return resp
|
||||
} catch (err) {
|
||||
console.error('elastic error', JSON.stringify(err, null, 2))
|
||||
return { docs: [] }
|
||||
}
|
||||
}
|
||||
|
||||
async search (
|
||||
_classes: Ref<Class<Doc>>[],
|
||||
query: DocumentQuery<Doc>,
|
||||
|
@ -13,7 +13,19 @@
|
||||
// limitations under the License.
|
||||
//
|
||||
|
||||
import { Class, Doc, DocumentQuery, FindOptions, FindResult, Ref, ServerStorage, Tx } from '@hcengineering/core'
|
||||
import {
|
||||
Class,
|
||||
Doc,
|
||||
DocumentQuery,
|
||||
FindOptions,
|
||||
FindResult,
|
||||
Ref,
|
||||
ServerStorage,
|
||||
Tx,
|
||||
SearchQuery,
|
||||
SearchOptions,
|
||||
SearchResult
|
||||
} from '@hcengineering/core'
|
||||
import { Middleware, SessionContext, TxMiddlewareResult } from '@hcengineering/server-core'
|
||||
|
||||
/**
|
||||
@ -31,6 +43,10 @@ export abstract class BaseMiddleware {
|
||||
return await this.provideFindAll(ctx, _class, query, options)
|
||||
}
|
||||
|
||||
async searchFulltext (ctx: SessionContext, query: SearchQuery, options: SearchOptions): Promise<SearchResult> {
|
||||
return await this.provideSearchFulltext(ctx, query, options)
|
||||
}
|
||||
|
||||
protected async provideTx (ctx: SessionContext, tx: Tx): Promise<TxMiddlewareResult> {
|
||||
if (this.next !== undefined) {
|
||||
return await this.next.tx(ctx, tx)
|
||||
@ -50,4 +66,15 @@ export abstract class BaseMiddleware {
|
||||
}
|
||||
return await this.storage.findAll(ctx, _class, query, options)
|
||||
}
|
||||
|
||||
protected async provideSearchFulltext (
|
||||
ctx: SessionContext,
|
||||
query: SearchQuery,
|
||||
options: SearchOptions
|
||||
): Promise<SearchResult> {
|
||||
if (this.next !== undefined) {
|
||||
return await this.next.searchFulltext(ctx, query, options)
|
||||
}
|
||||
return await this.storage.searchFulltext(ctx, query, options)
|
||||
}
|
||||
}
|
||||
|
@ -12,7 +12,6 @@
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//
|
||||
|
||||
import core, {
|
||||
Account,
|
||||
AttachedDoc,
|
||||
@ -38,7 +37,10 @@ import core, {
|
||||
TxRemoveDoc,
|
||||
TxUpdateDoc,
|
||||
TxWorkspaceEvent,
|
||||
WorkspaceEvent
|
||||
WorkspaceEvent,
|
||||
SearchResult,
|
||||
SearchQuery,
|
||||
SearchOptions
|
||||
} from '@hcengineering/core'
|
||||
import platform, { PlatformError, Severity, Status } from '@hcengineering/platform'
|
||||
import { BroadcastFunc, Middleware, SessionContext, TxMiddlewareResult } from '@hcengineering/server-core'
|
||||
@ -400,6 +402,20 @@ export class SpaceSecurityMiddleware extends BaseMiddleware implements Middlewar
|
||||
return findResult
|
||||
}
|
||||
|
||||
override async searchFulltext (
|
||||
ctx: SessionContext,
|
||||
query: SearchQuery,
|
||||
options: SearchOptions
|
||||
): Promise<SearchResult> {
|
||||
const newQuery = { ...query }
|
||||
const account = await getUser(this.storage, ctx)
|
||||
if (!isSystem(account)) {
|
||||
newQuery.spaces = await this.getAllAllowedSpaces(account)
|
||||
}
|
||||
const result = await this.provideSearchFulltext(ctx, newQuery, options)
|
||||
return result
|
||||
}
|
||||
|
||||
async isUnavailable (ctx: SessionContext, space: Ref<Space>): Promise<boolean> {
|
||||
if (this.privateSpaces[space] === undefined) return false
|
||||
const account = await getUser(this.storage, ctx)
|
||||
|
@ -166,6 +166,7 @@ describe('mongo operations', () => {
|
||||
const st: ClientConnection = {
|
||||
findAll: async (_class, query, options) => await serverStorage.findAll(ctx, _class, query, options),
|
||||
tx: async (tx) => (await serverStorage.tx(ctx, tx))[0],
|
||||
searchFulltext: async () => ({ docs: [] }),
|
||||
close: async () => {},
|
||||
loadChunk: async (domain): Promise<DocChunk> => await Promise.reject(new Error('unsupported')),
|
||||
closeChunk: async (idx) => {},
|
||||
|
@ -80,7 +80,10 @@ describe('server', () => {
|
||||
}),
|
||||
load: async (domain: Domain, docs: Ref<Doc>[]) => [],
|
||||
upload: async (domain: Domain, docs: Doc[]) => {},
|
||||
clean: async (domain: Domain, docs: Ref<Doc>[]) => {}
|
||||
clean: async (domain: Domain, docs: Ref<Doc>[]) => {},
|
||||
searchFulltext: async (ctx, query, options) => {
|
||||
return { docs: [] }
|
||||
}
|
||||
}),
|
||||
sessionFactory: (token, pipeline, broadcast) => new ClientSession(broadcast, token, pipeline),
|
||||
port: 3335,
|
||||
@ -175,7 +178,10 @@ describe('server', () => {
|
||||
}),
|
||||
load: async (domain: Domain, docs: Ref<Doc>[]) => [],
|
||||
upload: async (domain: Domain, docs: Doc[]) => {},
|
||||
clean: async (domain: Domain, docs: Ref<Doc>[]) => {}
|
||||
clean: async (domain: Domain, docs: Ref<Doc>[]) => {},
|
||||
searchFulltext: async (ctx, query, options) => {
|
||||
return { docs: [] }
|
||||
}
|
||||
}),
|
||||
sessionFactory: (token, pipeline, broadcast) => new ClientSession(broadcast, token, pipeline),
|
||||
port: 3336,
|
||||
|
@ -33,7 +33,10 @@ import core, {
|
||||
TxResult,
|
||||
TxWorkspaceEvent,
|
||||
WorkspaceEvent,
|
||||
generateId
|
||||
generateId,
|
||||
SearchQuery,
|
||||
SearchOptions,
|
||||
SearchResult
|
||||
} from '@hcengineering/core'
|
||||
import { Pipeline, SessionContext } from '@hcengineering/server-core'
|
||||
import { Token } from '@hcengineering/server-token'
|
||||
@ -110,6 +113,12 @@ export class ClientSession implements Session {
|
||||
return await this._pipeline.findAll(context, _class, query, options)
|
||||
}
|
||||
|
||||
async searchFulltext (ctx: MeasureContext, query: SearchQuery, options: SearchOptions): Promise<SearchResult> {
|
||||
const context = ctx as SessionContext
|
||||
context.userEmail = this.token.email
|
||||
return await this._pipeline.searchFulltext(context, query, options)
|
||||
}
|
||||
|
||||
async tx (ctx: MeasureContext, tx: Tx): Promise<TxResult> {
|
||||
this.total.tx++
|
||||
this.current.tx++
|
||||
|
Loading…
Reference in New Issue
Block a user