diff --git a/models/tracker/src/actions.ts b/models/tracker/src/actions.ts index 27784de88c..659c90358d 100644 --- a/models/tracker/src/actions.ts +++ b/models/tracker/src/actions.ts @@ -722,6 +722,26 @@ export function createActions (builder: Builder, issuesId: string, componentsId: actions: [view.action.Delete] }) + createAction( + builder, + { + action: view.actionImpl.CopyAsMarkdownTable, + actionProps: { + cardClass: tracker.class.Issue + }, + label: view.string.CopyAsMarkdownTable, + icon: view.icon.Print, + input: 'selection', + category: view.category.General, + target: tracker.class.Issue, + context: { + mode: ['context', 'browser'], + group: 'copy' + } + }, + tracker.action.CopyAsMarkdownTable + ) + createAction( builder, { diff --git a/plugins/tracker-resources/src/index.ts b/plugins/tracker-resources/src/index.ts index 4015e1a640..3ae81ee064 100644 --- a/plugins/tracker-resources/src/index.ts +++ b/plugins/tracker-resources/src/index.ts @@ -101,6 +101,7 @@ import { resolveLocation } from './issues' import tracker from './plugin' +import './issueTableFormatter' import MilestoneEditor from './components/milestones/MilestoneEditor.svelte' import MilestonePresenter from './components/milestones/MilestonePresenter.svelte' diff --git a/plugins/tracker-resources/src/issueTableFormatter.ts b/plugins/tracker-resources/src/issueTableFormatter.ts new file mode 100644 index 0000000000..61470086ec --- /dev/null +++ b/plugins/tracker-resources/src/issueTableFormatter.ts @@ -0,0 +1,316 @@ +// +// Copyright © 2025 Hardcore Engineering Inc. +// +// Licensed under the Eclipse Public License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. You may +// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import { type Class, type Doc, type Hierarchy, type Ref, type PersonId } from '@hcengineering/core' +import trackerPlugin, { type Component, type IssueStatus, type Milestone, type Project } from '@hcengineering/tracker' +import { type AttributeModel } from '@hcengineering/view' +import { getClient } from '@hcengineering/presentation' +import { registerValueFormatterForClass } from '@hcengineering/view-resources' +import { getName, getPersonByPersonId } from '@hcengineering/contact' + +/** + * Cache for IssueStatus ID -> name mappings to reduce database calls + */ +const statusCache = new Map, string>() + +/** + * Load IssueStatus name from status reference with caching support + */ +async function loadStatusName (statusRef: Ref): Promise { + // Check cache first + const cachedName = statusCache.get(statusRef) + if (cachedName !== undefined) { + return cachedName + } + + try { + const client = getClient() + const status = await client.findOne(trackerPlugin.class.IssueStatus, { _id: statusRef }) + if (status !== undefined && status !== null) { + const name = status.name ?? String(statusRef) + // Store in cache + statusCache.set(statusRef, name) + return name + } + } catch (error) { + console.warn('Failed to lookup IssueStatus name for:', statusRef, error) + } + + // Return original reference if lookup failed + return String(statusRef) +} + +/** + * Cache for Component ID -> label mappings to reduce database calls + */ +const componentCache = new Map, string>() + +/** + * Load Component label from component reference with caching support + */ +async function loadComponentLabel (componentRef: Ref): Promise { + // Check cache first + const cachedLabel = componentCache.get(componentRef) + if (cachedLabel !== undefined) { + return cachedLabel + } + + try { + const client = getClient() + const component = await client.findOne(trackerPlugin.class.Component, { _id: componentRef }) + if (component !== undefined && component !== null) { + const label = component.label ?? String(componentRef) + // Store in cache + componentCache.set(componentRef, label) + return label + } + } catch (error) { + console.warn('Failed to lookup Component label for:', componentRef, error) + } + + // Return original reference if lookup failed + return String(componentRef) +} + +/** + * Cache for Project ID -> name mappings to reduce database calls + */ +const projectCache = new Map, string>() + +/** + * Load Project name from project reference with caching support + */ +async function loadProjectName (projectRef: Ref): Promise { + // Check cache first + const cachedName = projectCache.get(projectRef) + if (cachedName !== undefined) { + return cachedName + } + + try { + const client = getClient() + const project = await client.findOne(trackerPlugin.class.Project, { _id: projectRef }) + if (project !== undefined && project !== null) { + const name = project.name ?? String(projectRef) + // Store in cache + projectCache.set(projectRef, name) + return name + } + } catch (error) { + console.warn('Failed to lookup Project name for:', projectRef, error) + } + + // Return original reference if lookup failed + return String(projectRef) +} + +/** + * Cache for Milestone ID -> label mappings to reduce database calls + */ +const milestoneCache = new Map, string>() + +/** + * Load Milestone label from milestone reference with caching support + */ +async function loadMilestoneLabel (milestoneRef: Ref): Promise { + // Check cache first + const cachedLabel = milestoneCache.get(milestoneRef) + if (cachedLabel !== undefined) { + return cachedLabel + } + + try { + const client = getClient() + const milestone = await client.findOne(trackerPlugin.class.Milestone, { _id: milestoneRef }) + if (milestone !== undefined && milestone !== null) { + const label = milestone.label ?? String(milestoneRef) + // Store in cache + milestoneCache.set(milestoneRef, label) + return label + } + } catch (error) { + console.warn('Failed to lookup Milestone label for:', milestoneRef, error) + } + + // Return original reference if lookup failed + return String(milestoneRef) +} + +/** + * Cache for Person ID -> name mappings to reduce database calls + */ +const personCache = new Map() + +/** + * Load person name from PersonId with caching support + */ +async function loadPersonName (personId: PersonId): Promise { + // Check cache first + const cachedName = personCache.get(personId) + if (cachedName !== undefined) { + return cachedName + } + + try { + const client = getClient() + const person = await getPersonByPersonId(client, personId) + if (person !== undefined && person !== null) { + const name = getName(client.getHierarchy(), person) + // Store in cache + personCache.set(personId, name) + return name + } + } catch (error) { + console.warn('Failed to lookup person name for:', personId, error) + } + + // Return original ID if lookup failed + return String(personId) +} + +/** + * Value formatter for issue fields + * Handles special cases for status, assignee, component, space (project), and milestone fields + */ +async function formatIssueValue ( + attr: AttributeModel, + card: Doc, + hierarchy: Hierarchy, + _class: Ref>, + language: string | undefined +): Promise { + // Check if this is an Issue class + const isIssueClass = hierarchy.isDerived(_class, trackerPlugin.class.Issue) + if (!isIssueClass) { + return undefined + } + + const issueDoc = card as unknown as Record + + // Handle status field - format IssueStatus ID to name + if (attr.key === 'status') { + const statusValue: unknown = issueDoc.status + if (statusValue !== undefined && statusValue !== null) { + // Check if status is in lookup + const issueWithLookup = issueDoc as Record & { $lookup?: Record } + const lookupStatus: unknown = issueWithLookup.$lookup?.status + if (lookupStatus !== undefined && lookupStatus !== null) { + const statusObj = lookupStatus as Record + const statusName: unknown = statusObj.name + if (typeof statusName === 'string') { + return statusName + } + } + // If not in lookup, fetch it + if (typeof statusValue === 'string') { + return await loadStatusName(statusValue as Ref) + } + } + return '' + } + + // Handle assignee field - format PersonId to person name + if (attr.key === 'assignee') { + const assigneeValue: unknown = issueDoc.assignee + if (assigneeValue !== undefined && assigneeValue !== null) { + // Check if assignee is in lookup + const issueWithLookup = issueDoc as Record & { $lookup?: Record } + const lookupAssignee: unknown = issueWithLookup.$lookup?.assignee + if (lookupAssignee !== undefined && lookupAssignee !== null) { + const assigneeObj = lookupAssignee as Record + const assigneeName: unknown = assigneeObj.name + if (typeof assigneeName === 'string') { + return assigneeName + } + } + // If not in lookup, fetch it + if (typeof assigneeValue === 'string') { + return await loadPersonName(assigneeValue as PersonId) + } + } + return '' + } + + // Handle component field - format Component ID to label + if (attr.key === 'component') { + const componentValue: unknown = issueDoc.component + if (componentValue !== undefined && componentValue !== null) { + // Check if component is in lookup + const issueWithLookup = issueDoc as Record & { $lookup?: Record } + const lookupComponent: unknown = issueWithLookup.$lookup?.component + if (lookupComponent !== undefined && lookupComponent !== null) { + const componentObj = lookupComponent as Record + const componentLabel: unknown = componentObj.label + if (typeof componentLabel === 'string') { + return componentLabel + } + } + // If not in lookup, fetch it + if (typeof componentValue === 'string') { + return await loadComponentLabel(componentValue as Ref) + } + } + return '' + } + + // Handle space field (Project) - format Project ID to name + if (attr.key === 'space') { + const spaceValue: unknown = issueDoc.space + if (spaceValue !== undefined && spaceValue !== null) { + // Check if space is in lookup + const issueWithLookup = issueDoc as Record & { $lookup?: Record } + const lookupSpace: unknown = issueWithLookup.$lookup?.space + if (lookupSpace !== undefined && lookupSpace !== null) { + const spaceObj = lookupSpace as Record + const spaceName: unknown = spaceObj.name + if (typeof spaceName === 'string') { + return spaceName + } + } + // If not in lookup, fetch it + if (typeof spaceValue === 'string') { + return await loadProjectName(spaceValue as Ref) + } + } + return '' + } + + // Handle milestone field - format Milestone ID to label + if (attr.key === 'milestone') { + const milestoneValue: unknown = issueDoc.milestone + if (milestoneValue !== undefined && milestoneValue !== null) { + // Check if milestone is in lookup + const issueWithLookup = issueDoc as Record & { $lookup?: Record } + const lookupMilestone: unknown = issueWithLookup.$lookup?.milestone + if (lookupMilestone !== undefined && lookupMilestone !== null) { + const milestoneObj = lookupMilestone as Record + const milestoneLabel: unknown = milestoneObj.label + if (typeof milestoneLabel === 'string') { + return milestoneLabel + } + } + // If not in lookup, fetch it + if (typeof milestoneValue === 'string') { + return await loadMilestoneLabel(milestoneValue as Ref) + } + } + return '' + } + + return undefined +} + +// Register the formatter for Issue class +registerValueFormatterForClass(trackerPlugin.class.Issue, formatIssueValue) diff --git a/plugins/tracker/src/index.ts b/plugins/tracker/src/index.ts index d0dbc7e037..f90a75abf3 100644 --- a/plugins/tracker/src/index.ts +++ b/plugins/tracker/src/index.ts @@ -478,6 +478,7 @@ const pluginState = plugin(trackerId, { Issue: '' as Ref }, action: { + CopyAsMarkdownTable: '' as Ref>, SetDueDate: '' as Ref>, SetParent: '' as Ref>, SetStatus: '' as Ref,