From 826264ef25e7b2cd60b4fb911ec0dfa70211cebd Mon Sep 17 00:00:00 2001 From: Denis Bykhov Date: Thu, 30 Apr 2026 12:22:29 +0500 Subject: [PATCH] enable support for association-based attributes in markdown table (#10811) Signed-off-by: Denis Bykhov --- .../src/__tests__/copyAsMarkdownTable.test.ts | 1 + .../formatter.valueFormatter.test.ts | 4 + .../src/data/relationshipBuilder.ts | 7 +- .../src/formatter/valueFormatter.ts | 45 ++++++++++ .../src/markdown/tableBuilder.ts | 84 ++++++++++++++----- 5 files changed, 116 insertions(+), 25 deletions(-) diff --git a/plugins/converter-resources/src/__tests__/copyAsMarkdownTable.test.ts b/plugins/converter-resources/src/__tests__/copyAsMarkdownTable.test.ts index a54f2b415d..c835d37305 100644 --- a/plugins/converter-resources/src/__tests__/copyAsMarkdownTable.test.ts +++ b/plugins/converter-resources/src/__tests__/copyAsMarkdownTable.test.ts @@ -49,6 +49,7 @@ jest.mock('@hcengineering/view-resources', () => ({ copyMarkdown: jest.fn(), buildModel: jest.fn(), buildConfigLookup: jest.fn(() => ({})), + buildConfigAssociation: jest.fn(), getObjectLinkFragment: jest.fn() })) diff --git a/plugins/converter-resources/src/__tests__/formatter.valueFormatter.test.ts b/plugins/converter-resources/src/__tests__/formatter.valueFormatter.test.ts index 1e93368991..ed4e80a3c6 100644 --- a/plugins/converter-resources/src/__tests__/formatter.valueFormatter.test.ts +++ b/plugins/converter-resources/src/__tests__/formatter.valueFormatter.test.ts @@ -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) })) diff --git a/plugins/converter-resources/src/data/relationshipBuilder.ts b/plugins/converter-resources/src/data/relationshipBuilder.ts index d4e191287c..d5e7370629 100644 --- a/plugins/converter-resources/src/data/relationshipBuilder.ts +++ b/plugins/converter-resources/src/data/relationshipBuilder.ts @@ -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 }) } diff --git a/plugins/converter-resources/src/formatter/valueFormatter.ts b/plugins/converter-resources/src/formatter/valueFormatter.ts index cf7397f81f..54a8323330 100644 --- a/plugins/converter-resources/src/formatter/valueFormatter.ts +++ b/plugins/converter-resources/src/formatter/valueFormatter.ts @@ -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 + 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) } diff --git a/plugins/converter-resources/src/markdown/tableBuilder.ts b/plugins/converter-resources/src/markdown/tableBuilder.ts index de78cfaaf2..30297054cf 100644 --- a/plugins/converter-resources/src/markdown/tableBuilder.ts +++ b/plugins/converter-resources/src/markdown/tableBuilder.ts @@ -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 { + 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 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() @@ -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 )