From c0d9f64f8dfe65bc14ff5ec83ac804aab30e4c9a Mon Sep 17 00:00:00 2001 From: Artyom Savchenko Date: Mon, 16 Mar 2026 20:00:58 +0700 Subject: [PATCH] Load RefTo fields in markdown table (#10642) * Load RefTo fields in markdown table Signed-off-by: Artem Savchenko * Fix Polynomial regexp Signed-off-by: Artem Savchenko * Fix formatting Signed-off-by: Artem Savchenko --------- Signed-off-by: Artem Savchenko --- .../formatter.valueFormatter.test.ts | 5 ++ .../src/formatter/valueFormatter.ts | 30 +++---- .../src/markdown/tableBuilder.ts | 78 ++++++++++++++++++- 3 files changed, 93 insertions(+), 20 deletions(-) diff --git a/plugins/converter-resources/src/__tests__/formatter.valueFormatter.test.ts b/plugins/converter-resources/src/__tests__/formatter.valueFormatter.test.ts index d80fbe0790..1e93368991 100644 --- a/plugins/converter-resources/src/__tests__/formatter.valueFormatter.test.ts +++ b/plugins/converter-resources/src/__tests__/formatter.valueFormatter.test.ts @@ -30,6 +30,11 @@ jest.mock('../data/personLoader', () => ({ loadPersonName: jest.fn(async (personId: string) => personId) })) +// Avoid pulling in Svelte/UI dependencies via createMarkdownLink +jest.mock('../markdown/link', () => ({ + createMarkdownLink: jest.fn(async (_hierarchy: any, _doc: any, text: string) => text) +})) + describe('formatter/valueFormatter', () => { const baseCard = { _id: 'card-1', diff --git a/plugins/converter-resources/src/formatter/valueFormatter.ts b/plugins/converter-resources/src/formatter/valueFormatter.ts index f66eb613ad..b4aa3c11bb 100644 --- a/plugins/converter-resources/src/formatter/valueFormatter.ts +++ b/plugins/converter-resources/src/formatter/valueFormatter.ts @@ -34,6 +34,7 @@ import { DocumentAttributeKey, DateFormatOption } from './utils' +import { createMarkdownLink } from '../markdown/link' import { loadPersonName } from '../data/personLoader' import type { ValueFormatter } from '../types' @@ -251,14 +252,10 @@ export async function formatCustomAttributeValue ( if (isRef && attribute !== undefined) { const cardWithLookup = card as any const lookupData = cardWithLookup.$lookup?.[attribute.name] - if (lookupData !== undefined && lookupData !== null) { - if (typeof lookupData === 'object' && 'title' in lookupData) { - const title = lookupData.title ?? '' - if (typeof title === 'string' && isIntlString(title)) { - return await translate(title as unknown as IntlString, {}, language) - } - return String(title) - } + if (lookupData !== undefined && lookupData !== null && typeof lookupData === 'object') { + const title = await extractObjectTitleOrName(lookupData as Doc, language) + const text = title !== '' ? title : value + return await createMarkdownLink(hierarchy, lookupData as Doc, text) } } @@ -294,7 +291,9 @@ async function formatValueFallback ( return '' } - const isCustomAttribute = attr.key === '' && typeof attr.label === 'string' && attr.label.startsWith('custom') + const isCustomAttribute = + (ctx.attribute as any)?.isCustom === true || + (attr.key === '' && typeof attr.label === 'string' && attr.label.startsWith('custom')) if (isCustomAttribute) { return await formatCustomAttributeValue(value, ctx.attribute, card, hierarchy, language) } @@ -334,15 +333,10 @@ async function formatValueFallback ( const isRef = attrType?._class === core.class.RefTo if (isRef) { const lookupData = getLookupData(card, ctx.lookupKey, attribute?.name ?? '', attr.key) - if (lookupData !== undefined && lookupData !== null) { - const resolvedObj = lookupData - if (typeof resolvedObj === 'object' && resolvedObj !== null && 'title' in resolvedObj) { - const title = resolvedObj[DocumentAttributeKey.Title] ?? '' - if (typeof title === 'string' && isIntlString(title)) { - return await translate(title as unknown as IntlString, {}, language) - } - return String(title) - } + if (lookupData !== undefined && lookupData !== null && typeof lookupData === 'object') { + const title = await extractObjectTitleOrName(lookupData as Doc, language) + const text = title !== '' ? title : value + return await createMarkdownLink(hierarchy, lookupData as Doc, text) } } diff --git a/plugins/converter-resources/src/markdown/tableBuilder.ts b/plugins/converter-resources/src/markdown/tableBuilder.ts index 1649b0468c..652c942e2d 100644 --- a/plugins/converter-resources/src/markdown/tableBuilder.ts +++ b/plugins/converter-resources/src/markdown/tableBuilder.ts @@ -13,7 +13,7 @@ // limitations under the License. // -import type { Class, Client, Doc, Hierarchy, Ref, PersonId } from '@hcengineering/core' +import core, { type Class, type Client, type Doc, type Hierarchy, type Ref, type PersonId } from '@hcengineering/core' import { getCurrentLanguage } from '@hcengineering/theme' import type { AttributeModel, @@ -31,6 +31,74 @@ import { rebuildRelationshipTableViewModel, isRelationshipTable } from '../data' import { escapeMarkdownLinkText } from './escape' import { createMarkdownLink } from './link' +async function preloadRefLookups ( + docs: Doc[], + model: AttributeModel[], + hierarchy: Hierarchy, + client: Client +): Promise { + const refAttrs = model.filter((attr) => { + const a = attr.attribute as any + if (a?.type === undefined || a.type === null) return false + const t = a.type + if (t._class === core.class.RefTo) return true + if (t._class === core.class.ArrOf && t.of?._class === core.class.RefTo) return true + return false + }) + + if (refAttrs.length === 0) return + + for (const attr of refAttrs) { + const a = attr.attribute as any + const t = a.type + const isArray = t._class === core.class.ArrOf + const refType = isArray ? t.of : t + const targetClass = refType.to as Ref> + + const idSet = new Set() + for (const doc of docs) { + const raw = getAttributeValue(attr, doc, hierarchy) + if (raw === undefined || raw === null) continue + if (Array.isArray(raw)) { + for (const v of raw) { + if (typeof v === 'string' && v.trim() !== '') idSet.add(v) + } + } else if (typeof raw === 'string' && raw.trim() !== '') { + idSet.add(raw) + } + } + + if (idSet.size === 0) continue + + const ids = Array.from(idSet) + const refDocs = await client.findAll(targetClass, { _id: { $in: ids as any } }) + const byId = new Map() + for (const d of refDocs) { + byId.set(d._id as string, d) + } + + for (const doc of docs) { + const raw = getAttributeValue(attr, doc, hierarchy) + if (raw === undefined || raw === null) continue + const cardWithLookup = doc as any + cardWithLookup.$lookup ??= {} + if (isArray && Array.isArray(raw)) { + const resolved = raw + .map((v: any) => (typeof v === 'string' ? byId.get(v) : undefined)) + .filter((v): v is Doc => v !== undefined) + if (resolved.length > 0) { + cardWithLookup.$lookup[a.name] = resolved + } + } else if (!isArray && typeof raw === 'string') { + const resolved = byId.get(raw) + if (resolved !== undefined) { + cardWithLookup.$lookup[a.name] = resolved + } + } + } + } +} + async function buildRelationshipTablePropsFromMetadata ( docs: Doc[], metadata: BuildMarkdownTableMetadata, @@ -156,6 +224,9 @@ export async function buildMarkdownTableFromDocs ( return '' } + // Preload referenced documents for RefTo / ArrOf attributes into $lookup + await preloadRefLookups(docs, displayableModel, hierarchy, client) + const language = getCurrentLanguage() const userCache = new Map() const firstDocClass = docs.length > 0 ? docs[0]._class : props.cardClass @@ -182,7 +253,10 @@ export async function buildMarkdownTableFromDocs ( const linkValue = await createMarkdownLink(hierarchy, card, value) row.push(linkValue) } else { - row.push(escapeMarkdownLinkText(value)) + // If formatter already returned a markdown link, do not escape it again. + const looksLikeMarkdownLink = + typeof value === 'string' && value.startsWith('[') && value.includes('](') && value.endsWith(')') + row.push(looksLikeMarkdownLink ? value : escapeMarkdownLinkText(value)) } } rows.push(row)