Display documents table source (#10433)

* Display documents table source

Signed-off-by: Artem Savchenko <armisav@gmail.com>

* Clean up

Signed-off-by: Artem Savchenko <armisav@gmail.com>

* Fix warning

Signed-off-by: Artem Savchenko <armisav@gmail.com>

* Fix review comments

Signed-off-by: Artem Savchenko <armisav@gmail.com>

---------

Signed-off-by: Artem Savchenko <armisav@gmail.com>
This commit is contained in:
Artyom Savchenko
2026-01-23 11:20:06 +07:00
committed by GitHub
parent f36ac1a6dc
commit 1d450682b1
24 changed files with 282 additions and 21 deletions
+3 -1
View File
@@ -79,6 +79,8 @@
"ConvertToLinkPreview": "Zobrazit jako odkaz",
"ConvertToEmbedPreview": "Zobrazit jako náhled obsahu",
"UnableToLoadEmbeddedContent": "Náhled odkazu nelze načíst kvůli nastavení oprávnění nebo nepodporovanému obsahu",
"CannotConnectToCollaborationService": "Nelze se připojit k službě spolupráce"
"CannotConnectToCollaborationService": "Nelze se připojit k službě spolupráce",
"SourceURL": "Zdrojová URL",
"SelectedDocuments": "Vybrané dokumenty ({count, plural, =1 {1 položka} other {# položek}})"
}
}
+3 -1
View File
@@ -78,6 +78,8 @@
"ConvertToLinkPreview": "Als Link anzeigen",
"ConvertToEmbedPreview": "Als Inhaltsvorschau anzeigen",
"UnableToLoadEmbeddedContent": "Die Linkvorschau konnte aufgrund von Berechtigungseinstellungen oder nicht unterstütztem Inhalt nicht geladen werden",
"CannotConnectToCollaborationService": "Kann keine Verbindung zum Kollaborationsdienst herstellen"
"CannotConnectToCollaborationService": "Kann keine Verbindung zum Kollaborationsdienst herstellen",
"SourceURL": "Quell-URL",
"SelectedDocuments": "Ausgewählte Dokumente ({count, plural, =1 {1 Element} other {# Elemente}})"
}
}
+3 -1
View File
@@ -80,6 +80,8 @@
"ConvertToLinkPreview": "Show as a link",
"ConvertToEmbedPreview": "Show as a content preview",
"UnableToLoadEmbeddedContent": "Link preview couldn't be loaded due to permission settings or unsupported content",
"CannotConnectToCollaborationService": "Cannot connect to collaboration service"
"CannotConnectToCollaborationService": "Cannot connect to collaboration service",
"SourceURL": "Source URL",
"SelectedDocuments": "Selected documents ({count, plural, =1 {1 item} other {# items}})"
}
}
+3 -1
View File
@@ -69,6 +69,8 @@
"ConvertToLinkPreview": "Mostrar como enlace",
"ConvertToEmbedPreview": "Mostrar como vista previa de contenido",
"UnableToLoadEmbeddedContent": "No se pudo cargar la vista previa del enlace debido a la configuración de permisos o contenido no compatible",
"CannotConnectToCollaborationService": "No se puede conectar con el colaborador"
"CannotConnectToCollaborationService": "No se puede conectar con el colaborador",
"SourceURL": "URL de origen",
"SelectedDocuments": "Documentos seleccionados ({count, plural, =1 {1 elemento} other {# elementos}})"
}
}
+3 -1
View File
@@ -69,6 +69,8 @@
"ConvertToLinkPreview": "Afficher comme lien",
"ConvertToEmbedPreview": "Afficher comme aperçu de contenu",
"UnableToLoadEmbeddedContent": "L’aperçu du lien n’a pas pu être chargé en raison des paramètres d’autorisation ou d’un contenu non pris en charge",
"CannotConnectToCollaborationService": "Impossible de se connecter au collaborateur"
"CannotConnectToCollaborationService": "Impossible de se connecter au collaborateur",
"SourceURL": "URL source",
"SelectedDocuments": "Documents sélectionnés ({count, plural, =1 {1 élément} other {# éléments}})"
}
}
+3 -1
View File
@@ -78,6 +78,8 @@
"ConvertToLinkPreview": "Mostra come link",
"ConvertToEmbedPreview": "Mostra come anteprima del contenuto",
"UnableToLoadEmbeddedContent": "Impossibile caricare l'anteprima del link a causa delle impostazioni dei permessi o di contenuto non supportato",
"CannotConnectToCollaborationService": "Impossibile connettersi al servizio di collaborazione"
"CannotConnectToCollaborationService": "Impossibile connettersi al servizio di collaborazione",
"SourceURL": "URL di origine",
"SelectedDocuments": "Documenti selezionati ({count, plural, =1 {1 elemento} other {# elementi}})"
}
}
+3 -1
View File
@@ -79,6 +79,8 @@
"ConvertToLinkPreview": "リンクとして表示",
"ConvertToEmbedPreview": "コンテンツプレビューとして表示",
"UnableToLoadEmbeddedContent": "リンクのプレビューを読み込めません。権限設定または非対応のコンテンツが原因です",
"CannotConnectToCollaborationService": "コラボレーションサービスに接続できません"
"CannotConnectToCollaborationService": "コラボレーションサービスに接続できません",
"SourceURL": "ソースURL",
"SelectedDocuments": "選択されたドキュメント ({count, plural, =1 {1 項目} other {# 項目}})"
}
}
+3 -1
View File
@@ -69,6 +69,8 @@
"ConvertToLinkPreview": "Mostrar como link",
"ConvertToEmbedPreview": "Mostrar como pré-visualização de conteúdo",
"UnableToLoadEmbeddedContent": "Não foi possível carregar a pré-visualização do link devido às permissões ou a conteúdo não suportado",
"CannotConnectToCollaborationService": "Não foi possível conectar ao serviço de colaboração"
"CannotConnectToCollaborationService": "Não foi possível conectar ao serviço de colaboração",
"SourceURL": "URL de origem",
"SelectedDocuments": "Documentos selecionados ({count, plural, =1 {1 item} other {# itens}})"
}
}
+3 -1
View File
@@ -79,6 +79,8 @@
"ConvertToLinkPreview": "Показать как ссылку",
"ConvertToEmbedPreview": "Показать как превью контента",
"UnableToLoadEmbeddedContent": "Не удалось загрузить превью ссылки из-за настроек доступа или неподдерживаемого содержимого",
"CannotConnectToCollaborationService": "Не удалось подключиться к сервису совместной работы"
"CannotConnectToCollaborationService": "Не удалось подключиться к сервису совместной работы",
"SourceURL": "Исходный URL",
"SelectedDocuments": "Выбранные документы ({count, plural, =1 {1 элемент} other {# элементов}})"
}
}
+3 -1
View File
@@ -79,6 +79,8 @@
"ConvertToLinkPreview": "Bağlantı olarak göster",
"ConvertToEmbedPreview": "İçerik önizlemesi olarak göster",
"UnableToLoadEmbeddedContent": "Bağlantı önizlemesi izin ayarları veya desteklenmeyen içerik nedeniyle yüklenemedi",
"CannotConnectToCollaborationService": "İşbirliği servisine bağlanılamıyor"
"CannotConnectToCollaborationService": "İşbirliği servisine bağlanılamıyor",
"SourceURL": "Kaynak URL",
"SelectedDocuments": "Seçilen belgeler ({count, plural, =1 {1 öğe} other {# öğe}})"
}
}
+3 -1
View File
@@ -71,6 +71,8 @@
"ConvertToLinkPreview": "显示为链接",
"ConvertToEmbedPreview": "显示为内容预览",
"UnableToLoadEmbeddedContent": "由于权限设置或不支持的内容,无法加载链接预览",
"CannotConnectToCollaborationService": "无法连接到协作者"
"CannotConnectToCollaborationService": "无法连接到协作者",
"SourceURL": "源URL",
"SelectedDocuments": "选定的文档 ({count, plural, =1 {1 项} other {# 项}})"
}
}
@@ -20,6 +20,9 @@
import { Component, Loading } from '@hcengineering/ui'
import type { BuildModelKey, Viewlet } from '@hcengineering/view'
import { createEventDispatcher } from 'svelte'
import type { TableMetadata } from '../tableMetadata'
import TableSourceInfo from './TableSourceInfo.svelte'
export let _class: Ref<Class<Doc>>
export let config: Array<string | BuildModelKey> | undefined = undefined
@@ -27,6 +30,7 @@
export let query: DocumentQuery<Doc> | undefined = undefined
export let viewlet: Viewlet | undefined = undefined
export let viewletWithLookup: any | undefined = undefined
export let metadata: TableMetadata | undefined = undefined
const dispatch = createEventDispatcher()
@@ -84,6 +88,9 @@
<Loading />
{/if}
</div>
{#if metadata}
<TableSourceInfo {metadata} />
{/if}
</Card>
<style lang="scss">
@@ -18,11 +18,14 @@
import presentation, { Card } from '@hcengineering/presentation'
import textEditor from '@hcengineering/text-editor'
import { createEventDispatcher } from 'svelte'
import type { TableMetadata } from '../tableMetadata'
import MarkupDiffViewer from '../../../MarkupDiffViewer.svelte'
import TableSourceInfo from './TableSourceInfo.svelte'
export let oldMarkdown: string
export let newMarkdown: string
export let metadata: TableMetadata
const dispatch = createEventDispatcher()
@@ -47,11 +50,12 @@
<div class="table-diff-container">
<MarkupDiffViewer content={newContent} comparedVersion={oldContent} />
</div>
<TableSourceInfo {metadata} />
</Card>
<style>
.table-diff-container {
padding: 1rem;
padding: 0rem 1rem;
overflow: auto;
max-height: 80vh;
}
@@ -19,11 +19,14 @@
import textEditor from '@hcengineering/text-editor'
import { Button } from '@hcengineering/ui'
import { createEventDispatcher } from 'svelte'
import type { TableMetadata } from '../tableMetadata'
import MarkupDiffViewer from '../../../MarkupDiffViewer.svelte'
import TableSourceInfo from './TableSourceInfo.svelte'
export let oldMarkdown: string
export let newMarkdown: string
export let metadata: TableMetadata
export let onApply: () => void = () => {}
const dispatch = createEventDispatcher()
@@ -53,6 +56,7 @@
<div class="table-diff-container">
<MarkupDiffViewer content={newContent} comparedVersion={oldContent} />
</div>
<TableSourceInfo {metadata} />
<svelte:fragment slot="buttons">
<Button label={presentation.string.Cancel} kind="secondary" size="large" on:click={handleCancel} />
</svelte:fragment>
@@ -60,7 +64,7 @@
<style>
.table-diff-container {
padding: 1rem;
padding: 0rem 1rem;
overflow: auto;
max-height: 80vh;
}
@@ -0,0 +1,188 @@
<!--
//
// Copyright © 2026 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.
//
-->
<script lang="ts">
import core from '@hcengineering/core'
import { IconWithEmoji, getClient } from '@hcengineering/presentation'
import ui, { Icon, Label } from '@hcengineering/ui'
import view from '@hcengineering/view'
import textEditor from '@hcengineering/text-editor'
import type { TableMetadata } from '../tableMetadata'
export let metadata: TableMetadata
const client = getClient()
const hierarchy = client.getHierarchy()
$: cardClassRef = metadata.cardClass as any
$: cardClass = hierarchy.findClass(cardClassRef)
$: classLabel = cardClass?.label
$: classIcon = cardClass?.icon
// Format query information
$: queryInfo = (() => {
if (metadata.documentIds !== undefined && metadata.documentIds.length > 0) {
return undefined
} else if (metadata.query !== null && metadata.query !== undefined) {
try {
return JSON.stringify(metadata.query, null, 2)
} catch {
return String(metadata.query)
}
}
return undefined
})()
$: documentCount = metadata.documentIds?.length ?? 0
$: originalUrlDisplay = (() => {
if (metadata.originalUrl == null || metadata.originalUrl === '') {
return undefined
}
try {
const url = new URL(metadata.originalUrl)
const path = url.pathname.replace(/\/$/, '')
return url.hostname + path
} catch {
return metadata.originalUrl.replace(/^https?:\/\//, '').replace(/\/$/, '')
}
})()
</script>
<div class="table-source-info">
<div class="source-content">
{#if originalUrlDisplay != null && metadata.originalUrl != null}
<div class="source-row">
<span class="source-label">
<Label label={textEditor.string.SourceURL} />
</span>
<div class="source-value">
<a href={metadata.originalUrl} target="_blank" rel="noopener noreferrer" class="link">
{originalUrlDisplay}
</a>
</div>
</div>
{:else if cardClass !== undefined && cardClass !== null && classLabel !== undefined}
<div class="source-row">
<span class="source-label">
<Label label={core.string.Class} />
</span>
<div class="source-value">
{#if classIcon}
<Icon
icon={classIcon === view.ids.IconWithEmoji ? IconWithEmoji : classIcon}
iconProps={classIcon === view.ids.IconWithEmoji ? { icon: cardClass.color } : {}}
size="small"
/>
{/if}
<Label label={classLabel} />
</div>
</div>
{/if}
{#if queryInfo != null || (metadata.documentIds !== undefined && metadata.documentIds.length > 0)}
<div class="source-row">
<span class="source-label">
<Label label={ui.string.Filter} />
</span>
<div class="source-value query-value">
{#if metadata.documentIds !== undefined && metadata.documentIds.length > 0}
<span class="query-text">
<Label label={textEditor.string.SelectedDocuments} params={{ count: documentCount }} />
</span>
{:else if queryInfo}
<pre class="query-json"><code>{queryInfo}</code></pre>
{/if}
</div>
</div>
{/if}
</div>
</div>
<style lang="scss">
.table-source-info {
margin-top: 0.5rem;
border: 1px solid var(--theme-border);
border-radius: 0.5rem;
background: var(--theme-surface-01);
overflow: hidden;
font-size: 0.875rem;
}
.source-content {
padding: 0.5rem 1rem;
display: grid;
grid-template-columns: auto 1fr;
align-items: flex-start;
gap: 0.75rem;
row-gap: 0.5rem;
}
.source-row {
display: contents;
}
.source-label {
color: var(--caption-color);
font-weight: 500;
white-space: nowrap;
}
.source-value {
display: flex;
align-items: center;
gap: 0.5rem;
min-width: 0;
}
.query-value {
flex-direction: column;
align-items: flex-start;
}
.query-text {
color: var(--theme-text-primary);
}
.query-json {
margin: 0;
padding: 0.75rem;
background: var(--theme-surface-02);
border: 1px solid var(--theme-border);
border-radius: 0.25rem;
font-size: 0.75rem;
color: var(--theme-text-primary);
overflow-x: auto;
max-width: 100%;
max-height: 10rem;
overflow-y: auto;
width: 100%;
box-sizing: border-box;
code {
display: block;
white-space: pre;
}
}
.link {
color: var(--theme-link-color);
text-decoration: none;
word-break: break-all;
&:hover {
text-decoration: underline;
}
}
</style>
@@ -97,6 +97,7 @@ export async function refreshTable (editor: Editor): Promise<void> {
{
oldMarkdown: currentTableMarkdown,
newMarkdown: freshTableMarkdown,
metadata,
onApply: () => {
applyTableRefresh(editor, table, freshTableMarkdown, metadata)
}
@@ -95,7 +95,8 @@ export async function seeOriginalTableData (editor: Editor): Promise<void> {
documentIds: documentIds.length > 0 ? documentIds : [],
query: documentIds.length === 0 ? metadata.query : undefined,
viewlet,
viewletWithLookup
viewletWithLookup,
metadata
},
'center'
)
@@ -50,7 +50,8 @@ export async function showTableDiff (editor: Editor): Promise<void> {
TableDiffViewer,
{
oldMarkdown: currentTableMarkdown,
newMarkdown: freshTableMarkdown
newMarkdown: freshTableMarkdown,
metadata
},
'center'
)
@@ -33,7 +33,8 @@ export async function buildMarkdownTableFromDocs (
cardClass: metadata.cardClass,
viewletId: metadata.viewletId,
config: metadata.config,
query: metadata.query
query: metadata.query,
originalUrl: metadata.originalUrl
}
return await buildFunction(docs, buildMetadata, client)
} catch (error) {
@@ -21,6 +21,7 @@ export interface TableMetadata extends BuildMarkdownTableMetadata {
documentIds: string[]
timestamp: number
workspace?: string
originalUrl?: string // Original URL of the page/view where the table was created
}
/**
+2 -1
View File
@@ -19,6 +19,7 @@ import { type IntlString, plugin } from '@hcengineering/platform'
export default plugin(textEditorId, {
string: {
CannotConnectToCollaborationService: '' as IntlString
CannotConnectToCollaborationService: '' as IntlString,
SourceURL: '' as IntlString
}
})
+3 -1
View File
@@ -117,7 +117,9 @@ export default plugin(textEditorId, {
ConvertToLinkPreview: '' as IntlString,
ConvertToEmbedPreview: '' as IntlString,
UnableToLoadEmbeddedContent: '' as IntlString
UnableToLoadEmbeddedContent: '' as IntlString,
SourceURL: '' as IntlString,
SelectedDocuments: '' as IntlString<{ count: number }>
},
icon: {
Header1: '' as Asset,
@@ -26,7 +26,7 @@ import core, {
getObjectValue
} from '@hcengineering/core'
import { translate, type IntlString, getMetadata } from '@hcengineering/platform'
import { addNotification, NotificationSeverity, locationToUrl } from '@hcengineering/ui'
import { addNotification, NotificationSeverity, locationToUrl, getCurrentResolvedLocation } from '@hcengineering/ui'
import { getCurrentLanguage } from '@hcengineering/theme'
import viewPlugin, {
type Viewlet,
@@ -492,6 +492,7 @@ export interface TableMetadata {
documentIds: Array<Ref<Doc>>
timestamp: number
workspace?: string // Optional workspace identifier
originalUrl?: string // Original URL of the page/view where the table was created
}
/**
@@ -510,6 +511,19 @@ async function buildTableMetadata (
viewletId = viewlet?._id
}
// Capture the original URL of the current page/view
let originalUrl: string | undefined
try {
const currentLocation = getCurrentResolvedLocation()
const relativeUrl = locationToUrl(currentLocation)
const frontUrl =
getMetadata(presentation.metadata.FrontUrl) ?? (typeof window !== 'undefined' ? window.location.origin : '')
originalUrl = concatLink(frontUrl, relativeUrl)
} catch (error) {
// If URL capture fails, continue without it
console.warn('Failed to capture original URL for table metadata:', error)
}
return {
version: '1.0',
cardClass: props.cardClass,
@@ -517,7 +531,8 @@ async function buildTableMetadata (
config: props.config,
query: props.query,
documentIds: docs.map((d) => d._id),
timestamp: Date.now()
timestamp: Date.now(),
originalUrl
}
}
@@ -528,6 +543,17 @@ export function buildRelationshipTableMetadata (
props: CopyRelationshipTableAsMarkdownProps,
docs: Doc[]
): TableMetadata {
let originalUrl: string | undefined
try {
const currentLocation = getCurrentResolvedLocation()
const relativeUrl = locationToUrl(currentLocation)
const frontUrl =
getMetadata(presentation.metadata.FrontUrl) ?? (typeof window !== 'undefined' ? window.location.origin : '')
originalUrl = concatLink(frontUrl, relativeUrl)
} catch (error) {
console.warn('Failed to capture original URL for relationship table metadata:', error)
}
return {
version: '1.0',
cardClass: props.cardClass,
@@ -535,7 +561,8 @@ export function buildRelationshipTableMetadata (
config: props.model.map((m) => m.key),
query: props.query,
documentIds: docs.map((d) => d._id),
timestamp: Date.now()
timestamp: Date.now(),
originalUrl
}
}
+1
View File
@@ -904,4 +904,5 @@ export interface BuildMarkdownTableMetadata {
viewletId?: string | Ref<Viewlet>
config?: Array<string | BuildModelKey>
query?: Record<string, any> | DocumentQuery<Doc>
originalUrl?: string // Original URL of the page/view where the table was created
}