Leads with Mixins (#737)

Signed-off-by: Andrey Sobolev <haiodo@gmail.com>
This commit is contained in:
Andrey Sobolev 2021-12-30 16:04:32 +07:00 committed by GitHub
parent b05e230246
commit 275b2b0800
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
55 changed files with 1079 additions and 253 deletions

View File

@ -31,6 +31,7 @@
"@anticrm/client": "~0.6.1",
"@anticrm/dev-storage": "~0.6.6",
"@anticrm/server-core": "~0.6.1",
"@anticrm/model-all": "~0.6.0"
"@anticrm/model-all": "~0.6.0",
"@anticrm/devmodel": "~0.6.0"
}
}

View File

@ -15,9 +15,10 @@
import { Class, ClientConnection, Doc, DocumentQuery, FindOptions, FindResult, Ref, ServerStorage, Tx, TxHander, TxResult, DOMAIN_TX, MeasureMetricsContext } from '@anticrm/core'
import { createInMemoryAdapter, createInMemoryTxAdapter } from '@anticrm/dev-storage'
import { protoDeserialize, protoSerialize } from '@anticrm/platform'
import { protoDeserialize, protoSerialize, setMetadata } from '@anticrm/platform'
import type { DbConfiguration } from '@anticrm/server-core'
import { createServerStorage, FullTextAdapter, IndexedDoc } from '@anticrm/server-core'
import devmodel from '@anticrm/devmodel'
class ServerStorageWrapper implements ClientConnection {
measureCtx = new MeasureMetricsContext('client', {})
@ -80,5 +81,6 @@ export async function connect (handler: (tx: Tx) => void): Promise<ClientConnect
workspace: ''
}
const serverStorage = await createServerStorage(conf)
setMetadata(devmodel.metadata.DevModel, serverStorage)
return new ServerStorageWrapper(serverStorage, handler)
}

View File

@ -31,7 +31,9 @@ services:
- ELASTICSEARCH_PORT_NUMBER=9200
- BITNAMI_DEBUG=true
- discovery.type=single-node
- "ES_JAVA_OPTS=-Xms1024m -Xmx1024m"
- ES_JAVA_OPTS=-Xms1024m -Xmx1024m
- http.cors.enabled=true
- http.cors.allow-origin=http://localhost:8082
healthcheck:
interval: 20s
retries: 10

16
dev/readme.md Normal file
View File

@ -0,0 +1,16 @@
# Docker Compose dev image
## Running platform inside docker compose
```bash
rush build
rush bundle
rush docker:build
docker-compose up -d --force-recreate
```
## Running ElasticVUE to check elastic intance
```bash
docker run -p 8082:8080 -d cars10/elasticvue
```

View File

@ -34,6 +34,7 @@
"@anticrm/core": "~0.6.11",
"@anticrm/ui": "~0.6.0",
"@anticrm/platform": "~0.6.5",
"@anticrm/contact": "~0.6.2"
"@anticrm/contact": "~0.6.2",
"@anticrm/workbench": "~0.6.1"
}
}

View File

@ -55,6 +55,7 @@ export function TypeChannel (): Type<Channel> {
}
@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 {
@Prop(TypeString(), 'Name' as IntlString)
@Index(IndexKind.FullText)
@ -147,7 +148,7 @@ export function createModel (builder: Builder): void {
}
]
}
})
}, contact.app.Contacts)
builder.createDoc(view.class.Viewlet, core.space.Model, {
attachTo: contact.class.Person,

View File

@ -19,8 +19,12 @@ 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'
export const ids = mergeIds(contactId, contact, {
app: {
Contacts: '' as Ref<Application>
},
component: {
PersonPresenter: '' as AnyComponent,
ContactPresenter: '' as AnyComponent,

View File

@ -13,13 +13,12 @@
// limitations under the License.
//
import type { Class, Doc, Mixin, Ref, Type } from '@anticrm/core'
import type { Class, Ref, Type } from '@anticrm/core'
import core, { coreId } from '@anticrm/core'
import { mergeIds } from '@anticrm/platform'
export default mergeIds(coreId, core, {
class: {
Mixin: '' as Ref<Class<Mixin<Doc>>>,
Type: '' as Ref<Class<Type<any>>>
}
})

View File

@ -30,7 +30,8 @@ import type {
Type,
Collection,
RefTo,
ArrOf
ArrOf,
Interface
} from '@anticrm/core'
import { DOMAIN_MODEL } from '@anticrm/core'
import { Model, Prop, TypeRef, TypeString, TypeTimestamp } from '@anticrm/model'
@ -81,6 +82,13 @@ export class TClass extends TDoc implements Class<Obj> {
@Model(core.class.Mixin, core.class.Class)
export class TMixin extends TClass implements Mixin<Doc> {}
@Model(core.class.Interface, core.class.Class)
export class TInterface extends TDoc implements Interface<Doc> {
kind!: ClassifierKind
label!: IntlString
extends?: Ref<Interface<Doc>>[]
}
@Model(core.class.Attribute, core.class.Doc)
export class TAttribute extends TDoc implements AnyAttribute {
attributeOf!: Ref<Class<Obj>>

View File

@ -15,7 +15,7 @@
import { Builder } from '@anticrm/model'
import core from './component'
import { TAttribute, TArrOf, TClass, TDoc, TMixin, TObj, TType, TTypeString, TTypeBoolean, TTypeTimestamp, TTypeDate, TAttachedDoc, TCollection, TRefTo } from './core'
import { TAttribute, TArrOf, TClass, TDoc, TMixin, TObj, TType, TTypeString, TTypeBoolean, TTypeTimestamp, TTypeDate, TAttachedDoc, TCollection, TRefTo, TInterface } from './core'
import { TSpace, TAccount } from './security'
import { TTx, TTxCreateDoc, TTxMixin, TTxUpdateDoc, TTxCUD, TTxPutBag, TTxRemoveDoc, TTxBulkWrite, TTxCollectionCUD } from './tx'
@ -31,6 +31,7 @@ export function createModel (builder: Builder): void {
TDoc,
TClass,
TMixin,
TInterface,
TTx,
TTxCUD,
TTxCreateDoc,

View File

@ -19,8 +19,8 @@ import type {
Data,
Doc,
DocumentUpdate,
ExtendedAttributes,
Mixin,
MixinUpdate,
PropertyType,
Ref,
Space,
@ -73,7 +73,7 @@ export class TTxPutBag<T extends PropertyType> extends TTxCUD<Doc> implements Tx
@Model(core.class.TxMixin, core.class.TxCUD)
export class TTxMixin<D extends Doc, M extends D> extends TTxCUD<D> implements TxMixin<D, M> {
mixin!: Ref<Mixin<M>>
attributes!: ExtendedAttributes<D, M>
attributes!: MixinUpdate<D, M>
}
@Model(core.class.TxUpdateDoc, core.class.TxCUD)

View File

@ -15,19 +15,19 @@
//
// To help typescript locate view plugin properly
import type { Contact, Employee } from '@anticrm/contact'
import type { Employee } from '@anticrm/contact'
import type { Doc, FindOptions, Ref } from '@anticrm/core'
import type { Funnel, Lead } from '@anticrm/lead'
import { Builder, Collection, Model, Prop, TypeRef, TypeString, UX } from '@anticrm/model'
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'
import chunter from '@anticrm/model-chunter'
import contact from '@anticrm/model-contact'
import contact, { TPerson } from '@anticrm/model-contact'
import core from '@anticrm/model-core'
import task, { TSpaceWithStates, TTask } from '@anticrm/model-task'
import view from '@anticrm/model-view'
import workbench from '@anticrm/model-workbench'
import type { IntlString } from '@anticrm/platform'
import type {} from '@anticrm/view'
import type { } from '@anticrm/view'
import lead from './plugin'
@Model(lead.class.Funnel, task.class.SpaceWithStates)
@ -37,12 +37,12 @@ export class TFunnel extends TSpaceWithStates implements Funnel {}
@Model(lead.class.Lead, task.class.Task)
@UX('Lead' as IntlString, lead.icon.Lead, undefined, 'title')
export class TLead extends TTask implements Lead {
@Prop(TypeRef(contact.class.Contact), lead.string.Customer)
declare attachedTo: Ref<Customer>
@Prop(TypeString(), 'Title' as IntlString)
title!: string
@Prop(TypeRef(contact.class.Contact), lead.string.Customer)
customer!: Ref<Contact>
@Prop(Collection(chunter.class.Comment), 'Comments' as IntlString)
comments?: number
@ -53,8 +53,18 @@ export class TLead extends TTask implements Lead {
declare assignee: Ref<Employee> | null
}
@Mixin(lead.mixin.Customer, contact.class.Contact)
@UX('Customer' as IntlString, contact.icon.Person) // <-- Use general customer icons here.
export class TCustomer extends TPerson implements Customer {
@Prop(Collection(lead.class.Lead), 'Leads' as IntlString)
leads?: number
@Prop(TypeString(), 'Description' as IntlString)
description!: string
}
export function createModel (builder: Builder): void {
builder.createModel(TFunnel, TLead)
builder.createModel(TFunnel, TLead, TCustomer)
builder.mixin(lead.class.Funnel, core.class.Class, workbench.mixin.SpaceView, {
view: {
@ -71,6 +81,15 @@ export function createModel (builder: Builder): void {
icon: lead.icon.LeadApplication,
hidden: false,
navigatorModel: {
specials: [
{
id: 'customers',
label: lead.string.Customers,
icon: contact.icon.Person, // <-- Put contact general icon here.
component: lead.component.Customers,
position: 'bottom'
}
],
spaces: [
{
label: lead.string.Funnels,
@ -103,18 +122,18 @@ export function createModel (builder: Builder): void {
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
options: {
lookup: {
customer: contact.class.Contact,
attachedTo: contact.class.Contact,
state: task.class.State
}
} as FindOptions<Doc>, // TODO: fix
config: [
'',
'$lookup.customer',
'$lookup.attachedTo',
'$lookup.state',
{ presenter: attachment.component.AttachmentsPresenter, label: 'Files', sortingKey: 'attachments' },
{ presenter: chunter.component.CommentsPresenter, label: 'Comments', sortingKey: 'comments' },
'modifiedOn',
'$lookup.customer.channels'
'$lookup.attachedTo.channels'
]
})
@ -144,6 +163,10 @@ export function createModel (builder: Builder): void {
presenter: lead.component.LeadPresenter
})
builder.mixin(lead.class.Lead, core.class.Class, view.mixin.AttributeEditor, {
editor: lead.component.Leads
})
builder.createDoc(
task.class.KanbanTemplateSpace,
core.space.Model,
@ -159,6 +182,6 @@ export function createModel (builder: Builder): void {
)
}
export { default } from './plugin'
export { leadOperation } from './migration'
export { createDeps } from './creation'
export { leadOperation } from './migration'
export { default } from './plugin'

View File

@ -42,7 +42,7 @@ export const leadOperation: MigrateOperation = {
await client.update<TxCreateDoc<Lead>>(DOMAIN_TX, { _id: tx._id }, {
attributes: {
...tx.attributes,
attachedTo: tx.attributes.customer,
attachedTo: (tx.attributes as any).customer,
attachedToClass: contact.class.Contact
}
})
@ -57,7 +57,7 @@ export const leadOperation: MigrateOperation = {
for (const lead of leads) {
if (lead.attachedTo === undefined) {
await ops.updateDoc(lead._class, lead.space, lead._id, {
attachedTo: lead.customer,
attachedTo: (lead as any).customer,
attachedToClass: contact.class.Contact
})
}

View File

@ -38,7 +38,9 @@ export default mergeIds(leadId, lead, {
EditLead: '' as AnyComponent,
KanbanCard: '' as AnyComponent,
LeadPresenter: '' as AnyComponent,
TemplatesIcon: '' as AnyComponent
TemplatesIcon: '' as AnyComponent,
Customers: '' as AnyComponent,
Leads: '' as AnyComponent
},
space: {
DefaultFunnel: '' as Ref<Space>

View File

@ -45,15 +45,6 @@ describe('memdb', () => {
expect(result.length).toBe(txes.filter((tx) => tx._class === core.class.TxCreateDoc).length)
})
it('should query model', async () => {
const { model } = await createModel()
const result = await model.findAll(core.class.Class, {})
expect(result.length).toBeGreaterThan(5)
const result2 = await model.findAll('class:workbench.Application' as Ref<Class<Doc>>, { _id: undefined })
expect(result2).toHaveLength(0)
})
it('should create space', async () => {
const { model } = await createModel()
@ -85,11 +76,17 @@ describe('memdb', () => {
expect(result2.length).toBe(0)
})
it('should fail query wrong class', async () => {
const { model } = await createModel()
await expect(model.findAll('class:workbench.Application' as Ref<Class<Doc>>, { _id: undefined })).rejects.toThrow()
})
it('should create mixin', async () => {
const { model } = await createModel()
const ops = new TxOperations(model, core.account.System)
await ops.createMixin(core.class.Obj, core.class.Class, test.mixin.TestMixin, { arr: ['hello'] })
await ops.createMixin<Doc, TestMixin>(core.class.Obj, core.class.Class, core.space.Model, test.mixin.TestMixin, { arr: ['hello'] })
const objClass = (await model.findAll(core.class.Class, { _id: core.class.Obj }))[0] as any
expect(objClass['test:mixin:TestMixin'].arr).toEqual(expect.arrayContaining(['hello']))

View File

@ -67,10 +67,16 @@ class ClientImpl implements Client {
options?: FindOptions<T>
): Promise<FindResult<T>> {
const domain = this.hierarchy.getDomain(_class)
if (domain === DOMAIN_MODEL) {
return await this.model.findAll(_class, query, options)
const result = (domain === DOMAIN_MODEL)
? await this.model.findAll(_class, query, options)
: await this.conn.findAll(_class, query, options)
// In case of mixin we need to create mixin proxies.
const baseClass = this.hierarchy.getBaseClass(_class)
if (baseClass !== _class) {
return result.map(v => this.hierarchy.as(v, _class))
}
return await this.conn.findAll(_class, query, options)
return result
}
async findOne<T extends Doc>(
@ -86,8 +92,11 @@ class ClientImpl implements Client {
this.hierarchy.tx(tx)
await this.model.tx(tx)
}
// We need to handle it on server, before performing local live query updates.
const result = await this.conn.tx(tx)
this.notify?.(tx)
return await this.conn.tx(tx)
return result
}
async updateFromRemote (tx: Tx): Promise<void> {

View File

@ -14,6 +14,7 @@
//
import type { Plugin, StatusCode } from '@anticrm/platform'
import { plugin } from '@anticrm/platform'
import { Mixin } from '.'
import type { Account, ArrOf, AnyAttribute, AttachedDoc, Class, Doc, Interface, Obj, PropertyType, Ref, Space, Timestamp, Type, Collection, RefTo } from './classes'
import type { Tx, TxBulkWrite, TxCollectionCUD, TxCreateDoc, TxCUD, TxMixin, TxPutBag, TxRemoveDoc, TxUpdateDoc } from './tx'
@ -28,6 +29,7 @@ export default plugin(coreId, {
Doc: '' as Ref<Class<Doc>>,
AttachedDoc: '' as Ref<Class<AttachedDoc>>,
Class: '' as Ref<Class<Class<Obj>>>,
Mixin: '' as Ref<Class<Mixin<Doc>>>,
Interface: '' as Ref<Class<Interface<Doc>>>,
Attribute: '' as Ref<Class<AnyAttribute>>,
Tx: '' as Ref<Class<Tx>>,

View File

@ -19,6 +19,9 @@ import core from './component'
import type { Tx, TxCreateDoc, TxMixin } from './tx'
import { TxProcessor } from './tx'
const PROXY_TARGET_KEY = '$___proxy_target'
const PROXY_MIXIN_CLASS_KEY = '$__mixin'
/**
* @public
*/
@ -35,6 +38,13 @@ export class Hierarchy {
const ancestorProxy = ancestor.kind === ClassifierKind.MIXIN ? this.getMixinProxyHandler(ancestor._id) : null
return {
get (target: any, property: string, receiver: any): any {
if (property === PROXY_TARGET_KEY) {
return target
}
// We need to override _class property, to return proper mixin class.
if (property === PROXY_MIXIN_CLASS_KEY) {
return mixin
}
const value = target[mixin]?.[property]
if (value === undefined) {
return ancestorProxy !== null ? ancestorProxy.get?.(target, property, receiver) : target[property]
@ -58,6 +68,28 @@ export class Hierarchy {
return new Proxy(doc, this.getMixinProxyHandler(mixin)) as M
}
static toDoc<D extends Doc>(doc: D): D {
const targetDoc = (doc as any)[PROXY_TARGET_KEY]
if (targetDoc !== undefined) {
return targetDoc as D
}
return doc
}
static mixinClass<D extends Doc, M extends D>(doc: D): Ref<Mixin<M>>|undefined {
return (doc as any)[PROXY_MIXIN_CLASS_KEY]
}
hasMixin<D extends Doc, M extends D>(doc: D, mixin: Ref<Mixin<M>>): boolean {
const d = Hierarchy.toDoc(doc)
return typeof (d as any)[mixin] === 'object'
}
isMixin (_class: Ref<Class<Doc>>): boolean {
const data = this.classifiers.get(_class)
return data !== undefined && this._isMixin(data)
}
getAncestors (_class: Ref<Classifier>): Ref<Classifier>[] {
const result = this.ancestors.get(_class)
if (result === undefined) {
@ -113,7 +145,7 @@ export class Hierarchy {
}
private txCreateDoc (tx: TxCreateDoc<Doc>): void {
if (tx.objectClass === core.class.Class || tx.objectClass === core.class.Interface) {
if (tx.objectClass === core.class.Class || tx.objectClass === core.class.Interface || tx.objectClass === core.class.Mixin) {
const _id = tx.objectId as Ref<Classifier>
this.classifiers.set(_id, TxProcessor.createDoc2Doc(tx as TxCreateDoc<Classifier>))
this.addAncestors(_id)
@ -127,7 +159,7 @@ export class Hierarchy {
private txMixin (tx: TxMixin<Doc, Doc>): void {
if (tx.objectClass === core.class.Class) {
const obj = this.getClass(tx.objectId as Ref<Class<Obj>>) as any
obj[tx.mixin] = tx.attributes
TxProcessor.updateMixin4Doc(obj, tx.mixin, tx.attributes)
}
}
@ -144,6 +176,19 @@ export class Hierarchy {
return false
}
/**
* Return first non interface/mixin parent
*/
getBaseClass<T extends Doc>(_class: Ref<Mixin<T>>): Ref<Class<T>> {
let cl: Ref<Class<T>> | undefined = _class
while (cl !== undefined) {
const clz = this.getClass(cl)
if (this.isClass(clz)) return cl
cl = clz.extends
}
return core.class.Doc
}
/**
* Check if passed _class implements passed interfaces `from`.
* It will check for class parents and they interfaces.
@ -220,7 +265,7 @@ export class Hierarchy {
private ancestorsOf (classifier: Ref<Classifier>): Ref<Classifier>[] {
const attrs = this.classifiers.get(classifier)
const result: Ref<Classifier>[] = []
if (this.isClass(attrs)) {
if (this.isClass(attrs) || this._isMixin(attrs)) {
const cls = attrs as Class<Doc>
if (cls.extends !== undefined) {
result.push(cls.extends)
@ -237,6 +282,10 @@ export class Hierarchy {
return attrs?.kind === ClassifierKind.CLASS
}
private _isMixin (attrs?: Classifier): boolean {
return attrs?.kind === ClassifierKind.MIXIN
}
private isInterface (attrs?: Classifier): boolean {
return attrs?.kind === ClassifierKind.INTERFACE
}
@ -251,9 +300,12 @@ export class Hierarchy {
attributes.set(attribute.name, attribute)
}
getAllAttributes (clazz: Ref<Classifier>): Map<string, AnyAttribute> {
getAllAttributes (clazz: Ref<Classifier>, to?: Ref<Classifier>): Map<string, AnyAttribute> {
const result = new Map<string, AnyAttribute>()
const ancestors = this.getAncestors(clazz)
let ancestors = this.getAncestors(clazz)
if (to !== undefined) {
ancestors = ancestors.filter(c => this.isDerived(c, to) && c !== to)
}
for (const cls of ancestors) {
const attributes = this.attributes.get(cls)

View File

@ -99,17 +99,23 @@ export abstract class MemDb extends TxProcessor {
options?: FindOptions<T>
): Promise<FindResult<T>> {
let result: Doc[]
const baseClass = this.hierarchy.getBaseClass(_class)
if (
Object.prototype.hasOwnProperty.call(query, '_id') &&
(typeof query._id === 'string' || query._id?.$in !== undefined || query._id === undefined || query._id === null)
) {
result = this.getByIdQuery(query, _class)
result = this.getByIdQuery(query, baseClass)
} else {
result = this.getObjectsByClass(_class)
result = this.getObjectsByClass(baseClass)
}
result = matchQuery(result, query)
if (baseClass !== _class) {
// We need to filter instances without mixin was set
result = result.filter(r => (r as any)[_class] !== undefined)
}
if (options?.lookup !== undefined) result = this.lookup(result as T[], options.lookup)
if (options?.sort !== undefined) resultSort(result, options?.sort)
@ -206,7 +212,7 @@ export class ModelDb extends MemDb implements Storage {
// TODO: process ancessor mixins
protected async txMixin (tx: TxMixin<Doc, Doc>): Promise<TxResult> {
const obj = this.getObject(tx.objectId) as any
obj[tx.mixin] = tx.attributes
TxProcessor.updateMixin4Doc(obj, tx.mixin, tx.attributes)
return {}
}
}

View File

@ -19,6 +19,7 @@ import type { DocumentQuery, FindOptions, FindResult, Storage, WithLookup, TxRes
import core from './component'
import { generateId } from './utils'
import { _getOperator } from './operator'
import { Hierarchy } from './hierarchy'
/**
* @public
@ -72,20 +73,21 @@ export interface TxBulkWrite extends Tx {
/**
* @public
*/
export type ExtendedAttributes<D extends Doc, M extends D> = Omit<M, keyof D>
export type MixinUpdate<D extends Doc, M extends D> = Partial<Omit<M, keyof D>> & PushOptions<Omit<M, keyof D>> & IncOptions<Omit<M, keyof D>>
/**
* Define Create/Update for mixin attributes.
* @public
*/
export interface TxMixin<D extends Doc, M extends D> extends TxCUD<D> {
mixin: Ref<Mixin<M>>
attributes: ExtendedAttributes<D, M>
attributes: MixinUpdate<D, M>
}
/**
* @public
*/
export type ArrayAsElement<T extends Doc> = {
export type ArrayAsElement<T> = {
[P in keyof T]: T[P] extends Arr<infer X> ? X : never
}
@ -108,21 +110,21 @@ export interface MoveDescriptor<X extends PropertyType> {
/**
* @public
*/
export type ArrayAsElementPosition<T extends Doc> = {
export type ArrayAsElementPosition<T extends object> = {
[P in keyof T]: T[P] extends Arr<infer X> ? X | Position<X> : never
}
/**
* @public
*/
export type ArrayMoveDescriptor<T extends Doc> = {
export type ArrayMoveDescriptor<T extends object> = {
[P in keyof T]: T[P] extends Arr<infer X> ? MoveDescriptor<X> : never
}
/**
* @public
*/
export type NumberProperties<T extends Doc> = {
export type NumberProperties<T extends object> = {
[P in keyof T]: T[P] extends number | undefined ? T[P] : never
}
@ -134,7 +136,7 @@ export type OmitNever<T extends object> = Omit<T, KeysByType<T, never>>
/**
* @public
*/
export interface PushOptions<T extends Doc> {
export interface PushOptions<T extends object> {
$push?: Partial<OmitNever<ArrayAsElementPosition<T>>>
$pull?: Partial<OmitNever<ArrayAsElement<T>>>
$move?: Partial<OmitNever<ArrayMoveDescriptor<T>>>
@ -153,7 +155,7 @@ export interface PushMixinOptions<D extends Doc> {
/**
* @public
*/
export interface IncOptions<T extends Doc> {
export interface IncOptions<T extends object> {
$inc?: Partial<OmitNever<NumberProperties<T>>>
}
@ -231,7 +233,8 @@ export abstract class TxProcessor implements WithTx {
} as T
}
static updateDoc2Doc<T extends Doc>(doc: T, tx: TxUpdateDoc<T>): T {
static updateDoc2Doc<T extends Doc>(rawDoc: T, tx: TxUpdateDoc<T>): T {
const doc = Hierarchy.toDoc(rawDoc)
const ops = tx.operations as any
for (const key in ops) {
if (key.startsWith('$')) {
@ -243,7 +246,23 @@ export abstract class TxProcessor implements WithTx {
}
doc.modifiedBy = tx.modifiedBy
doc.modifiedOn = tx.modifiedOn
return doc
return rawDoc
}
static updateMixin4Doc<D extends Doc, M extends D>(rawDoc: D, mixinClass: Ref<Class<M>>, operations: MixinUpdate<D, M>): D {
const ops = operations as any
const doc = Hierarchy.toDoc(rawDoc)
const mixin = (doc as any)[mixinClass] ?? {}
for (const key in ops) {
if (key.startsWith('$')) {
const operator = _getOperator(key)
operator(mixin, ops[key])
} else {
mixin[key] = ops[key]
}
}
(doc as any)[mixinClass] = mixin
return rawDoc
}
protected abstract txCreateDoc (tx: TxCreateDoc<Doc>): Promise<TxResult>
@ -405,10 +424,22 @@ export class TxOperations implements Storage {
createMixin<D extends Doc, M extends D>(
objectId: Ref<D>,
objectClass: Ref<Class<D>>,
objectSpace: Ref<Space>,
mixin: Ref<Mixin<M>>,
attributes: ExtendedAttributes<D, M>
attributes: MixinUpdate<D, M>
): Promise<TxResult> {
const tx = this.txFactory.createTxMixin(objectId, objectClass, mixin, attributes)
const tx = this.txFactory.createTxMixin(objectId, objectClass, objectSpace, mixin, attributes)
return this.storage.tx(tx)
}
updateMixin<D extends Doc, M extends D>(
objectId: Ref<D>,
objectClass: Ref<Class<D>>,
objectSpace: Ref<Space>,
mixin: Ref<Mixin<M>>,
attributes: MixinUpdate<D, M>
): Promise<TxResult> {
const tx = this.txFactory.createTxMixin(objectId, objectClass, objectSpace, mixin, attributes)
return this.storage.tx(tx)
}
}
@ -515,7 +546,7 @@ export class TxFactory {
}
}
createTxMixin<D extends Doc, M extends D>(objectId: Ref<D>, objectClass: Ref<Class<D>>, mixin: Ref<Mixin<M>>, attributes: ExtendedAttributes<D, M>): TxMixin<D, M> {
createTxMixin<D extends Doc, M extends D>(objectId: Ref<D>, objectClass: Ref<Class<D>>, objectSpace: Ref<Space>, mixin: Ref<Mixin<M>>, attributes: MixinUpdate<D, M>): TxMixin<D, M> {
return {
_id: generateId(),
_class: core.class.TxMixin,
@ -524,9 +555,9 @@ export class TxFactory {
modifiedOn: Date.now(),
objectId,
objectClass,
objectSpace: core.space.Model,
objectSpace,
mixin,
attributes
attributes: attributes
}
}

View File

@ -14,10 +14,7 @@
//
import core, {
ArrOf as TypeArrOf,
Account,
AttachedDoc, Collection as TypeCollection, RefTo,
Attribute, Class, Classifier, ClassifierKind, Data, Doc, Domain, ExtendedAttributes, generateId, IndexKind, Interface, Mixin as IMixin, Obj, PropertyType, Ref, Space, Tx, TxCreateDoc, TxFactory, TxProcessor, Type
Account, ArrOf as TypeArrOf, AttachedDoc, Attribute, Class, Classifier, ClassifierKind, Collection as TypeCollection, Data, Doc, Domain, generateId, IndexKind, Interface, Mixin as IMixin, MixinUpdate, Obj, PropertyType, Ref, RefTo, Space, Tx, TxCreateDoc, TxFactory, TxProcessor, Type
} from '@anticrm/core'
import type { Asset, IntlString } from '@anticrm/platform'
import toposort from 'toposort'
@ -225,8 +222,13 @@ const txFactory = new TxFactory(core.account.System)
function _generateTx (tx: ClassTxes): Tx[] {
const objectId = tx._id
const _cl = {
[ClassifierKind.CLASS]: core.class.Class,
[ClassifierKind.INTERFACE]: core.class.Interface,
[ClassifierKind.MIXIN]: core.class.Mixin
}
const createTx = txFactory.createTxCreateDoc<Doc>(
core.class.Class,
_cl[tx.kind],
core.space.Model,
{
...(tx.domain !== undefined ? { domain: tx.domain } : {}),
@ -307,9 +309,9 @@ export class Builder {
objectId: Ref<D>,
objectClass: Ref<Class<D>>,
mixin: Ref<IMixin<M>>,
attributes: ExtendedAttributes<D, M>
attributes: MixinUpdate<D, M>
): void {
this.txes.push(txFactory.createTxMixin(objectId, objectClass, mixin, attributes))
this.txes.push(txFactory.createTxMixin(objectId, objectClass, core.space.Model, mixin, attributes))
}
getTxes (): Tx[] {

View File

@ -0,0 +1,32 @@
import core, { AnyAttribute, AttachedDoc, Class, Client, Doc, Ref, TxOperations } from '@anticrm/core'
/**
* @public
*/
export interface KeyedAttribute {
key: string
attr: AnyAttribute
}
export async function updateAttribute (client: Client & TxOperations, object: Doc, _class: Ref<Class<Doc>>, attribute: KeyedAttribute, value: any): Promise<void> {
const doc = object
const attributeKey = attribute.key
const attr = attribute.attr
if (client.getHierarchy().isMixin(attr.attributeOf)) {
await client.updateMixin(doc._id, _class, doc.space, attr.attributeOf, { [attributeKey]: value })
} else if (client.getHierarchy().isDerived(object._class, core.class.AttachedDoc)) {
const adoc = object as AttachedDoc
await client.updateCollection(_class, object.space, adoc._id, adoc.attachedTo, adoc.attachedToClass, adoc.collection, { [attributeKey]: value })
} else {
await client.updateDoc(_class, doc.space, doc._id, { [attributeKey]: value })
}
}
export function getAttribute (client: Client, object: any, key: KeyedAttribute): any {
// Check if attr is mixin and return it's value
if (client.getHierarchy().isMixin(key.attr.attributeOf)) {
return (object[key.attr.attributeOf] ?? {})[key.key]
} else {
return object[key.key]
}
}

View File

@ -15,16 +15,16 @@
-->
<script lang="ts">
import type { AttachedDoc, Doc } from '@anticrm/core'
import core from '@anticrm/core'
import type { Doc } from '@anticrm/core'
import { getResource } from '@anticrm/platform'
import type { AnySvelteComponent } from '@anticrm/ui'
import { CircleButton, Label } from '@anticrm/ui'
import view from '@anticrm/view'
import { getAttribute, KeyedAttribute, updateAttribute } from '../attributes'
import { getAttributePresenterClass, getClient } from '../utils'
// export let _class: Ref<Class<Doc>>
export let key: string
export let key: KeyedAttribute | string
export let object: Doc
export let maxWidth: string | undefined = undefined
export let focus: boolean = false
@ -35,7 +35,8 @@
const client = getClient()
const hierarchy = client.getHierarchy()
$: attribute = hierarchy.getAttribute(_class, key)
$: attribute = typeof key === 'string' ? hierarchy.getAttribute(_class, key) : key.attr
$: attributeKey = typeof key === 'string' ? key : key.key
$: typeClassId = (attribute !== undefined) ? getAttributePresenterClass(attribute) : undefined
let editor: Promise<AnySvelteComponent> | undefined
@ -47,12 +48,8 @@
}
function onChange (value: any) {
if (client.getHierarchy().isDerived(object._class, core.class.AttachedDoc)) {
const adoc = object as AttachedDoc
client.updateCollection(_class, object.space, adoc._id, adoc.attachedTo, adoc.attachedToClass, adoc.collection, { [key]: value })
} else {
client.updateDoc(_class, object.space, object._id, { [key]: value }, true)
}
const doc = object as Doc
updateAttribute(client, doc, _class, { key: attributeKey, attr: attribute }, value)
}
</script>
@ -69,17 +66,17 @@
{#if showHeader}
<Label label={attribute.label} />
{/if}
<div class="value"><svelte:component this={instance} label={attribute?.label} placeholder={attribute?.label} {maxWidth} bind:value={object[key]} {onChange} {focus}/></div>
<div class="value"><svelte:component this={instance} label={attribute?.label} placeholder={attribute?.label} {maxWidth} value={getAttribute(client, object, { key: attributeKey, attr: attribute })} {onChange} {focus}/></div>
</div>
{/if}
</div>
{:else if showHeader}
<div class="flex-col">
<Label label={attribute.label} />
<div class="value"><svelte:component this={instance} label={attribute?.label} placeholder={attribute?.label} {maxWidth} bind:value={object[key]} {onChange} {focus}/></div>
<div class="value"><svelte:component this={instance} label={attribute?.label} placeholder={attribute?.label} {maxWidth} value={getAttribute(client, object, { key: attributeKey, attr: attribute })} {onChange} {focus}/></div>
</div>
{:else}
<svelte:component this={instance} {maxWidth} bind:value={object[key]} {onChange} {focus}/>
<svelte:component this={instance} {maxWidth} value={getAttribute(client, object, { key: attributeKey, attr: attribute })} {onChange} {focus}/>
{/if}
{/await}

View File

@ -15,14 +15,16 @@
-->
<script lang="ts">
import type { Class, Doc, Ref } from '@anticrm/core'
import type { AttachedDoc, Class, Doc, Ref } from '@anticrm/core'
import core from '@anticrm/core'
import { getResource } from '@anticrm/platform'
import type { AnySvelteComponent } from '@anticrm/ui'
import view from '@anticrm/view'
import { getAttribute, KeyedAttribute, updateAttribute } from '../attributes'
import { getAttributePresenterClass, getClient } from '../utils'
export let _class: Ref<Class<Doc>>
export let key: string
export let key: KeyedAttribute | string
export let object: any
export let maxWidth: string
export let focus: boolean = false
@ -30,7 +32,8 @@
const client = getClient()
const hierarchy = client.getHierarchy()
$: attribute = hierarchy.getAttribute(_class, key)
$: attribute = typeof key === 'string' ? hierarchy.getAttribute(_class, key) : key.attr
$: attributeKey = typeof key === 'string' ? key : key.key
$: typeClassId = (attribute !== undefined) ? getAttributePresenterClass(attribute) : undefined
let editor: Promise<AnySvelteComponent> | undefined
@ -43,7 +46,7 @@
function onChange (value: any) {
const doc = object as Doc
client.updateDoc(_class, doc.space, doc._id, { [key]: value })
updateAttribute(client, doc, _class, { key: attributeKey, attr: attribute }, value)
}
</script>
@ -51,7 +54,7 @@
{#await editor}
...
{:then instance}
<svelte:component this={instance} label={attribute?.label} placeholder={attribute?.label} {maxWidth} bind:value={object[key]} {onChange} {focus}/>
<svelte:component this={instance} label={attribute?.label} placeholder={attribute?.label} {maxWidth} value={getAttribute(client, object, { key: attributeKey, attr: attribute })} {onChange} {focus}/>
{/await}
{/if}

View File

@ -16,10 +16,11 @@
<script lang="ts">
import type { Doc } from '@anticrm/core'
import { KeyedAttribute } from '../attributes'
import AttributeBarEditor from './AttributeBarEditor.svelte'
export let object: Doc
export let keys: string[]
export let keys: (string|KeyedAttribute)[]
</script>
<div class="flex-row-center small-text">

View File

@ -38,14 +38,14 @@
const dispatch = createEventDispatcher()
const query = createQuery()
$: query.query(_class, { name: { $like: '%' + search + '%' } }, result => { objects = result })
$: query.query(_class, { name: { $like: '%' + search + '%' } }, result => { objects = result }, { limit: 200 })
afterUpdate(() => { dispatch('update', Date.now()) })
</script>
<div class="popup">
<div class="title"><Label label={title} /></div>
<div class="flex-col header">
<EditWithIcon icon={IconSearch} bind:value={search} placeholder={'Search...'} />
<EditWithIcon icon={IconSearch} bind:value={search} placeholder={'Search...'} focus />
<div class="caption"><Label label={caption} /></div>
</div>
<div class="scroll">
@ -72,6 +72,7 @@
border: 1px solid var(--theme-button-border-enabled);
border-radius: .75rem;
box-shadow: 0px 10px 20px rgba(0, 0, 0, .2);
max-height: 70%;
}
.title {

View File

@ -18,6 +18,7 @@ import { addStringsLoader } from '@anticrm/platform'
import { presentationId } from './plugin'
export * from './utils'
export * from './attributes'
export { default as UserBox } from './components/UserBox.svelte'
export { default as UserInfo } from './components/UserInfo.svelte'

View File

@ -14,36 +14,34 @@
//
import core, {
Ref,
AttachedDoc,
Class,
Doc,
Tx,
DocumentQuery,
TxCreateDoc,
TxRemoveDoc,
Client,
Doc,
DocumentQuery,
FindOptions,
TxUpdateDoc,
_getOperator,
TxProcessor,
resultSort,
SortingQuery,
findProperty,
FindResult,
Hierarchy,
Refs,
WithLookup,
LookupData,
TxMixin,
TxPutBag,
ModelDb,
Ref,
Refs,
resultSort,
SortingQuery,
Tx,
TxBulkWrite,
TxResult,
TxCollectionCUD,
AttachedDoc,
findProperty
TxCreateDoc,
TxMixin,
TxProcessor,
TxPutBag,
TxRemoveDoc,
TxResult,
TxUpdateDoc,
WithLookup
} from '@anticrm/core'
import clone from 'just-clone'
import justClone from 'just-clone'
interface Query {
_class: Ref<Class<Doc>>
@ -79,7 +77,11 @@ export class LiveQuery extends TxProcessor implements Client {
private match (q: Query, doc: Doc): boolean {
if (!this.getHierarchy().isDerived(doc._class, q._class)) {
return false
// Check if it is not a mixin and not match class
const mixinClass = Hierarchy.mixinClass(doc)
if (mixinClass === undefined || !this.getHierarchy().isDerived(mixinClass, q._class)) {
return false
}
}
const query = q.query
for (const key in query) {
@ -157,8 +159,29 @@ export class LiveQuery extends TxProcessor implements Client {
return {}
}
protected txMixin (tx: TxMixin<Doc, Doc>): Promise<TxResult> {
throw new Error('Method not implemented.')
protected override async txMixin (tx: TxMixin<Doc, Doc>): Promise<TxResult> {
for (const q of this.queries) {
if (this.client.getHierarchy().isDerived(q._class, core.class.Tx)) {
// handle add since Txes are immutable
await this.handleDocAdd(q, tx)
continue
}
if (q.result instanceof Promise) {
q.result = await q.result
}
let updatedDoc = q.result.find((p) => p._id === tx.objectId)
if (updatedDoc !== undefined) {
// Create or apply mixin value
updatedDoc = TxProcessor.updateMixin4Doc(updatedDoc, tx.mixin, tx.attributes)
await this.callback(updatedDoc, q)
} else {
if (this.getHierarchy().isDerived(tx.mixin, q._class)) {
// Mixin potentially added to object we doesn't have in out results
await this.refresh(q)
}
}
}
return {}
}
protected async txCollectionCUD (tx: TxCollectionCUD<Doc, AttachedDoc>): Promise<TxResult> {
@ -222,10 +245,23 @@ export class LiveQuery extends TxProcessor implements Client {
}
}
/**
* Clone document with respect to mixin inner document cloning.
*/
private clone<T extends Doc>(results: T[]): T[] {
const result: T[] = []
const h = this.getHierarchy()
for (const doc of results) {
const m = Hierarchy.mixinClass(doc)
result.push(m !== undefined ? h.as(Hierarchy.toDoc(doc), m) : justClone(doc))
}
return result
}
private async refresh (q: Query): Promise<void> {
const res = await this.client.findAll(q._class, q.query, q.options)
q.result = res
q.callback(clone(res))
q.callback(this.clone(res))
}
// Check if query is partially matched.
@ -281,10 +317,10 @@ export class LiveQuery extends TxProcessor implements Client {
if (q.options?.limit !== undefined && q.result.length > q.options.limit) {
if (q.result.pop()?._id !== doc._id) {
q.callback(clone(q.result))
q.callback(this.clone(q.result))
}
} else {
q.callback(clone(q.result))
q.callback(this.clone(q.result))
}
}
}
@ -315,7 +351,7 @@ export class LiveQuery extends TxProcessor implements Client {
}
if (index > -1) {
q.result.splice(index, 1)
q.callback(clone(q.result))
q.callback(this.clone(q.result))
}
}
@ -327,15 +363,12 @@ export class LiveQuery extends TxProcessor implements Client {
return await super.tx(tx)
}
// why this is separate from txUpdateDoc?
private async __updateDoc (q: Query, updatedDoc: WithLookup<Doc>, tx: TxUpdateDoc<Doc>): Promise<void> {
TxProcessor.updateDoc2Doc(updatedDoc, tx)
const ops = tx.operations as any
for (const key in ops) {
if (key.startsWith('$')) {
const operator = _getOperator(key)
operator(updatedDoc, ops[key])
} else {
;(updatedDoc as any)[key] = ops[key]
if (!key.startsWith('$')) {
if (q.options !== undefined) {
const lookup = (q.options.lookup as any)?.[key]
if (lookup !== undefined) {
@ -344,8 +377,6 @@ export class LiveQuery extends TxProcessor implements Client {
}
}
}
updatedDoc.modifiedBy = tx.modifiedBy
updatedDoc.modifiedOn = tx.modifiedOn
}
private sort (q: Query, tx: TxUpdateDoc<Doc>): void {
@ -380,7 +411,7 @@ export class LiveQuery extends TxProcessor implements Client {
}
if (q.result.pop()?._id !== updatedDoc._id) q.callback(q.result)
} else {
q.callback(clone(q.result))
q.callback(this.clone(q.result))
}
}
}

View File

@ -20,7 +20,7 @@
// import Icon from './Icon.svelte'
import Loading from './Loading.svelte'
import ErrorBoundary from './internal/ErrorBoundary'
import ErrorPresenter from './ErrorPresenter.svelte';
import ErrorPresenter from './ErrorPresenter.svelte'
export let is: AnyComponent
export let props = {}

View File

@ -23,8 +23,16 @@
export let width: string | undefined = undefined
export let value: string | undefined = undefined
export let placeholder: string = 'placeholder'
export let focus: boolean = false
let textHTML: HTMLInputElement
$: if (textHTML !== undefined) {
if (focus) {
textHTML.focus()
focus = false
}
}
</script>
<div class="flex-between editbox" style={width ? 'width: ' + width : ''} on:click={() => textHTML.focus()}>

View File

@ -112,7 +112,7 @@ export function showPopup (component: AnySvelteComponent | AnyComponent, props:
popups.push({ is: resolved, props, element, onClose })
return popups
})
})
}).catch(err => console.log(err))
} else {
popupstore.update(popups => {
popups.push({ is: component, props, element, onClose })
@ -146,9 +146,9 @@ export function closeTooltip (): void {
}
export const ticker = readable(Date.now(), set => {
const interval = setInterval(() => {
set(Date.now())
}, 10000)
const interval = setInterval(() => {
set(Date.now())
}, 10000)
})
addStringsLoader(uiId, async (lang: string) => {

View File

@ -12,6 +12,7 @@ import core, {
TxCollectionCUD,
TxCreateDoc,
TxCUD,
TxMixin,
TxProcessor,
TxUpdateDoc
} from '@anticrm/core'
@ -52,10 +53,14 @@ export interface DisplayTx {
// Type check for updateTx
updateTx?: TxUpdateDoc<Doc>
// Type check for updateTx
mixinTx?: TxMixin<Doc, Doc>
// Document in case it is required.
doc?: Doc
updated: boolean
mixin: boolean
removed: boolean
}
@ -113,7 +118,7 @@ class ActivityImpl implements Activity {
? { 'tx.objectId': object._id as Ref<AttachedDoc> }
: {
objectId: object._id,
_class: { $in: [core.class.TxCreateDoc, core.class.TxUpdateDoc, core.class.TxRemoveDoc] }
_class: { $in: [core.class.TxCreateDoc, core.class.TxUpdateDoc, core.class.TxRemoveDoc, core.class.TxMixin] }
},
(result) => {
this.txes1 = result
@ -148,15 +153,14 @@ class ActivityImpl implements Activity {
let results: DisplayTx[] = []
for (const tx of txCUD) {
const { collectionCUD, updateCUD, result, tx: ntx } = this.createDisplayTx(tx, parents)
const { collectionCUD, updateCUD, mixinCUD, result, tx: ntx } = this.createDisplayTx(tx, parents)
// We do not need collection object updates, in main list of displayed transactions.
if (this.isDisplayTxRequired(collectionCUD, updateCUD, ntx, object)) {
if (this.isDisplayTxRequired(collectionCUD, updateCUD || mixinCUD, ntx, object)) {
// Combine previous update transaction for same field and if same operation and time treshold is ok
results = this.integrateTxWithResults(results, result)
this.updateRemovedState(result, results)
}
}
console.log('DISPLAY TX', results)
return Array.from(results)
}
@ -175,8 +179,8 @@ class ActivityImpl implements Activity {
return a.modifiedOn - b.modifiedOn
}
isDisplayTxRequired (collectionCUD: boolean, updateCUD: boolean, ntx: TxCUD<Doc>, object: Doc): boolean {
return !(collectionCUD && updateCUD) || ntx.objectId === object._id
isDisplayTxRequired (collectionCUD: boolean, cudOp: boolean, ntx: TxCUD<Doc>, object: Doc): boolean {
return !(collectionCUD && cudOp) || ntx.objectId === object._id
}
private readonly getUpdateTx = (tx: TxCUD<Doc>): TxUpdateDoc<Doc> | undefined => {
@ -216,9 +220,10 @@ class ActivityImpl implements Activity {
createDisplayTx (
tx: TxCUD<Doc>,
parents: Map<Ref<Doc>, DisplayTx>
): { collectionCUD: boolean, updateCUD: boolean, result: DisplayTx, tx: TxCUD<Doc> } {
): { collectionCUD: boolean, updateCUD: boolean, mixinCUD: boolean, result: DisplayTx, tx: TxCUD<Doc> } {
let collectionCUD = false
let updateCUD = false
let mixinCUD = false
const hierarchy = this.client.getHierarchy()
if (hierarchy.isDerived(tx._class, core.class.TxCollectionCUD)) {
tx = getCollectionTx(tx as TxCollectionCUD<Doc, AttachedDoc>)
@ -234,9 +239,10 @@ class ActivityImpl implements Activity {
// If we have updates also apply them all.
updateCUD = this.checkUpdateState(result, firstTx)
mixinCUD = this.checkMixinState(result, firstTx)
this.checkRemoveState(hierarchy, tx, firstTx, result)
return { collectionCUD, updateCUD, result, tx }
return { collectionCUD, updateCUD, mixinCUD, result, tx }
}
private checkRemoveState (hierarchy: Hierarchy, tx: TxCUD<Doc>, firstTx: DisplayTx, result: DisplayTx): void {
@ -256,6 +262,17 @@ class ActivityImpl implements Activity {
return false
}
checkMixinState (result: DisplayTx, firstTx: DisplayTx): boolean {
if (this.client.getHierarchy().isDerived(result.tx._class, core.class.TxMixin) && result.doc !== undefined) {
const mix = result.tx as TxMixin<Doc, Doc>
firstTx.doc = TxProcessor.updateMixin4Doc(result.doc, mix.mixin, mix.attributes)
firstTx.mixin = true
result.mixin = true
return true
}
return false
}
newDisplayTx (tx: TxCUD<Doc>): DisplayTx {
const hierarchy = this.client.getHierarchy()
const createTx = hierarchy.isDerived(tx._class, core.class.TxCreateDoc) ? (tx as TxCreateDoc<Doc>) : undefined
@ -266,20 +283,24 @@ class ActivityImpl implements Activity {
updateTx: hierarchy.isDerived(tx._class, core.class.TxUpdateDoc) ? (tx as TxUpdateDoc<Doc>) : undefined,
updated: false,
removed: false,
mixin: false,
mixinTx: hierarchy.isDerived(tx._class, core.class.TxMixin) ? (tx as TxMixin<Doc, Doc>) : undefined,
doc: createTx !== undefined ? TxProcessor.createDoc2Doc(createTx) : undefined
}
}
integrateTxWithResults (results: DisplayTx[], result: DisplayTx): DisplayTx[] {
const curUpdate = result.tx as unknown as TxUpdateDoc<Doc>
const curUpdate: any = (result.tx._class === core.class.TxUpdateDoc)
? (result.tx as unknown as TxUpdateDoc<Doc>).operations
: (result.tx as unknown as TxMixin<Doc, Doc>).attributes
const newResult = results.filter((prevTx) => {
if (this.isSameKindTx(prevTx, result)) {
const prevUpdate = prevTx.tx as unknown as TxUpdateDoc<Doc>
if (this.isSameKindTx(prevTx, result, result.tx._class)) {
const prevUpdate: any = (prevTx.tx._class === core.class.TxUpdateDoc)
? (prevTx.tx as unknown as TxUpdateDoc<Doc>).operations
: (prevTx.tx as unknown as TxMixin<Doc, Doc>).attributes
if (
result.tx.modifiedOn - prevUpdate.modifiedOn < combineThreshold &&
isEqualOps(prevUpdate.operations, curUpdate.operations)
) {
result.tx.modifiedOn - prevTx.tx.modifiedOn < combineThreshold && isEqualOps(prevUpdate, curUpdate) ) {
// we have same keys,
// Remember previous transactions
result.txes.push(...prevTx.txes, prevTx.tx)
@ -293,11 +314,11 @@ class ActivityImpl implements Activity {
return newResult
}
isSameKindTx (prevTx: DisplayTx, result: DisplayTx): boolean {
isSameKindTx (prevTx: DisplayTx, result: DisplayTx, _class: Ref<Class<Doc>>): boolean {
return (
prevTx.tx.objectId === result.tx.objectId && // Same document id
prevTx.tx._class === result.tx._class && // Same transaction class
result.tx._class === core.class.TxUpdateDoc &&
result.tx._class === _class &&
prevTx.tx.modifiedBy === result.tx.modifiedBy // Same user
)
}

View File

@ -124,14 +124,28 @@
.filter(([, attr]) => attr.hidden === true)
.map(([k]) => k))
buildModel(ops).then((m) => {
model = m.filter((x) => !hiddenAttrs.has(x.key))
})
} else if (tx.mixinTx !== undefined) {
const _class = tx.mixinTx.mixin
const ops = {
client,
_class,
keys: Object.keys(tx.mixinTx.attributes).filter((id) => !id.startsWith('$')),
ignoreMissing: true
}
const hiddenAttrs = new Set([...client.getHierarchy().getAllAttributes(_class).entries()]
.filter(([, attr]) => attr.hidden === true)
.map(([k]) => k))
buildModel(ops).then((m) => {
model = m.filter((x) => !hiddenAttrs.has(x.key))
})
}
async function getValue (m: AttributeModel, utx: TxUpdateDoc<Doc>): Promise<any> {
const val = (utx.operations as any)[m.key]
console.log(m._class, m.key, val, typeof val)
async function getValue (m: AttributeModel, utx: any): Promise<any> {
const val = (utx as any)[m.key]
if (client.getHierarchy().isDerived(m._class, core.class.Doc) && typeof val === 'string') {
// We have an reference, we need to find a real object to pass for presenter
@ -171,7 +185,6 @@
props = { ...props, edit }
}
</script>
{#if (viewlet !== undefined && !((viewlet?.hideOnRemove ?? false) && tx.removed)) || model.length > 0}
<div class="flex-between msgactivity-container">
@ -215,14 +228,25 @@
{/if}
{#if viewlet === undefined && model.length > 0 && tx.updateTx}
{#each model as m}
{#await getValue(m, tx.updateTx) then value}
{#if value === null}
<span>unset <Label label={m.label} /></span>
{:else}
<span>changed <Label label={m.label} /> to</span>
<div class="strong"><svelte:component this={m.presenter} {value} /></div>
{/if}
{/await}
{#await getValue(m, tx.updateTx.operations) then value}
{#if value === null}
<span>unset <Label label={m.label} /></span>
{:else}
<span>changed <Label label={m.label} /> to</span>
<div class="strong"><svelte:component this={m.presenter} {value} /></div>
{/if}
{/await}
{/each}
{:else if viewlet === undefined && model.length > 0 && tx.mixinTx}
{#each model as m}
{#await getValue(m, tx.mixinTx.attributes) then value}
{#if value === null}
<span>unset <Label label={m.label} /></span>
{:else}
<span>changed <Label label={m.label} /> to</span>
<div class="strong"><svelte:component this={m.presenter} {value} /></div>
{/if}
{/await}
{/each}
{:else if viewlet && viewlet.display === 'inline' && viewlet.component}
<div>

View File

@ -14,19 +14,26 @@
// limitations under the License.
-->
<script lang="ts">
import { createEventDispatcher } from 'svelte'
import { Class, Doc, Ref } from '@anticrm/core'
import { Component, AnyComponent } from '@anticrm/ui'
import { AttributesBar, getClient, createQuery, getAttributePresenterClass } from '@anticrm/presentation'
import { Panel } from '@anticrm/panel'
import contact from '../plugin'
import { Contact, formatName } from '@anticrm/contact'
import core from '@anticrm/core'
import core, { Class, ClassifierKind, Doc, Mixin, Ref } from '@anticrm/core'
import { Panel } from '@anticrm/panel'
import { Asset } from '@anticrm/platform'
import {
AttributesBar,
createQuery,
getAttributePresenterClass,
getClient,
KeyedAttribute
} from '@anticrm/presentation'
import { ActionIcon, AnyComponent, Component, Label } from '@anticrm/ui'
import view from '@anticrm/view'
import { createEventDispatcher } from 'svelte'
import contact from '../plugin'
export let _id: Ref<Contact>
let object: Contact
let objectClass: Class<Doc>
let rightSection: AnyComponent | undefined
let fullSize: boolean = true
@ -40,37 +47,59 @@
object = result[0]
})
$: if (object) objectClass = client.getHierarchy().getClass(object._class)
let selectedClass: Ref<Class<Doc>> | undefined
let prevSelected = selectedClass
let keys: KeyedAttribute[] = []
let collectionKeys: KeyedAttribute[] = []
let mixins: Mixin<Doc>[] = []
let selectedMixin: Mixin<Doc> | undefined
$: if (object && prevSelected !== object._class) {
prevSelected = object._class
selectedClass = objectClass._id
selectedMixin = undefined
const h = client.getHierarchy()
mixins = h.getDescendants(contact.class.Contact)
.filter((m) => h.getClass(m).kind === ClassifierKind.MIXIN && h.hasMixin(object, m)).map(m => h.getClass(m) as Mixin<Doc>)
}
const dispatch = createEventDispatcher()
let keys: string[] = []
let collectionKeys: string[] = []
function getFiltredKeys (ignoreKeys: string[]): string[] {
let keys = [...hierarchy.getAllAttributes(object._class).entries()]
.filter(([, value]) => value.hidden !== true)
.map(([key]) => key)
keys = keys.filter((k) => !docKeys.has(k))
keys = keys.filter((k) => !ignoreKeys.includes(k))
function filterKeys (keys: KeyedAttribute[], ignoreKeys: string[]): KeyedAttribute[] {
keys = keys.filter((k) => !docKeys.has(k.key))
keys = keys.filter((k) => !ignoreKeys.includes(k.key))
return keys
}
function getKeys (ignoreKeys: string[]): void {
const filtredKeys = getFiltredKeys(ignoreKeys)
function getFiltredKeys (objectClass: Ref<Class<Doc>>, ignoreKeys: string[]): KeyedAttribute[] {
const keys = [...hierarchy.getAllAttributes(objectClass).entries()]
.filter(([, value]) => value.hidden !== true)
.map(([key, attr]) => ({ key, attr }))
return filterKeys(keys, ignoreKeys)
}
function updateKeys (ignoreKeys: string[]): void {
const filtredKeys = getFiltredKeys(selectedClass ?? object._class, ignoreKeys)
keys = collectionsFilter(filtredKeys, false)
collectionKeys = collectionsFilter(filtredKeys, true)
}
function collectionsFilter (keys: string[], get: boolean): string[] {
const result: string[] = []
function collectionsFilter (keys: KeyedAttribute[], get: boolean): KeyedAttribute[] {
const result: KeyedAttribute[] = []
for (const key of keys) {
if (isCollectionAttr(key) === get) result.push(key)
}
return result
}
function isCollectionAttr (key: string): boolean {
const attribute = hierarchy.getAttribute(object._class, key)
return hierarchy.isDerived(attribute.type._class, core.class.Collection)
function isCollectionAttr (key: KeyedAttribute): boolean {
return hierarchy.isDerived(key.attr.type._class, core.class.Collection)
}
async function getEditor (_class: Ref<Class<Doc>>): Promise<AnyComponent> {
@ -80,15 +109,22 @@
return editorMixin.editor
}
async function getCollectionEditor (key: string): Promise<AnyComponent> {
const attribute = hierarchy.getAttribute(object._class, key)
const attrClass = getAttributePresenterClass(attribute)
async function getEditorOrDefault (_class: Ref<Class<Doc>> | undefined, defaultClass: Ref<Class<Doc>>): Promise<AnyComponent> {
const editor = _class !== undefined ? await getEditor(_class) : undefined
if (editor !== undefined) {
return editor
}
return getEditor(defaultClass)
}
async function getCollectionEditor (key: KeyedAttribute): Promise<AnyComponent> {
const attrClass = getAttributePresenterClass(key.attr)
const clazz = client.getHierarchy().getClass(attrClass)
const editorMixin = client.getHierarchy().as(clazz, view.mixin.AttributeEditor)
return editorMixin.editor
}
$: icon = object && (hierarchy.getClass(object._class).icon as Asset)
$: icon = (objectClass?.icon ?? contact.class.Person) as Asset
</script>
{#if object !== undefined}
@ -102,23 +138,43 @@
dispatch('close')
}}
>
<div slot="subtitle">
{#if keys}
<AttributesBar {object} {keys} />
{/if}
<div slot="subtitle" class="flex flex-reverse flex-grow">
<div class='flex'>
{#if mixins.length > 0}
<div class='mixin-selector' class:selected={selectedClass === objectClass._id}>
<ActionIcon icon={objectClass.icon} size={'medium'} label={objectClass.label} action={() => {
selectedClass = objectClass._id
selectedMixin = undefined
}} />
</div>
{#each mixins as mixin}
<div class='mixin-selector' class:selected={selectedClass === mixin._id}>
<ActionIcon icon={mixin.icon} size={'medium'} label={mixin.label} action={() => {
selectedClass = mixin._id
selectedMixin = mixin
}} />
</div>
{/each}
{/if}
</div>
<div class="flex-grow">
{#if keys}
<AttributesBar {object} {keys} />
{/if}
</div>
</div>
{#await getEditor(object._class) then is}
<Component
{is}
props={{ object }}
on:open={(ev) => {
getKeys(ev.detail.ignoreKeys)
}}
on:click={(ev) => {
fullSize = true
rightSection = ev.detail.presenter
}}
/>
{#await getEditorOrDefault(selectedClass, object._class) then is}
<Component
{is}
props={{ object }}
on:open={(ev) => {
updateKeys(ev.detail.ignoreKeys)
}}
on:click={(ev) => {
fullSize = true
rightSection = ev.detail.presenter
}}
/>
{/await}
{#each collectionKeys as collection}
<div class="mt-14">
@ -127,5 +183,68 @@
{/await}
</div>
{/each}
<!-- {#each mixins as mixin}
<div class="mixin-container">
<div class="header">
<div class="icon" />
<Label label={mixin._class.label} />
</div>
<div class="attributes">
{#if mixin.keys.length > 0}
<AttributesBar {object} keys={mixin.keys} />
{/if}
</div>
<div class="collections">
{#each mixin.collectionKeys as collection}
<div class="mt-14">
{#await getCollectionEditor(collection) then is}
<Component {is} props={{ objectId: object._id, _class: object._class, space: object.space }} />
{/await}
</div>
{/each}
</div>
</div>
{/each} -->
</Panel>
{/if}
<style lang="scss">
.mixin-container {
margin-top: 2rem;
padding-top: 2rem;
border-top: 1px solid var(--theme-zone-bg);
.header {
display: flex;
font-weight: 500;
font-size: 16px;
line-height: 150%;
align-items: center;
.icon {
width: 10px;
height: 10px;
/* Dark / Green 01 */
background: #77c07b;
border: 2px solid #18181e;
border-radius: 50px;
margin-right: 1rem;
}
}
.attributes {
margin: 1rem;
}
.collections {
margin: 1rem;
}
}
.mixin-selector {
opacity: 0.6;
margin: 0.25rem;
&.selected {
opacity: 1 !important;
}
}
</style>

View File

@ -14,7 +14,7 @@
// limitations under the License.
-->
<script lang="ts">
import { createEventDispatcher, onMount } from 'svelte'
import { createEventDispatcher, onMount, afterUpdate } from 'svelte'
import { getCurrentAccount, Ref, Space } from '@anticrm/core'
import { CircleButton, EditBox, showPopup, IconEdit, IconAdd, Label, IconActivity } from '@anticrm/ui'
import { getClient, createQuery, Channels, Avatar } from '@anticrm/presentation'
@ -58,9 +58,9 @@
integrations = new Set(res.map((p) => p.type))
})
onMount(() => {
dispatch('open', { ignoreKeys: ['comments', 'name', 'channels'] })
})
const sendOpen = () => dispatch('open', { ignoreKeys: ['comments', 'name', 'channels'] })
onMount(sendOpen)
afterUpdate(sendOpen)
</script>
{#if object !== undefined}

View File

@ -13,7 +13,7 @@
// limitations under the License.
//
import { IntlString, Resources } from '@anticrm/platform'
import { getMetadata, IntlString, Resources } from '@anticrm/platform'
import ModelView from './components/ModelView.svelte'
import QueryView from './components/QueryView.svelte'
import core, { Class, Client, Doc, DocumentQuery, FindOptions, Ref, FindResult, Hierarchy, ModelDb, Tx, TxResult, WithLookup, Metrics } from '@anticrm/core'
@ -43,7 +43,7 @@ class ModelClient implements Client {
constructor (readonly client: Client) {
client.notify = (tx) => {
this.notify?.(tx)
console.info('devmodel# notify=>', tx, this.client.getModel())
console.info('devmodel# notify=>', tx, this.client.getModel(), getMetadata(devmodel.metadata.DevModel))
notifications.push(tx)
if (notifications.length > 500) {
notifications.shift()
@ -63,7 +63,7 @@ class ModelClient implements Client {
async findOne <T extends Doc>(_class: Ref<Class<T>>, query: DocumentQuery<T>, options?: FindOptions<T>): Promise<WithLookup<T> | undefined> {
const result = await this.client.findOne(_class, query, options)
console.info('devmodel# findOne=>', _class, query, options, 'result => ', result, ' =>model', this.client.getModel())
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 })
if (queries.length > 100) {
queries.shift()
@ -73,7 +73,7 @@ class ModelClient implements Client {
async findAll<T extends Doc>(_class: Ref<Class<T>>, query: DocumentQuery<T>, options?: FindOptions<T>): Promise<FindResult<T>> {
const result = await this.client.findAll(_class, query, options)
console.info('devmodel# findAll=>', _class, query, options, 'result => ', result, ' =>model', this.client.getModel())
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 })
if (queries.length > 100) {
queries.shift()
@ -83,7 +83,7 @@ class ModelClient implements Client {
async tx (tx: Tx): Promise<TxResult> {
const result = await this.client.tx(tx)
console.info('devmodel# tx=>', tx, result)
console.info('devmodel# tx=>', tx, result, getMetadata(devmodel.metadata.DevModel))
transactions.push({ tx, result })
if (transactions.length > 100) {
transactions.shift()

View File

@ -13,10 +13,10 @@
// limitations under the License.
//
import type { Asset, Plugin, Resource } from '@anticrm/platform'
import { ClientHook } from '@anticrm/client'
import type { Asset, Metadata, Plugin, Resource } from '@anticrm/platform'
import { plugin } from '@anticrm/platform'
import type { AnyComponent } from '@anticrm/ui'
import { ClientHook } from '@anticrm/client'
/**
* @public
@ -35,5 +35,8 @@ export default plugin(devModelId, {
},
hook: {
Hook: '' as Resource<ClientHook>
},
metadata: {
DevModel: '' as Metadata<any>
}
})

View File

@ -12,6 +12,8 @@
"LeadName": "Lead name",
"More": "More...",
"Customer": "Customer",
"Customers": "Customers",
"Leads": "Leads",
"SelectCustomer": "Select customer"
}
}

View File

@ -44,6 +44,7 @@
"@anticrm/view-resources": "~0.6.0",
"@anticrm/attachment-resources": "~0.6.0",
"@anticrm/contact-resources": "~0.6.0",
"@anticrm/chunter-resources": "~0.6.0"
"@anticrm/chunter-resources": "~0.6.0",
"@anticrm/workbench-resources": "~0.6.1"
}
}

View File

@ -19,7 +19,7 @@
import { generateId } from '@anticrm/core'
import { OK, Status } from '@anticrm/platform'
import { Card, getClient, UserBox } from '@anticrm/presentation'
import type { Lead } from '@anticrm/lead'
import type { Customer, Lead } from '@anticrm/lead'
import { EditBox, Grid, Status as StatusControl } from '@anticrm/ui'
import { createEventDispatcher } from 'svelte'
import lead from '../plugin'
@ -72,11 +72,19 @@
doneState: null,
number: (incResult as any).object.sequence,
title: title,
customer: customer!,
rank: calcRank(lastOne, undefined)
rank: calcRank(lastOne, undefined),
assignee: null
}
await client.addCollection(lead.class.Lead, _space, customer!, contact.class.Contact, 'leads', value, leadId)
const customerInstance = await client.findOne(contact.class.Contact, { _id: customer! })
if (customerInstance === undefined) {
throw new Error('contact not found')
}
if (!client.getHierarchy().hasMixin(customerInstance, lead.mixin.Customer)) {
await client.createMixin<Contact, Customer>(customerInstance._id, customerInstance._class, customerInstance.space, lead.mixin.Customer, {})
}
await client.addCollection(lead.class.Lead, _space, customer!, lead.mixin.Customer, 'leads', value, leadId)
dispatch('close')
}
</script>
@ -103,6 +111,6 @@
maxWidth={'16rem'}
focus
/>
<UserBox _class={contact.class.Contact} title="Customer" caption="Select customer" bind:value={customer} />
<UserBox _class={contact.class.Contact} title={lead.string.Customer} caption={lead.string.SelectCustomer} bind:value={customer} />
</Grid>
</Card>

View File

@ -0,0 +1,112 @@
<!--
// 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 { EditWithIcon, Icon, IconSearch, Label, ScrollBox } from '@anticrm/ui'
import { Table } from '@anticrm/view-resources'
import lead from '../plugin'
let search = ''
$: resultQuery = search === '' ? { } : { $search: search }
</script>
<div class="customers-header-container">
<div class="header-container">
<div class="flex-row-center">
<span class="icon"><Icon icon={lead.icon.Lead} size={'small'}/></span>
<span class="label"><Label label={lead.string.Customers}/></span>
</div>
</div>
<EditWithIcon icon={IconSearch} placeholder={'Search'} bind:value={search} />
</div>
<div class="container">
<div class="panel-component">
<ScrollBox vertical stretch noShift>
<Table
_class={lead.mixin.Customer}
config={[
'',
{ key: 'leads', presenter: lead.component.LeadsPresenter, label: lead.string.Leads },
'modifiedOn',
'channels'
]}
options={ {} }
query={ resultQuery }
enableChecking
/>
</ScrollBox>
</div>
</div>
<style lang="scss">
.container {
display: flex;
height: 100%;
padding-bottom: 1.25rem;
.panel-component {
flex-grow: 1;
display: flex;
flex-direction: column;
margin-right: 1rem;
height: 100%;
border-radius: 1.25rem;
background-color: var(--theme-bg-color);
overflow: hidden;
}
}
.customers-header-container {
display: grid;
grid-template-columns: auto;
grid-auto-flow: column;
grid-auto-columns: min-content;
gap: .75rem;
align-items: center;
padding: 0 1.75rem 0 2.5rem;
height: 4rem;
min-height: 4rem;
.header-container {
display: flex;
flex-direction: column;
flex-grow: 1;
.icon {
margin-right: .5rem;
opacity: .6;
}
.label, .description {
flex-grow: 1;
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
max-width: 35rem;
}
.label {
font-weight: 500;
font-size: 1rem;
color: var(--theme-caption-color);
}
.description {
font-size: .75rem;
color: var(--theme-content-trans-color);
}
}
}
</style>

View File

@ -27,6 +27,8 @@
}
</script>
<div class="sm-tool-icon" on:click={show}>
<span class="icon"><Icon icon={lead.icon.Lead} size={'small'} /></span>{value.title}
</div>
{#if value}
<div class="sm-tool-icon" on:click={show}>
<span class="icon"><Icon icon={lead.icon.Lead} size={'small'} /></span>{value.title}
</div>
{/if}

View File

@ -0,0 +1,87 @@
<!--
// Copyright © 2020 Anticrm Platform Contributors.
//
// 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 { Ref } from '@anticrm/core'
import type { Customer, Lead } from '@anticrm/lead'
import { createQuery } from '@anticrm/presentation'
import task from '@anticrm/task'
import { CircleButton, IconAdd, Label, showPopup } from '@anticrm/ui'
import { Table } from '@anticrm/view-resources'
import lead from '../plugin'
import CreateLead from './CreateLead.svelte'
export let objectId: Ref<Customer>
let leads: Lead[] = []
const query = createQuery()
$: query.query(lead.class.Lead, { attachedTo: objectId }, result => { leads = result })
const createLead = (ev: MouseEvent): void =>
showPopup(CreateLead, { candidate: objectId, preserveCandidate: true }, ev.target as HTMLElement)
</script>
<div class="applications-container">
<div class="flex-row-center">
<div class="title">Leads</div>
<CircleButton icon={IconAdd} size={'small'} selected on:click={createLead} />
</div>
{#if leads.length > 0}
<Table
_class={lead.class.Lead}
config={['', '$lookup.state']}
options={
{
lookup: {
state: task.class.State
}
}
}
query={ { attachedTo: objectId } }
/>
{:else}
<div class="flex-col-center mt-5 createapp-container">
<div class="small-text content-dark-color mt-2">
<Label label={lead.string.NoLeadsForDocument} />
</div>
<div class="small-text">
<a href={'#'} on:click={createLead}><Label label={lead.string.CreateLead} /></a>
</div>
</div>
{/if}
</div>
<style lang="scss">
.applications-container {
display: flex;
flex-direction: column;
.title {
margin-right: .75rem;
font-weight: 500;
font-size: 1.25rem;
color: var(--theme-caption-color);
}
}
.createapp-container {
padding: 1rem;
color: var(--theme-caption-color);
background: var(--theme-bg-accent-color);
border: 1px solid var(--theme-bg-accent-color);
border-radius: .75rem;
}
</style>

View File

@ -0,0 +1,38 @@
<!--
// 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 { FindOptions } from '@anticrm/core'
import type { Customer, Lead } from '@anticrm/lead'
import task from '@anticrm/task'
import { Table } from '@anticrm/view-resources'
import leads from '../plugin'
export let value: Customer
const options: FindOptions<Lead> = {
lookup: {
state: task.class.State
}
}
</script>
<Table
_class={leads.class.Lead}
config={['', '$lookup.state']}
options={options}
query={ { attachedTo: value._id } }
/>

View File

@ -0,0 +1,34 @@
<!--
// 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 { Customer } from '@anticrm/lead'
import { Icon, Tooltip } from '@anticrm/ui'
import LeadsPopup from './LeadsPopup.svelte'
import leads from '../plugin'
export let value: Customer
</script>
{#if value.leads && value.leads > 0}
<Tooltip label={leads.string.Leads} component={LeadsPopup} props={{ value }}>
<div class="sm-tool-icon">
<span class="icon"><Icon icon={leads.icon.Lead} size={'small'} /></span>&nbsp;{value.leads}
</div>
</Tooltip>
{/if}

View File

@ -17,10 +17,13 @@
import { Resources } from '@anticrm/platform'
import CreateFunnel from './components/CreateFunnel.svelte'
import CreateLead from './components/CreateLead.svelte'
import Customers from './components/Customers.svelte'
import EditLead from './components/EditLead.svelte'
import KanbanCard from './components/KanbanCard.svelte'
import LeadPresenter from './components/LeadPresenter.svelte'
import LeadsPresenter from './components/LeadsPresenter.svelte'
import TemplatesIcon from './components/TemplatesIcon.svelte'
import Leads from './components/Leads.svelte'
export default async (): Promise<Resources> => ({
component: {
@ -29,6 +32,9 @@ export default async (): Promise<Resources> => ({
EditLead,
KanbanCard,
LeadPresenter,
TemplatesIcon
TemplatesIcon,
Customers,
LeadsPresenter,
Leads
}
})

View File

@ -16,6 +16,7 @@
import { IntlString, mergeIds } from '@anticrm/platform'
import lead, { leadId } from '@anticrm/lead'
import { AnyComponent } from '@anticrm/ui'
export default mergeIds(leadId, lead, {
string: {
@ -28,6 +29,13 @@ export default mergeIds(leadId, lead, {
SelectFunnel: '' as IntlString,
CreateLead: '' as IntlString,
Customer: '' as IntlString,
SelectCustomer: '' as IntlString
SelectCustomer: '' as IntlString,
Customers: '' as IntlString,
Leads: '' as IntlString,
NoLeadsForDocument: '' as IntlString
},
component: {
CreateCustomer: '' as AnyComponent,
LeadsPresenter: '' as AnyComponent
}
})

View File

@ -16,6 +16,7 @@
import type { Contact } from '@anticrm/contact'
import type { Class, Ref } from '@anticrm/core'
import { Mixin } from '@anticrm/core'
import type { Asset, Plugin } from '@anticrm/platform'
import { plugin } from '@anticrm/platform'
import type { KanbanTemplateSpace, SpaceWithStates, Task } from '@anticrm/task'
@ -25,12 +26,25 @@ import type { KanbanTemplateSpace, SpaceWithStates, Task } from '@anticrm/task'
*/
export interface Funnel extends SpaceWithStates {}
/**
* @public
*
* @Mixin
*/
export interface Customer extends Contact {
leads?: number
description: string
}
/**
* @public
*/
export interface Lead extends Task {
attachedTo: Ref<Customer>
title: string
customer: Ref<Contact>
comments?: number
attachments?: number
@ -46,6 +60,9 @@ const lead = plugin(leadId, {
Lead: '' as Ref<Class<Lead>>,
Funnel: '' as Ref<Class<Funnel>>
},
mixin: {
Customer: '' as Ref<Mixin<Customer>>
},
icon: {
Funnel: '' as Asset,
Lead: '' as Asset,

View File

@ -22,22 +22,25 @@
import ExpandRightDouble from './icons/ExpandRightDouble.svelte'
import recruit from '../plugin'
import { Ref } from '@anticrm/core'
export let object: Applicant
let candidate: Candidate
let vacancy: Vacancy
const candidateQuery = createQuery()
$: if (object !== undefined)
{candidateQuery.query(recruit.class.Candidate, { _id: object.attachedTo }, (result) => {
candidate = result[0]
})}
$: if (object !== undefined) {
candidateQuery.query(recruit.mixin.Candidate, { _id: object.attachedTo as Ref<Candidate> }, (result) => {
candidate = result[0]
})
}
const vacancyQuery = createQuery()
$: if (object !== undefined)
{vacancyQuery.query(recruit.class.Vacancy, { _id: object.space }, (result) => {
vacancy = result[0]
})}
$: if (object !== undefined) {
vacancyQuery.query(recruit.class.Vacancy, { _id: object.space }, (result) => {
vacancy = result[0]
})
}
const dispatch = createEventDispatcher()

View File

@ -16,7 +16,7 @@
<script lang="ts">
import core from '@anticrm/core'
import type { Space } from '@anticrm/core'
import type { NavigatorModel } from '@anticrm/workbench'
import type { NavigatorModel, SpecialNavModel } from '@anticrm/workbench'
import { getCurrentLocation, navigate } from '@anticrm/ui'
import { createQuery } from '@anticrm/presentation'
import view from '@anticrm/view'
@ -47,11 +47,14 @@
loc.path.length = 3
navigate(loc)
}
function getSpecials (specials: SpecialNavModel[], state: 'top' | 'bottom'): SpecialNavModel[] {
return specials.filter(p => (p.position ?? 'top') === state)
}
</script>
{#if model}
{#if model.specials}
{#each model.specials as special}
{#each getSpecials(model.specials, 'top') as special}
<SpecialElement label={special.label} icon={special.icon} on:click={() => selectSpecial(special.id)} />
{/each}
{/if}
@ -66,6 +69,11 @@
{#each model.spaces as m (m.label)}
<SpacesNav model={m}/>
{/each}
{#if model.specials}
{#each getSpecials(model.specials, 'bottom') as special}
<SpecialElement label={special.label} icon={special.icon} on:click={() => selectSpecial(special.id)} />
{/each}
{/if}
{/if}
<style lang="scss">

View File

@ -15,13 +15,14 @@
import WorkbenchApp from './components/WorkbenchApp.svelte'
import ApplicationPresenter from './components/ApplicationPresenter.svelte'
import { Resources } from '@anticrm/platform'
/*!
* Anticrm Platform Workbench Plugin
* © 2020 Anticrm Platform Contributors. All Rights Reserved.
* Licensed under the Eclipse Public License, Version 2.0
*/
export default async () => ({
export default async (): Promise<Resources> => ({
component: {
WorkbenchApp,
ApplicationPresenter

View File

@ -57,6 +57,7 @@ export interface SpecialNavModel {
label: IntlString
icon: Asset
component: AnyComponent
position?: 'top'|'bottom' // undefined == 'top
}
/**

View File

@ -59,8 +59,9 @@ export class FullTextIndex implements WithFind {
return {}
}
protected txMixin (ctx: MeasureContext, tx: TxMixin<Doc, Doc>): Promise<TxResult> {
throw new Error('Method not implemented.')
protected async txMixin (ctx: MeasureContext, tx: TxMixin<Doc, Doc>): Promise<TxResult> {
console.log('FullTextIndex.txMixin: Method not implemented')
return {}
}
async tx (ctx: MeasureContext, tx: Tx): Promise<TxResult> {

View File

@ -145,11 +145,19 @@ class TServerStorage implements ServerStorage {
attachedTo = (await this.findAll(ctx, _class, { _id }, { limit: 1 }))[0]
if (attachedTo !== undefined) {
const txFactory = new TxFactory(tx.modifiedBy)
return [
txFactory.createTxUpdateDoc(_class, attachedTo.space, _id, {
const baseClass = this.hierarchy.getBaseClass(_class)
if (baseClass !== _class) {
// Mixin opeeration is required.
return [txFactory.createTxMixin(_id, attachedTo._class, attachedTo.space, _class, {
$inc: { [colTx.collection]: isCreateTx ? 1 : -1 }
})
]
})]
} else {
return [
txFactory.createTxUpdateDoc(_class, attachedTo.space, _id, {
$inc: { [colTx.collection]: isCreateTx ? 1 : -1 }
})
]
}
}
}
}

View File

@ -17,7 +17,7 @@ import core, {
Class,
Doc,
DocumentQuery, DOMAIN_MODEL, DOMAIN_TX, FindOptions,
FindResult, Hierarchy, isOperator, ModelDb, Ref, SortingOrder, Tx,
FindResult, Hierarchy, isOperator, Mixin, ModelDb, Ref, SortingOrder, Tx,
TxCreateDoc,
TxMixin, TxProcessor, TxPutBag,
TxRemoveDoc,
@ -56,12 +56,18 @@ abstract class MongoAdapterBase extends TxProcessor {
}
translated[key] = value
}
const classes = this.hierarchy.getDescendants(clazz)
const baseClass = this.hierarchy.getBaseClass(clazz)
const classes = this.hierarchy.getDescendants(baseClass)
// Only replace if not specified
if (translated._class?.$in === undefined) {
translated._class = { $in: classes }
}
if (baseClass !== clazz) {
// Add an mixin to be exists flag
translated[clazz] = { $exists: true }
}
// return Object.assign({}, query, { _class: { $in: classes } })
return translated
}
@ -168,8 +174,91 @@ class MongoAdapter extends MongoAdapterBase {
return {}
}
protected txMixin (tx: TxMixin<Doc, Doc>): Promise<TxResult> {
throw new Error('Method not implemented.')
protected async txMixin (tx: TxMixin<Doc, Doc>): Promise<TxResult> {
const domain = this.hierarchy.getDomain(tx.objectClass)
if (isOperator(tx.attributes)) {
const operator = Object.keys(tx.attributes)[0]
if (operator === '$move') {
const keyval = (tx.attributes as any).$move
const arr = tx.mixin + '.' + Object.keys(keyval)[0]
const desc = keyval[arr]
const ops = [
{
updateOne: {
filter: { _id: tx.objectId },
update: {
$pull: {
[arr]: desc.$value
}
}
}
},
{
updateOne: {
filter: { _id: tx.objectId },
update: {
$set: {
modifiedBy: tx.modifiedBy,
modifiedOn: tx.modifiedOn
},
$push: {
[arr]: {
$each: [desc.$value],
$position: desc.$position
}
}
}
}
}
]
return await this.db.collection(domain).bulkWrite(ops as any)
} else {
return await this.db.collection(domain).updateOne(
{ _id: tx.objectId },
{
...this.translateMixinAttrs(tx.mixin, tx.attributes),
$set: {
modifiedBy: tx.modifiedBy,
modifiedOn: tx.modifiedOn
}
}
)
}
} else {
return await this.db
.collection(domain)
.updateOne(
{ _id: tx.objectId },
{
$set: {
...this.translateMixinAttrs(tx.mixin, tx.attributes),
modifiedBy: tx.modifiedBy,
modifiedOn: tx.modifiedOn
}
}
)
}
}
private translateMixinAttrs (mixin: Ref<Mixin<Doc>>, attributes: Record<string, any>): Record<string, any> {
const attrs: Record<string, any> = {}
let count = 0
for (const [k, v] of Object.entries(attributes)) {
if (k.startsWith('$')) {
attrs[k] = this.translateMixinAttrs(mixin, v)
} else {
attrs[mixin + '.' + k] = v
}
count++
}
if (count === 0) {
// We need at least one attribute, to be inside for first time,
// for mongo to create embedded object, if we don't want to get object first.
attrs[mixin + '.' + '__mixin'] = 'true'
}
return attrs
}
protected override async txCreateDoc (tx: TxCreateDoc<Doc>): Promise<TxResult> {