enable support for association-based attributes in markdown table (#10811)

Signed-off-by: Denis Bykhov <bykhov.denis@gmail.com>
This commit is contained in:
Denis Bykhov
2026-04-30 12:22:29 +05:00
committed by GitHub
parent cb92341556
commit 826264ef25
5 changed files with 116 additions and 25 deletions
@@ -49,6 +49,7 @@ jest.mock('@hcengineering/view-resources', () => ({
copyMarkdown: jest.fn(),
buildModel: jest.fn(),
buildConfigLookup: jest.fn(() => ({})),
buildConfigAssociation: jest.fn(),
getObjectLinkFragment: jest.fn()
}))
@@ -26,6 +26,10 @@ jest.mock('@hcengineering/platform', () => {
}
})
jest.mock('@hcengineering/presentation', () => ({
getClient: jest.fn()
}))
jest.mock('../data/personLoader', () => ({
loadPersonName: jest.fn(async (personId: string) => personId)
}))
@@ -188,10 +188,13 @@ export async function rebuildRelationshipTableViewModel (
continue
}
const span = rowSpanByLevel[0][rowIdx]
const isFirstInSpan =
rowIdx === 0 || expandedRows[rowIdx - 1].docsByLevel[0]?._id !== rowData.docsByLevel[0]?._id
cells.push({
attribute: attr,
rowSpan: 1,
object: rowIdx === 0 ? parentDoc : undefined,
rowSpan: isFirstInSpan ? span : 0,
object: isFirstInSpan ? parentDoc : undefined,
parentObject: undefined
})
}
@@ -20,9 +20,11 @@ import core, {
type Hierarchy,
type Ref,
type PersonId,
type Association,
getDisplayTime,
getObjectValue
} from '@hcengineering/core'
import { getClient } from '@hcengineering/presentation'
import { translate, type IntlString, getResource } from '@hcengineering/platform'
import type { AttributeModel } from '@hcengineering/view'
import converter from '@hcengineering/converter'
@@ -188,6 +190,49 @@ function resolveDisplayContext (
} else {
value = undefined
}
} else if (attr.key.startsWith('$associations')) {
const parts = attr.key.split('.')
// Find the last association segment
let lastAssocIndex = -1
for (let i = 0; i < parts.length; i++) {
if (parts[i] === '$associations' && i + 1 < parts.length) {
lastAssocIndex = i
}
}
if (lastAssocIndex !== -1) {
const assocIdWithDirection = parts[lastAssocIndex + 1]
const fragments = assocIdWithDirection.split('_')
const assocId = fragments[0] as Ref<Association>
const direction = fragments[1] === 'a' ? -1 : 1
const client = getClient()
const assoc = client.getModel().findObject(assocId)
if (assoc !== undefined) {
const targetClass = direction === -1 ? assoc.classA : assoc.classB
const subFieldParts = parts.slice(lastAssocIndex + 2)
const cardWithAssociations = card as any
const assocData = cardWithAssociations.$associations?.[assocIdWithDirection]
if (assocData !== undefined) {
const firstDoc = Array.isArray(assocData) ? assocData[0] : assocData
if (firstDoc !== undefined) {
if (subFieldParts.length > 0) {
const subKey = subFieldParts.join('.')
if (Array.isArray(assocData)) {
value = assocData.map((d) => getObjectValue(subKey, d)).filter((v) => v !== undefined)
} else {
value = getObjectValue(subKey, assocData)
}
} else {
value = assocData
}
displayDoc = firstDoc
displayClass = targetClass
}
}
}
}
} else {
value = getObjectValue(attr.key, card)
}
@@ -23,7 +23,7 @@ import type {
BuildModelKey
} from '@hcengineering/view'
import viewPlugin from '@hcengineering/view'
import { buildConfigLookup, buildModel, getAttributeValue } from '@hcengineering/view-resources'
import { buildConfigLookup, buildModel, getAttributeValue, buildConfigAssociation } from '@hcengineering/view-resources'
import type { CopyAsMarkdownTableProps, CopyRelationshipTableAsMarkdownProps } from '../types'
import { formatValue } from '../formatter'
import { generateHeaders, loadViewletConfig, buildTableModel } from '../model'
@@ -99,6 +99,36 @@ async function preloadRefLookups (
}
}
async function preloadAssociations (docs: Doc[], model: AttributeModel[], client: Client): Promise<void> {
const associationQueries = buildConfigAssociation(model.map((m) => m.key))
if (associationQueries === undefined || associationQueries.length === 0) return
const ids = docs.map((d) => d._id)
const firstDoc = docs[0]
if (firstDoc === undefined) return
try {
const refreshedDocs = await client.findAll(
firstDoc._class,
{ _id: { $in: ids as any } },
{
associations: associationQueries
}
)
const refreshedMap = new Map(refreshedDocs.map((d) => [d._id, d]))
for (const doc of docs) {
const refreshed = refreshedMap.get(doc._id)
if (refreshed !== undefined) {
;(doc as any).$associations = (refreshed as any).$associations
}
}
} catch (error) {
console.warn('Failed to preload associations for markdown table', error)
}
}
function collectRelationshipDocsForRefPreload (
props: CopyRelationshipTableAsMarkdownProps,
hierarchy: Hierarchy
@@ -252,6 +282,8 @@ export async function buildMarkdownTableFromDocs (
// Preload referenced documents for RefTo / ArrOf<RefTo> attributes into $lookup
await preloadRefLookups(docs, displayableModel, hierarchy, client)
// Preload associations for $associations keys
await preloadAssociations(docs, displayableModel, client)
const language = getCurrentLanguage()
const userCache = new Map<PersonId, string>()
@@ -358,45 +390,51 @@ export async function buildRelationshipTableMarkdown (
continue
}
const rawValue = getAttributeValue(cell.attribute, doc, hierarchy)
let docToUse = doc
let docToUse: Doc | undefined = doc
let docClass = props.cardClass
let attributeToUse = cell.attribute
if (isAssociationKey) {
if (rawValue !== undefined && rawValue !== null && typeof rawValue === 'object' && '_class' in rawValue) {
docToUse = rawValue as Doc
if (cell.object !== undefined) {
docToUse = cell.object
docClass = docToUse._class
const parts = cell.attribute.key.split('$associations.')
if (parts.length > 1) {
const afterAssoc = parts[1].substring(1)
const dotIndex = afterAssoc.indexOf('.')
if (dotIndex > 0) {
const attributeName = afterAssoc.substring(dotIndex + 1)
attributeToUse = {
...cell.attribute,
key: attributeName
}
} else {
attributeToUse = {
...cell.attribute,
key: ''
}
// Strip association prefix for formatValue
const parts = cell.attribute.key.split('.')
let lastAssocIndex = -1
for (let i = 0; i < parts.length; i++) {
if (parts[i] === '$associations' && i + 1 < parts.length) {
lastAssocIndex = i
}
}
if (lastAssocIndex !== -1) {
attributeToUse = {
...cell.attribute,
key: parts.slice(lastAssocIndex + 2).join('.')
}
}
} else {
docToUse = cell.parentObject
if (docToUse !== undefined) {
docClass = docToUse._class
}
}
}
if (docToUse === undefined) {
if (cell.rowSpan === 0) continue
row[attrIndex] = ''
continue
}
const isFirstColumn = attrIndex === 0
const allowEmptyKey = isFirstColumn || isAssociationKey
let value = await formatValue(
attributeToUse,
docToUse,
hierarchy,
docClass,
language,
allowEmptyKey,
isFirstColumn || isAssociationKey,
userCache,
props.valueFormatter
)