From 306d1d14f662677dd5944123d2cd3ec92de5012f Mon Sep 17 00:00:00 2001 From: Artem Savchenko Date: Tue, 13 Jan 2026 08:33:16 +0700 Subject: [PATCH] Copy relationship table Signed-off-by: Artem Savchenko --- plugins/card-resources/src/card.ts | 3 +- .../src/components/EditCardNewContent.svelte | 2 +- .../src/components/MasterTagAttributes.svelte | 2 +- .../src/components/TagAttributes.svelte | 2 +- .../src/components/RelationshipTable.svelte | 25 +++ .../view-resources/src/copyAsMarkdownTable.ts | 205 ++++++++++++++++++ plugins/view-resources/src/index.ts | 4 + 7 files changed, 238 insertions(+), 5 deletions(-) diff --git a/plugins/card-resources/src/card.ts b/plugins/card-resources/src/card.ts index a3bc20ca141..a1b5aee1bbc 100644 --- a/plugins/card-resources/src/card.ts +++ b/plugins/card-resources/src/card.ts @@ -13,9 +13,8 @@ import { getClient } from '@hcengineering/presentation' import cardPlugin, { type Card, type CardSection } from '@hcengineering/card' -import { getMetadata, getResource } from '@hcengineering/platform' +import { getResource } from '@hcengineering/platform' import { type Heading } from '@hcengineering/text-editor' -import communication from '@hcengineering/communication' export async function getCardSections (card: Card): Promise { const client = getClient() diff --git a/plugins/card-resources/src/components/EditCardNewContent.svelte b/plugins/card-resources/src/components/EditCardNewContent.svelte index a6a980ed776..6a94fbd19e9 100644 --- a/plugins/card-resources/src/components/EditCardNewContent.svelte +++ b/plugins/card-resources/src/components/EditCardNewContent.svelte @@ -40,7 +40,7 @@ inputHeight = e.clientHeight if (delta === 0) return content?.hideScrollBar() - if (scrollDiv && delta > 0) { + if (scrollDiv !== undefined && scrollDiv !== null && delta > 0) { const bottomOffset = Math.max( 0, Math.floor(scrollDiv.scrollHeight - scrollDiv.scrollTop - scrollDiv.clientHeight - delta) diff --git a/plugins/card-resources/src/components/MasterTagAttributes.svelte b/plugins/card-resources/src/components/MasterTagAttributes.svelte index 274eca2a76c..215d1b81b8b 100644 --- a/plugins/card-resources/src/components/MasterTagAttributes.svelte +++ b/plugins/card-resources/src/components/MasterTagAttributes.svelte @@ -64,7 +64,7 @@ kind={'link'} size={'medium'} showTooltip={{ label: setting.string.AddAttribute }} - on:click={(ev) => { + on:click={() => { showPopup(setting.component.CreateAttributePopup, { _class: value._class, isCard: true }, 'top') }} /> diff --git a/plugins/card-resources/src/components/TagAttributes.svelte b/plugins/card-resources/src/components/TagAttributes.svelte index 044f86f5bcc..4949b5fe4ea 100644 --- a/plugins/card-resources/src/components/TagAttributes.svelte +++ b/plugins/card-resources/src/components/TagAttributes.svelte @@ -75,7 +75,7 @@ kind={'link'} size={'medium'} showTooltip={{ label: setting.string.AddAttribute }} - on:click={(ev) => { + on:click={() => { showPopup(setting.component.CreateAttributePopup, { _class: tag._id, isCard: true }, 'top') }} /> diff --git a/plugins/view-resources/src/components/RelationshipTable.svelte b/plugins/view-resources/src/components/RelationshipTable.svelte index 9b1beeeaec2..54ace5243e6 100644 --- a/plugins/view-resources/src/components/RelationshipTable.svelte +++ b/plugins/view-resources/src/components/RelationshipTable.svelte @@ -34,6 +34,7 @@ import { createQuery, getClient, reduceCalls, updateAttribute } from '@hcengineering/presentation' import ui, { Button, + IconCopy, Label, Loading, eventToHTMLElement, @@ -50,6 +51,7 @@ import view from '../plugin' import { buildConfigAssociation, buildConfigLookup, buildModel, restrictionStore } from '../utils' import { getResultOptions, getResultQuery } from '../viewOptions' + import { CopyRelationshipTableAsMarkdown } from '../copyAsMarkdownTable' import IconUpDown from './icons/UpDown.svelte' import RelationsSelectorPopup from './RelationsSelectorPopup.svelte' @@ -545,6 +547,16 @@ eventToHTMLElement(e) ) } + + async function handleCopyAsMarkdown (e: MouseEvent): Promise { + if (model === undefined || viewModel.length === 0) return + await CopyRelationshipTableAsMarkdown(e, { + viewModel, + model, + objects, + cardClass: _class + }) + } {#if !model || isBuildingModel} @@ -673,6 +685,19 @@ }} /> {/if} + + {#if objects.length > 0 && viewModel.length > 0 && model !== undefined} + +
+
+ {/if} {/if} diff --git a/plugins/view-resources/src/copyAsMarkdownTable.ts b/plugins/view-resources/src/copyAsMarkdownTable.ts index a9bdde98087..61ef80bb7bb 100644 --- a/plugins/view-resources/src/copyAsMarkdownTable.ts +++ b/plugins/view-resources/src/copyAsMarkdownTable.ts @@ -449,6 +449,28 @@ export interface CopyAsMarkdownTableProps { valueFormatter?: ValueFormatter } +/** + * Interface for RelationshipTable's row and cell models + */ +export interface RelationshipCellModel { + attribute: AttributeModel + rowSpan: number + object: Doc | undefined + parentObject: Doc | undefined +} + +export interface RelationshipRowModel { + cells: RelationshipCellModel[] +} + +export interface CopyRelationshipTableAsMarkdownProps { + viewModel: RelationshipRowModel[] + model: AttributeModel[] + objects: Doc[] + cardClass: Ref> + valueFormatter?: ValueFormatter +} + export async function CopyAsMarkdownTable ( doc: Doc | Doc[], evt: Event, @@ -576,3 +598,186 @@ export async function CopyAsMarkdownTable ( ) } } + +/** + * Copy RelationshipTable as markdown table + * Handles hierarchical data with row spans by duplicating cell values across spanned rows + */ +export async function CopyRelationshipTableAsMarkdown ( + evt: Event, + props: CopyRelationshipTableAsMarkdownProps +): Promise { + try { + if (props.viewModel.length === 0 || props.model.length === 0) { + return + } + + const client = getClient() + const hierarchy = client.getHierarchy() + const cardClass = hierarchy.getClass(props.cardClass) + if (cardClass == null) { + return + } + + const language = getCurrentLanguage() + + // Cache for user ID (PersonId) -> name mappings to reduce database calls + const userCache = new Map() + + // Extract headers from model + const headers: string[] = [] + for (const attr of props.model) { + let label: string + if (typeof attr.label === 'string') { + label = isIntlString(attr.label) + ? await translate(attr.label as unknown as IntlString, {}, language) + : attr.label + } else { + label = await translate(attr.label, {}, language) + } + headers.push(label) + } + + // Build a map of attribute keys to their index in the model for quick lookup + const attributeKeyToIndex = new Map() + props.model.forEach((attr, index) => { + attributeKeyToIndex.set(attr.key, index) + }) + + // Track active row spans - maps attribute key to remaining span count + const activeRowSpans = new Map() + + // Process rows from viewModel + const rows: string[][] = [] + for (let rowIdx = 0; rowIdx < props.viewModel.length; rowIdx++) { + const rowModel = props.viewModel[rowIdx] + const row: string[] = new Array(headers.length).fill('') + + // First, handle cells that are continuing from previous rows (row spans) + for (const [attrKey, spanInfo] of activeRowSpans.entries()) { + if (spanInfo.remaining > 0) { + const attrIndex = attributeKeyToIndex.get(attrKey) + if (attrIndex !== undefined) { + row[attrIndex] = spanInfo.value + spanInfo.remaining-- + if (spanInfo.remaining === 0) { + activeRowSpans.delete(attrKey) + } + } + } + } + + // Then, process cells in the current row + for (const cell of rowModel.cells) { + const attrIndex = attributeKeyToIndex.get(cell.attribute.key) + if (attrIndex === undefined) continue + + // Get the document object (prefer cell.object, fall back to parentObject) + const doc = cell.object ?? cell.parentObject + if (doc === undefined) { + // Empty cell + row[attrIndex] = '' + continue + } + + // Handle association keys - they need special treatment + // For association keys, the cell.object is the related document + // and we need to extract the attribute key after the association path + const assoc = '$associations' + let attributeToUse = cell.attribute + let docToUse = doc + let isAssociationKey = false + + if (cell.attribute.key.startsWith(assoc)) { + isAssociationKey = true + // For association keys, the object is the related document + docToUse = cell.object ?? doc + + // Extract the part after the association path + // e.g., "$associations.relationName.attributeName" -> "attributeName" + // or "$associations.relationName" -> "" (for the document itself) + const parts = cell.attribute.key.split(assoc) + if (parts.length > 1) { + const afterAssoc = parts[1].substring(1) // Remove leading dot + if (afterAssoc.length > 0) { + // Create a modified attribute with the extracted key + attributeToUse = { + ...cell.attribute, + key: afterAssoc + } + } else { + // Association key points to the document itself (use empty key for title) + attributeToUse = { + ...cell.attribute, + key: '' + } + } + } + } + + // Format the value + const isFirstColumn = attrIndex === 0 + const docClass = isAssociationKey && docToUse !== undefined ? docToUse._class : props.cardClass + let value = await formatValue( + attributeToUse, + docToUse, + hierarchy, + docClass, + language, + isFirstColumn, + userCache, + props.valueFormatter + ) + + // If this is the first column with empty key (title attribute), create a markdown link + if ( + isFirstColumn && + (cell.attribute.key === '' || (isAssociationKey && attributeToUse.key === '')) && + cell.object !== undefined + ) { + value = await createMarkdownLink(hierarchy, cell.object, value) + } else { + value = escapeMarkdownLinkText(value) + } + + row[attrIndex] = value + + // If this cell has a row span > 1, track it for subsequent rows + if (cell.rowSpan > 1) { + activeRowSpans.set(cell.attribute.key, { + value, + remaining: cell.rowSpan - 1 + }) + } + } + + rows.push(row) + } + + // Build markdown table + let markdown = '| ' + headers.join(' | ') + ' |\n' + markdown += '| ' + headers.map(() => '---').join(' | ') + ' |\n' + for (const row of rows) { + markdown += '| ' + row.join(' | ') + ' |\n' + } + + await copyText(markdown, 'text/markdown') + + addNotification( + await translate(view.string.Copied, {}, language), + await translate(view.string.TableCopiedToClipboard, {}, language), + SimpleNotification, + undefined, + NotificationSeverity.Success + ) + } catch (error) { + const language = getCurrentLanguage() + addNotification( + await translate(view.string.Copied, {}, language), + await translate(view.string.TableCopyFailed, {}, language), + SimpleNotification, + undefined, + NotificationSeverity.Error + ) + } +} diff --git a/plugins/view-resources/src/index.ts b/plugins/view-resources/src/index.ts index 28e3b571c18..940b271637e 100644 --- a/plugins/view-resources/src/index.ts +++ b/plugins/view-resources/src/index.ts @@ -219,6 +219,10 @@ export * from './viewOptions' export { CopyAsMarkdownTable, type CopyAsMarkdownTableProps, + CopyRelationshipTableAsMarkdown, + type CopyRelationshipTableAsMarkdownProps, + type RelationshipCellModel, + type RelationshipRowModel, type ValueFormatter, registerValueFormatterForClass, registerValueFormatter,