feat: implement reference versioning functionality across various com… (#10941)

* feat: implement reference versioning functionality across various components and plugins

Signed-off-by: Denis Bykhov <bykhov.denis@gmail.com>

* fix: simplify provider function calls and label retrieval in MentionPopup component

Signed-off-by: Denis Bykhov <bykhov.denis@gmail.com>

* refactor: improve readability of version selection and reference handling in MentionVersionPopup and reference.ts

Signed-off-by: Denis Bykhov <bykhov.denis@gmail.com>

* feat: add CardReferenceObjectProvider and integrate into card model and resources

Signed-off-by: Denis Bykhov <bykhov.denis@gmail.com>

---------

Signed-off-by: Denis Bykhov <bykhov.denis@gmail.com>
This commit is contained in:
Denis Bykhov
2026-07-01 21:40:17 +07:00
committed by GitHub
parent b8da3437ab
commit 65cb5987bf
18 changed files with 328 additions and 33 deletions
@@ -22,6 +22,7 @@ export interface ReferenceNodeProps {
id: Ref<Doc>
objectclass: Ref<Class<Doc>>
label: string
fixed?: boolean
}
export interface ReferenceOptions {
@@ -42,7 +43,19 @@ export const ReferenceNode = Node.create<ReferenceOptions>({
return {
id: getDataAttribute('id'),
objectclass: getDataAttribute('objectclass'),
label: getDataAttribute('label')
label: getDataAttribute('label'),
fixed: {
default: null,
parseHTML: (element) => element.getAttribute('data-fixed') === 'true',
renderHTML: (attributes) => {
if (attributes.fixed !== true) {
return {}
}
return {
'data-fixed': 'true'
}
}
}
}
},
@@ -91,6 +104,7 @@ function getAttrs (el: HTMLSpanElement): Attrs | false {
const id = el.dataset.id?.trim()
const label = el.dataset.label?.trim()
const objectclass = el.dataset.objectclass?.trim()
const fixed = el.getAttribute('data-fixed') === 'true'
if (id == null || label == null || objectclass == null) {
return false
@@ -99,6 +113,7 @@ function getAttrs (el: HTMLSpanElement): Attrs | false {
return {
id,
label,
objectclass
objectclass,
fixed: fixed || null
}
}
+4
View File
@@ -963,6 +963,10 @@ export function createModel (builder: Builder): void {
titleProvider: card.function.CardTitleProvider
})
builder.mixin(card.class.Card, core.class.Class, view.mixin.ReferenceObjectProvider, {
provider: card.function.CardReferenceObjectProvider
})
builder.mixin(card.class.Card, core.class.Class, view.mixin.LinkProvider, {
encode: card.function.GetCardLink
})
+4
View File
@@ -471,6 +471,10 @@ export function createModel (builder: Builder): void {
titleProvider: documents.function.DocumentMetaTitleProvider
})
builder.mixin(documents.class.DocumentMeta, core.class.Class, view.mixin.ReferenceVersionsProvider, {
provider: documents.function.DocumentMetaReferenceVersionsProvider
})
builder.mixin(documents.class.DocumentMeta, core.class.Class, view.mixin.LinkProvider, {
encode: documents.function.GetDocumentMetaLinkFragment
})
+8
View File
@@ -92,6 +92,8 @@ import {
type ObjectValidator,
type PreviewPresenter,
type ReferenceObjectProvider,
type ReferenceVersion,
type ReferenceVersionsProvider,
type SortFunc,
type SpaceHeader,
type SpaceName,
@@ -275,6 +277,11 @@ export class TReferenceObjectProvider extends TClass implements ReferenceObjectP
provider!: Resource<<T extends Doc>(client: Client, ref: Ref<T>, doc?: T) => Promise<Doc | undefined>>
}
@Mixin(view.mixin.ReferenceVersionsProvider, core.class.Class)
export class TReferenceVersionsProvider extends TClass implements ReferenceVersionsProvider {
provider!: Resource<<T extends Doc>(client: Client, ref: Ref<T>, doc?: T) => Promise<ReferenceVersion[]>>
}
@Mixin(view.mixin.ObjectTooltip, core.class.Class)
export class TObjectTooltip extends TClass implements ObjectTooltip {
provider!: Resource<(client: Client, doc?: Doc | null) => Promise<LabelAndProps | undefined>>
@@ -523,6 +530,7 @@ export function createModel (builder: Builder): void {
TGroupping,
TObjectIdentifier,
TReferenceObjectProvider,
TReferenceVersionsProvider,
TObjectTooltip,
TObjectIcon,
TAttrPresenter,
+2
View File
@@ -28,6 +28,7 @@ import {
editSpace,
cardCustomLinkEncode,
cardCustomLinkMatch,
cardReferenceObjectProvider,
openCardInSidebar,
checkRelationsSectionVisibility,
checkOldMessagesSectionVisibility,
@@ -169,6 +170,7 @@ export default async (): Promise<Resources> => ({
},
function: {
CardTitleProvider: getCardTitle,
CardReferenceObjectProvider: cardReferenceObjectProvider,
GetCardLink: getCardLink,
CardCustomLinkMatch: cardCustomLinkMatch,
CardCustomLinkEncode: cardCustomLinkEncode,
+4 -1
View File
@@ -14,7 +14,7 @@
//
import card, { type Card, cardId } from '@hcengineering/card'
import { type Permission, type Doc, type Ref } from '@hcengineering/core'
import { type Client, type Permission, type Doc, type Ref } from '@hcengineering/core'
import { type IntlString, mergeIds, type Resource } from '@hcengineering/platform'
import { type ObjectSearchCategory, type ObjectSearchFactory } from '@hcengineering/presentation'
import { type AnyComponent } from '@hcengineering/ui/src/types'
@@ -62,6 +62,9 @@ export default mergeIds(cardId, card, {
},
function: {
CardFactory: '' as Resource<(props?: Record<string, any>) => Promise<Ref<Doc> | undefined>>,
CardReferenceObjectProvider: '' as Resource<
<T extends Doc>(client: Client, ref: Ref<T>, doc?: T) => Promise<Doc | undefined>
>,
FormatCardMarkdownValue: '' as Resource<ValueFormatter>,
CheckChildrenSectionVisibility: '' as Resource<(doc: Card) => Promise<boolean>>
},
+20
View File
@@ -486,6 +486,26 @@ export async function getCardTitle (client: TxOperations, ref: Ref<Card>, doc?:
return ids + ' ' + object.title + ' ' + version
}
export async function cardReferenceObjectProvider<T extends Doc> (
client: Client,
ref: Ref<T>,
doc?: T
): Promise<Doc | undefined> {
const object =
(doc as unknown as Card | undefined) ?? (await client.findOne(card.class.Card, { _id: ref as any as Ref<Card> }))
if (object === undefined) return
const versioningEnabled = client
.getHierarchy()
.classHierarchyMixin(object._class, core.mixin.VersionableClass)?.enabled
if (versioningEnabled !== true) return object
const baseId = object.baseId ?? object._id
if (object.isLatest === true) return object
return (await client.findOne(object._class, { baseId, isLatest: true } as any)) ?? object
}
export async function getCardLink (doc: Card): Promise<Location> {
const loc = getCurrentResolvedLocation()
loc.path.length = 2
@@ -103,6 +103,7 @@ import {
createFolder,
createTemplate,
deleteFolder,
documentMetaReferenceVersionsProvider,
documentIdentifierProvider,
controlledDocumentReferenceObjectProvider,
projectDocumentReferenceObjectProvider,
@@ -442,6 +443,7 @@ export default async (): Promise<Resources> => ({
DocumentIdentifierProvider: documentIdentifierProvider,
ControlledDocumentReferenceObjectProvider: controlledDocumentReferenceObjectProvider,
ProjectDocumentReferenceObjectProvider: projectDocumentReferenceObjectProvider,
DocumentMetaReferenceVersionsProvider: documentMetaReferenceVersionsProvider,
ControlledDocumentTitleProvider: getControlledDocumentTitle,
DocumentMetaTitleProvider: getDocumentMetaTitle,
FormatDocumentMarkdownValue: formatControlledDocumentValue,
@@ -21,7 +21,7 @@ import documents, {
import { type Client, type Doc, type Ref, type Space } from '@hcengineering/core'
import { type IntlString, mergeIds, type Resource } from '@hcengineering/platform'
import { type AnyComponent, type Location } from '@hcengineering/ui/src/types'
import type { KeyFilter, SortFunc, ViewActionAvailabilityFunction } from '@hcengineering/view'
import type { KeyFilter, ReferenceVersion, SortFunc, ViewActionAvailabilityFunction } from '@hcengineering/view'
export default mergeIds(documentsId, documents, {
component: {
@@ -256,6 +256,9 @@ export default mergeIds(documentsId, documents, {
CanPrintDocument: '' as Resource<(doc?: Doc | Doc[]) => Promise<boolean>>,
CanTransferDocument: '' as Resource<(doc?: Doc | Doc[]) => Promise<boolean>>,
ControlledDocumentTitleProvider: '' as Resource<(client: Client, ref: Ref<Doc>, doc?: Doc) => Promise<string>>,
DocumentMetaTitleProvider: '' as Resource<(client: Client, ref: Ref<Doc>, doc?: Doc) => Promise<string>>
DocumentMetaTitleProvider: '' as Resource<(client: Client, ref: Ref<Doc>, doc?: Doc) => Promise<string>>,
DocumentMetaReferenceVersionsProvider: '' as Resource<
(client: Client, ref: Ref<Doc>, doc?: Doc) => Promise<ReferenceVersion[]>
>
}
})
@@ -64,7 +64,7 @@ import { createQuery, getClient, MessageBox } from '@hcengineering/presentation'
import request, { type Request, RequestStatus } from '@hcengineering/request'
import { isEmptyMarkup } from '@hcengineering/text'
import { type Location, getUserTimezone, showPopup } from '@hcengineering/ui'
import { type KeyFilter } from '@hcengineering/view'
import { type KeyFilter, type ReferenceVersion } from '@hcengineering/view'
import { makeRank } from '@hcengineering/rank'
import { getProjectDocumentLink } from './navigation'
@@ -842,6 +842,29 @@ export async function getDocumentMetaTitle (
return object.title + ` (${hint})`
}
export async function documentMetaReferenceVersionsProvider (
client: Client,
ref: Ref<DocumentMeta>
): Promise<ReferenceVersion[]> {
const versions = await client.findAll(
documents.class.ControlledDocument,
{ attachedTo: ref },
{
sort: {
major: SortingOrder.Descending,
minor: SortingOrder.Descending
}
}
)
return versions.map((doc) => ({
id: doc._id,
objectclass: doc._class,
label: `${doc.title} (${getDocumentVersionString(doc)})`,
fixed: true
}))
}
export async function controlledDocumentReferenceObjectProvider (
client: Client,
ref: Ref<ControlledDocument>,
+3 -2
View File
@@ -87,6 +87,7 @@
"UnableToLoadEmbeddedContent": "Link preview couldn't be loaded due to permission settings or unsupported content",
"CannotConnectToCollaborationService": "Cannot connect to collaboration service",
"SourceURL": "Source URL",
"SelectedDocuments": "Selected documents ({count, plural, =1 {1 item} other {# items}})"
"SelectedDocuments": "Selected documents ({count, plural, =1 {1 item} other {# items}})",
"LatestVersion": "Latest"
}
}
}
+6 -5
View File
@@ -82,10 +82,11 @@
"CannotConnectToCollaborationService": "Не удалось подключиться к сервису совместной работы",
"SourceURL": "Исходный URL",
"SelectedDocuments": "Выбранные документы ({count, plural, =1 {1 элемент} other {# элементов}})",
"Highlight": "Highlight",
"Subscript": "Subscript",
"Superscript": "Superscript",
"Mathematics": "Mathematics",
"MathematicsBlock": "Mathematics block"
"Highlight": "Выделение",
"Subscript": "Нижний индекс",
"Superscript": "Верхний индекс",
"Mathematics": "Формула",
"MathematicsBlock": "Блок формул",
"LatestVersion": "Последняя"
}
}
@@ -14,19 +14,22 @@
// limitations under the License.
-->
<script lang="ts">
import core, { SearchResultDoc, Ref, Class, Doc } from '@hcengineering/core'
import contact from '@hcengineering/contact'
import core, { Class, Doc, Ref, SearchResultDoc, SortingOrder, type VersionableDoc } from '@hcengineering/core'
import { getResource, translate } from '@hcengineering/platform'
import presentation, {
SearchResult,
getClient,
reduceCalls,
searchFor,
type SearchItem,
getClient
SearchResult,
type SearchItem
} from '@hcengineering/presentation'
import { Label, ListView, resizeObserver } from '@hcengineering/ui'
import { Label, ListView, resizeObserver, Submenu } from '@hcengineering/ui'
import view, { type ReferenceVersion, type ReferenceVersionsProvider } from '@hcengineering/view'
import { createEventDispatcher } from 'svelte'
import contact from '@hcengineering/contact'
import { getReferenceLabel, getReferenceObject } from './extension/reference'
import { translate } from '@hcengineering/platform'
import MentionVersionPopup from './MentionVersionPopup.svelte'
export let query: string = ''
export let multipleMentions: boolean = false
@@ -34,6 +37,62 @@
let items: SearchItem[] = []
function getReferenceVersionsProvider (doc: SearchResultDoc['doc']): ReferenceVersionsProvider | undefined {
return client.getHierarchy().classHierarchyMixin(doc._class, view.mixin.ReferenceVersionsProvider)
}
function getVersionableDoc (doc: SearchResultDoc['doc']): VersionableDoc | undefined {
const enabled = client.getHierarchy().classHierarchyMixin(doc._class, core.mixin.VersionableClass)?.enabled
if (enabled !== true) return
const versionedDoc = doc as VersionableDoc
if (versionedDoc.baseId === undefined) return
if (versionedDoc.isLatest === true && versionedDoc.baseId === versionedDoc._id) return
return versionedDoc
}
function hasReferenceVersions (doc: SearchResultDoc['doc']): boolean {
return getReferenceVersionsProvider(doc) !== undefined || getVersionableDoc(doc) !== undefined
}
async function getReferenceVersions (doc: SearchResultDoc['doc']): Promise<ReferenceVersion[]> {
const provider = getReferenceVersionsProvider(doc)
if (provider !== undefined) {
const providerFn = await getResource(provider.provider)
return await providerFn(client, doc._id)
}
const versionedDoc = getVersionableDoc(doc)
if (versionedDoc === undefined) return []
const versions = await client.findAll(
doc._class,
{ baseId: versionedDoc.baseId } as any,
{ sort: { version: SortingOrder.Descending } } as any
)
return await Promise.all(
versions.map(async (version) => ({
id: version._id,
objectclass: version._class,
label: await getReferenceLabel(version._class, version._id, version),
fixed: true
}))
)
}
async function getLatestReference (item: SearchResultDoc): Promise<ReferenceVersion> {
return {
id: item.doc._id,
objectclass: item.doc._class,
label: await getReferenceLabel(item.doc._class, item.doc._id)
}
}
function selectReference (props: ReferenceVersion): void {
dispatch('close', props)
}
const dispatch = createEventDispatcher()
const client = getClient()
@@ -107,6 +166,11 @@
return
}
if (getReferenceVersionsProvider(item.doc) !== undefined) {
dispatch('close', await getLatestReference(item))
return
}
const obj = (await getReferenceObject(item.doc._class, item.doc._id)) ?? item.doc
const label = await getReferenceLabel(obj._class, obj._id)
dispatch('close', {
@@ -183,15 +247,36 @@
<svelte:fragment slot="item" let:item={num}>
{@const item = items[num]}
{@const doc = item.item}
<!-- svelte-ignore a11y-no-static-element-interactions -->
<div
class="ap-menuItem withComp h-8"
on:click={() => {
void handleSelectItem(doc)
}}
>
<SearchResult value={doc} />
</div>
{#if hasReferenceVersions(doc.doc)}
<Submenu
withoutMargin
withHover
props={{
latest: {
id: doc.doc._id,
objectclass: doc.doc._class,
label: doc.title ?? ''
},
versions: getReferenceVersions(doc.doc),
onSelect: selectReference
}}
options={{ component: MentionVersionPopup }}
>
<div slot="item" class="mention-version-item">
<SearchResult value={doc} />
</div>
</Submenu>
{:else}
<!-- svelte-ignore a11y-no-static-element-interactions -->
<div
class="ap-menuItem withComp h-8"
on:click={() => {
void handleSelectItem(doc)
}}
>
<SearchResult value={doc} />
</div>
{/if}
</svelte:fragment>
</ListView>
{/if}
@@ -213,6 +298,14 @@
padding-top: 0.5rem;
}
.mention-version-item {
display: flex;
align-items: center;
height: 2rem;
min-width: 0;
width: 100%;
}
.mentonCategory {
padding: 0.5rem 1rem;
font-size: 0.625rem;
@@ -0,0 +1,90 @@
<!--
// Copyright © 2026 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 { createEventDispatcher } from 'svelte'
import type { ReferenceVersion } from '@hcengineering/view'
import { Label, Scroller, resizeObserver } from '@hcengineering/ui'
import textEditor from '../plugin'
export let latest: ReferenceVersion
export let versions: Promise<ReferenceVersion[]>
export let onSelect: (props: ReferenceVersion) => void
const dispatch = createEventDispatcher()
function selectLatest (): void {
onSelect(latest)
dispatch('close')
}
function selectVersion (version: ReferenceVersion): void {
onSelect(version)
dispatch('close')
}
</script>
<div class="selectPopup dropdown" use:resizeObserver={() => dispatch('changeContent')}>
<div class="menu-space" />
<Scroller>
<button class="px-1 py-2 menu-item" on:click={selectLatest}>
<div class="w-full flex-between">
<div class="mr-2 ml-4 flex-row-center">
<span class="title mr-1-5">{latest.label}</span>
<span class="version mr-1-5"><Label label={textEditor.string.LatestVersion} /></span>
</div>
</div>
</button>
{#await versions then versionItems}
{#if versionItems.length > 0}
<div class="menu-separator" />
{#each versionItems as version}
<button
class="px-1 py-2 menu-item"
on:click={() => {
selectVersion(version)
}}
>
<div class="w-full flex-between">
<div class="mr-2 ml-4 flex-row-center">
<span class="title mr-1-5">{version.label}</span>
</div>
</div>
</button>
{/each}
{/if}
{/await}
</Scroller>
<div class="menu-space" />
</div>
<style lang="scss">
.dropdown {
min-width: 25rem;
}
.title {
white-space: nowrap;
font-weight: 500;
overflow: hidden;
text-overflow: ellipsis;
}
.version {
font-weight: 500;
opacity: 0.6;
}
</style>
@@ -200,15 +200,21 @@ export const ReferenceExtension = ReferenceNode.extend<ReferenceExtensionOptions
void renderLabel({ id, objectclass, label: node.attrs.label })
resetTooltipHandle(undefined)
} else {
const label = await getReferenceLabel(objectclass, id, obj)
const targetObj =
node.attrs.fixed === true ? obj : ((await getReferenceObject(objectclass, id, obj)) ?? obj)
const label = await getReferenceLabel(targetObj._class, targetObj._id, targetObj)
if (label === '') return
let tooltipOptions: LabelAndProps | undefined = await getReferenceTooltip(objectclass, id, obj)
let tooltipOptions: LabelAndProps | undefined = await getReferenceTooltip(
targetObj._class,
targetObj._id,
targetObj
)
if (tooltipOptions.component === undefined) {
tooltipOptions = undefined
}
resetTooltipHandle(tooltip(root, tooltipOptions))
void renderLabel({ id, objectclass, label })
void renderLabel({ id: targetObj._id, objectclass: targetObj._class, label })
}
})
} else if (withoutDoc) {
+2 -1
View File
@@ -20,6 +20,7 @@ import { type IntlString, plugin } from '@hcengineering/platform'
export default plugin(textEditorId, {
string: {
CannotConnectToCollaborationService: '' as IntlString,
SourceURL: '' as IntlString
SourceURL: '' as IntlString,
LatestVersion: '' as IntlString
}
})
+2
View File
@@ -59,6 +59,7 @@ import {
OpenDocumentFunction,
PreviewPresenter,
ReferenceObjectProvider,
ReferenceVersionsProvider,
SpaceHeader,
SpaceName,
SpacePresenter,
@@ -103,6 +104,7 @@ const view = plugin(viewId, {
ObjectTitle: '' as Ref<Mixin<ObjectTitle>>,
ObjectIdentifier: '' as Ref<Mixin<ObjectIdentifier>>,
ReferenceObjectProvider: '' as Ref<Mixin<ReferenceObjectProvider>>,
ReferenceVersionsProvider: '' as Ref<Mixin<ReferenceVersionsProvider>>,
ObjectTooltip: '' as Ref<Mixin<ObjectTooltip>>,
SpaceHeader: '' as Ref<Mixin<SpaceHeader>>,
SpaceName: '' as Ref<Mixin<SpaceName>>,
+17
View File
@@ -308,6 +308,23 @@ export interface ReferenceObjectProvider extends Class<Doc> {
provider: Resource<<T extends Doc>(client: Client, ref: Ref<T>, doc?: T) => Promise<Doc | undefined>>
}
/**
* @public
*/
export interface ReferenceVersion {
id: Ref<Doc>
objectclass: Ref<Class<Doc>>
label: string
fixed?: boolean
}
/**
* @public
*/
export interface ReferenceVersionsProvider extends Class<Doc> {
provider: Resource<<T extends Doc>(client: Client, ref: Ref<T>, doc?: T) => Promise<ReferenceVersion[]>>
}
/**
* @public
*/