Load RefTo fields in markdown table (#10642)

* Load RefTo fields in markdown table

Signed-off-by: Artem Savchenko <armisav@gmail.com>

* Fix Polynomial regexp

Signed-off-by: Artem Savchenko <armisav@gmail.com>

* Fix formatting

Signed-off-by: Artem Savchenko <armisav@gmail.com>

---------

Signed-off-by: Artem Savchenko <armisav@gmail.com>
This commit is contained in:
Artyom Savchenko
2026-03-16 20:00:58 +07:00
committed by GitHub
parent 9ddebe0d9c
commit c0d9f64f8d
3 changed files with 93 additions and 20 deletions
@@ -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',
@@ -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)
}
}
@@ -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<void> {
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<Class<Doc>>
const idSet = new Set<string>()
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<string, Doc>()
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<RefTo> attributes into $lookup
await preloadRefLookups(docs, displayableModel, hierarchy, client)
const language = getCurrentLanguage()
const userCache = new Map<PersonId, string>()
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)