Files
huly-platform/plugins/activity-resources/src/activity.ts
T
2022-12-25 23:40:13 +07:00

455 lines
14 KiB
TypeScript

import core, {
AnyAttribute,
AttachedDoc,
Attribute,
Class,
Client,
Collection,
Doc,
Hierarchy,
Ref,
SortingOrder,
Tx,
TxCollectionCUD,
TxCreateDoc,
TxCUD,
TxMixin,
TxProcessor,
TxUpdateDoc
} from '@hcengineering/core'
import { createQuery, LiveQuery } from '@hcengineering/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: any, op2: any): boolean {
if (typeof op1 === 'string' && typeof op2 === 'string') {
return op1 === op2
}
if (typeof op1 !== typeof op2) {
return false
}
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: DisplayTx[]
txDocIds?: Set<Ref<Doc>>
// type check for createTx
createTx?: TxCreateDoc<Doc>
// 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
collectionAttribute?: Attribute<Collection<AttachedDoc>>
}
/**
* @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,
editable: Map<Ref<Class<Doc>>, boolean>
) => void
}
class ActivityImpl implements Activity {
private readonly ownTxQuery: LiveQuery
private readonly attachedTxQuery: LiveQuery
private readonly attachedChangeTxQuery: LiveQuery
private readonly hiddenAttributes: Set<string>
private editable: Map<Ref<Class<Doc>>, boolean> | undefined
private ownTxes: Array<TxCUD<Doc>> = []
private attachedTxes: Array<TxCollectionCUD<Doc, AttachedDoc>> = []
private attacheChangedTxes: Array<TxCollectionCUD<Doc, AttachedDoc>> = []
private readonly hierarchy: Hierarchy
constructor (readonly client: Client, attributes: Map<string, AnyAttribute>) {
this.hierarchy = client.getHierarchy()
this.hiddenAttributes = new Set(
[...attributes.entries()].filter(([, value]) => value.hidden === true).map(([key]) => key)
)
this.ownTxQuery = createQuery()
this.attachedTxQuery = createQuery()
this.attachedChangeTxQuery = createQuery()
}
private notify (object: Doc, listener: DisplayTxListener, sort: SortingOrder): void {
if (this.editable != null) {
this.combineTransactions(object, this.ownTxes, this.attachedTxes, this.attacheChangedTxes, this.editable).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, editable: Map<Ref<Class<Doc>>, boolean>): void {
let isAttached = false
isAttached = this.hierarchy.isDerived(object._class, core.class.AttachedDoc)
this.editable = editable
this.ownTxQuery.query<TxCUD<Doc>>(
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, core.class.TxMixin]
}
},
(result) => {
this.ownTxes = result
this.notify(object, listener, sort)
},
{ sort: { modifiedOn: SortingOrder.Ascending } }
)
this.attachedTxQuery.query<TxCollectionCUD<Doc, AttachedDoc>>(
core.class.TxCollectionCUD,
{
objectId: object._id,
'tx._class': { $in: [core.class.TxCreateDoc, core.class.TxUpdateDoc, core.class.TxRemoveDoc] }
},
(result) => {
this.attachedTxes = result
this.notify(object, listener, sort)
},
{ sort: { modifiedOn: SortingOrder.Ascending } }
)
this.attachedChangeTxQuery.query<TxCollectionCUD<Doc, AttachedDoc>>(
core.class.TxCollectionCUD,
{
'tx.operations.attachedTo': object._id,
'tx._class': core.class.TxUpdateDoc
},
(result) => {
this.attacheChangedTxes = result
this.notify(object, listener, sort)
},
{ sort: { modifiedOn: SortingOrder.Ascending } }
)
// In case editable is changed
this.notify(object, listener, sort)
}
async combineTransactions (
doc: Doc,
ownTxes: Array<TxCUD<Doc>>,
attachedTxes: Array<TxCollectionCUD<Doc, AttachedDoc>>,
attachedChangeTxes: Array<TxCollectionCUD<Doc, AttachedDoc>>,
editable: Map<Ref<Class<Doc>>, boolean>
): Promise<DisplayTx[]> {
const parents = new Map<Ref<Doc>, DisplayTx>()
let ownResults: DisplayTx[] = []
let attachedResults: DisplayTx[] = []
for (const tx of ownTxes) {
if (!this.filterUpdateTx(tx)) continue
const [result] = this.createDisplayTx(tx, parents)
// Combine previous update transaction for same field and if same operation and time treshold is ok
ownResults = this.integrateTxWithResults(ownResults, result, editable)
this.updateRemovedState(result, ownResults)
}
for (let tx of Array.from(attachedTxes)
.concat(attachedChangeTxes)
.sort((a, b) => a.modifiedOn - b.modifiedOn)) {
if (!this.filterUpdateTx(tx)) continue
const changeAttached = this.isChangeAttachedTx(tx)
if (changeAttached || this.isDisplayTxRequired(tx)) {
if (changeAttached) {
tx = await this.createFakeTx(doc, tx)
}
const [result, isUpdated, isMixin] = this.createDisplayTx(tx, parents)
if (!(isUpdated || isMixin)) {
// Combine previous update transaction for same field and if same operation and time treshold is ok
attachedResults = this.integrateTxWithResults(attachedResults, result, editable)
this.updateRemovedState(result, attachedResults)
}
}
}
return Array.from(ownResults).concat(attachedResults)
}
private async createFakeTx (
doc: Doc,
cltx: TxCollectionCUD<Doc, AttachedDoc>
): Promise<TxCollectionCUD<Doc, AttachedDoc>> {
if (doc._id === cltx.objectId) {
cltx.tx._class = core.class.TxRemoveDoc
} else {
const createTx = await this.client.findOne(core.class.TxCollectionCUD, {
'tx.objectId': cltx.tx.objectId,
'tx._class': core.class.TxCreateDoc
})
if (createTx !== undefined) {
cltx.tx = createTx.tx
cltx.tx.modifiedBy = cltx.modifiedBy
cltx.tx.modifiedOn = cltx.modifiedOn
}
}
return cltx
}
private isChangeAttachedTx (cltx: TxCollectionCUD<Doc, AttachedDoc>): boolean {
const tx = TxProcessor.extractTx(cltx)
if (this.hierarchy.isDerived(tx._class, core.class.TxUpdateDoc)) {
const utx = tx as TxUpdateDoc<AttachedDoc>
return utx.operations.attachedTo !== undefined
}
return false
}
private updateRemovedState (result: DisplayTx, results: DisplayTx[]): void {
if (result.removed) {
// We need to mark all transactions for same object as removed as well.
for (const t of results) {
if (t.tx.objectId === result.tx.objectId) {
t.removed = true
}
}
}
}
private isDisplayTxRequired (cltx: TxCollectionCUD<Doc, AttachedDoc>): boolean {
// Check if collection attribute is hidden
if (this.hiddenAttributes.has(cltx.collection)) {
return false
}
const tx = TxProcessor.extractTx(cltx)
if (
[core.class.TxCreateDoc, core.class.TxUpdateDoc, core.class.TxRemoveDoc, core.class.TxMixin].includes(tx._class)
) {
return true
}
return false
}
private readonly getUpdateTx = (tx: TxCUD<Doc>): TxUpdateDoc<Doc> | undefined => {
if (this.hierarchy.isDerived(tx._class, core.class.TxCollectionCUD)) {
const cltx = tx as TxCollectionCUD<Doc, AttachedDoc>
tx = TxProcessor.extractTx(cltx) as TxCUD<Doc>
}
if (tx._class !== core.class.TxUpdateDoc) {
return undefined
}
return tx as TxUpdateDoc<Doc>
}
filterUpdateTx (tx: TxCUD<Doc>): boolean {
const utx = this.getUpdateTx(tx)
if (utx === undefined) {
return true
}
const ops = Object.keys(utx.operations)
if (ops.length > 1) {
return true
}
return !this.hiddenAttributes.has(ops[0])
}
createDisplayTx (tx: TxCUD<Doc>, parents: Map<Ref<Doc>, DisplayTx>): [DisplayTx, boolean, boolean] {
let collectionAttribute: Attribute<Collection<AttachedDoc>> | undefined
if (this.hierarchy.isDerived(tx._class, core.class.TxCollectionCUD)) {
const cltx = tx as TxCollectionCUD<Doc, AttachedDoc>
tx = TxProcessor.extractTx(cltx) as TxCUD<Doc>
// Check mixin classes for desired attribute
for (const cl of this.hierarchy.getDescendants(cltx.objectClass)) {
try {
collectionAttribute = this.hierarchy.getAttribute(cl, cltx.collection) as Attribute<Collection<AttachedDoc>>
if (collectionAttribute !== undefined) {
break
}
} catch (err: any) {
// Ignore
}
}
}
let firstTx = parents.get(tx.objectId)
const result: DisplayTx = newDisplayTx(tx, this.hierarchy)
result.collectionAttribute = collectionAttribute
result.doc = firstTx?.doc ?? result.doc
firstTx = firstTx ?? result
parents.set(tx.objectId, firstTx)
// If we have updates also apply them all.
const isUpdated = this.checkUpdateState(result, firstTx)
const isMixin = this.checkMixinState(result, firstTx)
this.checkRemoveState(tx, firstTx, result)
return [result, isUpdated, isMixin]
}
private checkRemoveState (tx: TxCUD<Doc>, firstTx: DisplayTx, result: DisplayTx): void {
if (this.hierarchy.isDerived(tx._class, core.class.TxRemoveDoc)) {
firstTx.removed = true
result.removed = true
}
}
checkUpdateState (result: DisplayTx, firstTx: DisplayTx): boolean {
if (this.hierarchy.isDerived(result.tx._class, core.class.TxUpdateDoc) && result.doc !== undefined) {
firstTx.doc = TxProcessor.updateDoc2Doc(result.doc, result.tx as TxUpdateDoc<Doc>)
firstTx.updated = true
result.updated = true
return true
}
return false
}
checkMixinState (result: DisplayTx, firstTx: DisplayTx): boolean {
if (this.hierarchy.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)
firstTx.mixin = true
result.mixin = true
return true
}
return false
}
integrateTxWithResults (
results: DisplayTx[],
result: DisplayTx,
editable: Map<Ref<Class<Doc>>, boolean>
): DisplayTx[] {
const curUpdate: any = getCombineOpFromTx(result)
if (curUpdate === undefined || (result.doc !== undefined && editable.has(result.doc._class))) {
results.push(result)
return results
}
const newResult = results.filter((prevTx) => {
const prevUpdate: any = getCombineOpFromTx(prevTx)
// If same tx or same collection
if (this.isSameKindTx(prevTx, result) || prevUpdate === curUpdate) {
if (result.tx.modifiedOn - prevTx.tx.modifiedOn < combineThreshold && isEqualOps(prevUpdate, curUpdate)) {
// we have same keys,
// Remember previous transactions
if (result.txDocIds === undefined) {
result.txDocIds = new Set(prevTx.txDocIds)
}
if (prevTx.doc?._id !== undefined) {
result.txDocIds?.add(prevTx.doc._id)
}
if (result.doc?._id !== undefined) {
result.txDocIds?.add(result.doc._id)
}
result.txes.push(...prevTx.txes, prevTx)
return false
}
}
return true
})
newResult.push(result)
return newResult
}
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
prevTx.tx.modifiedBy === result.tx.modifiedBy // Same user
)
}
}
function getCombineOpFromTx (result: DisplayTx): any {
let curUpdate: any
if (result.tx._class === core.class.TxUpdateDoc) {
curUpdate = (result.tx as unknown as TxUpdateDoc<Doc>).operations
}
if (result.tx._class === core.class.TxMixin) {
curUpdate = (result.tx as unknown as TxMixin<Doc, Doc>).attributes
}
if (curUpdate === undefined && result.collectionAttribute !== undefined) {
curUpdate = result.collectionAttribute.attributeOf + '.' + result.collectionAttribute.name
}
return curUpdate
}
export function newDisplayTx (tx: TxCUD<Doc>, hierarchy: Hierarchy): DisplayTx {
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,
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
}
}
/**
* Construct an new activity, to listend for displayed transactions in UI.
* @param client
*/
export function newActivity (client: Client, attributes: Map<string, AnyAttribute>): Activity {
return new ActivityImpl(client, attributes)
}