Fix tables (#10813)

Signed-off-by: Denis Bykhov <bykhov.denis@gmail.com>
This commit is contained in:
Denis Bykhov
2026-05-01 21:43:15 +05:00
committed by GitHub
parent 826264ef25
commit 1dc41f0b1d
11 changed files with 295 additions and 208 deletions
+7 -1
View File
@@ -19096,6 +19096,12 @@ importers:
'@hcengineering/presentation':
specifier: workspace:^0.7.0
version: link:../../packages/presentation
'@hcengineering/text':
specifier: workspace:^0.7.19
version: link:../../foundations/core/packages/text
'@hcengineering/text-markdown':
specifier: workspace:^0.7.21
version: link:../../foundations/core/packages/text-markdown
'@hcengineering/theme':
specifier: workspace:^0.7.0
version: link:../../packages/theme
@@ -61587,7 +61593,7 @@ snapshots:
node-loader@2.0.0(webpack@5.102.1):
dependencies:
loader-utils: 2.0.4
webpack: 5.102.1
webpack: 5.102.1(esbuild@0.25.12)(webpack-cli@5.1.4)
node-localstorage@2.2.1:
dependencies:
@@ -33,7 +33,6 @@
const hierarchy = client.getHierarchy()
function updateKeys (_class: Ref<Class<Doc>>, to: Ref<Class<Doc>> | undefined): void {
console.log('tag', _class, to)
const filtredKeys = [...hierarchy.getAllAttributes(_class, to).entries()]
.filter(([key, value]) => value.hidden !== true && value.type._class === core.class.TypeMarkup)
.map(([key, attr]) => ({ key, attr }))
@@ -16,6 +16,7 @@
<script lang="ts">
import { Card } from '@hcengineering/card'
import { Doc, Mixin } from '@hcengineering/core'
import { getClient } from '@hcengineering/presentation'
import { getDocMixins } from '@hcengineering/view-resources'
import { createEventDispatcher, onMount } from 'svelte'
import MarkupProperties from '../MarkupProperties.svelte'
@@ -25,6 +26,8 @@
export let hidden: boolean = false
const dispatch = createEventDispatcher()
const client = getClient()
const h = client.getHierarchy()
let mixins: Array<Mixin<Doc>> = []
$: mixins = getDocMixins(doc)
@@ -35,9 +38,14 @@
{#if !hidden}
<div class="w-full flex flex-col flex-gap-4">
<MarkupProperties {doc} {readonly} tag={undefined} on:update />
<MarkupProperties
{doc}
readonly={readonly || h.getAncestors(doc._class).some((p) => doc.readonlySections?.includes(p))}
tag={undefined}
on:update
/>
{#each mixins as mixin}
<MarkupProperties {doc} {readonly} tag={mixin} on:update />
<MarkupProperties {doc} readonly={readonly || doc.readonlySections?.includes(mixin._id)} tag={mixin} on:update />
{/each}
</div>
{/if}
+2
View File
@@ -50,6 +50,8 @@
"@hcengineering/theme": "workspace:^0.7.0",
"@hcengineering/contact": "workspace:^0.7.0",
"@hcengineering/view-resources": "workspace:^0.7.0",
"@hcengineering/text": "workspace:^0.7.19",
"@hcengineering/text-markdown": "workspace:^0.7.21",
"svelte": "^4.2.20"
}
}
@@ -15,6 +15,15 @@
import { isIntlString, extractObjectTitleOrName } from '../formatter/utils'
jest.mock('@hcengineering/presentation', () => ({
getClient: jest.fn(() => ({
getModel: jest.fn(() => ({
findObject: jest.fn()
})),
getHierarchy: jest.fn()
}))
}))
jest.mock('@hcengineering/platform', () => {
const actual = jest.requireActual('@hcengineering/platform')
return {
@@ -16,6 +16,15 @@
import type { AttributeModel } from '@hcengineering/view'
import { modelToConfig } from '../model/tableModel'
jest.mock('@hcengineering/presentation', () => ({
getClient: jest.fn(() => ({
getModel: jest.fn(() => ({
findObject: jest.fn()
})),
getHierarchy: jest.fn()
}))
}))
jest.mock('@hcengineering/view-resources', () => ({
buildModel: jest.fn(),
buildConfigLookup: jest.fn()
@@ -13,9 +13,9 @@
// limitations under the License.
//
import type { Hierarchy, PersonId } from '@hcengineering/core'
import { getName, getPersonByPersonId, getPersonByPersonRef } from '@hcengineering/contact'
import { type Doc, type Hierarchy, type PersonId, type Ref } from '@hcengineering/core'
import { getClient } from '@hcengineering/presentation'
import { getName, getPersonByPersonId } from '@hcengineering/contact'
/**
* Load person display name by PersonId with optional caching
@@ -48,3 +48,35 @@ export async function loadPersonName (
return personId
}
/**
* Load person display name by Person Ref (Person document ID)
*/
export async function loadPersonNameByRef (
personRef: Ref<Doc>,
hierarchy: Hierarchy,
userCache?: Map<string, string>
): Promise<string> {
if (userCache !== undefined) {
const cachedName = userCache.get(personRef)
if (cachedName !== undefined) {
return cachedName
}
}
try {
const client = getClient()
const person = await getPersonByPersonRef(client, personRef as any)
if (person !== null) {
const name = getName(hierarchy, person)
if (userCache !== undefined) {
userCache.set(personRef, name)
}
return name
}
} catch (error) {
console.warn('Failed to lookup user name for Person Ref:', personRef, error)
}
return personRef
}
@@ -13,8 +13,20 @@
// limitations under the License.
//
import core, { type AnyAttribute, type Class, type Doc, type Ref } from '@hcengineering/core'
import contact from '@hcengineering/contact'
import core, {
getDisplayTime,
type AnyAttribute,
type Class,
type Doc,
type Hierarchy,
type PersonId,
type Ref
} from '@hcengineering/core'
import { translate, type IntlString } from '@hcengineering/platform'
import { markupToJSON } from '@hcengineering/text'
import { markupToMarkdown } from '@hcengineering/text-markdown'
import { loadPersonNameByRef } from '../data/personLoader'
export enum DocumentAttributeKey {
CreatedBy = 'createdBy',
@@ -30,11 +42,114 @@ export enum DateFormatOption {
Short = 'short'
}
export function formatDateValue (
value: number | string | Date,
isDateOnly: boolean,
language: string | undefined
): string | undefined {
if (!isDateOnly && typeof value === 'number') {
return getDisplayTime(value)
}
const parsedDate = value instanceof Date ? value : new Date(value)
if (Number.isNaN(parsedDate.getTime())) {
return undefined
}
const options: Intl.DateTimeFormatOptions = {
year: DateFormatOption.Numeric,
month: DateFormatOption.Short,
day: DateFormatOption.Numeric
}
return parsedDate.toLocaleDateString(language ?? 'default', options)
}
/**
* Check if a value is an IntlString id ({@link Id}: {@code plugin:resourceKind:key}) or
* {@link getEmbeddedLabel} output ({@code embedded:embedded:...}).
*
* Format a single value of any supported type.
*/
export async function formatSingleValue (
value: any,
attrType: any,
hierarchy: Hierarchy,
language: string | undefined,
userCache?: Map<PersonId, string>,
elementFormatter?: (doc: Doc, title: string) => Promise<string>
): Promise<string> {
if (value === null || value === undefined) {
return ''
}
if (
typeof value === 'number' &&
(attrType?._class === core.class.TypeTimestamp || attrType?._class === core.class.TypeDate)
) {
return formatDateValue(value, attrType?._class === core.class.TypeDate, language) ?? ''
}
if (value instanceof Date) {
return formatDateValue(value, true, language) ?? ''
}
if (
typeof value === 'string' &&
(attrType?._class === core.class.TypeTimestamp || attrType?._class === core.class.TypeDate)
) {
return formatDateValue(value, attrType?._class === core.class.TypeDate, language) ?? ''
}
const isMarkup =
attrType?._class === core.class.TypeMarkup ||
attrType?._class === core.class.TypeCollaborativeDoc ||
(typeof value === 'object' && value !== null && (value.type === 'doc' || value._class === 'core:class:Markup'))
if (isMarkup) {
try {
return markupToMarkdown(markupToJSON(value))
} catch (e) {
// fallback
}
}
if (typeof value === 'object' && value !== null) {
if ('title' in value || 'name' in value) {
const title = value.title ?? value.name ?? ''
const text =
typeof title === 'string' && isIntlString(title)
? await translate(title as unknown as IntlString, {}, language)
: String(title)
if (elementFormatter !== undefined) {
return await elementFormatter(value as Doc, text)
}
return text
}
}
if (typeof value === 'boolean') {
return value ? '✅ Yes' : '❌ No'
}
if (typeof value === 'number') {
return String(value)
}
if (typeof value === 'string') {
if (isIntlString(value)) {
return await translate(value as unknown as IntlString, {}, language)
}
const isRef = attrType?._class === core.class.RefTo
if (isRef) {
if (attrType.to !== undefined && hierarchy.isDerived(attrType.to, contact.mixin.Employee)) {
const name = await loadPersonNameByRef(value as any, hierarchy, userCache as any)
return name
}
}
}
return String(value)
}
export function isIntlString (value: unknown): value is string {
if (typeof value !== 'string' || value.length === 0) {
return false
@@ -60,60 +175,46 @@ export function isIntlString (value: unknown): value is string {
return true
}
/**
* Format an array of values, handling reference lookups if needed
*/
export async function formatArrayValue (
value: any[],
attrType: any,
attribute: AnyAttribute | undefined,
attrKey: string,
card: Doc,
language: string | undefined
hierarchy: Hierarchy,
language: string | undefined,
userCache?: Map<PersonId, string>,
elementFormatter?: (doc: Doc, title: string) => Promise<string>
): Promise<string> {
const isRefArray =
attrType?._class === core.class.ArrOf &&
(attrType as { of?: { _class?: Ref<Class<Doc>> } })?.of?._class === core.class.RefTo
const isRef =
attrType?._class === core.class.RefTo ||
(attrType?._class === core.class.ArrOf &&
(attrType as { of?: { _class?: Ref<Class<Doc>> } })?.of?._class === core.class.RefTo)
if (isRefArray && (attribute !== undefined || attrKey !== '')) {
const cardWithLookup = card as any
const lookupKey = attribute?.name ?? attrKey
const lookupData = cardWithLookup.$lookup?.[lookupKey]
const cardWithLookup = card as any
const lookupKey = attribute?.name ?? attrKey
const lookupData = cardWithLookup.$lookup?.[lookupKey]
if (lookupData !== undefined && lookupData !== null) {
const resolveItem = async (v: any, index: number): Promise<string> => {
// If we have lookup data and v is an ID, find the object
let item = v
if (isRef && lookupData !== undefined && typeof v === 'string') {
const resolvedArray = Array.isArray(lookupData) ? lookupData : [lookupData]
const translatedValues = await Promise.all(
resolvedArray.map(async (v) => {
if (typeof v === 'object' && v !== null && 'title' in v) {
const title = v.title ?? ''
if (typeof title === 'string' && isIntlString(title)) {
return await translate(title as unknown as IntlString, {}, language)
}
return String(title)
}
return typeof v === 'string' ? v : String(v)
})
)
return translatedValues.join(', ')
const found = resolvedArray.find((obj) => obj._id === v)
if (found !== undefined) {
item = found
} else if (resolvedArray[index] !== undefined) {
// Fallback to index-based lookup if no _id matches (useful for tests or simplified data)
item = resolvedArray[index]
}
}
const itemType = attrType?._class === core.class.ArrOf ? attrType.of : attrType
return await formatSingleValue(item, itemType, hierarchy, language, userCache, elementFormatter)
}
const translatedValues = await Promise.all(
value.map(async (v) => {
if (typeof v === 'object' && v !== null && 'title' in v) {
const title = v.title ?? ''
if (typeof title === 'string' && isIntlString(title)) {
return await translate(title as unknown as IntlString, {}, language)
}
return String(title)
}
if (typeof v === 'string' && isIntlString(v)) {
return await translate(v as unknown as IntlString, {}, language)
}
return typeof v === 'string' ? v : String(v)
})
)
return translatedValues.join(', ')
const formattedValues = await Promise.all(value.map(async (v, i) => await resolveItem(v, i)))
return formattedValues.filter((v) => v !== '').join(', ')
}
/**
@@ -123,6 +224,9 @@ export async function extractObjectTitleOrName (
obj: Record<string, any>,
language: string | undefined
): Promise<string> {
if (obj._class === core.class.TypeMarkup || obj._class === core.class.TypeCollaborativeDoc) {
return '' // Should be handled by markupToMarkdown
}
if ('title' in obj) {
const title = String(obj.title ?? '')
if (isIntlString(title)) {
@@ -13,32 +13,25 @@
// limitations under the License.
//
import converter from '@hcengineering/converter'
import core, {
type AnyAttribute,
type Association,
type Class,
type Doc,
type Hierarchy,
type Ref,
type PersonId,
type Association,
getDisplayTime,
type Ref,
getObjectValue
} from '@hcengineering/core'
import { getResource } from '@hcengineering/platform'
import { getClient } from '@hcengineering/presentation'
import { translate, type IntlString, getResource } from '@hcengineering/platform'
import type { AttributeModel } from '@hcengineering/view'
import converter from '@hcengineering/converter'
import { getFormattersForClass } from './registry'
import {
formatArrayValue,
extractObjectTitleOrName,
isIntlString,
DocumentAttributeKey,
DateFormatOption
} from './utils'
import { createMarkdownLink } from '../markdown/link'
import { loadPersonName } from '../data/personLoader'
import { createMarkdownLink } from '../markdown/link'
import type { ValueFormatter } from '../types'
import { getFormattersForClass } from './registry'
import { DocumentAttributeKey, extractObjectTitleOrName, formatArrayValue, formatSingleValue } from './utils'
/** Resolved context for formatting: which object we display and its value */
export interface DisplayContext {
@@ -92,29 +85,6 @@ function getLookupData (card: Doc, ...keys: string[]): any {
return undefined
}
function formatDateValue (
value: number | string | Date,
isDateOnly: boolean,
language: string | undefined
): string | undefined {
if (!isDateOnly && typeof value === 'number') {
return getDisplayTime(value)
}
const parsedDate = value instanceof Date ? value : new Date(value)
if (Number.isNaN(parsedDate.getTime())) {
return undefined
}
const options: Intl.DateTimeFormatOptions = {
year: DateFormatOption.Numeric,
month: DateFormatOption.Short,
day: DateFormatOption.Numeric
}
return parsedDate.toLocaleDateString(language ?? 'default', options)
}
/**
* Resolve which object should be displayed (card, ref, or custom attribute) and its value.
* Used so formatters and fallbacks can format all types consistently.
@@ -238,7 +208,8 @@ function resolveDisplayContext (
}
const attributeKey = getAttributeKey(attr)
const attribute = attr.attribute ?? hierarchy.findAttribute(displayClass, attributeKey)
const attribute = hierarchy.findAttribute(displayClass, attributeKey) ?? (attr as any).attribute
const lookupKey = attribute?.name ?? attributeKey
return { value, displayDoc, displayClass, attribute, lookupKey }
}
@@ -260,68 +231,28 @@ export async function formatCustomAttributeValue (
const attrType = attribute?.type
if (
typeof value === 'number' &&
(attrType?._class === core.class.TypeTimestamp || attrType?._class === core.class.TypeDate)
) {
const formattedDate = formatDateValue(value, attrType?._class === core.class.TypeDate, language)
if (formattedDate !== undefined) {
return formattedDate
}
}
if (value instanceof Date) {
return formatDateValue(value, true, language) ?? ''
}
if (
typeof value === 'string' &&
(attrType?._class === core.class.TypeTimestamp || attrType?._class === core.class.TypeDate)
) {
const formattedDate = formatDateValue(value, attrType?._class === core.class.TypeDate, language)
if (formattedDate !== undefined) {
return formattedDate
}
}
if (typeof value === 'number' || typeof value === 'boolean') {
return String(value)
}
if (typeof value === 'string') {
if (isIntlString(value)) {
try {
return await translate(value as unknown as IntlString, {}, language)
} catch {
console.warn('Failed to translate intl string', value)
}
}
const isRef = attrType?._class === core.class.RefTo
if (isRef && attribute !== undefined) {
const cardWithLookup = card as any
const lookupData = cardWithLookup.$lookup?.[attribute.name]
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)
}
}
return value
}
if (Array.isArray(value)) {
return await formatArrayValue(value, attrType, attribute, attribute?.name ?? '', card, language)
return await formatArrayValue(
value,
attrType,
attribute,
attribute?.name ?? '',
card,
hierarchy,
language,
undefined,
async (d, title) => await createMarkdownLink(hierarchy, d, title)
)
}
if (typeof value === 'object' && value !== null) {
const obj = value as Record<string, any>
const titleOrName = await extractObjectTitleOrName(obj, language)
return titleOrName !== '' ? titleOrName : String(value)
}
return String(value)
return await formatSingleValue(
value,
attrType,
hierarchy,
language,
undefined,
async (d, title) => await createMarkdownLink(hierarchy, d, title)
)
}
/**
@@ -350,69 +281,42 @@ async function formatValueFallback (
const attribute = ctx.attribute
const attrType = attribute?.type
if (
typeof value === 'number' &&
(attrType?._class === core.class.TypeTimestamp || attrType?._class === core.class.TypeDate)
) {
const formattedDate = formatDateValue(value, attrType?._class === core.class.TypeDate, language)
if (formattedDate !== undefined) {
return formattedDate
}
}
if (value instanceof Date) {
return formatDateValue(value, true, language) ?? ''
}
if (
typeof value === 'string' &&
(attrType?._class === core.class.TypeTimestamp || attrType?._class === core.class.TypeDate)
) {
const formattedDate = formatDateValue(value, attrType?._class === core.class.TypeDate, language)
if (formattedDate !== undefined) {
return formattedDate
}
}
if (typeof value === 'number' || typeof value === 'boolean') {
return String(value)
}
if (typeof value === 'string') {
const isRef = attrType?._class === core.class.RefTo
if (isRef) {
const lookupData = getLookupData(card, ctx.lookupKey, attribute?.name ?? '', attr.key)
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)
}
}
if (isIntlString(value)) {
try {
return await translate(value as unknown as IntlString, {}, language)
} catch {
console.warn('Failed to translate intl string', value)
}
}
if (attr.key === DocumentAttributeKey.CreatedBy || attr.key === DocumentAttributeKey.ModifiedBy) {
return await loadPersonName(value as PersonId, hierarchy, userCache)
}
return value
}
if (Array.isArray(value)) {
return await formatArrayValue(value, attrType, attribute, ctx.lookupKey, card, language)
return await formatArrayValue(
value,
attrType,
attribute,
ctx.lookupKey,
card,
hierarchy,
language,
userCache,
async (d, title) => await createMarkdownLink(hierarchy, d, title)
)
}
if (typeof value === 'object' && value !== null) {
const obj = value as Record<string, any>
const titleOrName = await extractObjectTitleOrName(obj, language)
return titleOrName !== '' ? titleOrName : String(value)
if (attr.key === DocumentAttributeKey.CreatedBy || attr.key === DocumentAttributeKey.ModifiedBy) {
return await loadPersonName(value as PersonId, hierarchy, userCache)
}
return String(value)
const isRef = attrType?._class === core.class.RefTo
if (isRef) {
const lookupData = getLookupData(card, ctx.lookupKey, attribute?.name ?? '', attr.key)
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)
}
}
return await formatSingleValue(
value,
attrType,
hierarchy,
language,
userCache,
async (d, title) => await createMarkdownLink(hierarchy, d, title)
)
}
/**
@@ -19,6 +19,7 @@
import { Button, eventToHTMLElement, Label, showPopup } from '@hcengineering/ui'
import MarkupEditorPopup from './MarkupEditorPopup.svelte'
import { StyledTextBox } from '@hcengineering/text-editor-resources'
import type { EditorKitOptions } from '@hcengineering/text-editor-resources'
import textEditorPlugin from '@hcengineering/text-editor'
// export let label: IntlString
@@ -31,6 +32,7 @@
// export let size: ButtonSize = 'x-large'
export let justify: 'left' | 'center' = 'center'
export let width: string | undefined = 'fit-content'
export let kitOptions: Partial<EditorKitOptions> = { reference: true, emoji: true }
let shown: boolean = false
</script>
@@ -44,7 +46,7 @@
height={value ? 'auto' : undefined}
on:click={(ev) => {
if (!shown && !readonly) {
showPopup(MarkupEditorPopup, { value }, eventToHTMLElement(ev), (res) => {
showPopup(MarkupEditorPopup, { value, kitOptions }, eventToHTMLElement(ev), (res) => {
if (res != null) {
value = res
onChange(value)
@@ -69,6 +71,7 @@
content={value}
{placeholder}
alwaysEdit
{kitOptions}
mode={2}
on:value={(e) => {
onChange(e.detail)
@@ -16,10 +16,12 @@
<script lang="ts">
import { Card } from '@hcengineering/presentation'
import { StyledTextBox } from '@hcengineering/text-editor-resources'
import type { EditorKitOptions } from '@hcengineering/text-editor-resources'
import { createEventDispatcher } from 'svelte'
import view from '../plugin'
export let value: string
export let kitOptions: Partial<EditorKitOptions> = { reference: true, emoji: true }
const dispatch = createEventDispatcher()
export let maxHeight: string = '40vh'
@@ -43,6 +45,15 @@
on:changeContent
>
<div class="flex-grow mt-4">
<StyledTextBox autofocus content={value} alwaysEdit mode={2} hideExtraButtons {maxHeight} on:value={checkValue} />
<StyledTextBox
autofocus
content={value}
alwaysEdit
{kitOptions}
mode={2}
hideExtraButtons
{maxHeight}
on:value={checkValue}
/>
</div>
</Card>