Skip to content

Commit baacc66

Browse files
Fix relationship table refresh (#10494)
* Fix relationship table refresh Signed-off-by: Artem Savchenko <armisav@gmail.com> * Fix warnings Signed-off-by: Artem Savchenko <armisav@gmail.com> --------- Signed-off-by: Artem Savchenko <armisav@gmail.com>
1 parent 7b133e0 commit baacc66

3 files changed

Lines changed: 323 additions & 51 deletions

File tree

Lines changed: 180 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,180 @@
1+
//
2+
// Copyright © 2026 Hardcore Engineering Inc.
3+
//
4+
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
5+
// you may not use this file except in compliance with the License. You may
6+
// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0
7+
//
8+
// Unless required by applicable law or agreed to in writing, software
9+
// distributed under the License is distributed on an "AS IS" BASIS,
10+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
11+
//
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
//
15+
16+
import type { AttributeModel } from '@hcengineering/view'
17+
import type { IntlString } from '@hcengineering/platform'
18+
import type { Class, Client, Doc, Hierarchy, Ref } from '@hcengineering/core'
19+
import { rebuildRelationshipTableViewModel } from '../data/relationshipBuilder'
20+
21+
jest.mock('@hcengineering/view-resources', () => ({
22+
buildConfigAssociation: jest.fn(() => []),
23+
buildConfigLookup: jest.fn(() => ({}))
24+
}))
25+
26+
type DocOverrides = Partial<Doc> & {
27+
$associations?: Record<string, Doc[] | Doc>
28+
title?: string
29+
}
30+
31+
function doc (id: string, overrides: DocOverrides = {}): Doc {
32+
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- test helper with spread overrides
33+
const result = {
34+
_id: id as Ref<Doc>,
35+
_class: 'test:class:Doc' as Ref<Class<Doc>>,
36+
space: 'test:space' as any,
37+
modifiedOn: 0,
38+
modifiedBy: '' as any,
39+
createdOn: 0,
40+
createdBy: '' as any,
41+
...overrides
42+
} as Doc
43+
return result
44+
}
45+
46+
function attr (key: string, label: string = key): AttributeModel {
47+
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- minimal AttributeModel for tests
48+
const result = {
49+
key,
50+
label: label as IntlString,
51+
_class: '' as Ref<Class<Doc>>,
52+
sortingKey: '',
53+
collectionAttr: false,
54+
isLookup: false
55+
} as AttributeModel
56+
return result
57+
}
58+
59+
describe('relationshipBuilder', () => {
60+
const hierarchy: Hierarchy = {} as any
61+
const client: Client = {} as any
62+
const cardClass = 'test:class:Card' as Ref<Class<Doc>>
63+
64+
describe('rebuildRelationshipTableViewModel', () => {
65+
it('returns one row when doc has no associations (single expanded row with undefined children)', async () => {
66+
const root = doc('root-1')
67+
const model: AttributeModel[] = [
68+
attr(''),
69+
attr('$associations.assoc1_b', 'Level1'),
70+
attr('$associations.assoc1_b.$associations.assoc2_b', 'Level2')
71+
]
72+
const viewModel = await rebuildRelationshipTableViewModel([root], model, cardClass, hierarchy, client)
73+
expect(viewModel).toHaveLength(1)
74+
expect(viewModel[0].cells).toHaveLength(3)
75+
expect(viewModel[0].cells[0].object).toBe(root)
76+
expect(viewModel[0].cells[0].rowSpan).toBe(1)
77+
expect(viewModel[0].cells[1].object).toBeUndefined()
78+
expect(viewModel[0].cells[1].parentObject).toBe(root)
79+
expect(viewModel[0].cells[2].object).toBeUndefined()
80+
expect(viewModel[0].cells[2].parentObject).toBeUndefined()
81+
})
82+
83+
it('builds one row per first-level child for single-level association', async () => {
84+
const b1 = doc('b1', { title: 'B1' })
85+
const b2 = doc('b2', { title: 'B2' })
86+
const root = doc('root-1', {
87+
$associations: { assoc1_b: [b1, b2] }
88+
})
89+
const model: AttributeModel[] = [attr(''), attr('$associations.assoc1_b', 'Level1')]
90+
const viewModel = await rebuildRelationshipTableViewModel([root], model, cardClass, hierarchy, client)
91+
expect(viewModel).toHaveLength(2)
92+
expect(viewModel[0].cells[0].object).toBe(root)
93+
expect(viewModel[0].cells[0].rowSpan).toBe(2)
94+
expect(viewModel[0].cells[1].object).toBe(b1)
95+
expect(viewModel[0].cells[1].parentObject).toBe(root)
96+
expect(viewModel[0].cells[1].rowSpan).toBe(1)
97+
expect(viewModel[1].cells[0].rowSpan).toBe(0)
98+
expect(viewModel[1].cells[0].object).toBeUndefined()
99+
expect(viewModel[1].cells[1].object).toBe(b2)
100+
expect(viewModel[1].cells[1].parentObject).toBe(root)
101+
})
102+
103+
it('builds rows with correct nested objects for two-level association (A -> B -> C)', async () => {
104+
const c1 = doc('c1', { title: 'C1' })
105+
const c2 = doc('c2', { title: 'C2' })
106+
const c3 = doc('c3', { title: 'C3' })
107+
const b1 = doc('b1', { title: 'B1', $associations: { assoc2_b: [c1, c2] } })
108+
const b2 = doc('b2', { title: 'B2', $associations: { assoc2_b: [c3] } })
109+
const root = doc('root-1', {
110+
$associations: { assoc1_b: [b1, b2] }
111+
})
112+
const model: AttributeModel[] = [
113+
attr(''),
114+
attr('$associations.assoc1_b', 'Level1'),
115+
attr('$associations.assoc1_b.$associations.assoc2_b', 'Level2')
116+
]
117+
const viewModel = await rebuildRelationshipTableViewModel([root], model, cardClass, hierarchy, client)
118+
expect(viewModel).toHaveLength(3)
119+
expect(viewModel[0].cells[0].object).toBe(root)
120+
expect(viewModel[0].cells[0].rowSpan).toBe(3)
121+
expect(viewModel[0].cells[1].object).toBe(b1)
122+
expect(viewModel[0].cells[1].parentObject).toBe(root)
123+
expect(viewModel[0].cells[1].rowSpan).toBe(2)
124+
expect(viewModel[0].cells[2].object).toBe(c1)
125+
expect(viewModel[0].cells[2].parentObject).toBe(b1)
126+
expect(viewModel[0].cells[2].rowSpan).toBe(1)
127+
expect(viewModel[1].cells[2].object).toBe(c2)
128+
expect(viewModel[1].cells[2].parentObject).toBe(b1)
129+
expect(viewModel[2].cells[1].object).toBe(b2)
130+
expect(viewModel[2].cells[1].parentObject).toBe(root)
131+
expect(viewModel[2].cells[1].rowSpan).toBe(1)
132+
expect(viewModel[2].cells[2].object).toBe(c3)
133+
expect(viewModel[2].cells[2].parentObject).toBe(b2)
134+
})
135+
136+
it('emits one row when first-level child has no nested children', async () => {
137+
const b1 = doc('b1', { title: 'B1' })
138+
const root = doc('root-1', { $associations: { assoc1_b: [b1] } })
139+
const model: AttributeModel[] = [
140+
attr(''),
141+
attr('$associations.assoc1_b', 'Level1'),
142+
attr('$associations.assoc1_b.$associations.assoc2_b', 'Level2')
143+
]
144+
const viewModel = await rebuildRelationshipTableViewModel([root], model, cardClass, hierarchy, client)
145+
expect(viewModel).toHaveLength(1)
146+
expect(viewModel[0].cells[0].object).toBe(root)
147+
expect(viewModel[0].cells[1].object).toBe(b1)
148+
expect(viewModel[0].cells[1].parentObject).toBe(root)
149+
expect(viewModel[0].cells[2].object).toBeUndefined()
150+
expect(viewModel[0].cells[2].parentObject).toBe(b1)
151+
})
152+
153+
it('handles multiple root docs each with their own expanded rows', async () => {
154+
const b1 = doc('b1')
155+
const b2 = doc('b2')
156+
const root1 = doc('root-1', { $associations: { assoc1_b: [b1] } })
157+
const root2 = doc('root-2', { $associations: { assoc1_b: [b2] } })
158+
const model: AttributeModel[] = [attr(''), attr('$associations.assoc1_b', 'Level1')]
159+
const viewModel = await rebuildRelationshipTableViewModel([root1, root2], model, cardClass, hierarchy, client)
160+
expect(viewModel).toHaveLength(2)
161+
expect(viewModel[0].cells[0].object).toBe(root1)
162+
expect(viewModel[0].cells[0].rowSpan).toBe(1)
163+
expect(viewModel[0].cells[1].object).toBe(b1)
164+
expect(viewModel[1].cells[0].object).toBe(root2)
165+
expect(viewModel[1].cells[1].object).toBe(b2)
166+
})
167+
168+
it('preserves model order for non-association attributes', async () => {
169+
const b1 = doc('b1')
170+
const root = doc('root-1', { $associations: { assoc1_b: [b1] } })
171+
const model: AttributeModel[] = [attr(''), attr('$associations.assoc1_b', 'Assoc'), attr('title', 'Title')]
172+
const viewModel = await rebuildRelationshipTableViewModel([root], model, cardClass, hierarchy, client)
173+
expect(viewModel).toHaveLength(1)
174+
expect(viewModel[0].cells).toHaveLength(3)
175+
expect(viewModel[0].cells[2].attribute.key).toBe('title')
176+
expect(viewModel[0].cells[2].object).toBe(root)
177+
expect(viewModel[0].cells[2].rowSpan).toBe(1)
178+
})
179+
})
180+
})

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

Lines changed: 118 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -18,9 +18,86 @@ import type { AttributeModel } from '@hcengineering/view'
1818
import { buildConfigAssociation, buildConfigLookup } from '@hcengineering/view-resources'
1919
import type { RelationshipCellModel, RelationshipRowModel } from '../types'
2020

21+
/**
22+
* Parse association attribute key into path of association keys.
23+
* e.g. "$associations.X_b" -> ["X_b"], "$associations.X_b.$associations.Y_b" -> ["X_b", "Y_b"]
24+
*/
25+
function parseAssociationKeyPath (key: string): string[] {
26+
if (!key.startsWith('$associations')) return []
27+
const parts = key.split('$associations.').filter((p) => p.length > 0)
28+
return parts.map((p) => (p.endsWith('.') ? p.slice(0, -1) : p).trim()).filter(Boolean)
29+
}
30+
31+
/**
32+
* Get doc at given depth from parent's $associations using path[depth].
33+
* depth 0 = parent itself, depth 1 = parent.$associations[path[0]][index], etc.
34+
*/
35+
function getAssocChildren (parent: any, pathKey: string): Doc[] | undefined {
36+
const arr = parent?.$associations?.[pathKey]
37+
if (Array.isArray(arr)) return arr
38+
if (arr !== undefined && arr !== null) return [arr as Doc]
39+
return undefined
40+
}
41+
42+
/**
43+
* Expand rows for one root: one row per (level0, level1, ..., levelN) with correct nesting.
44+
* Each row is [root, child1, child2, ...] where child1 from root.$associations[path[0]], child2 from child1.$associations[path[1]], etc.
45+
*/
46+
function expandRowsForRoot (
47+
root: Doc,
48+
associationAttrs: AttributeModel[],
49+
pathPerAttr: string[][]
50+
): Array<{ docsByLevel: (Doc | undefined)[] }> {
51+
const rows: Array<{ docsByLevel: (Doc | undefined)[] }> = []
52+
53+
function expand (parent: Doc | undefined, level: number, docsSoFar: (Doc | undefined)[]): void {
54+
if (level >= associationAttrs.length) {
55+
rows.push({ docsByLevel: docsSoFar })
56+
return
57+
}
58+
const path = pathPerAttr[level]
59+
const key = path[level]
60+
const children = parent !== undefined ? getAssocChildren(parent as any, key) : undefined
61+
const list = children !== undefined && children !== null && children.length > 0 ? children : [undefined]
62+
for (const doc of list) {
63+
expand(doc ?? undefined, level + 1, [...docsSoFar, doc])
64+
}
65+
}
66+
67+
const firstPath = pathPerAttr[0]
68+
const firstKey = firstPath[0]
69+
const firstChildren = getAssocChildren(root as any, firstKey)
70+
const firstList =
71+
firstChildren !== undefined && firstChildren !== null && firstChildren.length > 0 ? firstChildren : [undefined]
72+
for (const doc of firstList) {
73+
expand(doc ?? undefined, 1, [root, doc])
74+
}
75+
return rows
76+
}
77+
78+
/**
79+
* Compute rowSpan for a given level at each row index: how many consecutive rows share the same doc at this level.
80+
*/
81+
function computeRowSpans (rows: Array<{ docsByLevel: (Doc | undefined)[] }>, level: number): number[] {
82+
const spans: number[] = []
83+
for (let i = 0; i < rows.length; i++) {
84+
const doc = rows[i].docsByLevel[level]
85+
const docId = doc?._id
86+
let span = 1
87+
for (let j = i + 1; j < rows.length; j++) {
88+
if (rows[j].docsByLevel[level]?._id === docId) span++
89+
else break
90+
}
91+
spans.push(span)
92+
}
93+
return spans
94+
}
95+
2196
/**
2297
* Rebuild relationship table viewModel from documents and metadata
23-
* Recreates the hierarchical structure with row spans and separate rows for each associated child
98+
* Recreates the hierarchical structure with row spans and separate rows for each associated child.
99+
* Supports multi-level associations (A -> B -> C): nested keys like $associations.X_b.$associations.Y_b
100+
* are resolved from the correct parent doc per level.
24101
*/
25102
export async function rebuildRelationshipTableViewModel (
26103
docs: Doc[],
@@ -36,33 +113,21 @@ export async function rebuildRelationshipTableViewModel (
36113
const lookup = buildConfigLookup(hierarchy, cardClass, config)
37114

38115
const associationAttrs = model.filter((attr) => attr.key.startsWith('$associations'))
116+
const pathPerAttr = associationAttrs.map((attr) => parseAssociationKeyPath(attr.key))
39117

40118
let docsWithAssociations: Doc[] = docs
41119
if (associations !== undefined && associations.length > 0) {
42120
const docIds = docs.map((d) => d._id)
43121
const query = { _id: { $in: docIds } }
44122
docsWithAssociations = await client.findAll(cardClass, query, { lookup, associations })
45-
// Preserve order of input docs (findAll does not guarantee order)
46123
const idToIndex = new Map(docs.map((d, i) => [d._id, i]))
47124
docsWithAssociations.sort((a, b) => (idToIndex.get(a._id) ?? Infinity) - (idToIndex.get(b._id) ?? Infinity))
48125
}
49126

50127
for (const parentDoc of docsWithAssociations) {
51-
const docWithAssoc = parentDoc as any
52-
const parentAssociations = docWithAssoc.$associations ?? {}
53-
54-
let maxChildren = 0
55-
for (const assocAttr of associationAttrs) {
56-
const assocKey = assocAttr.key.replace('$associations.', '')
57-
const children = parentAssociations[assocKey]
58-
if (Array.isArray(children)) {
59-
maxChildren = Math.max(maxChildren, children.length)
60-
} else if (children !== undefined && children !== null) {
61-
maxChildren = Math.max(maxChildren, 1)
62-
}
63-
}
128+
const expandedRows = expandRowsForRoot(parentDoc, associationAttrs, pathPerAttr)
64129

65-
if (maxChildren === 0) {
130+
if (expandedRows.length === 0) {
66131
const cells: RelationshipCellModel[] = []
67132
for (const attr of model) {
68133
const isAssociationKey = attr.key.startsWith('$associations')
@@ -77,45 +142,59 @@ export async function rebuildRelationshipTableViewModel (
77142
continue
78143
}
79144

80-
for (let childIndex = 0; childIndex < maxChildren; childIndex++) {
145+
const rowSpanByLevel: number[][] = []
146+
for (let level = 0; level <= associationAttrs.length; level++) {
147+
rowSpanByLevel.push(computeRowSpans(expandedRows, level))
148+
}
149+
150+
for (let rowIdx = 0; rowIdx < expandedRows.length; rowIdx++) {
151+
const rowData = expandedRows[rowIdx]
81152
const cells: RelationshipCellModel[] = []
82153

83154
for (const attr of model) {
84155
const isAssociationKey = attr.key.startsWith('$associations')
85156

86157
if (attr.key === '') {
158+
const span = rowSpanByLevel[0][rowIdx]
159+
const isFirstInSpan =
160+
rowIdx === 0 || expandedRows[rowIdx - 1].docsByLevel[0]?._id !== rowData.docsByLevel[0]?._id
87161
cells.push({
88162
attribute: attr,
89-
rowSpan: maxChildren,
90-
object: parentDoc,
163+
rowSpan: isFirstInSpan ? span : 0,
164+
object: isFirstInSpan ? parentDoc : undefined,
91165
parentObject: undefined
92166
})
93-
} else if (isAssociationKey) {
94-
const assocKey = attr.key.replace('$associations.', '')
95-
const children = parentAssociations[assocKey]
96-
let childDoc: Doc | undefined
97-
if (Array.isArray(children) && children.length > childIndex) {
98-
childDoc = children[childIndex] as Doc
99-
} else if (!Array.isArray(children) && children !== undefined && children !== null && childIndex === 0) {
100-
childDoc = children as Doc
101-
}
167+
continue
168+
}
102169

170+
if (isAssociationKey) {
171+
const assocIdx = associationAttrs.indexOf(attr)
172+
if (assocIdx < 0) {
173+
cells.push({ attribute: attr, rowSpan: 1, object: undefined, parentObject: undefined })
174+
continue
175+
}
176+
const level = assocIdx + 1
177+
const docAtLevel = rowData.docsByLevel[level]
178+
const parentAtLevel = rowData.docsByLevel[level - 1]
179+
const span = rowSpanByLevel[level][rowIdx]
180+
const isFirstInSpan =
181+
rowIdx === 0 || expandedRows[rowIdx - 1].docsByLevel[level]?._id !== rowData.docsByLevel[level]?._id
103182
cells.push({
104183
attribute: attr,
105-
rowSpan: 1,
106-
object: childDoc,
107-
parentObject: parentDoc
108-
})
109-
} else {
110-
cells.push({
111-
attribute: attr,
112-
rowSpan: 1,
113-
object: childIndex === 0 ? parentDoc : undefined,
114-
parentObject: undefined
184+
rowSpan: isFirstInSpan ? span : 0,
185+
object: isFirstInSpan ? docAtLevel : undefined,
186+
parentObject: isFirstInSpan ? parentAtLevel : undefined
115187
})
188+
continue
116189
}
117-
}
118190

191+
cells.push({
192+
attribute: attr,
193+
rowSpan: 1,
194+
object: rowIdx === 0 ? parentDoc : undefined,
195+
parentObject: undefined
196+
})
197+
}
119198
viewModel.push({ cells })
120199
}
121200
}

0 commit comments

Comments
 (0)