diff --git a/common/config/rush/pnpm-lock.yaml b/common/config/rush/pnpm-lock.yaml index f6a607b855..0da7bdf721 100644 --- a/common/config/rush/pnpm-lock.yaml +++ b/common/config/rush/pnpm-lock.yaml @@ -11983,7 +11983,7 @@ packages: dev: false file:projects/front.tgz: - resolution: {integrity: sha512-RXsa4jlZB6UdPjSIAHmf07BEcWlH6N26QnAVFQ3QL5VdqLi73ohsPQV9seKz36c5jGsA//Z0BS9QYVCETuHdgA==, tarball: file:projects/front.tgz} + resolution: {integrity: sha512-Nto3Qer5qe5YaIELZhEaJugu6x/1SbThjaKd0Yyc5BCo6UfjeyvQyKz0iHmTtIVfbBfzQSqj+MMBGL0k6zW3dg==, tarball: file:projects/front.tgz} name: '@rush-temp/front' version: 0.0.0 dependencies: @@ -11998,6 +11998,7 @@ packages: '@typescript-eslint/eslint-plugin': 5.7.0_c25e8c1f4f4f7aaed27aa6f9ce042237 '@typescript-eslint/parser': 5.7.0_eslint@7.32.0+typescript@4.5.4 cors: 2.8.5 + cross-env: 7.0.3 esbuild: 0.12.29 eslint: 7.32.0 eslint-config-standard-with-typescript: 21.0.1_ce2fa0c4dfa1c256100cababd749a13a @@ -13383,7 +13384,7 @@ packages: dev: false file:projects/server.tgz: - resolution: {integrity: sha512-5U67EYHkOoUqolvKMKSN1++52yfIzKPOIG2pmnbra2Ffcg9O/7UCat9eBIUx8U+n1998DnVm+c9H3yHadlOdsw==, tarball: file:projects/server.tgz} + resolution: {integrity: sha512-b5qcL1erYSk29hFzWoJhG7GbtLdoTbEUHbScHKs7lLsQvftiiZmQRmuVcP8TVjiR3yTuMRJDt4ghH2d2UF9KIw==, tarball: file:projects/server.tgz} name: '@rush-temp/server' version: 0.0.0 dependencies: @@ -13394,6 +13395,7 @@ packages: '@types/ws': 7.4.7 '@typescript-eslint/eslint-plugin': 5.7.0_c25e8c1f4f4f7aaed27aa6f9ce042237 '@typescript-eslint/parser': 5.7.0_eslint@7.32.0+typescript@4.5.4 + cross-env: 7.0.3 elastic-apm-node: 3.26.0 esbuild: 0.12.29 eslint: 7.32.0 diff --git a/dev/generator/src/recruit.ts b/dev/generator/src/recruit.ts index d5879ef56b..1ca1aa6f6a 100644 --- a/dev/generator/src/recruit.ts +++ b/dev/generator/src/recruit.ts @@ -1,4 +1,4 @@ -import contact, { Employee, EmployeeAccount, Person } from '@anticrm/contact' +import contact, { Channel, Employee, EmployeeAccount, Person } from '@anticrm/contact' import core, { AttachedData, Data, @@ -225,7 +225,6 @@ async function genCandidate ( const candidate: Data = { name: fName + ',' + lName, city: faker.address.city(), - channels: [{ provider: contact.channelProvider.Email, value: faker.internet.email(fName, lName) }], avatar: imgId } @@ -238,10 +237,19 @@ async function genCandidate ( const candidateId = (options.random ? `candidate-${generateId()}-${i}` : `candidate-genid-${i}`) as Ref candidates.push(candidateId) + const channelId = (options.random ? `channel-${generateId()}-${i}` : `channel-genid-${i}`) as Ref // Update or create candidate await ctx.with('find-update', {}, async () => { await findOrUpdate(ctx, client, recruit.space.CandidatesPublic, contact.class.Person, candidateId, candidate) + await findOrUpdateAttached(ctx, client, recruit.space.CandidatesPublic, contact.class.Channel, channelId, { + provider: contact.channelProvider.Email, + value: faker.internet.email(fName, lName) + }, { + attachedTo: candidateId, + attachedClass: contact.class.Person, + collection: 'channels' + }) await client.updateMixin(candidateId, contact.class.Person, recruit.space.CandidatesPublic, recruit.mixin.Candidate, candidateMixin) }) diff --git a/dev/tool/src/importer.ts b/dev/tool/src/importer.ts index 189846ab58..53d9b77cec 100644 --- a/dev/tool/src/importer.ts +++ b/dev/tool/src/importer.ts @@ -16,7 +16,7 @@ import attachment, { Attachment } from '@anticrm/attachment' import chunter, { Comment } from '@anticrm/chunter' -import contact, { ChannelProvider, EmployeeAccount, Person } from '@anticrm/contact' +import contact, { Channel, ChannelProvider, EmployeeAccount, Person } from '@anticrm/contact' import core, { AttachedData, AttachedDoc, Class, Data, Doc, DocumentUpdate, Ref, SortingOrder, Space, TxOperations, TxResult, MixinData } from '@anticrm/core' import recruit from '@anticrm/model-recruit' import { Applicant, Candidate, Vacancy } from '@anticrm/recruit' @@ -261,8 +261,7 @@ async function createCandidate (_name: string, pos: number, len: number, c: any, const data: Data = { name: names.slice(1).join(' ') + ',' + names[0], - city: get(c, _.city) ?? '', - channels: [] + city: get(c, _.city) ?? '' } const candidateData: MixinData = { @@ -277,8 +276,10 @@ async function createCandidate (_name: string, pos: number, len: number, c: any, ].filter(p => p !== undefined && p.trim().length > 0).filter(onlyUniq).join('/') } - pushChannel(c, data, _.email, contact.channelProvider.Email) - pushChannel(c, data, _.phone, contact.channelProvider.Phone) + const channels: AttachedData[] = [] + + pushChannel(c, channels, _.email, contact.channelProvider.Email) + pushChannel(c, channels, _.phone, contact.channelProvider.Phone) const commentData: string[] = [] @@ -291,17 +292,23 @@ async function createCandidate (_name: string, pos: number, len: number, c: any, addComment(commentData, c, _.comment) if (telegram !== undefined) { - data.channels.push({ provider: contact.channelProvider.Telegram, value: telegram }) + channels.push({ provider: contact.channelProvider.Telegram, value: telegram }) } if (linkedin !== undefined) { - data.channels.push({ provider: contact.channelProvider.LinkedIn, value: linkedin }) + channels.push({ provider: contact.channelProvider.LinkedIn, value: linkedin }) } if (github !== undefined) { - data.channels.push({ provider: contact.channelProvider.GitHub, value: github }) + channels.push({ provider: contact.channelProvider.GitHub, value: github }) } await findOrUpdate(client, recruit.space.CandidatesPublic, contact.class.Person, candId, data) await client.updateMixin(candId, contact.class.Person, recruit.space.CandidatesPublic, recruit.mixin.Candidate, candidateData) + for (let i = 0; i < channels.length; i++) { + const element = channels[i] + const channelId = (candId + '.channel.' + i.toString()) as Ref + await findOrUpdateAttached(client, recruit.space.CandidatesPublic, contact.class.Channel, channelId, + element, { attachedTo: candId, attachedClass: recruit.mixin.Candidate, collection: 'channels' }) + } const commentId = (candId + '.description.comment') as Ref if (commentData.length > 0) { @@ -349,10 +356,10 @@ function parseSocials (c: any): { sourceFields: string[], telegram: string | und return { sourceFields, telegram, linkedin, github } } -function pushChannel (c: any, data: Data, key: string, provider: Ref): void { +function pushChannel (c: any, channels: AttachedData[], key: string, provider: Ref): void { const value = get(c, key) if (value !== undefined) { - data.channels.push({ provider, value }) + channels.push({ provider, value }) } } export async function findOrUpdate (client: TxOperations, space: Ref, _class: Ref>, objectId: Ref, data: Data): Promise { diff --git a/models/all/src/migration.ts b/models/all/src/migration.ts index 047f977fef..0debdbba42 100644 --- a/models/all/src/migration.ts +++ b/models/all/src/migration.ts @@ -22,6 +22,7 @@ import { attachmentOperation } from '@anticrm/model-attachment' import { leadOperation } from '@anticrm/model-lead' import { recruitOperation } from '@anticrm/model-recruit' import { viewOperation } from '@anticrm/model-view' +import { contactOperation } from '@anticrm/model-contact' export const migrateOperations: MigrateOperation[] = [ coreOperation, @@ -29,5 +30,6 @@ export const migrateOperations: MigrateOperation[] = [ attachmentOperation, leadOperation, recruitOperation, - viewOperation + viewOperation, + contactOperation ] diff --git a/models/contact/src/index.ts b/models/contact/src/index.ts index 020cd96f60..ee79cfb592 100644 --- a/models/contact/src/index.ts +++ b/models/contact/src/index.ts @@ -13,28 +13,23 @@ // limitations under the License. // -import type { Domain, Type, Ref } from '@anticrm/core' -import { DOMAIN_MODEL, IndexKind } from '@anticrm/core' -import { Builder, Model, Prop, TypeString, UX, Index, Collection, ArrOf } from '@anticrm/model' -import type { IntlString, Asset } from '@anticrm/platform' -import chunter from '@anticrm/model-chunter' -import core, { TAccount, TDoc, TSpace, TType } from '@anticrm/model-core' import type { - Contact, - Person, - Persons, - Organization, - Organizations, - Employee, Channel, - ChannelProvider, - EmployeeAccount + ChannelProvider, Contact, Employee, EmployeeAccount, Organization, + Organizations, Person, + Persons } from '@anticrm/contact' -import workbench from '@anticrm/model-workbench' -import view from '@anticrm/model-view' +import type { Domain, Ref } from '@anticrm/core' +import { DOMAIN_MODEL, IndexKind } from '@anticrm/core' +import { Builder, Collection, Index, Model, Prop, TypeRef, TypeString, UX } from '@anticrm/model' import attachment from '@anticrm/model-attachment' -import { ids as contact } from './plugin' +import chunter from '@anticrm/model-chunter' +import core, { TAccount, TAttachedDoc, TDoc, TSpace } from '@anticrm/model-core' import presentation from '@anticrm/model-presentation' +import view from '@anticrm/model-view' +import workbench from '@anticrm/model-workbench' +import type { Asset, IntlString } from '@anticrm/platform' +import { ids as contact } from './plugin' export const DOMAIN_CONTACT = 'contact' as Domain @@ -45,16 +40,6 @@ export class TChannelProvider extends TDoc implements ChannelProvider { placeholder!: IntlString } -@Model(contact.class.TypeChannel, core.class.Type) -export class TTypeChannels extends TType {} - -/** - * @public - */ -export function TypeChannel (): Type { - return { _class: contact.class.TypeChannel, label: 'Channel' as IntlString } -} - @Model(contact.class.Contact, core.class.Doc, DOMAIN_CONTACT) @UX('Contact' as IntlString, contact.icon.Person, undefined, 'name') export class TContact extends TDoc implements Contact { @@ -64,8 +49,8 @@ export class TContact extends TDoc implements Contact { avatar?: string - @Prop(ArrOf(TypeChannel()), 'Contact Info' as IntlString) - channels!: Channel[] + @Prop(Collection(contact.class.Channel), 'Contact Info' as IntlString) + channels?: number @Prop(Collection(attachment.class.Attachment), 'Attachments' as IntlString) attachments?: number @@ -77,6 +62,16 @@ export class TContact extends TDoc implements Contact { city!: string } +@Model(contact.class.Channel, core.class.AttachedDoc, DOMAIN_CONTACT) +@UX('Channel' as IntlString, contact.icon.Person) +export class TChannel extends TAttachedDoc implements Channel { + @Prop(TypeRef(contact.class.ChannelProvider), 'Channel provider' as IntlString) + provider!: Ref + + @Prop(TypeString(), 'Value' as IntlString) + value!: string +} + @Model(contact.class.Person, contact.class.Contact) @UX('Person' as IntlString, contact.icon.Person, undefined, 'name') export class TPerson extends TContact implements Person {} @@ -106,14 +101,14 @@ export class TPersons extends TSpace implements Persons {} export function createModel (builder: Builder): void { builder.createModel( TChannelProvider, - TTypeChannels, TContact, TPerson, TPersons, TOrganization, TOrganizations, TEmployee, - TEmployeeAccount + TEmployeeAccount, + TChannel ) builder.mixin(contact.class.Person, core.class.Class, view.mixin.ObjectFactory, { @@ -140,14 +135,16 @@ export function createModel (builder: Builder): void { attachTo: contact.class.Contact, descriptor: view.viewlet.Table, // eslint-disable-next-line @typescript-eslint/consistent-type-assertions - options: {}, + options: { + lookup: { _id: { channels: contact.class.Channel } } + }, config: [ '', 'city', { presenter: attachment.component.AttachmentsPresenter, label: 'Files', sortingKey: 'attachments' }, 'modifiedOn', { presenter: view.component.RolePresenter, label: 'Role' }, - 'channels' + '$lookup.channels' ] }) @@ -163,7 +160,7 @@ export function createModel (builder: Builder): void { editor: contact.component.EditOrganization }) - builder.mixin(contact.class.TypeChannel, core.class.Class, view.mixin.AttributePresenter, { + builder.mixin(contact.class.Channel, core.class.Class, view.mixin.AttributePresenter, { presenter: contact.component.ChannelsPresenter }) @@ -255,4 +252,5 @@ export function createModel (builder: Builder): void { }, contact.completion.OrganizationCategory) } +export { contactOperation } from './migration' export { contact as default } diff --git a/models/contact/src/migration.ts b/models/contact/src/migration.ts new file mode 100644 index 0000000000..a8eaa531de --- /dev/null +++ b/models/contact/src/migration.ts @@ -0,0 +1,214 @@ +// +// 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. +// + +import { Channel, ChannelProvider, Contact } from '@anticrm/contact' +import { Class, DOMAIN_TX, generateId, Ref, SortingOrder, TxCreateDoc, TxCUD, TxRemoveDoc, TxUpdateDoc } from '@anticrm/core' +import { MigrateOperation, MigrationClient, MigrationUpgradeClient } from '@anticrm/model' +import core from '@anticrm/model-core' +import contact, { DOMAIN_CONTACT } from './index' + +function createChannel (tx: TxCUD, channel: any): Channel { + const doc: Channel = { + _class: contact.class.Channel, + _id: generateId(), + attachedToClass: tx.objectClass, + attachedTo: tx.objectId, + space: tx.objectSpace, + modifiedBy: tx.modifiedBy, + modifiedOn: tx.modifiedOn, + collection: 'channels', + value: channel.value, + provider: channel.provider + } + return doc +} + +async function createTx (client: MigrationClient, tx: TxCUD, doc: Channel): Promise { + await client.create>(DOMAIN_TX, { + _class: core.class.TxCreateDoc, + _id: generateId(), + objectId: doc._id, + objectSpace: doc.space, + objectClass: doc._class, + space: tx.space, + modifiedBy: tx.modifiedBy, + modifiedOn: tx.modifiedOn, + attributes: { + collection: doc.collection, + attachedToClass: doc.attachedToClass, + attachedTo: doc.attachedTo, + value: doc.value, + provider: doc.provider + } + }) +} + +async function removeTx (client: MigrationClient, tx: TxCUD, doc: Channel): Promise { + await client.create>(DOMAIN_TX, { + _class: core.class.TxRemoveDoc, + _id: generateId(), + objectId: doc._id, + objectSpace: doc.space, + objectClass: doc._class, + space: tx.space, + modifiedBy: tx.modifiedBy, + modifiedOn: tx.modifiedOn + }) +} + +async function processCreateTxes (client: MigrationClient, createTxes: TxCreateDoc[]): Promise, Map, Channel>>> { + const result: Map, Map, Channel>> = new Map, Map, Channel>>() + for (const tx of createTxes) { + if (tx.attributes.channels == null) continue + const { channels, ...attributes } = tx.attributes + const current = result.get(tx.objectId) + for (const channel of (channels as any) ?? []) { + const doc = createChannel(tx, channel) + if (current !== undefined) { + current.set(channel.provider, doc) + } else { + const map = new Map, Channel>() + map.set(channel.provider, doc) + result.set(tx.objectId, map) + } + await createTx(client, tx, doc) + } + + await client.update>(DOMAIN_TX, { _id: tx._id }, { + attributes: attributes + }) + } + return result +} + +export async function processRemoveTxes (client: MigrationClient, txes: TxRemoveDoc[], result: Map, Map, Channel>>): Promise, Map, Channel>>> { + for (const tx of txes) { + const current = result.get(tx.objectId) + if (current != null) { + for (const provider of current.keys()) { + const doc = current.get(provider) + if (doc !== undefined) { + await removeTx(client, tx, doc) + current.delete(provider) + } + } + } + } + return result +} + +export async function migrateContactChannels (client: MigrationClient, classes: Ref>[]): Promise { + const objectIds: Ref[] = [] + const contacts = await client.find(DOMAIN_CONTACT, { _class: { $in: classes } }) + for (const doc of contacts) { + const obj = doc as any + if (obj.channels != null && Array.isArray(obj.channels)) { + objectIds.push(doc._id) + } + } + + const createTxes = await client.find>(DOMAIN_TX, { + _class: core.class.TxCreateDoc, + objectId: { $in: objectIds } + }) + const objectChannels = await processCreateTxes(client, createTxes) + + const updateTxes = await client.find>(DOMAIN_TX, { + _class: core.class.TxUpdateDoc, + objectId: { $in: objectIds } + }, { sort: { modifiedOn: SortingOrder.Ascending } }) + for (const tx of updateTxes) { + if (tx.operations.channels === undefined) continue + const { channels, ...operations } = tx.operations + const current = objectChannels.get(tx.objectId) + if (current !== undefined) { + const providers = new Set>(current.keys()) + for (const channel of (channels as any) ?? []) { + const doc = current.get(channel.provider) + if (doc !== undefined) { + providers.delete(doc.provider) + doc.value = channel.value + await client.create>(DOMAIN_TX, { + _class: core.class.TxUpdateDoc, + _id: generateId(), + objectId: doc._id, + objectSpace: doc.space, + objectClass: doc._class, + space: tx.space, + modifiedBy: tx.modifiedBy, + modifiedOn: tx.modifiedOn, + operations: { + value: doc.value + } + }) + } else { + const doc = createChannel(tx, channel) + current.set(channel.provider, doc) + await createTx(client, tx, doc) + } + } + + for (const provider of providers.keys()) { + const doc = current.get(provider) + if (doc !== undefined) { + await removeTx(client, tx, doc) + current.delete(provider) + } + } + } else { + for (const channel of (channels as any) ?? []) { + const doc = createChannel(tx, channel) + const map = new Map, Channel>() + map.set(channel.provider, doc) + objectChannels.set(tx.objectId, map) + } + } + if (Object.keys(operations).length > 0) { + await client.update>(DOMAIN_TX, { _id: tx._id }, { + operations: operations + }) + } else { + await client.delete>(DOMAIN_TX, tx._id) + } + } + + const removeTxes = await client.find>(DOMAIN_TX, { + _class: core.class.TxRemoveDoc, + objectId: { $in: objectIds } + }) + + const result = await processRemoveTxes(client, removeTxes, objectChannels) + for (const contact of result.values()) { + for (const channel of contact.values()) { + await client.create(DOMAIN_CONTACT, channel) + } + } + for (const id of objectIds) { + const channels = result.get(id)?.size ?? 0 + await client.update(DOMAIN_CONTACT, { _id: id }, { + channels: channels + }) + } +} + +export const contactOperation: MigrateOperation = { + async migrate (client: MigrationClient): Promise { + const classes = [contact.class.Contact, contact.class.Person, contact.class.Employee, contact.class.Organization] + await migrateContactChannels(client, classes) + }, + async upgrade (client: MigrationUpgradeClient): Promise { + } +} diff --git a/models/contact/src/plugin.ts b/models/contact/src/plugin.ts index ea94f61a2b..55a056237c 100644 --- a/models/contact/src/plugin.ts +++ b/models/contact/src/plugin.ts @@ -14,9 +14,8 @@ // import { IntlString, mergeIds, Resource } from '@anticrm/platform' -import type { Ref, Class, Type } from '@anticrm/core' +import type { Ref } from '@anticrm/core' import contact, { contactId } from '@anticrm/contact' -import type { Channel } from '@anticrm/contact' import type { AnyComponent } from '@anticrm/ui' import {} from '@anticrm/core' import { Application } from '@anticrm/workbench' @@ -51,9 +50,6 @@ export const ids = mergeIds(contactId, contact, { SearchPerson: '' as IntlString, SearchOrganization: '' as IntlString }, - class: { - TypeChannel: '' as Ref>> - }, completion: { PersonQuery: '' as Resource, EmployeeQuery: '' as Resource, diff --git a/models/demo/src/creation.ts b/models/demo/src/creation.ts index d9821a4eda..5d05a0e71f 100644 --- a/models/demo/src/creation.ts +++ b/models/demo/src/creation.ts @@ -33,8 +33,7 @@ export async function createDeps (client: Client): Promise { contact.space.Employee, { name: 'Chen,Rosamund', - city: 'Mountain View', - channels: [] + city: 'Mountain View' }, account.employee ) @@ -44,15 +43,13 @@ export async function createDeps (client: Client): Promise { recruit.space.CandidatesPublic, { name: 'P.,Andrey', - city: 'Monte Carlo', - channels: [ - { - provider: contact.channelProvider.Email, - value: 'andrey@hc.engineering' - } - ] + city: 'Monte Carlo' } ) + await tx.addCollection(contact.class.Channel, recruit.space.CandidatesPublic, u1, contact.class.Person, 'channels', { + provider: contact.channelProvider.Email, + value: 'andrey@hc.engineering' + }) await tx.createMixin(u1, contact.class.Person, recruit.space.CandidatesPublic, recruit.mixin.Candidate, { title: 'Chief Architect' @@ -63,15 +60,13 @@ export async function createDeps (client: Client): Promise { recruit.space.CandidatesPublic, { name: 'M.,Marina', - city: 'Los Angeles', - channels: [ - { - provider: contact.channelProvider.Email, - value: 'marina@hc.engineering' - } - ] + city: 'Los Angeles' } ) + await tx.addCollection(contact.class.Channel, recruit.space.CandidatesPublic, u2, contact.class.Person, 'channels', { + provider: contact.channelProvider.Email, + value: 'marina@hc.engineering' + }) await tx.createMixin(u2, contact.class.Person, recruit.space.CandidatesPublic, recruit.mixin.Candidate, { title: 'Chief Designer' }) @@ -81,15 +76,13 @@ export async function createDeps (client: Client): Promise { recruit.space.CandidatesPublic, { name: 'P.,Alex', - city: 'Krasnodar, Russia', - channels: [ - { - provider: contact.channelProvider.Email, - value: 'alex@hc.engineering' - } - ] + city: 'Krasnodar, Russia' } ) + await tx.addCollection(contact.class.Channel, recruit.space.CandidatesPublic, u3, contact.class.Person, 'channels', { + provider: contact.channelProvider.Email, + value: 'alex@hc.engineering' + }) await tx.createMixin(u3, contact.class.Person, recruit.space.CandidatesPublic, recruit.mixin.Candidate, { title: 'Frontend Engineer' }) diff --git a/models/inventory/src/index.ts b/models/inventory/src/index.ts index 77bda21d07..edd73774ac 100644 --- a/models/inventory/src/index.ts +++ b/models/inventory/src/index.ts @@ -14,17 +14,16 @@ // limitations under the License. // -// To help typescript locate view plugin properly -import type { Category, Product, Variant } from '@anticrm/inventory' import { Doc, Domain, FindOptions, Ref } from '@anticrm/core' +import type { Category, Product, Variant } from '@anticrm/inventory' import { Builder, Collection, Model, Prop, TypeRef, TypeString, UX } from '@anticrm/model' -import core, { TAttachedDoc } from '@anticrm/model-core' -import type { IntlString } from '@anticrm/platform' -import type {} from '@anticrm/view' -import inventory from './plugin' -import workbench from '@anticrm/model-workbench' -import view from '@anticrm/view' import attachment from '@anticrm/model-attachment' +import core, { TAttachedDoc } from '@anticrm/model-core' +import workbench from '@anticrm/model-workbench' +import type { IntlString } from '@anticrm/platform' +import type { } from '@anticrm/view' +import view from '@anticrm/view' +import inventory from './plugin' export const DOMAIN_INVENTORY = 'inventory' as Domain @Model(inventory.class.Category, core.class.AttachedDoc, DOMAIN_INVENTORY) @@ -96,9 +95,7 @@ export function createModel (builder: Builder): void { descriptor: view.viewlet.Table, // eslint-disable-next-line @typescript-eslint/consistent-type-assertions options: { - lookup: { - attachedTo: inventory.class.Category - } + lookup: { attachedTo: inventory.class.Category } } as FindOptions, config: ['', '$lookup.attachedTo', 'modifiedOn'] }) diff --git a/models/lead/src/index.ts b/models/lead/src/index.ts index e404b803ea..bff1c818d6 100644 --- a/models/lead/src/index.ts +++ b/models/lead/src/index.ts @@ -16,7 +16,7 @@ // To help typescript locate view plugin properly import type { Employee } from '@anticrm/contact' -import type { Doc, FindOptions, Ref } from '@anticrm/core' +import type { Doc, FindOptions, Lookup, Ref } from '@anticrm/core' import type { Customer, Funnel, Lead } from '@anticrm/lead' import { Builder, Collection, Mixin, Model, Prop, TypeRef, TypeString, UX } from '@anticrm/model' import attachment from '@anticrm/model-attachment' @@ -115,15 +115,33 @@ export function createModel (builder: Builder): void { lead.space.DefaultFunnel ) + builder.createDoc(view.class.Viewlet, core.space.Model, { + attachTo: lead.mixin.Customer, + descriptor: view.viewlet.Table, + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions + options: { + lookup: { _id: { channels: contact.class.Channel } } as any + } as FindOptions, // TODO: fix + config: [ + '', + { key: 'leads', presenter: lead.component.LeadsPresenter, label: lead.string.Leads }, + 'modifiedOn', + '$lookup.channels' + ] + }) + + const leadLookup: Lookup = + { + attachedTo: [contact.class.Contact, { _id: { channels: contact.class.Channel } }], + state: task.class.State + } + builder.createDoc(view.class.Viewlet, core.space.Model, { attachTo: lead.class.Lead, descriptor: view.viewlet.Table, // eslint-disable-next-line @typescript-eslint/consistent-type-assertions options: { - lookup: { - attachedTo: contact.class.Contact, - state: task.class.State - } + lookup: leadLookup } as FindOptions, // TODO: fix config: [ '', @@ -132,7 +150,7 @@ export function createModel (builder: Builder): void { { presenter: attachment.component.AttachmentsPresenter, label: 'Files', sortingKey: 'attachments' }, { presenter: chunter.component.CommentsPresenter, label: 'Comments', sortingKey: 'comments' }, 'modifiedOn', - '$lookup.attachedTo.channels' + '$lookup.attachedTo.$lookup.channels' ] }) @@ -141,10 +159,7 @@ export function createModel (builder: Builder): void { descriptor: task.viewlet.Kanban, // eslint-disable-next-line @typescript-eslint/consistent-type-assertions options: { - lookup: { - customer: contact.class.Contact, - state: task.class.State - } + lookup: leadLookup } as FindOptions, // TODO: fix config: ['$lookup.customer', '$lookup.state'] }) diff --git a/models/recruit/src/index.ts b/models/recruit/src/index.ts index 372bd9c466..3eb8da79a4 100644 --- a/models/recruit/src/index.ts +++ b/models/recruit/src/index.ts @@ -14,7 +14,7 @@ // import type { Employee } from '@anticrm/contact' -import { Doc, FindOptions, Ref, Timestamp } from '@anticrm/core' +import { Doc, FindOptions, Lookup, Ref, Timestamp } from '@anticrm/core' import { Builder, Collection, Mixin, Model, Prop, TypeBoolean, TypeDate, TypeRef, TypeString, UX } from '@anticrm/model' import attachment from '@anticrm/model-attachment' import chunter from '@anticrm/model-chunter' @@ -153,9 +153,7 @@ export function createModel (builder: Builder): void { descriptor: view.viewlet.Table, // eslint-disable-next-line @typescript-eslint/consistent-type-assertions options: { - // lookup: { - // resume: chunter.class.Attachment - // } + lookup: { _id: { channels: contact.class.Channel } } } as FindOptions, // TODO: fix config: [ '', @@ -165,21 +163,24 @@ export function createModel (builder: Builder): void { { presenter: attachment.component.AttachmentsPresenter, label: 'Files', sortingKey: 'attachments' }, { presenter: chunter.component.CommentsPresenter, label: 'Comments', sortingKey: 'comments' }, 'modifiedOn', - 'channels' + '$lookup.channels' ] }) + const applicantTableLookup: Lookup = + { + attachedTo: [recruit.mixin.Candidate, { _id: { channels: contact.class.Channel } }], + state: task.class.State, + assignee: contact.class.Employee, + doneState: task.class.DoneState + } + builder.createDoc(view.class.Viewlet, core.space.Model, { attachTo: recruit.class.Applicant, descriptor: view.viewlet.Table, // eslint-disable-next-line @typescript-eslint/consistent-type-assertions options: { - lookup: { - attachedTo: recruit.mixin.Candidate, - state: task.class.State, - assignee: contact.class.Employee, - doneState: task.class.DoneState - } + lookup: applicantTableLookup } as FindOptions, // TODO: fix config: [ '', @@ -190,21 +191,24 @@ export function createModel (builder: Builder): void { { presenter: attachment.component.AttachmentsPresenter, label: 'Files', sortingKey: 'attachments' }, { presenter: chunter.component.CommentsPresenter, label: 'Comments', sortingKey: 'comments' }, 'modifiedOn', - '$lookup.attachedTo.channels' + '$lookup.attachedTo.$lookup.channels' ] }) + const applicantKanbanLookup: Lookup = + { + attachedTo: [recruit.mixin.Candidate, { _id: { channels: contact.class.Channel } }], + state: task.class.State + } + builder.createDoc(view.class.Viewlet, core.space.Model, { attachTo: recruit.class.Applicant, descriptor: task.viewlet.Kanban, // eslint-disable-next-line @typescript-eslint/consistent-type-assertions options: { - lookup: { - attachedTo: recruit.mixin.Candidate, - state: task.class.State - } + lookup: applicantKanbanLookup } as FindOptions, // TODO: fix - config: ['$lookup.attachedTo', '$lookup.state', '$lookup.attachedTo.city', '$lookup.attachedTo.channels'] + config: ['$lookup.attachedTo', '$lookup.state', '$lookup.attachedTo.city', '$lookup.attachedTo.$lookup.channels'] }) builder.createDoc(view.class.Viewlet, core.space.Model, { @@ -212,12 +216,7 @@ export function createModel (builder: Builder): void { descriptor: task.viewlet.StatusTable, // eslint-disable-next-line @typescript-eslint/consistent-type-assertions options: { - lookup: { - attachedTo: recruit.mixin.Candidate, - state: task.class.State, - assignee: contact.class.Employee, - doneState: task.class.DoneState - } + lookup: applicantTableLookup } as FindOptions, // TODO: fix config: [ '', @@ -228,7 +227,7 @@ export function createModel (builder: Builder): void { { presenter: attachment.component.AttachmentsPresenter, label: 'Files', sortingKey: 'attachments' }, { presenter: chunter.component.CommentsPresenter, label: 'Comments', sortingKey: 'comments' }, 'modifiedOn', - '$lookup.attachedTo.channels' + '$lookup.attachedTo.$lookup.channels' ] }) diff --git a/models/task/src/index.ts b/models/task/src/index.ts index 8786881a5b..c9d6528597 100644 --- a/models/task/src/index.ts +++ b/models/task/src/index.ts @@ -13,7 +13,6 @@ // limitations under the License. // -// To help typescript locate view plugin properly import type { Employee } from '@anticrm/contact' import contact from '@anticrm/contact' import { Arr, Class, Doc, Domain, DOMAIN_MODEL, FindOptions, Ref, Space, Timestamp } from '@anticrm/core' @@ -281,9 +280,7 @@ export function createModel (builder: Builder): void { descriptor: view.viewlet.Table, // eslint-disable-next-line @typescript-eslint/consistent-type-assertions options: { - lookup: { - assignee: contact.class.Employee - } + lookup: { assignee: contact.class.Employee } } as FindOptions, config: [ '', @@ -313,9 +310,8 @@ export function createModel (builder: Builder): void { // eslint-disable-next-line @typescript-eslint/consistent-type-assertions options: { lookup: { - assignee: contact.class.Employee, - state: task.class.State - // attachedTo: core.class.Doc + state: task.class.State, + assignee: contact.class.Employee } } as FindOptions, // TODO: fix config: [ diff --git a/packages/core/src/__tests__/memdb.test.ts b/packages/core/src/__tests__/memdb.test.ts index 8355dedbf9..fef2af63c2 100644 --- a/packages/core/src/__tests__/memdb.test.ts +++ b/packages/core/src/__tests__/memdb.test.ts @@ -252,4 +252,33 @@ describe('memdb', () => { const result2 = await client.findAll(test.class.TestComment, {}) expect(result2).toHaveLength(1) }) + + it('lookups', async () => { + const { model } = await createModel() + + const client = new TxOperations(model, core.account.System) + const spaces = await client.findAll(core.class.Space, {}) + expect(spaces).toHaveLength(2) + + const first = await client.addCollection(test.class.TestComment, core.space.Model, spaces[0]._id, spaces[0]._class, 'comments', { + message: 'msg' + }) + + const second = await client.addCollection(test.class.TestComment, core.space.Model, first, test.class.TestComment, 'comments', { + message: 'msg2' + }) + + await client.addCollection(test.class.TestComment, core.space.Model, spaces[0]._id, spaces[0]._class, 'comments', { + message: 'msg3' + }) + + const simple = await client.findAll(test.class.TestComment, { _id: first }, { lookup: { attachedTo: spaces[0]._class } }) + expect(simple[0].$lookup?.attachedTo).toEqual(spaces[0]) + + const nested = await client.findAll(test.class.TestComment, { _id: second }, { lookup: { attachedTo: [test.class.TestComment, { attachedTo: spaces[0]._class } as any] } }) + expect((nested[0].$lookup?.attachedTo as any).$lookup?.attachedTo).toEqual(spaces[0]) + + const reverse = await client.findAll(spaces[0]._class, { _id: spaces[0]._id }, { lookup: { _id: { comments: test.class.TestComment } } }) + expect((reverse[0].$lookup as any).comments).toHaveLength(2) + }) }) diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 4d09032fd8..61a4c9f2af 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -21,6 +21,7 @@ export * from './hierarchy' export * from './memdb' export * from './client' export * from './operator' +export * from './objvalue' export * from './query' export * from './server' export * from './measurements' diff --git a/packages/core/src/memdb.ts b/packages/core/src/memdb.ts index e289c48e12..32182bafdc 100644 --- a/packages/core/src/memdb.ts +++ b/packages/core/src/memdb.ts @@ -15,11 +15,12 @@ import { PlatformError, Severity, Status } from '@anticrm/platform' import clone from 'just-clone' +import { Lookup, ReverseLookups } from '.' import type { Class, Doc, Ref } from './classes' import core from './component' import { Hierarchy } from './hierarchy' import { matchQuery, resultSort } from './query' -import type { DocumentQuery, FindOptions, FindResult, LookupData, Refs, Storage, TxResult, WithLookup } from './storage' +import type { DocumentQuery, FindOptions, FindResult, LookupData, Storage, TxResult, WithLookup } from './storage' import type { Tx, TxCreateDoc, TxMixin, TxPutBag, TxRemoveDoc, TxUpdateDoc } from './tx' import { TxProcessor } from './tx' @@ -76,16 +77,43 @@ export abstract class MemDb extends TxProcessor { return doc as T } - private lookup(docs: T[], lookup: Refs): WithLookup[] { + private async getLookupValue (doc: T, lookup: Lookup, result: LookupData): Promise { + for (const key in lookup) { + if (key === '_id') { + await this.getReverseLookupValue(doc, lookup, result) + continue + } + const value = (lookup as any)[key] + if (Array.isArray(value)) { + const [_class, nested] = value + const objects = await this.findAll(_class, { _id: (doc as any)[key] }) + ;(result as any)[key] = objects[0] + const nestedResult = {} + const parent = (result as any)[key] + await this.getLookupValue(parent, nested, nestedResult) + Object.assign(parent, { + $lookup: nestedResult + }) + } else { + const objects = await this.findAll(value, { _id: (doc as any)[key] }) + ;(result as any)[key] = objects[0] + } + } + } + + private async getReverseLookupValue (doc: T, lookup: ReverseLookups, result: LookupData): Promise { + for (const key in lookup._id) { + const value = lookup._id[key] + const objects = await this.findAll(value, { attachedTo: doc._id }) + ;(result as any)[key] = objects + } + } + + private async lookup(docs: T[], lookup: Lookup): Promise[]> { const withLookup: WithLookup[] = [] for (const doc of docs) { const result: LookupData = {} - for (const key in lookup) { - const id = (doc as any)[key] as Ref - if (id != null) { - (result as any)[key] = this.getObject(id) - } - } + await this.getLookupValue(doc, lookup, result) withLookup.push(Object.assign({}, doc, { $lookup: result })) } return withLookup @@ -114,7 +142,7 @@ export abstract class MemDb extends TxProcessor { result = result.filter(r => (r as any)[_class] !== undefined) } - if (options?.lookup !== undefined) result = this.lookup(result as T[], options.lookup) + if (options?.lookup !== undefined) result = await this.lookup(result as T[], options.lookup) if (options?.sort !== undefined) resultSort(result, options?.sort) diff --git a/packages/core/src/storage.ts b/packages/core/src/storage.ts index cd3db72a07..11bb73f74b 100644 --- a/packages/core/src/storage.ts +++ b/packages/core/src/storage.ts @@ -14,7 +14,7 @@ // import type { KeysByType } from 'simplytyped' -import type { Class, Doc, Ref } from './classes' +import type { AttachedDoc, Class, Doc, Ref } from './classes' import type { Tx } from './tx' /** @@ -49,13 +49,42 @@ export type DocumentQuery = { * @public */ export type ToClassRef = { - [P in keyof T]?: T[P] extends Ref ? Ref> : never + [P in keyof T]?: T[P] extends Ref | null ? Ref> | [Ref>, Lookup] : never } /** * @public */ -export type Refs = ToClassRef>>> +export type RefKeys = Pick> + +/** + * @public + */ +export type NullableRef = Ref | null + +/** + * @public + */ +export type Refs = ToClassRef> + +/** + * @public + */ +export interface ReverseLookups { + _id?: ReverseLookup +} + +/** + * @public + */ +export interface ReverseLookup { + [key: string]: Ref> +} + +/** + * @public + */ +export type Lookup = Refs | ReverseLookups | (Refs & ReverseLookups) /** * @public @@ -64,7 +93,7 @@ export type Refs = ToClassRef>>> export type FindOptions = { limit?: number sort?: SortingQuery - lookup?: Refs + lookup?: Lookup } /** @@ -85,8 +114,8 @@ export enum SortingOrder { /** * @public */ -export type RefsAsDocs = { - [P in keyof T]: T[P] extends Ref ? X : never +export type RefsAsDocs = { + [P in keyof T]: T[P] extends Ref ? (T extends X ? X : X | WithLookup) : never } /** @@ -97,7 +126,9 @@ export type RemoveNever = Omit> /** * @public */ -export type LookupData = Partial>> +export type LookupData = Partial>> | RemoveNever<{ + [key: string]: Doc[] +}> /** * @public diff --git a/packages/query/src/__tests__/minmodel.ts b/packages/query/src/__tests__/minmodel.ts index 07906a99c1..d2d2309856 100644 --- a/packages/query/src/__tests__/minmodel.ts +++ b/packages/query/src/__tests__/minmodel.ts @@ -13,7 +13,7 @@ // limitations under the License. // -import type { Account, Arr, Class, Data, Doc, Mixin, Obj, Ref, TxCreateDoc, TxCUD } from '@anticrm/core' +import type { Account, Arr, Class, Data, Doc, Domain, Mixin, Obj, Ref, TxCreateDoc, TxCUD } from '@anticrm/core' import core, { AttachedDoc, ClassifierKind, DOMAIN_MODEL, DOMAIN_TX, TxFactory } from '@anticrm/core' import type { IntlString, Plugin } from '@anticrm/platform' import { plugin } from '@anticrm/platform' @@ -62,6 +62,8 @@ export const test = plugin('test' as Plugin, { } }) +const DOMAIN_TEST: Domain = 'test' as Domain + /** * @public * Generate minimal model for testing purposes. @@ -86,7 +88,7 @@ export function genMinModel (): TxCUD[] { txes.push(createClass(test.mixin.TestMixin, { label: 'TestMixin' as IntlString, extends: core.class.Doc, kind: ClassifierKind.MIXIN })) - txes.push(createClass(test.class.TestComment, { label: 'TestComment' as IntlString, extends: core.class.AttachedDoc, kind: ClassifierKind.CLASS })) + txes.push(createClass(test.class.TestComment, { label: 'TestComment' as IntlString, extends: core.class.AttachedDoc, kind: ClassifierKind.CLASS, domain: DOMAIN_TEST })) const u1 = 'User1' as Ref const u2 = 'User2' as Ref diff --git a/packages/query/src/__tests__/query.test.ts b/packages/query/src/__tests__/query.test.ts index e0b6d5e96e..c532a956f1 100644 --- a/packages/query/src/__tests__/query.test.ts +++ b/packages/query/src/__tests__/query.test.ts @@ -13,8 +13,8 @@ // limitations under the License. // -import core, { createClient, Doc, SortingOrder, Space, Tx, TxCreateDoc, TxOperations } from '@anticrm/core' -import { genMinModel } from './minmodel' +import core, { createClient, Doc, generateId, Ref, SortingOrder, Space, Tx, TxCreateDoc, TxOperations, WithLookup } from '@anticrm/core' +import { AttachedComment, test, genMinModel } from './minmodel' import { LiveQuery } from '..' import { connect } from './connection' @@ -71,7 +71,6 @@ describe('query', () => { let attempt = 0 const pp = new Promise((resolve) => { liveQuery.query(core.class.Space, { private: false }, (result) => { - console.log('query result attempt', result, attempt) expect(result).toHaveLength(expectedLength + attempt) if (attempt > 0) { expect((result[expectedLength + attempt - 1] as any).x).toBe(attempt) @@ -213,20 +212,20 @@ describe('query', () => { const { liveQuery, factory } = await getClient() const limit = 1 - let attempt = -1 - let doneCount = 0 + let attempt = 0 + let descAttempt = 0 const pp1 = new Promise((resolve) => { liveQuery.query( core.class.Space, { private: true }, (result) => { - if (attempt === 0 && result.length > 0) { + if (result.length > 0) { expect(result.length).toEqual(limit) expect(result[0].name).toMatch('0') + attempt++ } - if (attempt === 0) doneCount++ - if (doneCount === 2) resolve(null) + if (attempt === 1) resolve(null) }, { limit: limit, sort: { name: SortingOrder.Ascending } } ) @@ -237,19 +236,18 @@ describe('query', () => { core.class.Space, { private: true }, (result) => { - if (attempt > 0 && result.length > 0) { + if (result.length > 0) { expect(result.length).toEqual(limit) - expect(result[0].name).toMatch(attempt.toString()) + expect(result[0].name).toMatch(descAttempt.toString()) + descAttempt++ } - if (attempt === 9) doneCount++ - if (doneCount === 2) resolve(null) + if (descAttempt === 10) resolve(null) }, { limit: limit, sort: { name: SortingOrder.Descending } } ) }) for (let i = 0; i < 10; i++) { - attempt = i await factory.createDoc(core.class.Space, core.space.Model, { private: true, name: i.toString(), @@ -362,6 +360,364 @@ describe('query', () => { await pp }) + it('lookup query add doc', async () => { + const { liveQuery, factory } = await getClient() + const futureSpace: Space = { + _id: generateId(), + _class: core.class.Space, + private: false, + members: [], + space: core.space.Model, + name: 'new space', + description: '', + archived: false, + modifiedBy: core.account.System, + modifiedOn: 0 + } + const comment = await factory.addCollection(test.class.TestComment, futureSpace._id, futureSpace._id, core.class.Space, 'comments', { + message: 'test' + }) + let attempt = 0 + const pp = new Promise((resolve) => { + liveQuery.query( + test.class.TestComment, + { _id: comment }, + (result) => { + const comment = result[0] + if (comment !== undefined) { + if (attempt > 0) { + expect((comment as WithLookup).$lookup?.space).toEqual(futureSpace) + resolve(null) + } else { + expect((comment as WithLookup).$lookup?.space).toBeUndefined() + attempt++ + } + } + }, + { lookup: { space: core.class.Space } } + ) + }) + + await factory.createDoc(core.class.Space, futureSpace.space, { + ...futureSpace + }, futureSpace._id) + await pp + }) + + it('lookup nested query add doc', async () => { + const { liveQuery, factory } = await getClient() + const futureSpace: Space = { + _id: generateId(), + _class: core.class.Space, + private: false, + members: [], + space: core.space.Model, + name: 'new space', + description: '', + archived: false, + modifiedBy: core.account.System, + modifiedOn: 0 + } + const comment = await factory.addCollection(test.class.TestComment, futureSpace._id, futureSpace._id, core.class.Space, 'comments', { + message: 'test' + }) + const childComment = await factory.addCollection(test.class.TestComment, futureSpace._id, comment, test.class.TestComment, 'comments', { + message: 'child' + }) + let attempt = 0 + const pp = new Promise((resolve) => { + liveQuery.query( + test.class.TestComment, + { _id: childComment }, + (result) => { + const comment = result[0] + if (comment !== undefined) { + if (attempt > 0) { + expect(((comment as WithLookup).$lookup?.attachedTo as WithLookup)?.$lookup?.space).toEqual(futureSpace) + resolve(null) + } else { + expect(((comment as WithLookup).$lookup?.attachedTo as WithLookup)?.$lookup?.space).toBeUndefined() + attempt++ + } + } + }, + { lookup: { attachedTo: [test.class.TestComment, { space: core.class.Space }] } } + ) + }) + + await factory.createDoc(core.class.Space, futureSpace.space, { + ...futureSpace + }, futureSpace._id) + await pp + }) + + it('lookup reverse query add doc', async () => { + const { liveQuery, factory } = await getClient() + const spaces = await liveQuery.findAll(core.class.Space, {}) + const parentComment = await factory.addCollection(test.class.TestComment, spaces[0]._id, spaces[0]._id, spaces[0]._class, 'comments', { + message: 'test' + }) + let attempt = 0 + const childLength = 3 + const pp = new Promise((resolve) => { + liveQuery.query( + test.class.TestComment, + { _id: parentComment }, + (result) => { + const comment = result[0] + if (comment !== undefined) { + expect(((comment as WithLookup).$lookup as any)?.comments).toHaveLength(attempt++) + } + if (attempt === childLength) { + resolve(null) + } + }, + { lookup: { _id: { comments: test.class.TestComment } } } + ) + }) + + for (let index = 0; index < childLength; index++) { + await factory.addCollection(test.class.TestComment, spaces[0]._id, parentComment, test.class.TestComment, 'comments', { + message: index.toString() + }) + } + await pp + }) + + it('lookup query remove doc', async () => { + const { liveQuery, factory } = await getClient() + const futureSpace = await factory.createDoc(core.class.Space, core.space.Model, { + name: 'new space', + description: '', + archived: false, + private: false, + members: [] + }) + const comment = await factory.addCollection(test.class.TestComment, futureSpace, futureSpace, core.class.Space, 'comments', { + message: 'test' + }) + let attempt = 0 + const pp = new Promise((resolve) => { + liveQuery.query( + test.class.TestComment, + { _id: comment }, + (result) => { + const comment = result[0] + if (comment !== undefined) { + if (attempt > 0) { + expect((comment as WithLookup).$lookup?.space).toBeUndefined() + resolve(null) + } else { + expect(((comment as WithLookup).$lookup?.space as Doc)?._id).toEqual(futureSpace) + attempt++ + } + } + }, + { lookup: { space: core.class.Space } } + ) + }) + + await factory.removeDoc(core.class.Space, core.space.Model, futureSpace) + + await pp + }) + + it('lookup nested query remove doc', async () => { + const { liveQuery, factory } = await getClient() + const futureSpace = await factory.createDoc(core.class.Space, core.space.Model, { + name: 'new space', + description: '', + archived: false, + private: false, + members: [] + }) + const comment = await factory.addCollection(test.class.TestComment, futureSpace, futureSpace, core.class.Space, 'comments', { + message: 'test' + }) + const childComment = await factory.addCollection(test.class.TestComment, futureSpace, comment, test.class.TestComment, 'comments', { + message: 'child' + }) + let attempt = 0 + const pp = new Promise((resolve) => { + liveQuery.query( + test.class.TestComment, + { _id: childComment }, + (result) => { + const comment = result[0] + if (comment !== undefined) { + if (attempt > 0) { + expect(((comment as WithLookup).$lookup?.attachedTo as WithLookup)?.$lookup?.space).toBeUndefined() + resolve(null) + } else { + expect((((comment as WithLookup).$lookup?.attachedTo as WithLookup)?.$lookup?.space as Doc)?._id).toEqual(futureSpace) + attempt++ + } + } + }, + { lookup: { attachedTo: [test.class.TestComment, { space: core.class.Space }] } } + ) + }) + + await factory.removeDoc(core.class.Space, core.space.Model, futureSpace) + + await pp + }) + + it('lookup reverse query remove doc', async () => { + const { liveQuery, factory } = await getClient() + const spaces = await liveQuery.findAll(core.class.Space, {}) + const comments = await liveQuery.findAll(test.class.TestComment, {}) + expect(comments).toHaveLength(0) + const parentComment = await factory.addCollection(test.class.TestComment, spaces[0]._id, spaces[0]._id, spaces[0]._class, 'comments', { + message: 'test' + }) + let attempt = 0 + const childLength = 3 + const childs: Ref[] = [] + for (let index = 0; index < childLength; index++) { + childs.push(await factory.addCollection(test.class.TestComment, spaces[0]._id, parentComment, test.class.TestComment, 'comments', { + message: index.toString() + })) + } + const pp = new Promise((resolve) => { + liveQuery.query( + test.class.TestComment, + { _id: parentComment }, + (result) => { + const comment = result[0] + if (comment !== undefined) { + expect(((comment as WithLookup).$lookup as any)?.comments).toHaveLength(childLength - attempt) + attempt++ + } + if (attempt === childLength) { + resolve(null) + } + }, + { lookup: { _id: { comments: test.class.TestComment } } } + ) + }) + + for (const child of childs) { + await factory.removeCollection(test.class.TestComment, spaces[0]._id, child, parentComment, test.class.TestComment, 'comments') + } + await pp + }) + + it('lookup query update doc', async () => { + const { liveQuery, factory } = await getClient() + let attempt = 0 + const futureSpace = await factory.createDoc(core.class.Space, core.space.Model, { + name: '0', + description: '', + archived: false, + private: false, + members: [] + }) + + const comment = await factory.addCollection(test.class.TestComment, futureSpace, futureSpace, core.class.Space, 'comments', { + message: 'test' + }) + const pp = new Promise((resolve) => { + liveQuery.query( + test.class.TestComment, + { _id: comment }, + (result) => { + const comment = result[0] + if (comment !== undefined) { + expect(((comment as WithLookup).$lookup?.space as Space).name).toEqual(attempt.toString()) + } + if (attempt > 0) { + resolve(null) + } else { + attempt++ + } + }, + { lookup: { space: core.class.Space } } + ) + }) + + await factory.updateDoc(core.class.Space, core.space.Model, futureSpace, { + name: '1' + }) + await pp + }) + + it('lookup nested query update doc', async () => { + const { liveQuery, factory } = await getClient() + let attempt = 0 + const futureSpace = await factory.createDoc(core.class.Space, core.space.Model, { + name: '0', + description: '', + archived: false, + private: false, + members: [] + }) + const comment = await factory.addCollection(test.class.TestComment, futureSpace, futureSpace, core.class.Space, 'comments', { + message: 'test' + }) + const childComment = await factory.addCollection(test.class.TestComment, futureSpace, comment, test.class.TestComment, 'comments', { + message: 'child' + }) + const pp = new Promise((resolve) => { + liveQuery.query( + test.class.TestComment, + { _id: childComment }, + (result) => { + const comment = result[0] + if (comment !== undefined) { + expect((((comment as WithLookup).$lookup?.attachedTo as WithLookup)?.$lookup?.space as Space).name).toEqual(attempt.toString()) + } + if (attempt > 0) { + resolve(null) + } else { + attempt++ + } + }, + { lookup: { attachedTo: [test.class.TestComment, { space: core.class.Space }] } } + ) + }) + + await factory.updateDoc(core.class.Space, core.space.Model, futureSpace, { + name: '1' + }) + await pp + }) + + it('lookup reverse query update doc', async () => { + const { liveQuery, factory } = await getClient() + const spaces = await liveQuery.findAll(core.class.Space, {}) + const parentComment = await factory.addCollection(test.class.TestComment, spaces[0]._id, spaces[0]._id, spaces[0]._class, 'comments', { + message: 'test' + }) + let attempt = 0 + const childComment = await factory.addCollection(test.class.TestComment, spaces[0]._id, parentComment, test.class.TestComment, 'comments', { + message: '0' + }) + const pp = new Promise((resolve) => { + liveQuery.query( + test.class.TestComment, + { _id: parentComment }, + (result) => { + const comment = result[0] + if (comment !== undefined) { + expect((((comment as WithLookup).$lookup as any)?.comments[0] as AttachedComment).message).toEqual(attempt.toString()) + } + if (attempt > 0) { + resolve(null) + } else { + attempt++ + } + }, + { lookup: { _id: { comments: test.class.TestComment } } } + ) + }) + + await factory.updateCollection(test.class.TestComment, spaces[0]._id, childComment, parentComment, test.class.TestComment, 'comments', { + message: '1' + }) + await pp + }) + // it('update with over limit', async () => { // const { liveQuery, factory } = await getClient() diff --git a/packages/query/src/index.ts b/packages/query/src/index.ts index 9d485944b2..b2e2bcff9a 100644 --- a/packages/query/src/index.ts +++ b/packages/query/src/index.ts @@ -22,12 +22,10 @@ import core, { FindOptions, findProperty, FindResult, - Hierarchy, + getObjectValue, + Hierarchy, Lookup, LookupData, - ModelDb, - Ref, - Refs, - resultSort, + ModelDb, Ref, resultSort, ReverseLookups, SortingQuery, Tx, TxBulkWrite, @@ -86,7 +84,6 @@ export class LiveQuery extends TxProcessor implements Client { const query = q.query for (const key in query) { if (key === '$search') continue - if (key === '_id' && ((query._id as any)?.$like === undefined || query._id === undefined)) continue const value = (query as any)[key] const result = findProperty([doc], key, value) if (result.length === 0) { @@ -123,7 +120,7 @@ export class LiveQuery extends TxProcessor implements Client { _class, query, result, - options, + options: options as FindOptions, callback: callback as (result: Doc[]) => void } this.queries.push(q) @@ -241,8 +238,49 @@ export class LiveQuery extends TxProcessor implements Client { this.sort(q, tx) await this.callback(updatedDoc, q) } else if (this.matchQuery(q, tx)) { - await this.refresh(q) + return await this.refresh(q) } + await this.handleDocUpdateLookup(q, tx) + } + + private async handleDocUpdateLookup (q: Query, tx: TxUpdateDoc): Promise { + if (q.options?.lookup === undefined) return + const lookup = q.options.lookup + if (q.result instanceof Promise) { + q.result = await q.result + } + let needCallback = false + needCallback = this.proccesLookupUpdateDoc(q.result, lookup, tx) + + if (needCallback) { + q.callback(this.clone(q.result)) + } + } + + private proccesLookupUpdateDoc (docs: Doc[], lookup: Lookup, tx: TxUpdateDoc): boolean { + let needCallback = false + const lookupWays = this.getLookupWays(lookup, tx.objectClass) + for (const lookupWay of lookupWays) { + const [objWay, key] = lookupWay + for (const resDoc of docs) { + const obj = getObjectValue(objWay, resDoc) + if (obj === undefined) continue + const value = getObjectValue('$lookup.' + key, obj) + if (Array.isArray(value)) { + const index = value.findIndex((p) => p._id === tx.objectId) + if (index !== -1) { + TxProcessor.updateDoc2Doc(value[index], tx) + needCallback = true + } + } else { + if (obj[key] === tx.objectId) { + TxProcessor.updateDoc2Doc(obj.$lookup[key], tx) + needCallback = true + } + } + } + } + return needCallback } /** @@ -280,13 +318,41 @@ export class LiveQuery extends TxProcessor implements Client { return false } - private async lookup (doc: Doc, lookup: Refs): Promise { - const result: LookupData = {} + private async getLookupValue (doc: T, lookup: Lookup, result: LookupData): Promise { for (const key in lookup) { - const _class = (lookup as any)[key] as Ref> - const _id = (doc as any)[key] as Ref - ;(result as any)[key] = await this.client.findOne(_class, { _id }) + if (key === '_id') { + await this.getReverseLookupValue(doc, lookup, result) + continue + } + const value = (lookup as any)[key] + if (Array.isArray(value)) { + const [_class, nested] = value + const objects = await this.findAll(_class, { _id: (doc as any)[key] }) + ;(result as any)[key] = objects[0] + const nestedResult = {} + const parent = (result as any)[key] + await this.getLookupValue(parent, nested, nestedResult) + Object.assign(parent, { + $lookup: nestedResult + }) + } else { + const objects = await this.findAll(value, { _id: (doc as any)[key] }) + ;(result as any)[key] = objects[0] + } } + } + + private async getReverseLookupValue (doc: T, lookup: ReverseLookups, result: LookupData): Promise { + for (const key in lookup._id) { + const value = lookup._id[key] + const objects = await this.findAll(value, { attachedTo: doc._id }) + ;(result as any)[key] = objects + } + } + + private async lookup (doc: T, lookup: Lookup): Promise { + const result: LookupData = {} + await this.getLookupValue(doc, lookup, result) ;(doc as WithLookup).$lookup = result } @@ -328,6 +394,49 @@ export class LiveQuery extends TxProcessor implements Client { q.callback(this.clone(q.result)) } } + + await this.handleDocAddLookup(q, doc) + } + + private async handleDocAddLookup (q: Query, doc: Doc): Promise { + if (q.options?.lookup === undefined) return + const lookup = q.options.lookup + if (q.result instanceof Promise) { + q.result = await q.result + } + let needCallback = false + needCallback = this.proccesLookupAddDoc(q.result, lookup, doc) + + if (needCallback) { + q.callback(this.clone(q.result)) + } + } + + private proccesLookupAddDoc (docs: Doc[], lookup: Lookup, doc: Doc): boolean { + let needCallback = false + const lookupWays = this.getLookupWays(lookup, doc._class) + for (const lookupWay of lookupWays) { + const [objWay, key] = lookupWay + for (const resDoc of docs) { + const obj = getObjectValue(objWay, resDoc) + if (obj === undefined) continue + const value = getObjectValue('$lookup.' + key, obj) + if (Array.isArray(value)) { + if (this.client.getHierarchy().isDerived(doc._class, core.class.AttachedDoc)) { + if ((doc as AttachedDoc).attachedTo === obj._id) { + value.push(doc) + needCallback = true + } + } + } else { + if (obj[key] === doc._id) { + obj.$lookup[key] = doc + needCallback = true + } + } + } + } + return needCallback } protected async txRemoveDoc (tx: TxRemoveDoc): Promise { @@ -346,7 +455,6 @@ export class LiveQuery extends TxProcessor implements Client { if (q.result instanceof Promise) { q.result = await q.result } - const index = q.result.findIndex((p) => p._id === tx.objectId) if ( q.options?.limit !== undefined && q.options.limit === q.result.length && @@ -354,10 +462,83 @@ export class LiveQuery extends TxProcessor implements Client { ) { return await this.refresh(q) } + const index = q.result.findIndex((p) => p._id === tx.objectId) if (index > -1) { q.result.splice(index, 1) q.callback(this.clone(q.result)) } + await this.handleDocRemoveLookup(q, tx) + } + + private async handleDocRemoveLookup (q: Query, tx: TxRemoveDoc): Promise { + if (q.options?.lookup === undefined) return + let needCallback = false + const lookupWays = this.getLookupWays(q.options.lookup, tx.objectClass) + if (lookupWays.length === 0) return + if (q.result instanceof Promise) { + q.result = await q.result + } + for (const lookupWay of lookupWays) { + const [objWay, key] = lookupWay + const docs = q.result + for (const doc of docs) { + const obj = getObjectValue(objWay, doc) + if (obj === undefined) continue + const value = getObjectValue('$lookup.' + key, obj) + if (value === undefined) continue + if (Array.isArray(value)) { + const index = value.findIndex((p) => p._id === tx.objectId) + if (index !== -1) { + value.splice(index, 1) + needCallback = true + } + } else { + if (value._id === tx.objectId) { + obj.$lookup[key] = undefined + needCallback = true + } + } + } + } + if (needCallback) { + q.callback(this.clone(q.result)) + } + } + + private getLookupWays (lookup: Lookup, _class: Ref>, parent: string = ''): [string, string][] { + const result: [string, string][] = [] + const hierarchy = this.client.getHierarchy() + if (lookup._id !== undefined) { + for (const key in lookup._id) { + const value = (lookup._id as any)[key] + const clazz = hierarchy.isMixin(value) ? hierarchy.getBaseClass(value) : value + if (hierarchy.isDerived(_class, clazz)) { + result.push([parent, key]) + } + } + } + for (const key in lookup) { + if (key === '_id') continue + const value = (lookup as any)[key] + if (Array.isArray(value)) { + const clazz = hierarchy.isMixin(value[0]) ? hierarchy.getBaseClass(value[0]) : value[0] + if (hierarchy.isDerived(_class, clazz)) { + result.push([parent, key]) + } + const lookupKey = '$lookup.' + key + const newParent = parent.length > 0 ? parent + '.' + lookupKey : lookupKey + const nested = this.getLookupWays(value[1], _class, newParent) + if (nested.length > 0) { + result.push(...nested) + } + } else { + const clazz = hierarchy.isMixin(value) ? hierarchy.getBaseClass(value) : value + if (hierarchy.isDerived(_class, clazz)) { + result.push([parent, key]) + } + } + } + return result } protected override async txBulkWrite (tx: TxBulkWrite): Promise { diff --git a/plugins/contact-resources/src/components/Channels.svelte b/plugins/contact-resources/src/components/Channels.svelte new file mode 100644 index 0000000000..9f21e7e98c --- /dev/null +++ b/plugins/contact-resources/src/components/Channels.svelte @@ -0,0 +1,61 @@ + + + +{#if !channels.length} + + showPopup(contact.component.SocialEditor, { values: channels }, ev.target, (result) => { + dispatch('change', result) + })} + /> + +{:else} + +
+ + showPopup(contact.component.SocialEditor, { values: channels }, ev.target, (result) => { + dispatch('change', result) + })} + /> +
+{/if} + + diff --git a/plugins/contact-resources/src/components/ChannelsEditor.svelte b/plugins/contact-resources/src/components/ChannelsEditor.svelte new file mode 100644 index 0000000000..a7f327948f --- /dev/null +++ b/plugins/contact-resources/src/components/ChannelsEditor.svelte @@ -0,0 +1,74 @@ + + + + { save(e.detail) }} /> diff --git a/plugins/contact-resources/src/components/CreateOrganization.svelte b/plugins/contact-resources/src/components/CreateOrganization.svelte index 7aa0fcf658..a657a39c40 100644 --- a/plugins/contact-resources/src/components/CreateOrganization.svelte +++ b/plugins/contact-resources/src/components/CreateOrganization.svelte @@ -16,19 +16,22 @@
- {#if !object.channels || object.channels.length === 0} - - showPopup(SocialEditor, { values: object.channels ?? [] }, ev.target, (result) => { - object.channels = result - })} - /> - - {:else} - -
- - showPopup(SocialEditor, { values: object.channels ?? [] }, ev.target, (result) => { - object.channels = result - })} - /> -
- {/if} + { channels = e.detail }} />
@@ -102,8 +89,5 @@ } .channels { margin-top: 1.25rem; - span { - margin-left: 0.5rem; - } } diff --git a/plugins/contact-resources/src/components/CreatePerson.svelte b/plugins/contact-resources/src/components/CreatePerson.svelte index c478b5b8fc..bacabcdd95 100644 --- a/plugins/contact-resources/src/components/CreatePerson.svelte +++ b/plugins/contact-resources/src/components/CreatePerson.svelte @@ -15,21 +15,23 @@ -->
- {#if !object.channels || object.channels.length === 0} - - showPopup(SocialEditor, { values: object.channels ?? [] }, ev.target, (result) => { - object.channels = result - })} - /> - - {:else} - -
- - showPopup(SocialEditor, { values: object.channels ?? [] }, ev.target, (result) => { - object.channels = result - })} - /> -
- {/if} + { channels = e.detail }} />
diff --git a/plugins/contact-resources/src/components/EditOrganization.svelte b/plugins/contact-resources/src/components/EditOrganization.svelte index 9e52839a01..660aea19d1 100644 --- a/plugins/contact-resources/src/components/EditOrganization.svelte +++ b/plugins/contact-resources/src/components/EditOrganization.svelte @@ -16,13 +16,14 @@ diff --git a/plugins/contact-resources/src/index.ts b/plugins/contact-resources/src/index.ts index e0beddf9e1..9be0306ed4 100644 --- a/plugins/contact-resources/src/index.ts +++ b/plugins/contact-resources/src/index.ts @@ -18,6 +18,8 @@ import { Contact, formatName } from '@anticrm/contact' import { Class, Client, Ref } from '@anticrm/core' import { Resources } from '@anticrm/platform' import { Avatar, ObjectSearchResult, UserInfo } from '@anticrm/presentation' +import ChannelsEditor from './components/ChannelsEditor.svelte' +import Channels from './components/Channels.svelte' import ChannelsPresenter from './components/ChannelsPresenter.svelte' import ContactPresenter from './components/ContactPresenter.svelte' import Contacts from './components/Contacts.svelte' @@ -32,7 +34,7 @@ import PersonPresenter from './components/PersonPresenter.svelte' import SocialEditor from './components/SocialEditor.svelte' import contact from './plugin' -export { ContactPresenter } +export { Channels, ChannelsEditor, ContactPresenter } async function queryContact (_class: Ref>, client: Client, search: string): Promise { return (await client.findAll(_class, { name: { $like: `%${search}%` } }, { limit: 200 })).map(e => ({ diff --git a/plugins/contact/src/index.ts b/plugins/contact/src/index.ts index 53b068f084..5dd8005c16 100644 --- a/plugins/contact/src/index.ts +++ b/plugins/contact/src/index.ts @@ -15,7 +15,7 @@ import { IntlString, plugin } from '@anticrm/platform' import type { Plugin, Asset } from '@anticrm/platform' -import type { Doc, Ref, Class, UXObject, Space, Account } from '@anticrm/core' +import type { Doc, Ref, Class, UXObject, Space, Account, AttachedDoc } from '@anticrm/core' import type { AnyComponent } from '@anticrm/ui' /** @@ -40,7 +40,7 @@ export interface ChannelProvider extends Doc, UXObject { /** * @public */ -export interface Channel { +export interface Channel extends AttachedDoc { provider: Ref value: string } @@ -53,7 +53,7 @@ export interface Contact extends Doc { avatar?: string attachments?: number comments?: number - channels: Channel[] + channels?: number city: string } @@ -121,6 +121,7 @@ export const contactId = 'contact' as Plugin export default plugin(contactId, { class: { ChannelProvider: '' as Ref>, + Channel: '' as Ref>, Contact: '' as Ref>, Person: '' as Ref>, Persons: '' as Ref>, diff --git a/plugins/devmodel-resources/src/index.ts b/plugins/devmodel-resources/src/index.ts index 55a98f4cda..ecdbc22f15 100644 --- a/plugins/devmodel-resources/src/index.ts +++ b/plugins/devmodel-resources/src/index.ts @@ -64,7 +64,7 @@ class ModelClient implements Client { async findOne (_class: Ref>, query: DocumentQuery, options?: FindOptions): Promise | undefined> { const result = await this.client.findOne(_class, query, options) console.info('devmodel# findOne=>', _class, query, options, 'result => ', result, ' =>model', this.client.getModel(), getMetadata(devmodel.metadata.DevModel)) - queries.push({ _class, query, options, result: result !== undefined ? [result] : [], findOne: true }) + queries.push({ _class, query, options: options as FindOptions, result: result !== undefined ? [result] : [], findOne: true }) if (queries.length > 100) { queries.shift() } @@ -74,7 +74,7 @@ class ModelClient implements Client { async findAll(_class: Ref>, query: DocumentQuery, options?: FindOptions): Promise> { const result = await this.client.findAll(_class, query, options) console.info('devmodel# findAll=>', _class, query, options, 'result => ', result, ' =>model', this.client.getModel(), getMetadata(devmodel.metadata.DevModel)) - queries.push({ _class, query, options, result, findOne: false }) + queries.push({ _class, query, options: options as FindOptions, result, findOne: false }) if (queries.length > 100) { queries.shift() } diff --git a/plugins/gmail-resources/src/components/Main.svelte b/plugins/gmail-resources/src/components/Main.svelte index 19bf9d8f24..2e6444fe88 100644 --- a/plugins/gmail-resources/src/components/Main.svelte +++ b/plugins/gmail-resources/src/components/Main.svelte @@ -14,16 +14,24 @@ // limitations under the License. --> -{#if contactString} +{#if channelValue} {#if newMessage} - + {:else if currentMessage} {:else} - + {/if} {/if} diff --git a/plugins/lead-resources/src/components/Customers.svelte b/plugins/lead-resources/src/components/Customers.svelte index f4b9fd21bc..d50bc6a48a 100644 --- a/plugins/lead-resources/src/components/Customers.svelte +++ b/plugins/lead-resources/src/components/Customers.svelte @@ -16,13 +16,18 @@ @@ -153,15 +162,7 @@
- {#if !object.channels || object.channels.length === 0} - showPopup(contact.component.SocialEditor, { values: object.channels ?? [] }, ev.target, (result) => { object.channels = result })} /> - - {:else} - -
- showPopup(contact.component.SocialEditor, { values: object.channels ?? [] }, ev.target, (result) => { object.channels = result })} /> -
- {/if} + { channels = e.detail }} />
.channels { margin-top: 1.25rem; - span { margin-left: .5rem; } } .locations { diff --git a/plugins/setting-resources/src/components/Profile.svelte b/plugins/setting-resources/src/components/Profile.svelte index dcac81ec62..73fc4c7a9d 100644 --- a/plugins/setting-resources/src/components/Profile.svelte +++ b/plugins/setting-resources/src/components/Profile.svelte @@ -13,22 +13,22 @@ // limitations under the License. -->