Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 1 addition & 2 deletions plugins/card-resources/src/card.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<CardSection[]> {
const client = getClient()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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')
}}
/>
Expand Down
2 changes: 1 addition & 1 deletion plugins/card-resources/src/components/TagAttributes.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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')
}}
/>
Expand Down
25 changes: 25 additions & 0 deletions plugins/view-resources/src/components/RelationshipTable.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@
import { createQuery, getClient, reduceCalls, updateAttribute } from '@hcengineering/presentation'
import ui, {
Button,
IconCopy,
Label,
Loading,
eventToHTMLElement,
Expand All @@ -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'

Expand Down Expand Up @@ -545,6 +547,16 @@
eventToHTMLElement(e)
)
}

async function handleCopyAsMarkdown (e: MouseEvent): Promise<void> {
if (model === undefined || viewModel.length === 0) return
await CopyRelationshipTableAsMarkdown(e, {
viewModel,
model,
objects,
cardClass: _class
})
}
</script>

{#if !model || isBuildingModel}
Expand Down Expand Up @@ -673,6 +685,19 @@
}}
/>
{/if}

{#if objects.length > 0 && viewModel.length > 0 && model !== undefined}
<!-- svelte-ignore a11y-click-events-have-key-events -->
<div class="px-1">
<Button
icon={IconCopy}
label={view.string.CopyToClipboard}
kind={'ghost'}
size={'small'}
on:click={handleCopyAsMarkdown}
/>
</div>
{/if}
</div>
</div>
{/if}
Expand Down
205 changes: 205 additions & 0 deletions plugins/view-resources/src/copyAsMarkdownTable.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Class<Doc>>
valueFormatter?: ValueFormatter
}

export async function CopyAsMarkdownTable (
doc: Doc | Doc[],
evt: Event,
Expand Down Expand Up @@ -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<void> {
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<PersonId, string>()

// 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<string, number>()
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<string, { value: string, remaining: number }>()

// 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
)
}
}
4 changes: 4 additions & 0 deletions plugins/view-resources/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -219,6 +219,10 @@ export * from './viewOptions'
export {
CopyAsMarkdownTable,
type CopyAsMarkdownTableProps,
CopyRelationshipTableAsMarkdown,
type CopyRelationshipTableAsMarkdownProps,
type RelationshipCellModel,
type RelationshipRowModel,
type ValueFormatter,
registerValueFormatterForClass,
registerValueFormatter,
Expand Down
Loading