Fix nested tags (#8474)
Some checks are pending
CI / build (push) Waiting to run
CI / svelte-check (push) Blocked by required conditions
CI / formatting (push) Blocked by required conditions
CI / test (push) Blocked by required conditions
CI / uitest (push) Waiting to run
CI / uitest-pg (push) Waiting to run
CI / uitest-qms (push) Waiting to run
CI / uitest-workspaces (push) Waiting to run
CI / docker-build (push) Blocked by required conditions
CI / dist-build (push) Blocked by required conditions

Signed-off-by: Denis Bykhov <bykhov.denis@gmail.com>
This commit is contained in:
Denis Bykhov 2025-04-04 21:09:00 +05:00 committed by GitHub
parent 3c58f2b7f9
commit 099fb90c59
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 127 additions and 62 deletions

View File

@ -94,6 +94,15 @@ export function createModel (builder: Builder): void {
} }
}) })
builder.createDoc(serverCore.class.Trigger, core.space.Model, {
trigger: serverCard.trigger.OnCardTag,
isAsync: true,
txMatch: {
_class: core.class.TxMixin,
objectClass: card.class.Card
}
})
builder.mixin(card.class.Card, core.class.Class, serverCore.mixin.SearchPresenter, { builder.mixin(card.class.Card, core.class.Class, serverCore.mixin.SearchPresenter, {
searchIcon: card.icon.Card, searchIcon: card.icon.Card,
title: [['title']] title: [['title']]

View File

@ -252,7 +252,7 @@ export class TxOperations implements Omit<Client, 'notify' | 'getConnection'> {
if (hierarchy.isMixin(mixClass)) { if (hierarchy.isMixin(mixClass)) {
const baseClass = hierarchy.getBaseClass(doc._class) const baseClass = hierarchy.getBaseClass(doc._class)
const byClass = this.splitMixinUpdate(update, mixClass, baseClass) const byClass = splitMixinUpdate(hierarchy, update, mixClass, baseClass)
const ops = this.apply(doc._id) const ops = this.apply(doc._id)
for (const it of byClass) { for (const it of byClass) {
if (hierarchy.isMixin(it[0])) { if (hierarchy.isMixin(it[0])) {
@ -323,17 +323,7 @@ export class TxOperations implements Omit<Client, 'notify' | 'getConnection'> {
date?: Timestamp, date?: Timestamp,
account?: PersonId account?: PersonId
): Promise<T> { ): Promise<T> {
// We need to update fields if they are different. const documentUpdate = getDiffUpdate(doc, update)
const documentUpdate: DocumentUpdate<T> = {}
for (const [k, v] of Object.entries(update)) {
if (['_class', '_id', 'modifiedBy', 'modifiedOn', 'space', 'attachedTo', 'attachedToClass'].includes(k)) {
continue
}
const dv = (doc as any)[k]
if (!deepEqual(dv, v) && v !== undefined) {
;(documentUpdate as any)[k] = v
}
}
if (Object.keys(documentUpdate).length > 0) { if (Object.keys(documentUpdate).length > 0) {
await this.update(doc, documentUpdate, false, date ?? Date.now(), account) await this.update(doc, documentUpdate, false, date ?? Date.now(), account)
TxProcessor.applyUpdate(doc, documentUpdate) TxProcessor.applyUpdate(doc, documentUpdate)
@ -372,56 +362,71 @@ export class TxOperations implements Omit<Client, 'notify' | 'getConnection'> {
} }
return doc return doc
} }
}
private splitMixinUpdate<T extends Doc>( export function getDiffUpdate<T extends Doc> (doc: T, update: T | Data<T> | DocumentUpdate<T>): DocumentUpdate<T> {
update: DocumentUpdate<T>, // We need to update fields if they are different.
mixClass: Ref<Class<T>>, const documentUpdate: DocumentUpdate<T> = {}
baseClass: Ref<Class<T>> for (const [k, v] of Object.entries(update)) {
): Map<Ref<Class<Doc>>, DocumentUpdate<T>> { if (['_class', '_id', 'modifiedBy', 'modifiedOn', 'space', 'attachedTo', 'attachedToClass'].includes(k)) {
const hierarchy = this.getHierarchy() continue
const attributes = hierarchy.getAllAttributes(mixClass)
const updateAttrs = Object.fromEntries(
Object.entries(update).filter((it) => !it[0].startsWith('$'))
) as DocumentUpdate<T>
const updateOps = Object.fromEntries(
Object.entries(update).filter((it) => it[0].startsWith('$'))
) as DocumentUpdate<T>
const result: Map<Ref<Class<Doc>>, DocumentUpdate<T>> = this.splitObjectAttributes(
updateAttrs,
baseClass,
attributes
)
for (const [key, value] of Object.entries(updateOps)) {
const updates = this.splitObjectAttributes(value as object, baseClass, attributes)
for (const [opsClass, opsUpdate] of updates) {
const upd: DocumentUpdate<T> = result.get(opsClass) ?? {}
result.set(opsClass, { ...upd, [key]: opsUpdate })
}
} }
const dv = (doc as any)[k]
if (!deepEqual(dv, v) && v !== undefined) {
;(documentUpdate as any)[k] = v
}
}
return documentUpdate
}
return result export function splitMixinUpdate<T extends Doc> (
hierarchy: Hierarchy,
update: DocumentUpdate<T>,
mixClass: Ref<Class<T>>,
baseClass: Ref<Class<T>>
): Map<Ref<Class<Doc>>, DocumentUpdate<T>> {
const attributes = hierarchy.getAllAttributes(mixClass)
const updateAttrs = Object.fromEntries(
Object.entries(update).filter((it) => !it[0].startsWith('$'))
) as DocumentUpdate<T>
const updateOps = Object.fromEntries(
Object.entries(update).filter((it) => it[0].startsWith('$'))
) as DocumentUpdate<T>
const result: Map<Ref<Class<Doc>>, DocumentUpdate<T>> = splitObjectAttributes(
hierarchy,
updateAttrs,
baseClass,
attributes
)
for (const [key, value] of Object.entries(updateOps)) {
const updates = splitObjectAttributes(hierarchy, value as object, baseClass, attributes)
for (const [opsClass, opsUpdate] of updates) {
const upd: DocumentUpdate<T> = result.get(opsClass) ?? {}
result.set(opsClass, { ...upd, [key]: opsUpdate })
}
} }
private splitObjectAttributes<T extends object>( return result
obj: T, }
objClass: Ref<Class<Doc>>,
attributes: Map<string, AnyAttribute>
): Map<Ref<Class<Doc>>, object> {
const hierarchy = this.getHierarchy()
const result = new Map<Ref<Class<Doc>>, any>() function splitObjectAttributes<T extends object> (
for (const [key, value] of Object.entries(obj)) { hierarchy: Hierarchy,
const attributeOf = attributes.get(key)?.attributeOf obj: T,
const clazz = attributeOf !== undefined && hierarchy.isMixin(attributeOf) ? attributeOf : objClass objClass: Ref<Class<Doc>>,
result.set(clazz, { ...(result.get(clazz) ?? {}), [key]: value }) attributes: Map<string, AnyAttribute>
} ): Map<Ref<Class<Doc>>, object> {
const result = new Map<Ref<Class<Doc>>, any>()
return result for (const [key, value] of Object.entries(obj)) {
const attributeOf = attributes.get(key)?.attributeOf
const clazz = attributeOf !== undefined && hierarchy.isMixin(attributeOf) ? attributeOf : objClass
result.set(clazz, { ...(result.get(clazz) ?? {}), [key]: value })
} }
return result
} }
export interface CommitResult { export interface CommitResult {

View File

@ -16,7 +16,7 @@
--> -->
<script lang="ts"> <script lang="ts">
import card, { Card, Tag } from '@hcengineering/card' import card, { Card, Tag } from '@hcengineering/card'
import { Class, Doc, fillDefaults, Ref } from '@hcengineering/core' import { Class, Doc, Mixin, Ref } from '@hcengineering/core'
import { createQuery, getClient } from '@hcengineering/presentation' import { createQuery, getClient } from '@hcengineering/presentation'
import { import {
ButtonIcon, ButtonIcon,
@ -77,14 +77,17 @@
async (res) => { async (res) => {
if (res !== undefined) { if (res !== undefined) {
await client.createMixin(doc._id, doc._class, doc.space, res, {}) await client.createMixin(doc._id, doc._class, doc.space, res, {})
const updated = fillDefaults(hierarchy, hierarchy.clone(doc), res)
await client.diffUpdate(doc, updated)
} }
} }
) )
} }
let divScroll: HTMLElement let divScroll: HTMLElement
function isRemoveable (mixinId: Ref<Mixin<Doc>>, activeTags: Tag[]): boolean {
const desc = hierarchy.getDescendants(mixinId)
return !desc.some((p) => hierarchy.hasMixin(doc, p) && p !== mixinId)
}
</script> </script>
<div class="container py-4 gap-2"> <div class="container py-4 gap-2">
@ -94,9 +97,12 @@
<ScrollerBar gap={'none'} bind:scroller={divScroll}> <ScrollerBar gap={'none'} bind:scroller={divScroll}>
<div class="tags gap-2"> <div class="tags gap-2">
{#each activeTags as mixin} {#each activeTags as mixin}
<div class="tag no-word-wrap"> {@const removable = isRemoveable(mixin._id, activeTags)}
<div class="tag no-word-wrap" class:removable>
<Label label={mixin.label} /> <Label label={mixin.label} />
<ButtonIcon icon={IconClose} size="extra-small" kind="tertiary" on:click={() => removeTag(mixin._id)} /> {#if removable}
<ButtonIcon icon={IconClose} size="extra-small" kind="tertiary" on:click={() => removeTag(mixin._id)} />
{/if}
</div> </div>
{/each} {/each}
{#if dropdownItems.length > 0} {#if dropdownItems.length > 0}
@ -114,7 +120,7 @@
align-items: center; align-items: center;
.tag { .tag {
padding: 0.25rem 0.25rem 0.25rem 0.5rem; padding: 0.25rem 0.5rem;
height: 1.5rem; height: 1.5rem;
border: 1px solid var(--theme-content-color); border: 1px solid var(--theme-content-color);
@ -126,6 +132,10 @@
align-items: center; align-items: center;
justify-content: center; justify-content: center;
gap: 0.25rem; gap: 0.25rem;
&.removable {
padding-right: 0.25rem;
}
} }
} }

View File

@ -18,9 +18,14 @@ import core, {
AnyAttribute, AnyAttribute,
Data, Data,
Doc, Doc,
fillDefaults,
getDiffUpdate,
Mixin,
Ref, Ref,
splitMixinUpdate,
Tx, Tx,
TxCreateDoc, TxCreateDoc,
TxMixin,
TxProcessor, TxProcessor,
TxRemoveDoc, TxRemoveDoc,
TxUpdateDoc TxUpdateDoc
@ -391,6 +396,40 @@ async function OnCardCreate (ctx: TxCreateDoc<Card>[], control: TriggerControl):
return res return res
} }
export async function OnCardTag (ctx: TxMixin<Card, Card>[], control: TriggerControl): Promise<Tx[]> {
const res: Tx[] = []
for (const tx of ctx) {
if (tx.space === core.space.DerivedTx) continue
if (tx._class !== core.class.TxMixin) continue
const target = tx.mixin
const to = control.hierarchy.getBaseClass(target)
const ancestors = control.hierarchy.getAncestors(target).filter((p) => control.hierarchy.isDerived(p, to))
const mixinAncestors: Ref<Mixin<Doc>>[] = []
const doc = (await control.findAll(control.ctx, tx.objectClass, { _id: tx.objectId }))[0]
if (doc === undefined) continue
for (const anc of ancestors) {
if (anc === target) continue
if (control.hierarchy.hasMixin(doc, anc)) break
if (anc === to) break
mixinAncestors.unshift(anc)
}
for (const anc of mixinAncestors) {
res.push(control.txFactory.createTxMixin(doc._id, doc._class, doc.space, anc, {}))
}
const updated = fillDefaults(control.hierarchy, control.hierarchy.as(control.hierarchy.clone(doc), target), target)
const diff = getDiffUpdate(doc, updated)
const splitted = splitMixinUpdate(control.hierarchy, diff, target, doc._class)
for (const it of splitted) {
if (control.hierarchy.isMixin(it[0])) {
res.push(control.txFactory.createTxMixin(doc._id, doc._class, doc.space, it[0], it[1]))
} else {
res.push(control.txFactory.createTxUpdateDoc(it[0], doc.space, doc._id, it[1]))
}
}
}
return res
}
// eslint-disable-next-line @typescript-eslint/explicit-function-return-type // eslint-disable-next-line @typescript-eslint/explicit-function-return-type
export default async () => ({ export default async () => ({
trigger: { trigger: {
@ -401,6 +440,7 @@ export default async () => ({
OnTagRemove, OnTagRemove,
OnCardRemove, OnCardRemove,
OnCardCreate, OnCardCreate,
OnCardUpdate OnCardUpdate,
OnCardTag
} }
}) })

View File

@ -34,6 +34,7 @@ export default plugin(serverCardId, {
OnMasterTagRemove: '' as Resource<TriggerFunc>, OnMasterTagRemove: '' as Resource<TriggerFunc>,
OnCardCreate: '' as Resource<TriggerFunc>, OnCardCreate: '' as Resource<TriggerFunc>,
OnCardUpdate: '' as Resource<TriggerFunc>, OnCardUpdate: '' as Resource<TriggerFunc>,
OnCardTag: '' as Resource<TriggerFunc>,
OnCardRemove: '' as Resource<TriggerFunc> OnCardRemove: '' as Resource<TriggerFunc>
} }
}) })