mirror of
https://github.com/hcengineering/platform.git
synced 2026-09-22 17:45:02 +02:00
Merge branch 'develop' of https://github.com/hcengineering/platform into staging-new
Signed-off-by: Artem Savchenko <armisav@gmail.com>
This commit is contained in:
@@ -519,6 +519,7 @@ services:
|
||||
- REGION=cockroach
|
||||
- QUEUE_CONFIG=${QUEUE_CONFIG}
|
||||
- OTEL_EXPORTER_OTLP_ENDPOINT=http://jaeger:4318/v1/traces
|
||||
- SECURE=true
|
||||
restart: unless-stopped
|
||||
hulylake:
|
||||
image: hardcoreeng/hulylake
|
||||
|
||||
@@ -17,6 +17,11 @@ import { concatLink } from '@hcengineering/core'
|
||||
import { FileStorage, FileStorageUploadOptions } from '../types'
|
||||
import { uploadMultipart, uploadXhr } from '../upload'
|
||||
|
||||
const getPathname = (url: string): string => {
|
||||
const base = window?.location?.href !== undefined ? window.location.href : 'http://localhost'
|
||||
return new URL(url, base).pathname
|
||||
}
|
||||
|
||||
/** @public */
|
||||
export class DatalakeStorage implements FileStorage {
|
||||
constructor (private readonly baseUrl: string) {}
|
||||
@@ -26,6 +31,11 @@ export class DatalakeStorage implements FileStorage {
|
||||
return concatLink(this.baseUrl, path)
|
||||
}
|
||||
|
||||
getCookiePath (workspace: string): string {
|
||||
const url = concatLink(this.baseUrl, `/blob/${workspace}`)
|
||||
return getPathname(url)
|
||||
}
|
||||
|
||||
async getFileMeta (token: string, workspace: string, file: string): Promise<Record<string, any>> {
|
||||
const url = concatLink(this.baseUrl, `/meta/${encodeURIComponent(workspace)}/${encodeURIComponent(file)}`)
|
||||
try {
|
||||
|
||||
@@ -17,6 +17,11 @@ import { concatLink } from '@hcengineering/core'
|
||||
import { FileStorage, FileStorageUploadOptions } from '../types'
|
||||
import { uploadXhr } from '../upload'
|
||||
|
||||
const getPathname = (url: string): string => {
|
||||
const base = window?.location?.href !== undefined ? window.location.href : 'http://localhost'
|
||||
return new URL(url, base).pathname
|
||||
}
|
||||
|
||||
/** @public */
|
||||
export class FrontStorage implements FileStorage {
|
||||
constructor (private readonly baseUrl: string) {}
|
||||
@@ -26,6 +31,11 @@ export class FrontStorage implements FileStorage {
|
||||
return concatLink(this.baseUrl, path)
|
||||
}
|
||||
|
||||
getCookiePath (workspace: string): string {
|
||||
const url = concatLink(this.baseUrl, `/${workspace}`)
|
||||
return getPathname(url)
|
||||
}
|
||||
|
||||
async getFileMeta (token: string, workspace: string, file: string): Promise<Record<string, any>> {
|
||||
return {}
|
||||
}
|
||||
|
||||
@@ -17,6 +17,11 @@ import { concatLink } from '@hcengineering/core'
|
||||
import { FileStorage, FileStorageUploadOptions } from '../types'
|
||||
import { uploadXhr } from '../upload'
|
||||
|
||||
const getPathname = (url: string): string => {
|
||||
const base = window?.location?.href !== undefined ? window.location.href : 'http://localhost'
|
||||
return new URL(url, base).pathname
|
||||
}
|
||||
|
||||
/** @public */
|
||||
export class HulylakeStorage implements FileStorage {
|
||||
constructor (private readonly baseUrl: string) {}
|
||||
@@ -26,6 +31,11 @@ export class HulylakeStorage implements FileStorage {
|
||||
return concatLink(this.baseUrl, path)
|
||||
}
|
||||
|
||||
getCookiePath (workspace: string): string {
|
||||
const url = concatLink(this.baseUrl, `/api/${workspace}`)
|
||||
return getPathname(url)
|
||||
}
|
||||
|
||||
async getFileMeta (token: string, workspace: string, file: string): Promise<Record<string, any>> {
|
||||
return {}
|
||||
}
|
||||
|
||||
@@ -29,6 +29,7 @@ export interface FileStorageUploadOptions {
|
||||
/** @public */
|
||||
export interface FileStorage {
|
||||
getFileUrl: (workspace: string, file: string, filename?: string) => string
|
||||
getCookiePath: (workspace: string) => string
|
||||
getFileMeta: (token: string, workspace: string, file: string) => Promise<Record<string, any>>
|
||||
uploadFile: (
|
||||
token: string,
|
||||
|
||||
@@ -891,15 +891,19 @@ export function isSpaceClass (_class: Ref<Class<Doc>>): boolean {
|
||||
}
|
||||
|
||||
export function setPresentationCookie (token: string, workspaceUuid: WorkspaceUuid): void {
|
||||
function setToken (path: string): void {
|
||||
const res =
|
||||
encodeURIComponent(plugin.metadata.Token.replaceAll(':', '-')) +
|
||||
'=' +
|
||||
encodeURIComponent(token) +
|
||||
`; path=${path}`
|
||||
document.cookie = res
|
||||
const cookieName = encodeURIComponent(plugin.metadata.Token.replaceAll(':', '-'))
|
||||
const cookieValue = encodeURIComponent(token)
|
||||
|
||||
const storage = getMetadata(plugin.metadata.FileStorage)
|
||||
if (storage !== undefined) {
|
||||
let path = `/files/${workspaceUuid}`
|
||||
try {
|
||||
path = storage.getCookiePath(workspaceUuid)
|
||||
} catch {}
|
||||
|
||||
const normalized = path.startsWith('/') ? path : `/${path}`
|
||||
document.cookie = `${cookieName}=${cookieValue}; path=${normalized}`
|
||||
}
|
||||
setToken('/files/' + workspaceUuid)
|
||||
}
|
||||
|
||||
export const upgradeDownloadProgress = writable(-1)
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
//
|
||||
// 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.
|
||||
//
|
||||
|
||||
import type { AttributeModel } from '@hcengineering/view'
|
||||
import type { IntlString } from '@hcengineering/platform'
|
||||
import type { Class, Client, Doc, Hierarchy, Ref } from '@hcengineering/core'
|
||||
import { rebuildRelationshipTableViewModel } from '../data/relationshipBuilder'
|
||||
|
||||
jest.mock('@hcengineering/view-resources', () => ({
|
||||
buildConfigAssociation: jest.fn(() => []),
|
||||
buildConfigLookup: jest.fn(() => ({}))
|
||||
}))
|
||||
|
||||
type DocOverrides = Partial<Doc> & {
|
||||
$associations?: Record<string, Doc[] | Doc>
|
||||
title?: string
|
||||
}
|
||||
|
||||
function doc (id: string, overrides: DocOverrides = {}): Doc {
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- test helper with spread overrides
|
||||
const result = {
|
||||
_id: id as Ref<Doc>,
|
||||
_class: 'test:class:Doc' as Ref<Class<Doc>>,
|
||||
space: 'test:space' as any,
|
||||
modifiedOn: 0,
|
||||
modifiedBy: '' as any,
|
||||
createdOn: 0,
|
||||
createdBy: '' as any,
|
||||
...overrides
|
||||
} as Doc
|
||||
return result
|
||||
}
|
||||
|
||||
function attr (key: string, label: string = key): AttributeModel {
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- minimal AttributeModel for tests
|
||||
const result = {
|
||||
key,
|
||||
label: label as IntlString,
|
||||
_class: '' as Ref<Class<Doc>>,
|
||||
sortingKey: '',
|
||||
collectionAttr: false,
|
||||
isLookup: false
|
||||
} as AttributeModel
|
||||
return result
|
||||
}
|
||||
|
||||
describe('relationshipBuilder', () => {
|
||||
const hierarchy: Hierarchy = {} as any
|
||||
const client: Client = {} as any
|
||||
const cardClass = 'test:class:Card' as Ref<Class<Doc>>
|
||||
|
||||
describe('rebuildRelationshipTableViewModel', () => {
|
||||
it('returns one row when doc has no associations (single expanded row with undefined children)', async () => {
|
||||
const root = doc('root-1')
|
||||
const model: AttributeModel[] = [
|
||||
attr(''),
|
||||
attr('$associations.assoc1_b', 'Level1'),
|
||||
attr('$associations.assoc1_b.$associations.assoc2_b', 'Level2')
|
||||
]
|
||||
const viewModel = await rebuildRelationshipTableViewModel([root], model, cardClass, hierarchy, client)
|
||||
expect(viewModel).toHaveLength(1)
|
||||
expect(viewModel[0].cells).toHaveLength(3)
|
||||
expect(viewModel[0].cells[0].object).toBe(root)
|
||||
expect(viewModel[0].cells[0].rowSpan).toBe(1)
|
||||
expect(viewModel[0].cells[1].object).toBeUndefined()
|
||||
expect(viewModel[0].cells[1].parentObject).toBe(root)
|
||||
expect(viewModel[0].cells[2].object).toBeUndefined()
|
||||
expect(viewModel[0].cells[2].parentObject).toBeUndefined()
|
||||
})
|
||||
|
||||
it('builds one row per first-level child for single-level association', async () => {
|
||||
const b1 = doc('b1', { title: 'B1' })
|
||||
const b2 = doc('b2', { title: 'B2' })
|
||||
const root = doc('root-1', {
|
||||
$associations: { assoc1_b: [b1, b2] }
|
||||
})
|
||||
const model: AttributeModel[] = [attr(''), attr('$associations.assoc1_b', 'Level1')]
|
||||
const viewModel = await rebuildRelationshipTableViewModel([root], model, cardClass, hierarchy, client)
|
||||
expect(viewModel).toHaveLength(2)
|
||||
expect(viewModel[0].cells[0].object).toBe(root)
|
||||
expect(viewModel[0].cells[0].rowSpan).toBe(2)
|
||||
expect(viewModel[0].cells[1].object).toBe(b1)
|
||||
expect(viewModel[0].cells[1].parentObject).toBe(root)
|
||||
expect(viewModel[0].cells[1].rowSpan).toBe(1)
|
||||
expect(viewModel[1].cells[0].rowSpan).toBe(0)
|
||||
expect(viewModel[1].cells[0].object).toBeUndefined()
|
||||
expect(viewModel[1].cells[1].object).toBe(b2)
|
||||
expect(viewModel[1].cells[1].parentObject).toBe(root)
|
||||
})
|
||||
|
||||
it('builds rows with correct nested objects for two-level association (A -> B -> C)', async () => {
|
||||
const c1 = doc('c1', { title: 'C1' })
|
||||
const c2 = doc('c2', { title: 'C2' })
|
||||
const c3 = doc('c3', { title: 'C3' })
|
||||
const b1 = doc('b1', { title: 'B1', $associations: { assoc2_b: [c1, c2] } })
|
||||
const b2 = doc('b2', { title: 'B2', $associations: { assoc2_b: [c3] } })
|
||||
const root = doc('root-1', {
|
||||
$associations: { assoc1_b: [b1, b2] }
|
||||
})
|
||||
const model: AttributeModel[] = [
|
||||
attr(''),
|
||||
attr('$associations.assoc1_b', 'Level1'),
|
||||
attr('$associations.assoc1_b.$associations.assoc2_b', 'Level2')
|
||||
]
|
||||
const viewModel = await rebuildRelationshipTableViewModel([root], model, cardClass, hierarchy, client)
|
||||
expect(viewModel).toHaveLength(3)
|
||||
expect(viewModel[0].cells[0].object).toBe(root)
|
||||
expect(viewModel[0].cells[0].rowSpan).toBe(3)
|
||||
expect(viewModel[0].cells[1].object).toBe(b1)
|
||||
expect(viewModel[0].cells[1].parentObject).toBe(root)
|
||||
expect(viewModel[0].cells[1].rowSpan).toBe(2)
|
||||
expect(viewModel[0].cells[2].object).toBe(c1)
|
||||
expect(viewModel[0].cells[2].parentObject).toBe(b1)
|
||||
expect(viewModel[0].cells[2].rowSpan).toBe(1)
|
||||
expect(viewModel[1].cells[2].object).toBe(c2)
|
||||
expect(viewModel[1].cells[2].parentObject).toBe(b1)
|
||||
expect(viewModel[2].cells[1].object).toBe(b2)
|
||||
expect(viewModel[2].cells[1].parentObject).toBe(root)
|
||||
expect(viewModel[2].cells[1].rowSpan).toBe(1)
|
||||
expect(viewModel[2].cells[2].object).toBe(c3)
|
||||
expect(viewModel[2].cells[2].parentObject).toBe(b2)
|
||||
})
|
||||
|
||||
it('emits one row when first-level child has no nested children', async () => {
|
||||
const b1 = doc('b1', { title: 'B1' })
|
||||
const root = doc('root-1', { $associations: { assoc1_b: [b1] } })
|
||||
const model: AttributeModel[] = [
|
||||
attr(''),
|
||||
attr('$associations.assoc1_b', 'Level1'),
|
||||
attr('$associations.assoc1_b.$associations.assoc2_b', 'Level2')
|
||||
]
|
||||
const viewModel = await rebuildRelationshipTableViewModel([root], model, cardClass, hierarchy, client)
|
||||
expect(viewModel).toHaveLength(1)
|
||||
expect(viewModel[0].cells[0].object).toBe(root)
|
||||
expect(viewModel[0].cells[1].object).toBe(b1)
|
||||
expect(viewModel[0].cells[1].parentObject).toBe(root)
|
||||
expect(viewModel[0].cells[2].object).toBeUndefined()
|
||||
expect(viewModel[0].cells[2].parentObject).toBe(b1)
|
||||
})
|
||||
|
||||
it('handles multiple root docs each with their own expanded rows', async () => {
|
||||
const b1 = doc('b1')
|
||||
const b2 = doc('b2')
|
||||
const root1 = doc('root-1', { $associations: { assoc1_b: [b1] } })
|
||||
const root2 = doc('root-2', { $associations: { assoc1_b: [b2] } })
|
||||
const model: AttributeModel[] = [attr(''), attr('$associations.assoc1_b', 'Level1')]
|
||||
const viewModel = await rebuildRelationshipTableViewModel([root1, root2], model, cardClass, hierarchy, client)
|
||||
expect(viewModel).toHaveLength(2)
|
||||
expect(viewModel[0].cells[0].object).toBe(root1)
|
||||
expect(viewModel[0].cells[0].rowSpan).toBe(1)
|
||||
expect(viewModel[0].cells[1].object).toBe(b1)
|
||||
expect(viewModel[1].cells[0].object).toBe(root2)
|
||||
expect(viewModel[1].cells[1].object).toBe(b2)
|
||||
})
|
||||
|
||||
it('preserves model order for non-association attributes', async () => {
|
||||
const b1 = doc('b1')
|
||||
const root = doc('root-1', { $associations: { assoc1_b: [b1] } })
|
||||
const model: AttributeModel[] = [attr(''), attr('$associations.assoc1_b', 'Assoc'), attr('title', 'Title')]
|
||||
const viewModel = await rebuildRelationshipTableViewModel([root], model, cardClass, hierarchy, client)
|
||||
expect(viewModel).toHaveLength(1)
|
||||
expect(viewModel[0].cells).toHaveLength(3)
|
||||
expect(viewModel[0].cells[2].attribute.key).toBe('title')
|
||||
expect(viewModel[0].cells[2].object).toBe(root)
|
||||
expect(viewModel[0].cells[2].rowSpan).toBe(1)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -18,9 +18,86 @@ import type { AttributeModel } from '@hcengineering/view'
|
||||
import { buildConfigAssociation, buildConfigLookup } from '@hcengineering/view-resources'
|
||||
import type { RelationshipCellModel, RelationshipRowModel } from '../types'
|
||||
|
||||
/**
|
||||
* Parse association attribute key into path of association keys.
|
||||
* e.g. "$associations.X_b" -> ["X_b"], "$associations.X_b.$associations.Y_b" -> ["X_b", "Y_b"]
|
||||
*/
|
||||
function parseAssociationKeyPath (key: string): string[] {
|
||||
if (!key.startsWith('$associations')) return []
|
||||
const parts = key.split('$associations.').filter((p) => p.length > 0)
|
||||
return parts.map((p) => (p.endsWith('.') ? p.slice(0, -1) : p).trim()).filter(Boolean)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get doc at given depth from parent's $associations using path[depth].
|
||||
* depth 0 = parent itself, depth 1 = parent.$associations[path[0]][index], etc.
|
||||
*/
|
||||
function getAssocChildren (parent: any, pathKey: string): Doc[] | undefined {
|
||||
const arr = parent?.$associations?.[pathKey]
|
||||
if (Array.isArray(arr)) return arr
|
||||
if (arr !== undefined && arr !== null) return [arr as Doc]
|
||||
return undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Expand rows for one root: one row per (level0, level1, ..., levelN) with correct nesting.
|
||||
* Each row is [root, child1, child2, ...] where child1 from root.$associations[path[0]], child2 from child1.$associations[path[1]], etc.
|
||||
*/
|
||||
function expandRowsForRoot (
|
||||
root: Doc,
|
||||
associationAttrs: AttributeModel[],
|
||||
pathPerAttr: string[][]
|
||||
): Array<{ docsByLevel: (Doc | undefined)[] }> {
|
||||
const rows: Array<{ docsByLevel: (Doc | undefined)[] }> = []
|
||||
|
||||
function expand (parent: Doc | undefined, level: number, docsSoFar: (Doc | undefined)[]): void {
|
||||
if (level >= associationAttrs.length) {
|
||||
rows.push({ docsByLevel: docsSoFar })
|
||||
return
|
||||
}
|
||||
const path = pathPerAttr[level]
|
||||
const key = path[level]
|
||||
const children = parent !== undefined ? getAssocChildren(parent as any, key) : undefined
|
||||
const list = children !== undefined && children !== null && children.length > 0 ? children : [undefined]
|
||||
for (const doc of list) {
|
||||
expand(doc ?? undefined, level + 1, [...docsSoFar, doc])
|
||||
}
|
||||
}
|
||||
|
||||
const firstPath = pathPerAttr[0]
|
||||
const firstKey = firstPath[0]
|
||||
const firstChildren = getAssocChildren(root as any, firstKey)
|
||||
const firstList =
|
||||
firstChildren !== undefined && firstChildren !== null && firstChildren.length > 0 ? firstChildren : [undefined]
|
||||
for (const doc of firstList) {
|
||||
expand(doc ?? undefined, 1, [root, doc])
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute rowSpan for a given level at each row index: how many consecutive rows share the same doc at this level.
|
||||
*/
|
||||
function computeRowSpans (rows: Array<{ docsByLevel: (Doc | undefined)[] }>, level: number): number[] {
|
||||
const spans: number[] = []
|
||||
for (let i = 0; i < rows.length; i++) {
|
||||
const doc = rows[i].docsByLevel[level]
|
||||
const docId = doc?._id
|
||||
let span = 1
|
||||
for (let j = i + 1; j < rows.length; j++) {
|
||||
if (rows[j].docsByLevel[level]?._id === docId) span++
|
||||
else break
|
||||
}
|
||||
spans.push(span)
|
||||
}
|
||||
return spans
|
||||
}
|
||||
|
||||
/**
|
||||
* Rebuild relationship table viewModel from documents and metadata
|
||||
* Recreates the hierarchical structure with row spans and separate rows for each associated child
|
||||
* Recreates the hierarchical structure with row spans and separate rows for each associated child.
|
||||
* Supports multi-level associations (A -> B -> C): nested keys like $associations.X_b.$associations.Y_b
|
||||
* are resolved from the correct parent doc per level.
|
||||
*/
|
||||
export async function rebuildRelationshipTableViewModel (
|
||||
docs: Doc[],
|
||||
@@ -36,33 +113,21 @@ export async function rebuildRelationshipTableViewModel (
|
||||
const lookup = buildConfigLookup(hierarchy, cardClass, config)
|
||||
|
||||
const associationAttrs = model.filter((attr) => attr.key.startsWith('$associations'))
|
||||
const pathPerAttr = associationAttrs.map((attr) => parseAssociationKeyPath(attr.key))
|
||||
|
||||
let docsWithAssociations: Doc[] = docs
|
||||
if (associations !== undefined && associations.length > 0) {
|
||||
const docIds = docs.map((d) => d._id)
|
||||
const query = { _id: { $in: docIds } }
|
||||
docsWithAssociations = await client.findAll(cardClass, query, { lookup, associations })
|
||||
// Preserve order of input docs (findAll does not guarantee order)
|
||||
const idToIndex = new Map(docs.map((d, i) => [d._id, i]))
|
||||
docsWithAssociations.sort((a, b) => (idToIndex.get(a._id) ?? Infinity) - (idToIndex.get(b._id) ?? Infinity))
|
||||
}
|
||||
|
||||
for (const parentDoc of docsWithAssociations) {
|
||||
const docWithAssoc = parentDoc as any
|
||||
const parentAssociations = docWithAssoc.$associations ?? {}
|
||||
const expandedRows = expandRowsForRoot(parentDoc, associationAttrs, pathPerAttr)
|
||||
|
||||
let maxChildren = 0
|
||||
for (const assocAttr of associationAttrs) {
|
||||
const assocKey = assocAttr.key.replace('$associations.', '')
|
||||
const children = parentAssociations[assocKey]
|
||||
if (Array.isArray(children)) {
|
||||
maxChildren = Math.max(maxChildren, children.length)
|
||||
} else if (children !== undefined && children !== null) {
|
||||
maxChildren = Math.max(maxChildren, 1)
|
||||
}
|
||||
}
|
||||
|
||||
if (maxChildren === 0) {
|
||||
if (expandedRows.length === 0) {
|
||||
const cells: RelationshipCellModel[] = []
|
||||
for (const attr of model) {
|
||||
const isAssociationKey = attr.key.startsWith('$associations')
|
||||
@@ -77,45 +142,59 @@ export async function rebuildRelationshipTableViewModel (
|
||||
continue
|
||||
}
|
||||
|
||||
for (let childIndex = 0; childIndex < maxChildren; childIndex++) {
|
||||
const rowSpanByLevel: number[][] = []
|
||||
for (let level = 0; level <= associationAttrs.length; level++) {
|
||||
rowSpanByLevel.push(computeRowSpans(expandedRows, level))
|
||||
}
|
||||
|
||||
for (let rowIdx = 0; rowIdx < expandedRows.length; rowIdx++) {
|
||||
const rowData = expandedRows[rowIdx]
|
||||
const cells: RelationshipCellModel[] = []
|
||||
|
||||
for (const attr of model) {
|
||||
const isAssociationKey = attr.key.startsWith('$associations')
|
||||
|
||||
if (attr.key === '') {
|
||||
const span = rowSpanByLevel[0][rowIdx]
|
||||
const isFirstInSpan =
|
||||
rowIdx === 0 || expandedRows[rowIdx - 1].docsByLevel[0]?._id !== rowData.docsByLevel[0]?._id
|
||||
cells.push({
|
||||
attribute: attr,
|
||||
rowSpan: maxChildren,
|
||||
object: parentDoc,
|
||||
parentObject: undefined
|
||||
})
|
||||
} else if (isAssociationKey) {
|
||||
const assocKey = attr.key.replace('$associations.', '')
|
||||
const children = parentAssociations[assocKey]
|
||||
let childDoc: Doc | undefined
|
||||
if (Array.isArray(children) && children.length > childIndex) {
|
||||
childDoc = children[childIndex] as Doc
|
||||
} else if (!Array.isArray(children) && children !== undefined && children !== null && childIndex === 0) {
|
||||
childDoc = children as Doc
|
||||
}
|
||||
|
||||
cells.push({
|
||||
attribute: attr,
|
||||
rowSpan: 1,
|
||||
object: childDoc,
|
||||
parentObject: parentDoc
|
||||
})
|
||||
} else {
|
||||
cells.push({
|
||||
attribute: attr,
|
||||
rowSpan: 1,
|
||||
object: childIndex === 0 ? parentDoc : undefined,
|
||||
rowSpan: isFirstInSpan ? span : 0,
|
||||
object: isFirstInSpan ? parentDoc : undefined,
|
||||
parentObject: undefined
|
||||
})
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
if (isAssociationKey) {
|
||||
const assocIdx = associationAttrs.indexOf(attr)
|
||||
if (assocIdx < 0) {
|
||||
cells.push({ attribute: attr, rowSpan: 1, object: undefined, parentObject: undefined })
|
||||
continue
|
||||
}
|
||||
const level = assocIdx + 1
|
||||
const docAtLevel = rowData.docsByLevel[level]
|
||||
const parentAtLevel = rowData.docsByLevel[level - 1]
|
||||
const span = rowSpanByLevel[level][rowIdx]
|
||||
const isFirstInSpan =
|
||||
rowIdx === 0 || expandedRows[rowIdx - 1].docsByLevel[level]?._id !== rowData.docsByLevel[level]?._id
|
||||
cells.push({
|
||||
attribute: attr,
|
||||
rowSpan: isFirstInSpan ? span : 0,
|
||||
object: isFirstInSpan ? docAtLevel : undefined,
|
||||
parentObject: isFirstInSpan ? parentAtLevel : undefined
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
cells.push({
|
||||
attribute: attr,
|
||||
rowSpan: 1,
|
||||
object: rowIdx === 0 ? parentDoc : undefined,
|
||||
parentObject: undefined
|
||||
})
|
||||
}
|
||||
viewModel.push({ cells })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,33 +25,45 @@ import { rebuildRelationshipTableViewModel, isRelationshipTable } from '../data'
|
||||
import { escapeMarkdownLinkText } from './escape'
|
||||
import { createMarkdownLink } from './link'
|
||||
|
||||
async function buildRelationshipTableFromMetadata (
|
||||
async function buildRelationshipTablePropsFromMetadata (
|
||||
docs: Doc[],
|
||||
metadata: BuildMarkdownTableMetadata,
|
||||
client: Client
|
||||
): Promise<string> {
|
||||
): Promise<CopyRelationshipTableAsMarkdownProps> {
|
||||
const hierarchy = client.getHierarchy()
|
||||
const cardClass = metadata.cardClass as Ref<Class<Doc>>
|
||||
|
||||
const config = metadata.config ?? []
|
||||
const lookup = buildConfigLookup(hierarchy, cardClass, config)
|
||||
const model = await buildModel({
|
||||
client,
|
||||
_class: cardClass,
|
||||
keys: config,
|
||||
lookup
|
||||
})
|
||||
let model: AttributeModel[]
|
||||
if (metadata.config !== undefined && metadata.config.length > 0) {
|
||||
const config = metadata.config
|
||||
const lookup = buildConfigLookup(hierarchy, cardClass, config)
|
||||
model = await buildModel({
|
||||
client,
|
||||
_class: cardClass,
|
||||
keys: config,
|
||||
lookup
|
||||
})
|
||||
} else {
|
||||
model = await buildTableModel(client, hierarchy, cardClass, undefined)
|
||||
}
|
||||
|
||||
const viewModel = await rebuildRelationshipTableViewModel(docs, model, cardClass, hierarchy, client)
|
||||
|
||||
const props: CopyRelationshipTableAsMarkdownProps = {
|
||||
return {
|
||||
viewModel,
|
||||
model,
|
||||
objects: docs,
|
||||
cardClass,
|
||||
query: metadata.query
|
||||
}
|
||||
}
|
||||
|
||||
async function buildRelationshipTableFromMetadata (
|
||||
docs: Doc[],
|
||||
metadata: BuildMarkdownTableMetadata,
|
||||
client: Client
|
||||
): Promise<string> {
|
||||
const hierarchy = client.getHierarchy()
|
||||
const props = await buildRelationshipTablePropsFromMetadata(docs, metadata, client)
|
||||
const language = getCurrentLanguage()
|
||||
return await buildRelationshipTableMarkdown(props, hierarchy, language)
|
||||
}
|
||||
@@ -234,6 +246,7 @@ export async function buildRelationshipTableMarkdown (
|
||||
}
|
||||
|
||||
if (doc === undefined) {
|
||||
if (cell.rowSpan === 0) continue
|
||||
row[attrIndex] = ''
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -35,6 +35,7 @@ export interface Config {
|
||||
DbUrl: string
|
||||
Buckets: BucketConfig[]
|
||||
CleanupInterval: number
|
||||
Secure: boolean
|
||||
Readonly: boolean
|
||||
Cache: CacheConfig
|
||||
}
|
||||
@@ -86,6 +87,7 @@ const config: Config = (() => {
|
||||
AccountsUrl: process.env.ACCOUNTS_URL,
|
||||
DbUrl: process.env.DB_URL,
|
||||
Buckets: parseBucketsConfig(process.env.BUCKETS),
|
||||
Secure: process.env.SECURE === 'true',
|
||||
Readonly: process.env.READONLY === 'true',
|
||||
Cache: {
|
||||
enabled: process.env.CACHE_ENABLED !== 'false',
|
||||
|
||||
@@ -38,6 +38,14 @@ export const keepAlive = (options: KeepAliveOptions): RequestHandler => {
|
||||
}
|
||||
}
|
||||
|
||||
export const withOptionalAuth = (secure: boolean): RequestHandler => {
|
||||
return secure
|
||||
? withAuthorization
|
||||
: (req: Request, res: Response, next: NextFunction) => {
|
||||
next()
|
||||
}
|
||||
}
|
||||
|
||||
export const withAdminAuthorization = (req: RequestWithAuth, res: Response, next: NextFunction): void => {
|
||||
try {
|
||||
const token = extractToken(req.headers)
|
||||
|
||||
@@ -36,7 +36,8 @@ import {
|
||||
withAuthorization,
|
||||
withBlob,
|
||||
withWorkspace,
|
||||
withReadonly
|
||||
withReadonly,
|
||||
withOptionalAuth
|
||||
} from './middleware'
|
||||
import {
|
||||
handleBlobDelete,
|
||||
@@ -176,13 +177,33 @@ export async function createServer (
|
||||
|
||||
app.get('/blob/:workspace', withAdminAuthorization, withWorkspace, wrapRequest(ctx, 'listBlobs', handleBlobList))
|
||||
|
||||
app.head('/blob/:workspace/:name', withBlob, wrapRequest(ctx, 'headBlob', handleBlobHead))
|
||||
app.head(
|
||||
'/blob/:workspace/:name',
|
||||
withOptionalAuth(config.Secure),
|
||||
withBlob,
|
||||
wrapRequest(ctx, 'headBlob', handleBlobHead)
|
||||
)
|
||||
|
||||
app.head('/blob/:workspace/:name/:filename', withBlob, wrapRequest(ctx, 'headBlob', handleBlobHead))
|
||||
app.head(
|
||||
'/blob/:workspace/:name/:filename',
|
||||
withOptionalAuth(config.Secure),
|
||||
withBlob,
|
||||
wrapRequest(ctx, 'headBlob', handleBlobHead)
|
||||
)
|
||||
|
||||
app.get('/blob/:workspace/:name', withBlob, wrapRequest(ctx, 'getBlob', handleBlobGet))
|
||||
app.get(
|
||||
'/blob/:workspace/:name',
|
||||
withOptionalAuth(config.Secure),
|
||||
withBlob,
|
||||
wrapRequest(ctx, 'getBlob', handleBlobGet)
|
||||
)
|
||||
|
||||
app.get('/blob/:workspace/:name/:filename', withBlob, wrapRequest(ctx, 'getBlob', handleBlobGet))
|
||||
app.get(
|
||||
'/blob/:workspace/:name/:filename',
|
||||
withOptionalAuth(config.Secure),
|
||||
withBlob,
|
||||
wrapRequest(ctx, 'getBlob', handleBlobGet)
|
||||
)
|
||||
|
||||
app.delete('/blob/:workspace/:name', withAuthorization, withBlob, wrapRequest(ctx, 'deleteBlob', handleBlobDelete))
|
||||
|
||||
@@ -206,7 +227,12 @@ export async function createServer (
|
||||
|
||||
// Blob meta
|
||||
|
||||
app.get('/meta/:workspace/:name', withBlob, wrapRequest(ctx, 'getMeta', handleMetaGet))
|
||||
app.get(
|
||||
'/meta/:workspace/:name',
|
||||
withOptionalAuth(config.Secure),
|
||||
withBlob,
|
||||
wrapRequest(ctx, 'getMeta', handleMetaGet)
|
||||
)
|
||||
|
||||
app.put('/meta/:workspace/:name', withAuthorization, withBlob, wrapRequest(ctx, 'putMeta', handleMetaPut))
|
||||
|
||||
|
||||
Reference in New Issue
Block a user