Relationship table (#10329)

Signed-off-by: Denis Bykhov <bykhov.denis@gmail.com>
This commit is contained in:
Denis Bykhov
2025-12-24 18:21:29 +05:00
committed by GitHub
parent f6f01ed3d3
commit 20e6bbd5df
27 changed files with 1019 additions and 34 deletions
+1 -1
View File
@@ -1 +1 @@
"0.7.320"
"0.7.324"
+89 -11
View File
@@ -16,6 +16,7 @@
import { Analytics } from '@hcengineering/analytics'
import core, {
Association,
AssociationQuery,
BulkUpdateEvent,
Class,
Client,
@@ -1116,28 +1117,44 @@ export class LiveQuery implements WithTx, Client {
if (q.options?.associations === undefined) return
if (doc._class !== core.class.Relation) return
const relation = doc as Relation
const assoc = q.options.associations.find((p) => p[0] === relation.association)
const assoc = findAssociation(q.options.associations, relation.association)
if (assoc !== undefined) {
if (q.result instanceof Promise) {
q.result = await q.result
}
const direct = assoc[1] === 1
const res = q.result.findDoc(direct ? relation.docA : relation.docB)
const res = findRelationDoc(q.result.getDocs(), q.options.associations, relation)
if (res === undefined) return
const association = this.getModel().findObject(assoc[0])
if (association === undefined) return
const docToPush = await this.findOne(direct ? association.classB : association.classA, {
_id: direct ? relation.docB : relation.docA
})
const nestedAssoc = assoc[2]
const options: FindOptions<Doc> | undefined =
nestedAssoc !== undefined
? {
associations: nestedAssoc
}
: undefined
const docToPush = await this.findOne(
direct ? association.classB : association.classA,
{
_id: res.targetId
},
options
)
if (docToPush === undefined) return
if (res?.$associations === undefined) {
res.$associations = {}
if (res.doc.$associations === undefined) {
res.doc.$associations = {}
}
const key = direct ? 'b' : 'a'
const arr = res?.$associations?.[`${relation.association}_${key}`] ?? []
arr.push(docToPush)
res.$associations[`${relation.association}_${key}`] = arr
q.result.updateDoc(res, false)
const arr = res.doc.$associations?.[`${assoc[0]}_${key}`] ?? []
const exists = arr.findIndex((p) => p._id === docToPush._id)
if (exists !== -1) {
arr[exists] = docToPush
} else {
arr.push(docToPush)
}
res.doc.$associations[`${assoc[0]}_${key}`] = arr
q.result.updateDoc(res.doc, false)
this.queriesToUpdate.set(q.id, q)
}
}
@@ -1611,3 +1628,64 @@ function getNestedLookup (lookup: Ref<Class<Doc>> | [Ref<Class<Doc>>, Lookup<Doc
function getLookupClass (lookup: Ref<Class<Doc>> | [Ref<Class<Doc>>, Lookup<Doc>]): Ref<Class<Doc>> {
return Array.isArray(lookup) ? lookup[0] : lookup
}
function findAssociation (
associations: AssociationQuery[],
association: Ref<Association>
): AssociationQuery | undefined {
for (const assoc of associations) {
if (assoc[0] === association) return assoc
}
for (const assoc of associations) {
if (assoc[2] !== undefined) {
const res = findAssociation(assoc[2], association)
if (res !== undefined) {
return assoc
}
}
}
}
function findRelationDoc (
result: WithLookup<Doc>[],
associations: AssociationQuery[] | undefined,
relation: Relation
):
| {
doc: WithLookup<Doc>
targetId: Ref<Doc>
}
| undefined {
if (associations === undefined) return
for (const assoc of associations) {
if (assoc[0] === relation.association) {
const sourceId = assoc[1] === 1 ? relation.docA : relation.docB
const targetId = assoc[1] === 1 ? relation.docB : relation.docA
for (const doc of result) {
if (doc._id === sourceId) {
return {
doc,
targetId
}
}
}
}
}
for (const assoc of associations) {
if (assoc[2] !== undefined) {
const key = assoc[1] === 1 ? 'b' : 'a'
for (const doc of result) {
const arr = doc.$associations?.[`${assoc[0]}_${key}`] ?? []
if (arr.length > 0) {
const res = findRelationDoc(arr, assoc[2], relation)
if (res !== undefined) {
return {
doc,
targetId: res.doc._id
}
}
}
}
}
}
}
+15
View File
@@ -632,6 +632,21 @@ export function createModel (builder: Builder): void {
card.viewlet.CardFeed
)
builder.createDoc(
view.class.Viewlet,
core.space.Model,
{
attachTo: card.class.Card,
descriptor: view.viewlet.RelationshipTable,
configOptions: {
hiddenKeys: ['content', 'title'],
sortable: true
},
config: ['']
},
card.viewlet.CardRelationshipTable
)
builder.mixin(card.class.Card, core.class.Class, view.mixin.ObjectPresenter, {
presenter: card.component.CardPresenter
})
+1 -1
View File
@@ -63,7 +63,7 @@ export const cardOperation: MigrateOperation = {
async upgrade (state: Map<string, Set<string>>, client: () => Promise<MigrationUpgradeClient>, mode): Promise<void> {
await tryUpgrade(mode, state, client, cardId, [
{
state: 'migrateViewlets-v5',
state: 'migrateViewlets-v6',
func: migrateViewlets
},
{
+11
View File
@@ -598,6 +598,17 @@ export function createModel (builder: Builder): void {
view.viewlet.Table
)
builder.createDoc(
view.class.ViewletDescriptor,
core.space.Model,
{
label: view.string.RelationshipTable,
icon: view.icon.Table2,
component: view.component.RelationshipTableBrowser
},
view.viewlet.RelationshipTable
)
builder.createDoc(
view.class.ViewletDescriptor,
core.space.Model,
+2
View File
@@ -68,6 +68,7 @@ export default mergeIds(viewId, view, {
TimestampPresenter: '' as AnyComponent,
DateTimePresenter: '' as AnyComponent,
TableBrowser: '' as AnyComponent,
RelationshipTableBrowser: '' as AnyComponent,
YoutubePresenter: '' as AnyComponent,
GithubPresenter: '' as AnyComponent,
ClassPresenter: '' as AnyComponent,
@@ -101,6 +102,7 @@ export default mergeIds(viewId, view, {
},
string: {
Table: '' as IntlString,
RelationshipTable: '' as IntlString,
Role: '' as IntlString,
// Keybaord actions
MoveUp: '' as IntlString,
@@ -19,7 +19,7 @@
import { ObjectPresenterType } from '@hcengineering/view'
import CardPresenter from './CardPresenter.svelte'
export let value: Card[]
export let value: Card[] | Card | undefined
export let disabled: boolean = false
export let noUnderline: boolean = disabled
export let colorInherit: boolean = false
@@ -28,11 +28,13 @@
export let showParent: boolean = false
export let type: ObjectPresenterType = 'link'
export let icon: Asset | AnySvelteComponent | undefined = undefined
$: arr = Array.isArray(value) ? value : [value]
</script>
{#if value}
<div class="flex-col-center">
{#each value as card}
<div class="flex-col">
{#each arr as card}
<CardPresenter value={card} {noUnderline} {noSelect} {colorInherit} {inline} {showParent} {type} {icon} />
{/each}
</div>
@@ -32,7 +32,6 @@
let title: string
let descriptor: Ref<ViewletDescriptor> | undefined = undefined
const association: Association | undefined = undefined
let viewConfigs: MasterDetailConfig[] = []
$: viewConfigs = [
{
@@ -29,6 +29,7 @@
view.viewlet.Tree,
view.viewlet.MasterDetail,
card.viewlet.CardFeedDescriptor,
view.viewlet.RelationshipTable,
view.viewlet.Document
]
: [
@@ -36,7 +37,8 @@
view.viewlet.List,
view.viewlet.Tree,
view.viewlet.MasterDetail,
card.viewlet.CardFeedDescriptor
card.viewlet.CardFeedDescriptor,
view.viewlet.RelationshipTable
]
</script>
+2 -1
View File
@@ -87,7 +87,8 @@ export default mergeIds(cardId, card, {
CardList: '' as Ref<Viewlet>,
CardChildList: '' as Ref<Viewlet>,
CardFeedDescriptor: '' as Ref<ViewletDescriptor>,
CardFeed: '' as Ref<Viewlet>
CardFeed: '' as Ref<Viewlet>,
CardRelationshipTable: '' as Ref<Viewlet>
},
string: {
CreateMasterTag: '' as IntlString,
+2 -2
View File
@@ -142,14 +142,14 @@ export async function duplicateCard (origin: Card): Promise<void> {
}
for (const rel of relationsA) {
await ops.createDoc(core.class.Relation, origin.space, {
await ops.createDoc(core.class.Relation, core.space.Workspace, {
docA: targetId,
docB: rel.docB,
association: rel.association
})
}
for (const rel of relationsB) {
await ops.createDoc(core.class.Relation, origin.space, {
await ops.createDoc(core.class.Relation, core.space.Workspace, {
docA: rel.docA,
docB: targetId,
association: rel.association
+2 -1
View File
@@ -153,6 +153,7 @@
"ValueIsSet": "Hodnota je nastavena",
"RoleLabel": "Role: {role}",
"ForbidAttributeChanges": "Zakázat změny: ",
"AllowAttributeChanges": "Povolit změny: "
"AllowAttributeChanges": "Povolit změny: ",
"RelationshipTable": "Tabulka vztahů"
}
}
+2 -1
View File
@@ -153,6 +153,7 @@
"ValueIsSet": "Wert ist gesetzt",
"RoleLabel": "Rolle: {role}",
"ForbidAttributeChanges": "Änderungen verbieten: ",
"AllowAttributeChanges": "Änderungen erlauben: "
"AllowAttributeChanges": "Änderungen erlauben: ",
"RelationshipTable": "Beziehungstabelle"
}
}
+2 -1
View File
@@ -153,6 +153,7 @@
"ValueIsSet": "Value is set",
"RoleLabel": "Role: {role}",
"ForbidAttributeChanges": "Forbid changes: ",
"AllowAttributeChanges": "Allow changes: "
"AllowAttributeChanges": "Allow changes: ",
"RelationshipTable": "Relationship table"
}
}
+2 -1
View File
@@ -148,6 +148,7 @@
"ValueIsSet": "El valor está establecido",
"RoleLabel": "Role: {role}",
"ForbidAttributeChanges": "Prohibir cambios: ",
"AllowAttributeChanges": "Permitir cambios: "
"AllowAttributeChanges": "Permitir cambios: ",
"RelationshipTable": "Tabla de relaciones"
}
}
+2 -1
View File
@@ -148,6 +148,7 @@
"ValueIsSet": "La valeur est définie",
"RoleLabel": "Rôle : {role}",
"ForbidAttributeChanges": "Interdire les modifications: ",
"AllowAttributeChanges": "Autoriser les modifications: "
"AllowAttributeChanges": "Autoriser les modifications: ",
"RelationshipTable": "Table des relations"
}
}
+2 -1
View File
@@ -148,6 +148,7 @@
"ValueIsSet": "Il valore è impostato",
"RoleLabel": "Ruolo: {role}",
"ForbidAttributeChanges": "Vieta modifiche: ",
"AllowAttributeChanges": "Consenti modifiche: "
"AllowAttributeChanges": "Consenti modifiche: ",
"RelationshipTable": "Tabella delle relazioni"
}
}
+2 -1
View File
@@ -148,6 +148,7 @@
"ValueIsSet": "値が設定されています",
"RoleLabel": "役割: {role}",
"ForbidAttributeChanges": "属性の変更を禁止: ",
"AllowAttributeChanges": "属性の変更を許可: "
"AllowAttributeChanges": "属性の変更を許可: ",
"RelationshipTable": "関係テーブル"
}
}
+2 -1
View File
@@ -148,6 +148,7 @@
"ValueIsSet": "O valor está definido",
"RoleLabel": "Cargo: {role}",
"ForbidAttributeChanges": "Proibir alterações: ",
"AllowAttributeChanges": "Permitir alterações: "
"AllowAttributeChanges": "Permitir alterações: ",
"RelationshipTable": "Tabela de relacionamentos"
}
}
+2 -1
View File
@@ -150,6 +150,7 @@
"ValueIsSet": "Значение установлено",
"RoleLabel": "Роль: {role}",
"ForbidAttributeChanges": "Запретить изменение: ",
"AllowAttributeChanges": "Разрешить изменение: "
"AllowAttributeChanges": "Разрешить изменение: ",
"RelationshipTable": "Таблица связей"
}
}
+2 -1
View File
@@ -148,6 +148,7 @@
"ValueIsSet": "Değer ayarlandı",
"RoleLabel": "Rol: {role}",
"ForbidAttributeChanges": "Özellik yasakla: ",
"AllowAttributeChanges": "Özellik izin ver: "
"AllowAttributeChanges": "Özellik izin ver: ",
"RelationshipTable": "İlişki tablosu"
}
}
+2 -1
View File
@@ -153,6 +153,7 @@
"ValueIsSet": "值已设置",
"RoleLabel": "角色:{role}",
"ForbidAttributeChanges": "禁止更改属性: ",
"AllowAttributeChanges": "允许更改属性: "
"AllowAttributeChanges": "允许更改属性: ",
"RelationshipTable": "关系表"
}
}
@@ -0,0 +1,80 @@
<!--
// 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.
-->
<script lang="ts">
import core, { Association, Doc, Ref, Relation } from '@hcengineering/core'
import { createQuery, getClient, ObjectPopup } from '@hcengineering/presentation'
import ObjectPresenter from './ObjectPresenter.svelte'
export let association: string
export let target: Doc
const parts = association.split('_')
const associationId = parts[0] as Ref<Association>
const direction = parts[1]
let relations: Relation[] = []
const client = getClient()
const assoc = client.getModel().getObject(associationId)
const multiSelect = assoc.type === 'N:N' || (assoc.type === '1:N' && direction === 'b')
const targetClass = direction === 'a' ? assoc.classA : assoc.classB
$: selectedObjects = relations.map((p) => (direction === 'a' ? p.docA : p.docB))
const query = createQuery()
query.query(
core.class.Relation,
{
association: associationId,
[direction === 'a' ? 'docB' : 'docA']: target._id
},
(res) => {
relations = res
}
)
async function handler (e: CustomEvent<any>): Promise<void> {
if (e.detail != null) {
const currentSet = new Set(selectedObjects)
const newSet = new Set(Array.isArray(e.detail) ? e.detail : [e.detail])
for (const _id of newSet) {
if (!currentSet.has(_id)) {
await client.createDoc(core.class.Relation, core.space.Workspace, {
association: associationId,
docA: direction === 'a' ? _id : target._id,
docB: direction === 'b' ? _id : target._id
})
}
}
for (const _id of currentSet) {
if (!newSet.has(_id)) {
const relation = relations.find((p) => p[direction === 'b' ? 'docB' : 'docA'] === _id)
if (relation !== undefined) {
await client.remove(relation)
}
}
}
}
}
</script>
<ObjectPopup _class={targetClass} {multiSelect} {selectedObjects} on:close={handler} on:update={handler}>
<svelte:fragment slot="item" let:item>
<ObjectPresenter value={item} props={{ type: 'text' }} />
</svelte:fragment>
</ObjectPopup>
@@ -0,0 +1,720 @@
<!--
// 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.
-->
<script lang="ts">
import contact, { PermissionsStore } from '@hcengineering/contact'
import core, {
AnyAttribute,
AssociationQuery,
Class,
Doc,
DocumentQuery,
FindOptions,
Lookup,
Ref,
SortingOrder,
TxOperations,
TypedSpace,
WithLookup,
getObjectValue,
mergeQueries
} from '@hcengineering/core'
import { getResource } from '@hcengineering/platform'
import { createQuery, getClient, reduceCalls, updateAttribute } from '@hcengineering/presentation'
import ui, {
Button,
Label,
Loading,
eventToHTMLElement,
lazyObserver,
resizeObserver,
showPopup
} from '@hcengineering/ui'
import { AttributeModel, BuildModelKey, BuildModelOptions, ViewOptionModel, ViewOptions } from '@hcengineering/view'
import { deepEqual } from 'fast-equals'
import { createEventDispatcher, onMount } from 'svelte'
import { Readable } from 'svelte/store'
import { showMenu } from '../actions'
import { canChangeAttribute } from '../permissions'
import view from '../plugin'
import { buildConfigAssociation, buildConfigLookup, buildModel, restrictionStore } from '../utils'
import { getResultOptions, getResultQuery } from '../viewOptions'
import IconUpDown from './icons/UpDown.svelte'
import RelationsSelectorPopup from './RelationsSelectorPopup.svelte'
export let _class: Ref<Class<Doc>>
export let query: DocumentQuery<Doc>
export let highlightRows: boolean = false
export let hiddenHeader: boolean = false
export let options: FindOptions<Doc> | undefined = undefined
export let baseMenuClass: Ref<Class<Doc>> | undefined = undefined
export let config: Array<BuildModelKey | string>
export let tableId: string | undefined = undefined
export let readonly = false
export let showFooter = false
export let viewOptionsConfig: ViewOptionModel[] | undefined = undefined
export let viewOptions: ViewOptions | undefined = undefined
export let totalQuery: DocumentQuery<Doc> | undefined = undefined
export let prefferedSorting: string = 'modifiedOn'
export let limit = 200
const assoc = '$associations'
const client = getClient()
const hierarchy = client.getHierarchy()
let lookup = buildConfigLookup(hierarchy, _class, config, options?.lookup)
let associations = buildConfigAssociation(config)
$: lookup = buildConfigLookup(hierarchy, _class, config, options?.lookup)
$: associations = buildConfigAssociation(config)
let _sortKey = prefferedSorting
let userSorting = false
$: if (!userSorting) {
_sortKey = prefferedSorting
}
let sortOrder = SortingOrder.Descending
let loading = 0
let objects: Doc[] = []
let gtotal: number = 0
let total: number = 0
let objectsRecieved = false
let rowLimit = 1
const oldClass = _class
$: if (oldClass !== _class) {
rowLimit = 1 // delayed show
}
const q = createQuery()
const dispatch = createEventDispatcher()
$: sortingFunction = (config.find((it) => typeof it !== 'string' && it.sortingKey === _sortKey) as BuildModelKey)
?.sortingFunction
function getSort (sortKey: string | string[]) {
return Array.isArray(sortKey)
? sortKey.reduce((acc: Record<string, SortingOrder>, val) => {
acc[val] = sortOrder
return acc
}, {})
: { ...(options?.sort ?? {}), [sortKey]: sortOrder }
}
let resultOptions = options
const update = reduceCalls(async function (
_class: Ref<Class<Doc>>,
query: DocumentQuery<Doc>,
sortKey: string | string[],
sortOrder: SortingOrder,
lookup: Lookup<Doc>,
associations: AssociationQuery[] | undefined,
limit: number,
options: FindOptions<Doc> | undefined
) {
const p = await getResultQuery(hierarchy, query, viewOptionsConfig, viewOptions)
const resultQuery = mergeQueries(p, query)
loading += q.query(
_class,
resultQuery,
(result) => {
if (sortingFunction !== undefined) {
const sf = sortingFunction
objects = result.sort((a, b) => -1 * sortOrder * sf(a, b))
} else {
objects = result
}
objectsRecieved = true
loading = 0
},
{ limit, ...options, sort: getSort(sortKey), lookup, associations, total: false }
)
? 1
: 0
})
$: void update(_class, query, _sortKey, sortOrder, lookup, associations, limit, resultOptions)
$: void getResultOptions(options, viewOptionsConfig, viewOptions).then((p) => {
resultOptions = p
})
$: dispatch('content', objects)
const qSlow = createQuery()
$: qSlow.query(
_class,
query,
(result) => {
total = result.total
if (totalQuery === undefined) {
gtotal = total
}
},
{ limit: 1, ...resultOptions, sort: getSort(_sortKey), lookup, associations, total: true }
)
const totalQueryQ = createQuery()
$: if (totalQuery !== undefined) {
totalQueryQ.query(
_class,
totalQuery,
(result) => {
gtotal = result.total === -1 ? 0 : result.total
},
{
...resultOptions,
lookup,
limit: 1,
total: true
}
)
} else {
gtotal = total
}
const showContextMenu = async (ev: MouseEvent, object: Doc, row: number): Promise<void> => {
showMenu(ev, { object, baseMenuClass })
}
function changeSorting (key: string | string[]): void {
if (key === '') {
return
}
userSorting = true
if (key !== _sortKey) {
_sortKey = Array.isArray(key) ? key[0] : key
sortOrder = SortingOrder.Ascending
} else {
sortOrder = sortOrder === SortingOrder.Ascending ? SortingOrder.Descending : SortingOrder.Ascending
}
}
const joinProps = (attribute: AttributeModel, object: Doc, readonly: boolean) => {
const readonlyParams =
readonly || (attribute?.attribute?.readonly ?? false)
? {
readonly: true,
editable: false,
disabled: true
}
: {
readonly: false,
editable: true
}
if (attribute.collectionAttr || attribute.attribute?.type?._class === core.class.TypeIdentifier) {
return { object, ...attribute.props, ...readonlyParams }
}
if (attribute.attribute?.type._class === core.class.EnumOf) {
return { ...attribute.props, type: attribute.attribute.type, ...readonlyParams }
}
return { ...attribute.props, space: object.space, ...readonlyParams }
}
function getValue (attribute: AttributeModel, object: Doc): any {
if (attribute.castRequest) {
return getObjectValue(
attribute.key.substring(attribute.castRequest.length + 1),
client.getHierarchy().as(object, attribute.castRequest)
)
}
if (attribute.key.startsWith(assoc)) {
return object
}
return getObjectValue(attribute.key, object)
}
function onChange (value: any, doc: Doc, key: string, attribute: AnyAttribute): void {
updateAttribute(client, doc, _class, { key, attr: attribute }, value)
}
function getOnChange (doc: Doc, attribute: AttributeModel) {
const attr = attribute.attribute
if (attr === undefined) return
if (attribute.collectionAttr) return
if (attribute.isLookup) return
if (attribute?.attribute?.readonly === true) return
const key = attribute.castRequest ? attribute.key.substring(attribute.castRequest.length + 1) : attribute.key
return (value: any) => {
onChange(value, doc, key, attr)
}
}
let width: number
let isBuildingModel = true
let model: AttributeModel[] | undefined
let modelOptions: BuildModelOptions | undefined
const updateModelOptions = reduceCalls(async function updateModelOptions (
client: TxOperations,
_class: Ref<Class<Doc>>,
config: Array<string | BuildModelKey>,
lookup?: Lookup<Doc>
): Promise<void> {
const newModelOpts = { client, _class, keys: config, lookup }
if (modelOptions == null || !deepEqual(modelOptions, newModelOpts)) {
modelOptions = newModelOpts
await build(modelOptions)
}
})
$: void updateModelOptions(client, _class, config, lookup)
async function build (modelOptions: BuildModelOptions): Promise<void> {
isBuildingModel = true
const res = await buildModel(modelOptions)
model = res
isBuildingModel = false
}
function contextHandler (object: Doc, row: number): (ev: MouseEvent) => void {
return (ev) => {
if (!readonly) {
void showContextMenu(ev, object, row)
}
}
}
let permissionsStore: Readable<PermissionsStore> | undefined = undefined
onMount(async () => {
permissionsStore = await getResource(contact.store.Permissions)
})
function canChangeAttr (
object: Doc,
attr: AnyAttribute | undefined,
permissionsStore: PermissionsStore | undefined
): boolean {
if (permissionsStore === undefined) return true
if (attr === undefined) return true
return canChangeAttribute(attr, object.space as Ref<TypedSpace>, permissionsStore, object._class)
}
interface CellModel {
attribute: AttributeModel
rowSpan: number
object: Doc | undefined
parentObject: Doc | undefined
}
interface RowModel {
cells: CellModel[]
}
interface WalkResult {
rows: RowModel[]
rowCount: number
}
function getView (objects: Doc[], model: AttributeModel[] | undefined): RowModel[] {
if (model === undefined) return []
const res: RowModel[] = []
for (const obj of objects) {
res.push(...walk(obj, model, undefined).rows)
}
return res
}
$: viewModel = getView(objects, model)
function getOwnAttributes (model: AttributeModel[], associationId?: string): AttributeModel[] {
return model.filter((attr) => {
if (associationId) return attr.key.startsWith(associationId) && !attr.key.startsWith(`${associationId}.${assoc}`)
return !attr.key.startsWith(assoc)
})
}
function createLeafResult (doc: Doc | undefined, attrs: AttributeModel[], parent: Doc | undefined): WalkResult {
return {
rowCount: 1,
rows: [
{
cells: attrs.map((a) => ({
attribute: a,
rowSpan: 1,
object: doc,
parentObject: parent
}))
}
]
}
}
function processBranches (
doc: Doc | undefined,
model: AttributeModel[],
associations: string[],
associationId?: string
): Map<string, WalkResult[]> {
const branchesByRelation = new Map<string, WalkResult[]>()
for (const relKey of associations) {
const relationBranches: WalkResult[] = []
if (doc !== undefined) {
const key = associationId ? relKey.substring(associationId.length + 1) : relKey
const relDocs = getObjectValue(key, doc)
if (relDocs !== undefined && relDocs.length > 0) {
for (const child of relDocs) {
relationBranches.push(walk(child as WithLookup<Doc>, model, relKey, doc))
}
} else {
relationBranches.push(walk(undefined, model, relKey, doc))
}
} else {
relationBranches.push(walk(undefined, model, relKey, doc))
}
branchesByRelation.set(relKey, relationBranches)
}
return branchesByRelation
}
function distributeExtraRows (totalRows: number, existingRows: number): number[] {
const diff = totalRows - existingRows
const extraPerRow = new Array(existingRows).fill(0)
if (diff <= 0) return extraPerRow
const base = Math.floor(diff / existingRows)
let remainder = diff % existingRows
for (let i = 0; i < existingRows; i++) {
extraPerRow[i] = base + (remainder > 0 ? 1 : 0)
if (remainder > 0) remainder--
}
return extraPerRow
}
function mergeRows (
doc: Doc | undefined,
ownAttrs: AttributeModel[],
branchesByRelation: Map<string, WalkResult[]>,
parentObject: Doc | undefined
): WalkResult {
let maxRows = 0
const relationRowCounts = new Map<string, number>()
const relationExtraRows = new Map<string, number[]>()
for (const [relKey, branches] of branchesByRelation) {
const count = branches.reduce((s, b) => s + b.rowCount, 0)
relationRowCounts.set(relKey, count)
maxRows = Math.max(maxRows, count)
}
for (const [relKey, branches] of branchesByRelation) {
const count = relationRowCounts.get(relKey) || 0
const extraRows = distributeExtraRows(maxRows, count)
relationExtraRows.set(relKey, extraRows)
}
const rows: RowModel[] = []
for (let rowIdx = 0; rowIdx < maxRows; rowIdx++) {
const cellMap = new Map<string, CellModel>()
if (rowIdx === 0) {
for (const attr of ownAttrs) {
cellMap.set(attr.key, {
attribute: attr,
rowSpan: maxRows,
object: doc,
parentObject
})
}
}
for (const [relKey, branches] of branchesByRelation) {
const allBranchRows = branches.flatMap((b) => b.rows)
const extraRows = relationExtraRows.get(relKey) || []
let accumulatedExtra = 0
let branchRowIdx = -1
for (let i = 0; i < allBranchRows.length; i++) {
const rowSpan = 1 + (extraRows[i] || 0)
if (rowIdx >= accumulatedExtra && rowIdx < accumulatedExtra + rowSpan) {
branchRowIdx = i
break
}
accumulatedExtra += rowSpan
}
if (branchRowIdx >= 0) {
const branchRow = allBranchRows[branchRowIdx]
let correctAccumulated = 0
for (let i = 0; i < branchRowIdx; i++) {
correctAccumulated += 1 + (extraRows[i] || 0)
}
const isFirst = rowIdx === correctAccumulated
if (isFirst) {
for (const cell of branchRow.cells) {
const additionalSpan = extraRows[branchRowIdx] || 0
cellMap.set(cell.attribute.key, {
...cell,
rowSpan: cell.rowSpan + additionalSpan
})
}
}
}
}
rows.push({
cells: Array.from(cellMap.values())
})
}
return {
rowCount: maxRows,
rows
}
}
function getAssociations (model: AttributeModel[], associationId?: string): string[] {
return model
.filter((p) => {
if (associationId) {
return (
p.key.startsWith(`${associationId}.${assoc}`) &&
p.key.split(assoc).filter((p) => p.length).length ===
associationId.split(assoc).filter((p) => p.length).length + 1
)
} else {
return p.key.startsWith(assoc) && p.key.split(assoc).filter((p) => p.length).length === 1
}
})
.map((p) => p.key)
}
function walk (
doc: WithLookup<Doc> | undefined,
model: AttributeModel[],
associationId?: string,
parent?: Doc
): WalkResult {
const associations = getAssociations(model, associationId)
const ownAttrs = getOwnAttributes(model, associationId)
if (associations.length === 0) {
return createLeafResult(doc, ownAttrs, parent)
}
const branchesByRelation = processBranches(doc, model, associations, associationId)
if (branchesByRelation.size === 0) {
return createLeafResult(doc, ownAttrs, parent)
}
const res = mergeRows(doc, ownAttrs, branchesByRelation, parent)
return res
}
function clickHandler (e: MouseEvent, cell: CellModel): void {
if (cell.parentObject === undefined) return
const parts = cell.attribute.key.split('$associations.')
const association = parts.pop()
showPopup(
RelationsSelectorPopup,
{
association,
target: cell.parentObject
},
eventToHTMLElement(e)
)
}
</script>
{#if !model || isBuildingModel}
<Loading />
{:else}
<table
id={tableId}
use:resizeObserver={(element) => {
width = element.clientWidth
}}
class="antiTable"
class:highlightRows
>
{#if !hiddenHeader}
<thead class="scroller-thead">
<tr class="scroller-thead__tr">
{#each model as attribute, i}
<th
class:sortable={attribute.sortingKey}
class:sorted={attribute.sortingKey === _sortKey}
class:align-left={attribute.displayProps?.align === 'left'}
class:align-center={attribute.displayProps?.align === 'center'}
class:align-right={attribute.displayProps?.align === 'right'}
class:first={i === 0}
on:click={() => {
changeSorting(attribute.sortingKey)
}}
>
<div class="antiTable-cells">
{#if attribute.label}
<Label label={attribute.label} />
{/if}
{#if attribute.sortingKey === _sortKey}
<div class="icon">
<IconUpDown size={'small'} descending={sortOrder === SortingOrder.Descending} />
</div>
{/if}
</div>
</th>
{/each}
</tr>
</thead>
{/if}
{#if viewModel.length > 0 || objectsRecieved}
<tbody>
{#each viewModel as r, row}
<tr
class="antiTable-body__row"
use:lazyObserver={(val) => {
if (val && row >= rowLimit) {
rowLimit = row + 10
}
}}
>
{#if row < rowLimit}
{#each r.cells as cell, i}
<td
class:align-left={cell.attribute.displayProps?.align === 'left'}
class:align-center={cell.attribute.displayProps?.align === 'center'}
class:align-right={cell.attribute.displayProps?.align === 'right'}
rowspan={cell.rowSpan}
class:empty={cell.object === undefined}
class:first={i === 0}
class:cursor-pointer={cell.parentObject}
on:click={(e) => {
clickHandler(e, cell)
}}
>
{#if cell.object}
<svelte:component
this={cell.attribute.presenter}
value={getValue(cell.attribute, cell.object)}
onChange={getOnChange(cell.object, cell.attribute)}
label={cell.attribute.label}
attribute={cell.attribute.attribute}
{...joinProps(
cell.attribute,
cell.object,
readonly ||
$restrictionStore.readonly ||
!canChangeAttr(cell.object, cell.attribute.attribute, $permissionsStore)
)}
/>
{/if}
</td>
{/each}
{/if}
</tr>
{/each}
</tbody>
{/if}
</table>
{#if loading > 0}<Loading />{/if}
{/if}
{#if showFooter}
<div class="space" />
<div class="footer" style="width: {width}px;">
<div class="content">
<span class="select-text">
<Label label={view.string.Total} params={{ total: gtotal }} />
</span>
{#if objects.length > 0 && (total !== gtotal || objects.length < total)}
<span class="select-text ml-2">
<Label
label={view.string.Shown}
params={{
total: objects.length === total || total === gtotal ? -1 : total,
len: objects.length
}}
/>
</span>
{/if}
{#if objects.length > 0 && objects.length < total}
<!-- svelte-ignore a11y-click-events-have-key-events -->
<Button
label={ui.string.ShowMore}
kind={'ghost'}
size={'small'}
on:click={() => {
limit = limit + 100
}}
/>
{/if}
</div>
</div>
{/if}
<style lang="scss">
.space {
flex-grow: 1;
height: 100%;
}
td,
th {
border-right: 1px solid var(--theme-trans-color);
&.first {
border-left: 1px solid var(--theme-trans-color);
}
}
.empty {
background-color: var(--theme-link-button-hover);
}
.antiTable-body__row {
border-bottom: 1px solid var(--theme-trans-color);
}
.footer {
width: 100%;
background-color: var(--theme-comp-header-color);
display: flex;
align-items: flex-end;
height: 2.5rem;
z-index: 2;
position: sticky;
bottom: 0;
.content {
display: flex;
align-items: center;
width: max-content;
position: sticky;
left: 0;
height: 2.5rem;
}
}
</style>
@@ -0,0 +1,61 @@
<!--
// 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.
-->
<script lang="ts">
import { Class, Doc, DocumentQuery, FindOptions, Ref } from '@hcengineering/core'
import { ActionContext } from '@hcengineering/presentation'
import { Scroller, tableSP } from '@hcengineering/ui'
import { BuildModelKey, Viewlet, ViewOptionModel, ViewOptions } from '@hcengineering/view'
import { onMount } from 'svelte'
import RelationshipTable from './RelationshipTable.svelte'
export let _class: Ref<Class<Doc>>
export let query: DocumentQuery<Doc>
export let totalQuery: DocumentQuery<Doc> | undefined = undefined
export let options: FindOptions<Doc> | undefined = undefined
export let baseMenuClass: Ref<Class<Doc>> | undefined = undefined
export let config: Array<BuildModelKey | string>
export let viewOptions: ViewOptions | undefined = undefined
export let viewOptionsConfig: ViewOptionModel[] | undefined = undefined
export let viewlet: Viewlet | undefined = undefined
export let readonly = false
onMount(() => {
;(document.activeElement as HTMLElement)?.blur()
})
</script>
<svelte:window />
<ActionContext
context={{
mode: 'browser'
}}
/>
<Scroller fade={tableSP} horizontal>
<RelationshipTable
{_class}
{config}
{options}
{query}
{totalQuery}
{baseMenuClass}
highlightRows
showFooter
{viewOptions}
viewOptionsConfig={viewOptionsConfig ?? viewlet?.viewOptions?.other}
{readonly}
/>
</Scroller>
+3 -1
View File
@@ -116,6 +116,7 @@ import ImageViewer from './components/viewer/ImageViewer.svelte'
import PDFViewer from './components/viewer/PDFViewer.svelte'
import TextViewer from './components/viewer/TextViewer.svelte'
import VideoViewer from './components/viewer/VideoViewer.svelte'
import RelationshipTableBrowser from './components/RelationshipTableBrowser.svelte'
import { blobImageMetadata, blobVideoMetadata } from './blob'
@@ -341,7 +342,8 @@ export default async (): Promise<Resources> => ({
TreeView,
MasterDetailBrowser,
ReadOnlyNotification,
ForbiddenNotification
ForbiddenNotification,
RelationshipTableBrowser
},
popup: {
PositionElementAlignment
+2 -1
View File
@@ -164,7 +164,8 @@ const view = plugin(viewId, {
List: '' as Ref<ViewletDescriptor>,
MasterDetail: '' as Ref<ViewletDescriptor>,
Tree: '' as Ref<ViewletDescriptor>,
Document: '' as Ref<ViewletDescriptor>
Document: '' as Ref<ViewletDescriptor>,
RelationshipTable: '' as Ref<ViewletDescriptor>
},
component: {
ActionsPopup: '' as AnyComponent,