Activity update (#362)

Signed-off-by: Andrey Sobolev <haiodo@gmail.com>
This commit is contained in:
Andrey Sobolev 2021-11-25 18:07:44 +07:00 committed by GitHub
parent 581f5f0e96
commit 6c281b38f8
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 386 additions and 134 deletions

11
.vscode/launch.json vendored
View File

@ -17,6 +17,17 @@
"sourceMaps": true,
"cwd": "${workspaceRoot}/server/server",
"protocol": "inspector"
},
{
"type": "node",
"request": "launch",
"name": "Debug Jest tests",
"program": "${fileDirname}/../../node_modules/@rushstack/heft/lib/start.js",
"cwd": "${fileDirname}/../../",
"args": ["--debug", "test", "--clean", "--test-path-pattern", "${file}"],
"console": "integratedTerminal",
"sourceMaps": true,
"protocol": "inspector"
}
]
}

View File

@ -14,17 +14,15 @@
//
import { PlatformError, Severity, Status } from '@anticrm/platform'
import clone from 'just-clone'
import type { Class, Doc, Ref } from './classes'
import type { Tx, TxCreateDoc, TxMixin, TxPutBag, TxRemoveDoc, TxUpdateDoc } from './tx'
import core from './component'
import type { Hierarchy } from './hierarchy'
import { _getOperator } from './operator'
import { findProperty, resultSort } from './query'
import type { DocumentQuery, FindOptions, FindResult, Storage, WithLookup, LookupData, Refs, TxResult } from './storage'
import type { DocumentQuery, FindOptions, FindResult, LookupData, Refs, Storage, TxResult, WithLookup } from './storage'
import type { Tx, TxCreateDoc, TxMixin, TxPutBag, TxRemoveDoc, TxUpdateDoc } from './tx'
import { TxProcessor } from './tx'
import clone from 'just-clone'
/**
* @public
*/
@ -200,17 +198,7 @@ export class ModelDb extends MemDb implements Storage {
protected async txUpdateDoc (tx: TxUpdateDoc<Doc>): Promise<TxResult> {
const doc = this.getObject(tx.objectId) as any
const ops = tx.operations as any
for (const key in ops) {
if (key.startsWith('$')) {
const operator = _getOperator(key)
operator(doc, ops[key])
} else {
doc[key] = ops[key]
}
}
doc.modifiedBy = tx.modifiedBy
doc.modifiedOn = tx.modifiedOn
TxProcessor.updateDoc2Doc(doc, tx)
return tx.retrieve === true ? { object: doc } : {}
}

View File

@ -18,6 +18,7 @@ import type { Class, Data, Doc, Domain, Ref, Account, Space, Arr, Mixin, Propert
import type { DocumentQuery, FindOptions, FindResult, Storage, WithLookup, TxResult } from './storage'
import core from './component'
import { generateId } from './utils'
import { _getOperator } from '.'
/**
* @public
@ -223,6 +224,21 @@ export abstract class TxProcessor implements WithTx {
} as T
}
static updateDoc2Doc<T extends Doc>(doc: T, tx: TxUpdateDoc<T>): T {
const ops = tx.operations as any
for (const key in ops) {
if (key.startsWith('$')) {
const operator = _getOperator(key)
operator(doc, ops[key])
} else {
(doc as any)[key] = ops[key]
}
}
doc.modifiedBy = tx.modifiedBy
doc.modifiedOn = tx.modifiedOn
return doc
}
protected abstract txCreateDoc (tx: TxCreateDoc<Doc>): Promise<TxResult>
protected abstract txPutBag (tx: TxPutBag<PropertyType>): Promise<TxResult>
protected abstract txUpdateDoc (tx: TxUpdateDoc<Doc>): Promise<TxResult>
@ -328,6 +344,25 @@ export class TxOperations implements Storage {
return tx.objectId
}
async removeCollection<T extends Doc, P extends AttachedDoc>(
_class: Ref<Class<P>>,
space: Ref<Space>,
objectId: Ref<P>,
attachedTo: Ref<T>,
attachedToClass: Ref<Class<T>>,
collection: string
): Promise<Ref<T>> {
const tx = this.txFactory.createTxCollectionCUD(
attachedToClass,
attachedTo,
space,
collection,
this.txFactory.createTxRemoveDoc(_class, space, objectId)
)
await this.storage.tx(tx)
return tx.objectId
}
putBag <P extends PropertyType>(
_class: Ref<Class<Doc>>,
space: Ref<Space>,

View File

@ -58,7 +58,7 @@ export function setClient(_client: Client) {
}
}
class LiveQuery {
export class LiveQuery {
private unsubscribe = () => {}
constructor() {

View File

@ -104,7 +104,7 @@
}
const whileShow = (ev: MouseEvent): void => {
if ($tooltip.element) {
if ($tooltip.element && tooltipHTML) {
const rectP = tooltipHTML.getBoundingClientRect()
const rectT = {
top: (dir === 'top') ? rect.top - 16 : rect.top,

View File

@ -0,0 +1,253 @@
import core, {
AttachedDoc,
Class,
Client,
Doc,
DocumentUpdate,
Hierarchy,
Ref,
SortingOrder,
Tx,
TxCollectionCUD,
TxCreateDoc,
TxCUD,
TxProcessor,
TxUpdateDoc
} from '@anticrm/core'
import { createQuery, LiveQuery } from '@anticrm/presentation'
/**
* @public
*/
export type ActivityKey = string
/**
* @public
*/
export function activityKey (objectClass: Ref<Class<Doc>>, txClass: Ref<Class<Tx>>): ActivityKey {
return objectClass + ':' + txClass
}
function isEqualOps (op1: DocumentUpdate<Doc>, op2: DocumentUpdate<Doc>): boolean {
const o1 = Object.keys(op1).sort().join('-')
const o2 = Object.keys(op2).sort().join('-')
return o1 === o2
}
/**
* Transaction being displayed.
* @public
*/
export interface DisplayTx {
// Source tx
tx: TxCUD<Doc>
// A set of collapsed transactions.
txes: Array<TxCUD<Doc>>
// type check for createTx
createTx?: TxCreateDoc<Doc>
// Type check for updateTx
updateTx?: TxUpdateDoc<Doc>
// Document in case it is required.
doc?: Doc
removed: boolean
}
/**
* @public
*/
export type DisplayTxListener = (txes: DisplayTx[]) => void
// Use 5 minutes to combine similar transactions.
const combineThreshold = 5 * 60 * 1000
/**
* Define activity.
*
* Allow to recieve a list of transactions and notify client about it.
*/
export interface Activity {
update: (object: Doc, listener: DisplayTxListener, sort: SortingOrder) => void
}
class ActivityImpl implements Activity {
private readonly txQuery1: LiveQuery
private readonly txQuery2: LiveQuery
private txes1: Array<TxCUD<Doc>> = []
private txes2: Array<TxCUD<Doc>> = []
constructor (readonly client: Client) {
this.txQuery1 = createQuery()
this.txQuery2 = createQuery()
}
private notify (object: Doc, listener: DisplayTxListener, sort: SortingOrder): void {
this.combineTransactions(object, this.txes1, this.txes2).then(
(result) => {
const sorted = result.sort((a, b) => (a.tx.modifiedOn - b.tx.modifiedOn) * sort)
listener(sorted)
},
(err) => {
console.error(err)
}
)
}
update (object: Doc, listener: DisplayTxListener, sort: SortingOrder): void {
let isAttached = false
isAttached = this.client.getHierarchy().isDerived(object._class, core.class.AttachedDoc)
this.txQuery1.query<TxCollectionCUD<Doc, AttachedDoc>>(
isAttached ? core.class.TxCollectionCUD : core.class.TxCUD,
isAttached
? { 'tx.objectId': object._id as Ref<AttachedDoc> }
: {
objectId: object._id,
_class: { $in: [core.class.TxCreateDoc, core.class.TxUpdateDoc, core.class.TxRemoveDoc] }
},
(result) => {
this.txes1 = result
this.notify(object, listener, sort)
},
{ sort: { modifiedOn: SortingOrder.Descending } }
)
this.txQuery2.query<TxCUD<Doc>>(
core.class.TxCollectionCUD,
{
objectId: object._id,
'tx._class': { $in: [core.class.TxCreateDoc, core.class.TxUpdateDoc, core.class.TxRemoveDoc] }
},
(result) => {
this.txes2 = result
this.notify(object, listener, sort)
},
{ sort: { modifiedOn: SortingOrder.Descending } }
)
}
async combineTransactions (object: Doc, txes1: Array<TxCUD<Doc>>, txes2: Array<TxCUD<Doc>>): Promise<DisplayTx[]> {
const hierarchy = this.client.getHierarchy()
// We need to sort with with natural order, to build a proper doc values.
const allTx = Array.from(txes1).concat(txes2).sort(this.sortByLastModified)
const txCUD: Array<TxCUD<Doc>> = this.filterTxCUD(allTx, hierarchy)
const parents = new Map<Ref<Doc>, DisplayTx>()
const results: DisplayTx[] = []
for (const tx of txCUD) {
const { collectionCUD, updateCUD, 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)) {
// Combine previous update transaction for same field and if same operation and time treshold is ok
this.checkIntegratePreviousTx(results, result)
results.push(result)
}
}
return Array.from(results)
}
sortByLastModified (a: TxCUD<Doc>, b: TxCUD<Doc>): number {
return a.modifiedOn - b.modifiedOn
}
isDisplayTxRequired (collectionCUD: boolean, updateCUD: boolean, ntx: TxCUD<Doc>, object: Doc): boolean {
return !(collectionCUD && updateCUD) || ntx.objectId === object._id
}
filterTxCUD (allTx: Array<TxCUD<Doc>>, hierarchy: Hierarchy): Array<TxCUD<Doc>> {
return allTx.filter((tx) => hierarchy.isDerived(tx._class, core.class.TxCUD))
}
createDisplayTx (
tx: TxCUD<Doc>,
parents: Map<Ref<Doc>, DisplayTx>
): { collectionCUD: boolean, updateCUD: boolean, result: DisplayTx, tx: TxCUD<Doc> } {
let collectionCUD = false
let updateCUD = false
const hierarchy = this.client.getHierarchy()
if (hierarchy.isDerived(tx._class, core.class.TxCollectionCUD)) {
tx = (tx as TxCollectionCUD<Doc, AttachedDoc>).tx
collectionCUD = true
}
let firstTx = parents.get(tx.objectId)
const result: DisplayTx = this.newDisplayTx(tx)
result.doc = firstTx?.doc ?? result.doc
firstTx = firstTx ?? result
parents.set(tx.objectId, firstTx)
// If we have updates also apply them all.
updateCUD = this.updateFirstTx(result, firstTx)
if (hierarchy.isDerived(tx._class, core.class.TxRemoveDoc) && result.doc !== undefined) {
firstTx.removed = true
}
return { collectionCUD, updateCUD, result, tx }
}
updateFirstTx (result: DisplayTx, firstTx: DisplayTx): boolean {
if (this.client.getHierarchy().isDerived(result.tx._class, core.class.TxUpdateDoc) && result.doc !== undefined) {
firstTx.doc = TxProcessor.updateDoc2Doc(result.doc, result.tx as TxUpdateDoc<Doc>)
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
return {
tx,
txes: [],
createTx,
updateTx: hierarchy.isDerived(tx._class, core.class.TxUpdateDoc) ? (tx as TxUpdateDoc<Doc>) : undefined,
removed: false,
doc: createTx !== undefined ? TxProcessor.createDoc2Doc(createTx) : undefined
}
}
checkIntegratePreviousTx (results: DisplayTx[], result: DisplayTx): void {
if (results.length > 0) {
const prevTx = results[results.length - 1]
if (this.isSameKindTx(prevTx, result)) {
const prevUpdate = prevTx.tx as unknown as TxUpdateDoc<Doc>
const curUpdate = result.tx as unknown as TxUpdateDoc<Doc>
if (
isEqualOps(prevUpdate.operations, curUpdate.operations) &&
result.tx.modifiedOn - prevUpdate.modifiedOn < combineThreshold
) {
// we have same keys, l
// Remember previous transactions
result.txes.push(...prevTx.txes, prevTx.tx)
// Remove last item
results.splice(results.length - 1, 1)
}
}
}
}
isSameKindTx (prevTx: DisplayTx, result: DisplayTx): 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 &&
prevTx.tx.modifiedBy === result.tx.modifiedBy // Same user
)
}
}
/**
* Construct an new activity, to listend for displayed transactions in UI.
* @param client
*/
export function newActivity (client: Client): Activity {
return new ActivityImpl(client)
}

View File

@ -13,62 +13,31 @@
// See the License for the specific language governing permissions and
// limitations under the License.
-->
<script lang="ts">
import activity, { TxViewlet } from '@anticrm/activity'
import chunter from '@anticrm/chunter'
import type { AttachedDoc, Doc, Ref, TxCollectionCUD, TxCUD } from '@anticrm/core'
import core, { SortingOrder } from '@anticrm/core'
import { Doc, SortingOrder } from '@anticrm/core'
import { createQuery, getClient } from '@anticrm/presentation'
import { ReferenceInput } from '@anticrm/text-editor'
import { Grid, IconActivity, ScrollBox } from '@anticrm/ui'
import { ActivityKey, activityKey } from '../utils'
import { ActivityKey, activityKey, DisplayTx, newActivity } from '../activity'
import TxView from './TxView.svelte'
export let fullSize: boolean = false
export let object: Doc
let txes1: TxCUD<Doc>[] = []
let txes2: TxCUD<Doc>[] = []
let txes: TxCUD<Doc>[]
$: txes = Array.from(txes1)
.concat(txes2)
.sort((a, b) => b.modifiedOn - a.modifiedOn)
let txes: DisplayTx[] = []
const client = getClient()
const txQuery1 = createQuery()
const txQuery2 = createQuery()
const activityQuery = newActivity(client)
let isAttached = false
$: isAttached = client.getHierarchy().isDerived(object._class, core.class.AttachedDoc)
$: txQuery1.query<TxCollectionCUD<Doc, AttachedDoc>>(
isAttached ? core.class.TxCollectionCUD : core.class.TxCUD,
isAttached
? { 'tx.objectId': object._id as Ref<AttachedDoc> }
: {
objectId: object._id,
_class: { $in: [core.class.TxCreateDoc, core.class.TxUpdateDoc, core.class.TxRemoveDoc] }
},
$: activityQuery.update(
object,
(result) => {
txes1 = result
txes = result
},
{ sort: { modifiedOn: SortingOrder.Descending } }
)
$: txQuery2.query<TxCUD<Doc>>(
core.class.TxCollectionCUD,
{
objectId: object._id,
'tx._class': { $in: [core.class.TxCreateDoc, core.class.TxRemoveDoc] }
},
(result) => {
txes2 = result
},
{ sort: { modifiedOn: SortingOrder.Descending } }
SortingOrder.Descending
)
function onMessage (event: CustomEvent) {
@ -94,7 +63,7 @@
<ScrollBox vertical stretch>
{#if txes}
<Grid column={1} rowGap={1.5}>
{#each txes as tx}
{#each txes as tx (tx.tx._id)}
<TxView {tx} {viewlets} />
{/each}
</Grid>

View File

@ -13,101 +13,98 @@
// See the License for the specific language governing permissions and
// limitations under the License.
-->
<script lang="ts">
import type { TxViewlet } from '@anticrm/activity'
import activity from '@anticrm/activity'
import contact, { EmployeeAccount, formatName } from '@anticrm/contact'
import core, {
AttachedDoc,
Doc,
Ref,
Tx,
TxCollectionCUD,
TxCreateDoc,
TxCUD,
TxProcessor,
TxUpdateDoc
} from '@anticrm/core'
import core, { Class, Doc, Ref, TxCUD, TxUpdateDoc } from '@anticrm/core'
import { IntlString } from '@anticrm/platform'
import { getClient } from '@anticrm/presentation'
import { AnyComponent, AnySvelteComponent, Component, Icon, Label, TimeSince } from '@anticrm/ui'
import type { AttributeModel } from '@anticrm/view'
import { buildModel, getObjectPresenter } from '@anticrm/view-resources'
import { activityKey, ActivityKey } from '../utils'
import { activityKey, ActivityKey, DisplayTx } from '../activity'
export let tx: Tx
export let tx: DisplayTx
export let viewlets: Map<ActivityKey, TxViewlet>
type TxDisplayViewlet =
| (Pick<TxViewlet, 'icon' | 'label' | 'display'> & { component?: AnyComponent | AnySvelteComponent })
| undefined
let viewlet: TxDisplayViewlet
let ptx: DisplayTx | undefined
let viewlet: TxDisplayViewlet | undefined
let props: any
let displayTx: TxCUD<Doc> | undefined
let utx: TxUpdateDoc<Doc> | undefined
let employee: EmployeeAccount | undefined
let model: AttributeModel[] = []
$: if (tx.tx._id !== ptx?.tx._id) {
viewlet = undefined
props = undefined
employee = undefined
model = []
ptx = tx
}
const client = getClient()
$: if (client.getHierarchy().isDerived(tx._class, core.class.TxCollectionCUD)) {
const colCUD = tx as TxCollectionCUD<Doc, AttachedDoc>
displayTx = colCUD.tx
viewlet = undefined
} else if (client.getHierarchy().isDerived(tx._class, core.class.TxCUD)) {
displayTx = tx as TxCUD<Doc>
viewlet = undefined
}
async function updateViewlet (displayTx?: TxCUD<Doc>): Promise<TxDisplayViewlet> {
if (displayTx === undefined) {
return undefined
async function createPseudoViewlet (dtx: DisplayTx, label: string): Promise<TxDisplayViewlet> {
const doc = dtx.doc
if (doc === undefined) {
return
}
const key = activityKey(displayTx.objectClass, displayTx._class)
let viewlet: TxDisplayViewlet = viewlets.get(key)
const docClass: Class<Doc> = client.getModel().getObject(doc._class)
props = { tx: displayTx }
if (viewlet === undefined && displayTx._class === core.class.TxCreateDoc) {
// Check if we have a class presenter we could have a pseudo viewlet based on class presenter.
const doc = TxProcessor.createDoc2Doc(displayTx as TxCreateDoc<Doc>)
const docClass = client.getModel().getObject(doc._class)
const presenter = await getObjectPresenter(client, doc._class, 'doc-presenter')
if (presenter !== undefined) {
viewlet = {
display: 'inline',
icon: docClass.icon ?? activity.icon.Activity,
label: ('created ' + docClass.label) as IntlString,
component: presenter.presenter
}
props = { value: doc }
const presenter = await getObjectPresenter(client, doc._class, 'doc-presenter')
if (presenter !== undefined) {
return {
display: 'inline',
icon: docClass.icon ?? activity.icon.Activity,
label: (`${label} ` + docClass.label) as IntlString,
component: presenter.presenter
}
}
return viewlet
}
$: updateViewlet(displayTx).then((result) => {
viewlet = result
async function updateViewlet (dtx: DisplayTx): Promise<{ viewlet: TxDisplayViewlet; id: Ref<TxCUD<Doc>> }> {
const key = activityKey(dtx.tx.objectClass, dtx.tx._class)
let viewlet: TxDisplayViewlet = viewlets.get(key)
props = { tx: dtx.tx, value: dtx.doc }
if (viewlet === undefined && dtx.tx._class === core.class.TxCreateDoc) {
// Check if we have a class presenter we could have a pseudo viewlet based on class presenter.
viewlet = await createPseudoViewlet(dtx, 'created')
}
if (viewlet === undefined && dtx.tx._class === core.class.TxRemoveDoc) {
viewlet = await createPseudoViewlet(dtx, 'deleted')
}
return { viewlet, id: dtx.tx._id }
}
$: updateViewlet(tx).then((result) => {
if (result.id === tx.tx._id) {
viewlet = result.viewlet
}
})
let employee: EmployeeAccount | undefined
$: client.findOne(contact.class.EmployeeAccount, { _id: tx.modifiedBy as Ref<EmployeeAccount> }).then((account) => {
employee = account
})
$: client
.findOne(contact.class.EmployeeAccount, { _id: tx.tx.modifiedBy as Ref<EmployeeAccount> })
.then((account) => {
employee = account
})
let model: AttributeModel[] = []
$: if (displayTx !== undefined && displayTx._class === core.class.TxUpdateDoc) {
utx = displayTx as TxUpdateDoc<Doc>
const ops = { client, _class: utx.objectClass, keys: Object.keys(utx.operations), ignoreMissing: true }
model = []
$: if (tx.updateTx !== undefined) {
const ops = {
client,
_class: tx.updateTx.objectClass,
keys: Object.keys(tx.updateTx.operations).filter(id => !id.startsWith('$')),
ignoreMissing: true
}
buildModel(ops).then((m) => {
model = m
})
} else {
model = []
utx = undefined
}
function getValue (utx: TxUpdateDoc<Doc>, key: string): any {
@ -115,7 +112,7 @@
}
</script>
{#if displayTx && (viewlet !== undefined || model.length > 0)}
{#if viewlet !== undefined || model.length > 0}
<div class="flex-col msgactivity-container">
<div class="flex-between">
<div class="flex-center icon">
@ -138,10 +135,10 @@
{#if viewlet && viewlet.label}
<div><Label label={viewlet.label} /></div>
{/if}
{#if viewlet === undefined && model.length > 0 && utx}
{#if viewlet === undefined && model.length > 0 && tx.updateTx}
{#each model as m}
<span>changed {m.label} to</span>
<div class="strong"><svelte:component this={m.presenter} value={getValue(utx, m.key)} /></div>
<div class="strong"><svelte:component this={m.presenter} value={getValue(tx.updateTx, m.key)} /></div>
{/each}
{:else if viewlet && viewlet.display === 'inline' && viewlet.component}
<div>
@ -153,7 +150,7 @@
</div>
{/if}
</div>
<div class="time"><TimeSince value={tx.modifiedOn} /></div>
<div class="time"><TimeSince value={tx.tx.modifiedOn} /></div>
</div>
{#if viewlet && viewlet.component && viewlet.display !== 'inline'}
<div class="content" class:emphasize={viewlet.display === 'emphasized'}>

View File

@ -1,7 +0,0 @@
import { Class, Doc, Ref, Tx } from '@anticrm/core'
export type ActivityKey = string
export function activityKey (objectClass: Ref<Class<Doc>>, txClass: Ref<Class<Tx>>): ActivityKey {
return objectClass + ':' + txClass
}

View File

@ -13,7 +13,8 @@
// limitations under the License.
//
import type { Doc } from '@anticrm/core'
import type { AttachedDoc, Doc } from '@anticrm/core'
import core from '@anticrm/core'
import StringEditor from './components/StringEditor.svelte'
import StringPresenter from './components/StringPresenter.svelte'
@ -39,7 +40,12 @@ function Delete(object: Doc): void {
}, undefined, (result) => {
if (result) {
const client = getClient()
client.removeDoc(object._class, object.space, object._id)
if(client.getHierarchy().isDerived(object._class, core.class.AttachedDoc)) {
const adoc = object as AttachedDoc
client.removeCollection(object._class, object.space, adoc._id, adoc.attachedTo, adoc.attachedToClass, adoc.collection)
} else {
client.removeDoc(object._class, object.space, object._id)
}
}
})
}