Skip to content

Commit 1dc41f0

Browse files
authored
Fix tables (#10813)
Signed-off-by: Denis Bykhov <bykhov.denis@gmail.com>
1 parent 826264e commit 1dc41f0

11 files changed

Lines changed: 293 additions & 206 deletions

File tree

common/config/rush/pnpm-lock.yaml

Lines changed: 7 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

plugins/card-resources/src/components/MarkupProperties.svelte

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,6 @@
3333
const hierarchy = client.getHierarchy()
3434
3535
function updateKeys (_class: Ref<Class<Doc>>, to: Ref<Class<Doc>> | undefined): void {
36-
console.log('tag', _class, to)
3736
const filtredKeys = [...hierarchy.getAllAttributes(_class, to).entries()]
3837
.filter(([key, value]) => value.hidden !== true && value.type._class === core.class.TypeMarkup)
3938
.map(([key, attr]) => ({ key, attr }))

plugins/card-resources/src/components/sections/MarkupPropertiesSection.svelte

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
<script lang="ts">
1717
import { Card } from '@hcengineering/card'
1818
import { Doc, Mixin } from '@hcengineering/core'
19+
import { getClient } from '@hcengineering/presentation'
1920
import { getDocMixins } from '@hcengineering/view-resources'
2021
import { createEventDispatcher, onMount } from 'svelte'
2122
import MarkupProperties from '../MarkupProperties.svelte'
@@ -25,6 +26,8 @@
2526
export let hidden: boolean = false
2627
2728
const dispatch = createEventDispatcher()
29+
const client = getClient()
30+
const h = client.getHierarchy()
2831
2932
let mixins: Array<Mixin<Doc>> = []
3033
$: mixins = getDocMixins(doc)
@@ -35,9 +38,14 @@
3538

3639
{#if !hidden}
3740
<div class="w-full flex flex-col flex-gap-4">
38-
<MarkupProperties {doc} {readonly} tag={undefined} on:update />
41+
<MarkupProperties
42+
{doc}
43+
readonly={readonly || h.getAncestors(doc._class).some((p) => doc.readonlySections?.includes(p))}
44+
tag={undefined}
45+
on:update
46+
/>
3947
{#each mixins as mixin}
40-
<MarkupProperties {doc} {readonly} tag={mixin} on:update />
48+
<MarkupProperties {doc} readonly={readonly || doc.readonlySections?.includes(mixin._id)} tag={mixin} on:update />
4149
{/each}
4250
</div>
4351
{/if}

plugins/converter-resources/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,8 @@
5050
"@hcengineering/theme": "workspace:^0.7.0",
5151
"@hcengineering/contact": "workspace:^0.7.0",
5252
"@hcengineering/view-resources": "workspace:^0.7.0",
53+
"@hcengineering/text": "workspace:^0.7.19",
54+
"@hcengineering/text-markdown": "workspace:^0.7.21",
5355
"svelte": "^4.2.20"
5456
}
5557
}

plugins/converter-resources/src/__tests__/formatter.utils.test.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,15 @@
1515

1616
import { isIntlString, extractObjectTitleOrName } from '../formatter/utils'
1717

18+
jest.mock('@hcengineering/presentation', () => ({
19+
getClient: jest.fn(() => ({
20+
getModel: jest.fn(() => ({
21+
findObject: jest.fn()
22+
})),
23+
getHierarchy: jest.fn()
24+
}))
25+
}))
26+
1827
jest.mock('@hcengineering/platform', () => {
1928
const actual = jest.requireActual('@hcengineering/platform')
2029
return {

plugins/converter-resources/src/__tests__/model.tableModel.test.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,15 @@
1616
import type { AttributeModel } from '@hcengineering/view'
1717
import { modelToConfig } from '../model/tableModel'
1818

19+
jest.mock('@hcengineering/presentation', () => ({
20+
getClient: jest.fn(() => ({
21+
getModel: jest.fn(() => ({
22+
findObject: jest.fn()
23+
})),
24+
getHierarchy: jest.fn()
25+
}))
26+
}))
27+
1928
jest.mock('@hcengineering/view-resources', () => ({
2029
buildModel: jest.fn(),
2130
buildConfigLookup: jest.fn()

plugins/converter-resources/src/data/personLoader.ts

Lines changed: 34 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,9 @@
1313
// limitations under the License.
1414
//
1515

16-
import type { Hierarchy, PersonId } from '@hcengineering/core'
16+
import { getName, getPersonByPersonId, getPersonByPersonRef } from '@hcengineering/contact'
17+
import { type Doc, type Hierarchy, type PersonId, type Ref } from '@hcengineering/core'
1718
import { getClient } from '@hcengineering/presentation'
18-
import { getName, getPersonByPersonId } from '@hcengineering/contact'
1919

2020
/**
2121
* Load person display name by PersonId with optional caching
@@ -48,3 +48,35 @@ export async function loadPersonName (
4848

4949
return personId
5050
}
51+
52+
/**
53+
* Load person display name by Person Ref (Person document ID)
54+
*/
55+
export async function loadPersonNameByRef (
56+
personRef: Ref<Doc>,
57+
hierarchy: Hierarchy,
58+
userCache?: Map<string, string>
59+
): Promise<string> {
60+
if (userCache !== undefined) {
61+
const cachedName = userCache.get(personRef)
62+
if (cachedName !== undefined) {
63+
return cachedName
64+
}
65+
}
66+
67+
try {
68+
const client = getClient()
69+
const person = await getPersonByPersonRef(client, personRef as any)
70+
if (person !== null) {
71+
const name = getName(hierarchy, person)
72+
if (userCache !== undefined) {
73+
userCache.set(personRef, name)
74+
}
75+
return name
76+
}
77+
} catch (error) {
78+
console.warn('Failed to lookup user name for Person Ref:', personRef, error)
79+
}
80+
81+
return personRef
82+
}

plugins/converter-resources/src/formatter/utils.ts

Lines changed: 149 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,20 @@
1313
// limitations under the License.
1414
//
1515

16-
import core, { type AnyAttribute, type Class, type Doc, type Ref } from '@hcengineering/core'
16+
import contact from '@hcengineering/contact'
17+
import core, {
18+
getDisplayTime,
19+
type AnyAttribute,
20+
type Class,
21+
type Doc,
22+
type Hierarchy,
23+
type PersonId,
24+
type Ref
25+
} from '@hcengineering/core'
1726
import { translate, type IntlString } from '@hcengineering/platform'
27+
import { markupToJSON } from '@hcengineering/text'
28+
import { markupToMarkdown } from '@hcengineering/text-markdown'
29+
import { loadPersonNameByRef } from '../data/personLoader'
1830

1931
export enum DocumentAttributeKey {
2032
CreatedBy = 'createdBy',
@@ -30,11 +42,114 @@ export enum DateFormatOption {
3042
Short = 'short'
3143
}
3244

45+
export function formatDateValue (
46+
value: number | string | Date,
47+
isDateOnly: boolean,
48+
language: string | undefined
49+
): string | undefined {
50+
if (!isDateOnly && typeof value === 'number') {
51+
return getDisplayTime(value)
52+
}
53+
54+
const parsedDate = value instanceof Date ? value : new Date(value)
55+
if (Number.isNaN(parsedDate.getTime())) {
56+
return undefined
57+
}
58+
59+
const options: Intl.DateTimeFormatOptions = {
60+
year: DateFormatOption.Numeric,
61+
month: DateFormatOption.Short,
62+
day: DateFormatOption.Numeric
63+
}
64+
65+
return parsedDate.toLocaleDateString(language ?? 'default', options)
66+
}
67+
3368
/**
34-
* Check if a value is an IntlString id ({@link Id}: {@code plugin:resourceKind:key}) or
35-
* {@link getEmbeddedLabel} output ({@code embedded:embedded:...}).
36-
*
69+
* Format a single value of any supported type.
3770
*/
71+
export async function formatSingleValue (
72+
value: any,
73+
attrType: any,
74+
hierarchy: Hierarchy,
75+
language: string | undefined,
76+
userCache?: Map<PersonId, string>,
77+
elementFormatter?: (doc: Doc, title: string) => Promise<string>
78+
): Promise<string> {
79+
if (value === null || value === undefined) {
80+
return ''
81+
}
82+
83+
if (
84+
typeof value === 'number' &&
85+
(attrType?._class === core.class.TypeTimestamp || attrType?._class === core.class.TypeDate)
86+
) {
87+
return formatDateValue(value, attrType?._class === core.class.TypeDate, language) ?? ''
88+
}
89+
90+
if (value instanceof Date) {
91+
return formatDateValue(value, true, language) ?? ''
92+
}
93+
94+
if (
95+
typeof value === 'string' &&
96+
(attrType?._class === core.class.TypeTimestamp || attrType?._class === core.class.TypeDate)
97+
) {
98+
return formatDateValue(value, attrType?._class === core.class.TypeDate, language) ?? ''
99+
}
100+
101+
const isMarkup =
102+
attrType?._class === core.class.TypeMarkup ||
103+
attrType?._class === core.class.TypeCollaborativeDoc ||
104+
(typeof value === 'object' && value !== null && (value.type === 'doc' || value._class === 'core:class:Markup'))
105+
106+
if (isMarkup) {
107+
try {
108+
return markupToMarkdown(markupToJSON(value))
109+
} catch (e) {
110+
// fallback
111+
}
112+
}
113+
114+
if (typeof value === 'object' && value !== null) {
115+
if ('title' in value || 'name' in value) {
116+
const title = value.title ?? value.name ?? ''
117+
const text =
118+
typeof title === 'string' && isIntlString(title)
119+
? await translate(title as unknown as IntlString, {}, language)
120+
: String(title)
121+
if (elementFormatter !== undefined) {
122+
return await elementFormatter(value as Doc, text)
123+
}
124+
return text
125+
}
126+
}
127+
128+
if (typeof value === 'boolean') {
129+
return value ? '✅ Yes' : '❌ No'
130+
}
131+
132+
if (typeof value === 'number') {
133+
return String(value)
134+
}
135+
136+
if (typeof value === 'string') {
137+
if (isIntlString(value)) {
138+
return await translate(value as unknown as IntlString, {}, language)
139+
}
140+
141+
const isRef = attrType?._class === core.class.RefTo
142+
if (isRef) {
143+
if (attrType.to !== undefined && hierarchy.isDerived(attrType.to, contact.mixin.Employee)) {
144+
const name = await loadPersonNameByRef(value as any, hierarchy, userCache as any)
145+
return name
146+
}
147+
}
148+
}
149+
150+
return String(value)
151+
}
152+
38153
export function isIntlString (value: unknown): value is string {
39154
if (typeof value !== 'string' || value.length === 0) {
40155
return false
@@ -60,60 +175,46 @@ export function isIntlString (value: unknown): value is string {
60175
return true
61176
}
62177

63-
/**
64-
* Format an array of values, handling reference lookups if needed
65-
*/
66178
export async function formatArrayValue (
67179
value: any[],
68180
attrType: any,
69181
attribute: AnyAttribute | undefined,
70182
attrKey: string,
71183
card: Doc,
72-
language: string | undefined
184+
hierarchy: Hierarchy,
185+
language: string | undefined,
186+
userCache?: Map<PersonId, string>,
187+
elementFormatter?: (doc: Doc, title: string) => Promise<string>
73188
): Promise<string> {
74-
const isRefArray =
75-
attrType?._class === core.class.ArrOf &&
76-
(attrType as { of?: { _class?: Ref<Class<Doc>> } })?.of?._class === core.class.RefTo
189+
const isRef =
190+
attrType?._class === core.class.RefTo ||
191+
(attrType?._class === core.class.ArrOf &&
192+
(attrType as { of?: { _class?: Ref<Class<Doc>> } })?.of?._class === core.class.RefTo)
77193

78-
if (isRefArray && (attribute !== undefined || attrKey !== '')) {
79-
const cardWithLookup = card as any
80-
const lookupKey = attribute?.name ?? attrKey
81-
const lookupData = cardWithLookup.$lookup?.[lookupKey]
194+
const cardWithLookup = card as any
195+
const lookupKey = attribute?.name ?? attrKey
196+
const lookupData = cardWithLookup.$lookup?.[lookupKey]
82197

83-
if (lookupData !== undefined && lookupData !== null) {
198+
const resolveItem = async (v: any, index: number): Promise<string> => {
199+
// If we have lookup data and v is an ID, find the object
200+
let item = v
201+
if (isRef && lookupData !== undefined && typeof v === 'string') {
84202
const resolvedArray = Array.isArray(lookupData) ? lookupData : [lookupData]
85-
const translatedValues = await Promise.all(
86-
resolvedArray.map(async (v) => {
87-
if (typeof v === 'object' && v !== null && 'title' in v) {
88-
const title = v.title ?? ''
89-
if (typeof title === 'string' && isIntlString(title)) {
90-
return await translate(title as unknown as IntlString, {}, language)
91-
}
92-
return String(title)
93-
}
94-
return typeof v === 'string' ? v : String(v)
95-
})
96-
)
97-
return translatedValues.join(', ')
203+
const found = resolvedArray.find((obj) => obj._id === v)
204+
if (found !== undefined) {
205+
item = found
206+
} else if (resolvedArray[index] !== undefined) {
207+
// Fallback to index-based lookup if no _id matches (useful for tests or simplified data)
208+
item = resolvedArray[index]
209+
}
98210
}
211+
212+
const itemType = attrType?._class === core.class.ArrOf ? attrType.of : attrType
213+
return await formatSingleValue(item, itemType, hierarchy, language, userCache, elementFormatter)
99214
}
100215

101-
const translatedValues = await Promise.all(
102-
value.map(async (v) => {
103-
if (typeof v === 'object' && v !== null && 'title' in v) {
104-
const title = v.title ?? ''
105-
if (typeof title === 'string' && isIntlString(title)) {
106-
return await translate(title as unknown as IntlString, {}, language)
107-
}
108-
return String(title)
109-
}
110-
if (typeof v === 'string' && isIntlString(v)) {
111-
return await translate(v as unknown as IntlString, {}, language)
112-
}
113-
return typeof v === 'string' ? v : String(v)
114-
})
115-
)
116-
return translatedValues.join(', ')
216+
const formattedValues = await Promise.all(value.map(async (v, i) => await resolveItem(v, i)))
217+
return formattedValues.filter((v) => v !== '').join(', ')
117218
}
118219

119220
/**
@@ -123,6 +224,9 @@ export async function extractObjectTitleOrName (
123224
obj: Record<string, any>,
124225
language: string | undefined
125226
): Promise<string> {
227+
if (obj._class === core.class.TypeMarkup || obj._class === core.class.TypeCollaborativeDoc) {
228+
return '' // Should be handled by markupToMarkdown
229+
}
126230
if ('title' in obj) {
127231
const title = String(obj.title ?? '')
128232
if (isIntlString(title)) {

0 commit comments

Comments
 (0)