Fixes for document hierarchy presentation in QMS (#7938)

* Fixes for document hierarchy presentation in QMS

Signed-off-by: Victor Ilyushchenko <alt13ri@gmail.com>

* fixed test

Signed-off-by: Victor Ilyushchenko <alt13ri@gmail.com>

* styling fix

Signed-off-by: Victor Ilyushchenko <alt13ri@gmail.com>

* cooler names

Signed-off-by: Victor Ilyushchenko <alt13ri@gmail.com>

---------

Signed-off-by: Victor Ilyushchenko <alt13ri@gmail.com>
This commit is contained in:
Victor Ilyushchenko 2025-02-07 18:10:25 +03:00 committed by GitHub
parent 50eac538a6
commit 0be8b860fc
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 375 additions and 423 deletions

View File

@ -14,184 +14,70 @@
-->
<script lang="ts">
import { type Ref, SortingOrder, getCurrentAccount } from '@hcengineering/core'
import { createQuery } from '@hcengineering/presentation'
import { type PersonAccount } from '@hcengineering/contact'
import documents, {
type ControlledDocument,
type DocumentMeta,
import {
DocumentBundle,
ProjectDocumentTree,
type HierarchyDocument,
type Project,
type ProjectMeta
type Project
} from '@hcengineering/controlled-documents'
import { type Ref } from '@hcengineering/core'
import { compareDocs, notEmpty } from '../../../../utils'
import { createDocumentHierarchyQuery } from '../../../../utils'
import DocumentFlatTreeElement from './DocumentFlatTreeElement.svelte'
export let document: HierarchyDocument | undefined
export let project: Ref<Project>
const currentUser = getCurrentAccount() as PersonAccount
const currentPerson = currentUser.person
let tree = new ProjectDocumentTree()
let meta: ProjectMeta | undefined
const projectMetaQuery = createQuery()
const query = createDocumentHierarchyQuery()
$: if (document !== undefined && project !== undefined) {
projectMetaQuery.query(
documents.class.ProjectMeta,
{
project,
meta: document.attachedTo
},
(result) => {
;[meta] = result
}
)
query.query(document.space, project, (data) => {
tree = data
})
}
let parentMetas: ProjectMeta[] | undefined
const parentMetasQuery = createQuery()
$: if (meta !== undefined) {
parentMetasQuery.query(
documents.class.ProjectMeta,
{
meta: { $in: meta.path },
project: meta.project
},
(result) => {
parentMetas = result
}
)
} else {
parentMetasQuery.unsubscribe()
}
$: docmetaid = tree.metaOf(document?._id)
let directChildrenMetas: ProjectMeta[] | undefined
const childrenMetasQuery = createQuery()
$: if (meta !== undefined) {
childrenMetasQuery.query(
documents.class.ProjectMeta,
{
parent: meta?.meta,
project: meta.project
},
(result) => {
if (meta === undefined) {
return
}
directChildrenMetas = result
}
)
} else {
childrenMetasQuery.unsubscribe()
}
let docs: Record<Ref<DocumentMeta>, ControlledDocument> = {}
const docsQuery = createQuery()
$: if (parentMetas !== undefined && directChildrenMetas !== undefined) {
docsQuery.query(
documents.class.ProjectDocument,
{
attachedTo: { $in: [...parentMetas.map((p) => p._id), ...directChildrenMetas.map((p) => p._id)] }
},
(result) => {
docs = {}
let lastTemplate: string | undefined = '###'
let lastSeqNumber = -1
for (const prjdoc of result) {
const doc = prjdoc.$lookup?.document as ControlledDocument | undefined
if (doc === undefined) continue
// TODO add proper fix, when document with no template copied, saved value is null
const template = doc.template ?? undefined
if (template === lastTemplate && doc.seqNumber === lastSeqNumber) {
continue
}
if (
doc.owner === currentPerson ||
doc.coAuthors.findIndex((emp) => emp === currentPerson) >= 0 ||
doc.approvers.findIndex((emp) => emp === currentPerson) >= 0 ||
doc.reviewers.findIndex((emp) => emp === currentPerson) >= 0
) {
docs[doc.attachedTo] = doc
lastTemplate = template
lastSeqNumber = doc.seqNumber
}
}
},
{
lookup: {
document: documents.class.ControlledDocument
},
sort: {
'$lookup.document.template': SortingOrder.Ascending,
'$lookup.document.seqNumber': SortingOrder.Ascending,
'$lookup.document.major': SortingOrder.Descending,
'$lookup.document.minor': SortingOrder.Descending,
'$lookup.document.patch': SortingOrder.Descending
}
}
)
} else {
docsQuery.unsubscribe()
}
let directChildrenDocs: ControlledDocument[] = []
$: if (directChildrenMetas !== undefined) {
directChildrenDocs = Object.values(docs).filter(
(d) => directChildrenMetas !== undefined && directChildrenMetas.findIndex((m) => m.meta === d.attachedTo) >= 0
)
directChildrenDocs.sort(compareDocs)
}
let parentDocs: ControlledDocument[] = []
$: if (meta !== undefined) {
parentDocs = [...meta.path]
.reverse()
.map((mId) => docs[mId])
.filter(notEmpty)
}
let levels: Array<[HierarchyDocument[], boolean]> = []
let levels: Array<[DocumentBundle[], boolean]> = []
$: {
levels = []
const parents = tree
.parentChainOf(docmetaid)
.reverse()
.map((ref) => tree.bundleOf(ref))
.filter((r) => r !== undefined)
const me = tree.bundleOf(docmetaid)
const children = tree
.childrenOf(docmetaid)
.map((ref) => tree.bundleOf(ref))
.filter((r) => r !== undefined)
if (parentDocs?.length > 0) {
levels.push([parentDocs, false])
}
if (document !== undefined) {
levels.push([[document], true])
}
if (directChildrenDocs?.length > 0) {
levels.push([directChildrenDocs, false])
if (parents.length > 0) levels.push([parents as DocumentBundle[], false])
if (me) {
levels.push([[me], true])
}
if (children.length > 0) levels.push([children as DocumentBundle[], false])
}
</script>
{#if levels.length > 0}
{@const [firstDocs, firstHltd] = levels[0]}
<div class="root">
{#each firstDocs as doc}
<DocumentFlatTreeElement {doc} {project} highlighted={firstHltd} />
{#each firstDocs as bundle}
<DocumentFlatTreeElement {bundle} {project} highlighted={firstHltd} />
{/each}
{#if levels.length > 1}
{@const [secondDocs, secondHltd] = levels[1]}
<div class="container">
{#each secondDocs as doc}
<DocumentFlatTreeElement {doc} {project} highlighted={secondHltd} />
{#each secondDocs as bundle}
<DocumentFlatTreeElement {bundle} {project} highlighted={secondHltd} />
{/each}
{#if levels.length > 2}
{@const [thirdDocs, thirdHltd] = levels[2]}
<div class="container">
{#each thirdDocs as doc}
<DocumentFlatTreeElement {doc} {project} highlighted={thirdHltd} />
{#each thirdDocs as bundle}
<DocumentFlatTreeElement {bundle} {project} highlighted={thirdHltd} />
{/each}
</div>
{/if}
@ -215,5 +101,8 @@
padding: 0 1rem;
border-left: 2px solid var(--theme-navpanel-border);
gap: 0.25rem;
padding-left: 0.25rem;
margin-left: 0.75rem;
}
</style>

View File

@ -14,17 +14,25 @@
-->
<script lang="ts">
import documents, { getDocumentName, type Document, type Project } from '@hcengineering/controlled-documents'
import documents, {
DocumentBundle,
getDocumentName,
isFolder,
type Project
} from '@hcengineering/controlled-documents'
import { type Ref } from '@hcengineering/core'
import { Icon, navigate } from '@hcengineering/ui'
import { getProjectDocumentLink } from '../../../../navigation'
export let doc: Document
export let bundle: DocumentBundle
export let project: Ref<Project>
export let highlighted: boolean = false
const icon = documents.icon.Document
$: meta = bundle?.DocumentMeta[0]
$: prjdoc = bundle?.ProjectDocument[0]
$: document = bundle?.ControlledDocument[0]
$: icon = isFolder(prjdoc) ? documents.icon.Folder : documents.icon.Document
</script>
<!-- svelte-ignore a11y-click-events-have-key-events -->
@ -32,7 +40,8 @@
<div
class="antiNav-element root"
on:click={() => {
const loc = getProjectDocumentLink(doc, project)
if (!document) return
const loc = getProjectDocumentLink(document, project)
navigate(loc)
}}
>
@ -48,7 +57,7 @@
</div>
{/if}
<span class="an-element__label" class:font-medium={highlighted}>
{getDocumentName(doc)}
{document ? getDocumentName(document) : meta.title}
</span>
</div>

View File

@ -13,23 +13,20 @@
// limitations under the License.
-->
<script lang="ts">
import { createEventDispatcher } from 'svelte'
import { type Ref, type Doc, SortingOrder, getCurrentAccount, WithLookup, toIdMap } from '@hcengineering/core'
import { createQuery } from '@hcengineering/presentation'
import type { PersonAccount } from '@hcengineering/contact'
import { type Action } from '@hcengineering/ui'
import documents, {
type ControlledDocument,
type DocumentMeta,
type ProjectMeta,
type ProjectDocument,
getDocumentName,
DocumentState
DocumentState,
ProjectDocumentTree,
getDocumentName
} from '@hcengineering/controlled-documents'
import { type Doc, type Ref } from '@hcengineering/core'
import { type Action } from '@hcengineering/ui'
import { TreeItem } from '@hcengineering/view-resources'
import { createEventDispatcher } from 'svelte'
export let tree = new ProjectDocumentTree()
export let documentIds: Ref<DocumentMeta>[] = []
export let projectMeta: ProjectMeta[] = []
export let childrenByParent: Record<Ref<DocumentMeta>, Array<ProjectMeta>>
export let selected: Ref<Doc> | undefined
export let level: number = 0
export let getMoreActions: ((obj: Doc, originalEvent?: MouseEvent) => Promise<Action[]>) | undefined = undefined
@ -45,113 +42,27 @@
import DropArea from './DropArea.svelte'
const removeStates = [DocumentState.Obsolete, DocumentState.Deleted]
const dispatch = createEventDispatcher()
const currentUser = getCurrentAccount() as PersonAccount
const currentPerson = currentUser.person
let docs: WithLookup<ProjectDocument>[] = []
function sortDocs (meta: ProjectMeta[]): void {
const metaById = new Map(meta.map((p) => [p._id, p]))
docs = docs.slice().sort((a, b) => {
const metaA = metaById.get(a.attachedTo)
const metaB = metaById.get(b.attachedTo)
if (metaA !== undefined && metaB !== undefined) {
return metaA.rank.localeCompare(metaB.rank)
}
return 0
})
}
$: sortDocs(projectMeta)
const docsQuery = createQuery()
$: docsQuery.query(
documents.class.ProjectDocument,
{
'$lookup.document.state': { $ne: DocumentState.Deleted },
attachedTo: { $in: projectMeta.map((p) => p._id) }
},
(result) => {
docs = []
let lastTemplate: string | undefined = '###'
let lastSeqNumber = -1
for (const prjdoc of result) {
if (prjdoc.document === documents.ids.Folder) {
docs.push(prjdoc)
continue
}
const doc = prjdoc.$lookup?.document as ControlledDocument | undefined
if (doc === undefined) continue
if (doc.state === DocumentState.Deleted) continue
// TODO add proper fix, when document with no template copied, saved value is null
const template = doc.template ?? undefined
if (template === lastTemplate && doc.seqNumber === lastSeqNumber) {
continue
}
if (
[DocumentState.Effective, DocumentState.Archived].includes(doc.state) ||
doc.owner === currentPerson ||
doc.coAuthors.findIndex((emp) => emp === currentPerson) >= 0 ||
doc.approvers.findIndex((emp) => emp === currentPerson) >= 0 ||
doc.reviewers.findIndex((emp) => emp === currentPerson) >= 0
) {
docs.push(prjdoc)
lastTemplate = template
lastSeqNumber = doc.seqNumber
}
}
sortDocs(projectMeta)
},
{
lookup: {
document: documents.class.ControlledDocument
},
sort: {
'$lookup.document.template': SortingOrder.Ascending,
'$lookup.document.seqNumber': SortingOrder.Ascending,
'$lookup.document.major': SortingOrder.Descending,
'$lookup.document.minor': SortingOrder.Descending,
'$lookup.document.patch': SortingOrder.Descending
}
}
)
let docsMeta: DocumentMeta[] = []
const metaQuery = createQuery()
$: metaQuery.query(documents.class.DocumentMeta, { _id: { $in: projectMeta.map((p) => p.meta) } }, (result) => {
docsMeta = result
})
async function getDocMoreActions (obj: Doc): Promise<Action[]> {
return getMoreActions !== undefined ? await getMoreActions(obj) : []
}
$: projectMetaById = toIdMap(projectMeta)
$: docsMetaById = toIdMap(docsMeta)
</script>
{#each docs as prjdoc}
{@const pjmeta = projectMetaById.get(prjdoc.attachedTo)}
{@const doc = prjdoc.$lookup?.document}
{@const metaid = pjmeta?.meta}
{@const meta = metaid ? docsMetaById.get(metaid) : undefined}
{#each documentIds as metaid}
{@const bundle = tree.bundleOf(metaid)}
{@const prjdoc = bundle?.ProjectDocument[0]}
{@const doc = bundle?.ControlledDocument[0]}
{@const meta = bundle?.DocumentMeta[0]}
{@const title = doc ? getDocumentName(doc) : meta?.title ?? ''}
{@const docid = doc?._id ?? prjdoc._id}
{@const isFolder = prjdoc.document === documents.ids.Folder}
{@const isObsolete = doc ? doc.state === DocumentState.Obsolete : false}
{@const children = metaid ? childrenByParent[metaid] ?? [] : []}
{@const docid = doc?._id ?? prjdoc?._id}
{@const isFolder = prjdoc?.document === documents.ids.Folder}
{@const children = tree.childrenOf(metaid)}
{@const isRemoved = doc && removeStates.includes(doc.state)}
{#if metaid && (!isObsolete || children.length > 0)}
{#if prjdoc && metaid}
{@const isDraggedOver = draggedOver === metaid}
<div class="flex-col relative">
{#if isDraggedOver}
@ -161,7 +72,7 @@
_id={docid}
icon={isFolder ? documents.icon.Folder : documents.icon.Document}
iconProps={{
fill: isObsolete ? 'var(--dangerous-bg-color)' : 'currentColor'
fill: isRemoved ? 'var(--dangerous-bg-color)' : 'currentColor'
}}
{title}
selected={selected === docid || selected === prjdoc._id}
@ -191,8 +102,8 @@
<svelte:fragment slot="dropbox">
{#if children.length}
<svelte:self
projectMeta={children}
{childrenByParent}
documentIds={children}
{tree}
{selected}
{collapsedPrefix}
{getMoreActions}

View File

@ -13,20 +13,14 @@
// limitations under the License.
-->
<script lang="ts">
import { createEventDispatcher } from 'svelte'
import { WithLookup, type Doc, type Ref } from '@hcengineering/core'
import { createQuery } from '@hcengineering/presentation'
import { getPlatformColorForTextDef, themeStore, getTreeCollapsed } from '@hcengineering/ui'
import documents, {
type DocumentMeta,
type DocumentSpace,
type Project,
type ProjectMeta
} from '@hcengineering/controlled-documents'
import documents, { ProjectDocumentTree, type DocumentSpace, type Project } from '@hcengineering/controlled-documents'
import { type Doc, type Ref } from '@hcengineering/core'
import { getPlatformColorForTextDef, themeStore } from '@hcengineering/ui'
import { TreeNode } from '@hcengineering/view-resources'
import { createEventDispatcher } from 'svelte'
import { createDocumentHierarchyQuery } from '../../utils'
import DocHierarchyLevel from './DocHierarchyLevel.svelte'
import { getProjectDocsHierarchy } from '../../utils'
export let space: DocumentSpace
export let project: Ref<Project> | undefined
@ -34,24 +28,17 @@
export let collapsedPrefix: string = ''
const dispatch = createEventDispatcher()
// let collapsed: boolean = getPrefixedTreeCollapsed(space._id, collapsedPrefix)
// $: setPrefixedTreeCollapsed(space._id, collapsedPrefix, collapsed)
// $: folderIcon = collapsed ? FolderCollapsed : FolderExpanded
let rootDocs: Array<WithLookup<ProjectMeta>> = []
let childrenByParent: Record<Ref<DocumentMeta>, Array<WithLookup<ProjectMeta>>> = {}
let tree = new ProjectDocumentTree()
const docsQuery = createQuery()
$: docsQuery.query(
documents.class.ProjectMeta,
{
space: space._id,
project
},
(result) => {
;({ rootDocs, childrenByParent } = getProjectDocsHierarchy(result))
}
)
const query = createDocumentHierarchyQuery()
$: if (document !== undefined && project !== undefined) {
query.query(space._id, project, (data) => {
tree = data
})
}
$: root = tree.childrenOf(documents.ids.NoParent)
</script>
<TreeNode
@ -63,12 +50,12 @@
title={space.name}
highlighted={selected !== undefined}
selected={selected === undefined}
empty={rootDocs.length === 0}
empty={root.length === 0}
{collapsedPrefix}
type={'nested-selectable'}
on:click={() => {
dispatch('selected', space)
}}
>
<DocHierarchyLevel projectMeta={rootDocs} {childrenByParent} {selected} {collapsedPrefix} on:selected />
<DocHierarchyLevel documentIds={root} {tree} {selected} {collapsedPrefix} on:selected />
</TreeNode>

View File

@ -13,20 +13,6 @@
// limitations under the License.
-->
<script lang="ts">
import { WithLookup, type Doc, type Ref, type Space } from '@hcengineering/core'
import { createQuery, getClient } from '@hcengineering/presentation'
import { getResource } from '@hcengineering/platform'
import {
type Action,
getPlatformColorForTextDef,
themeStore,
navigate,
IconEdit,
Label,
closeTooltip
} from '@hcengineering/ui'
import { getActions as getContributedActions, TreeNode, TreeItem } from '@hcengineering/view-resources'
import { ActionGroup } from '@hcengineering/view'
import {
type ControlledDocument,
type DocumentMeta,
@ -34,27 +20,41 @@
type DocumentSpaceType,
type Project,
type ProjectDocument,
type ProjectMeta,
ProjectDocumentTree,
getDocumentName
} from '@hcengineering/controlled-documents'
import { type Doc, type Ref, type Space, WithLookup } from '@hcengineering/core'
import { getResource } from '@hcengineering/platform'
import { createQuery, getClient } from '@hcengineering/presentation'
import {
type Action,
IconEdit,
Label,
closeTooltip,
getPlatformColorForTextDef,
navigate,
themeStore
} from '@hcengineering/ui'
import { ActionGroup } from '@hcengineering/view'
import { TreeItem, TreeNode, getActions as getContributedActions } from '@hcengineering/view-resources'
import ProjectSelector from '../project/ProjectSelector.svelte'
import DocHierarchyLevel from './DocHierarchyLevel.svelte'
import { getDocumentIdFromFragment, getProjectDocumentLink } from '../../navigation'
import {
canCreateChildDocument,
canCreateChildFolder,
createDocument,
createDocumentHierarchyQuery,
createFolder,
getCurrentProject,
getLatestProjectId,
setCurrentProject,
getProjectDocsHierarchy,
isEditableProject,
createDocument,
canCreateChildDocument,
moveDocument,
moveDocumentBefore,
moveDocumentAfter,
canCreateChildFolder,
createFolder
moveDocumentBefore,
setCurrentProject
} from '../../utils'
import ProjectSelector from '../project/ProjectSelector.svelte'
import DocHierarchyLevel from './DocHierarchyLevel.svelte'
import documents from '../../plugin'
@ -83,27 +83,14 @@
let project: Ref<Project> = documents.ids.NoProject
$: void selectProject(space)
let docsByMeta = new Map<Ref<DocumentMeta>, WithLookup<ProjectMeta>>()
let rootDocs: Array<WithLookup<ProjectMeta>> = []
let childrenByParent: Record<Ref<DocumentMeta>, Array<WithLookup<ProjectMeta>>> = {}
let tree = new ProjectDocumentTree()
const projectMetaQ = createQuery()
$: projectMetaQ.query(
documents.class.ProjectMeta,
{
space: space._id,
project
},
(result) => {
docsByMeta = new Map(result.map((r) => [r.meta, r]))
;({ rootDocs, childrenByParent } = getProjectDocsHierarchy(result))
},
{
lookup: {
meta: documents.class.DocumentMeta
}
}
)
const query = createDocumentHierarchyQuery()
$: if (document !== undefined && project !== undefined) {
query.query(space._id, project, (data) => {
tree = data
})
}
let selectedControlledDoc: ControlledDocument | undefined = undefined
@ -134,23 +121,6 @@
selectedControlledDoc = undefined
}
function getAllDescendants (obj: Ref<DocumentMeta>): Ref<DocumentMeta>[] {
const result: Ref<DocumentMeta>[] = []
const queue: Ref<DocumentMeta>[] = [obj]
while (queue.length > 0) {
const next = queue.pop()
if (next === undefined) break
const children = childrenByParent[next] ?? []
const childrenRefs = children.map((p) => p.meta)
result.push(...childrenRefs)
queue.push(...childrenRefs)
}
return result
}
async function selectProject (space: DocumentSpace): Promise<void> {
project = getCurrentProject(space._id) ?? (await getLatestProjectId(space._id, true)) ?? documents.ids.NoProject
}
@ -253,7 +223,7 @@
return
}
cannotDropTo = [object, ...getAllDescendants(object)]
cannotDropTo = [object, ...tree.descendantsOf(object)]
event.dataTransfer.effectAllowed = 'move'
event.dataTransfer.dropEffect = 'move'
@ -311,8 +281,8 @@
return
}
if (draggedItem !== undefined && canDrop(draggedItem, object)) {
const doc = docsByMeta.get(draggedItem)
const target = docsByMeta.get(object)
const doc = tree.bundleOf(draggedItem)?.ProjectMeta[0]
const target = tree.bundleOf(object)?.ProjectMeta[0]
if (doc !== undefined && target !== undefined && doc._id !== target._id) {
if (object === documents.ids.NoParent) {
@ -385,10 +355,11 @@
{/if}
</svelte:fragment>
{#if rootDocs.length > 0}
{@const root = tree.childrenOf(documents.ids.NoParent)}
{#if root.length > 0}
<DocHierarchyLevel
projectMeta={rootDocs}
{childrenByParent}
{tree}
documentIds={root}
{selected}
getMoreActions={getDocumentActions}
on:selected={(e) => {

View File

@ -27,9 +27,12 @@ import documents, {
type ProjectDocument,
type ProjectMeta,
ControlledDocumentState,
type DocumentBundle,
DocumentState,
emptyBundle,
getDocumentName,
getFirstRank
getFirstRank,
ProjectDocumentTree
} from '@hcengineering/controlled-documents'
import core, {
type Class,
@ -49,7 +52,7 @@ import core, {
getCurrentAccount
} from '@hcengineering/core'
import { type IntlString, translate } from '@hcengineering/platform'
import { getClient } from '@hcengineering/presentation'
import { createQuery, getClient } from '@hcengineering/presentation'
import request, { type Request, RequestStatus } from '@hcengineering/request'
import { isEmptyMarkup } from '@hcengineering/text'
import { type Location, getUserTimezone, showPopup } from '@hcengineering/ui'
@ -867,3 +870,53 @@ export async function moveDocumentAfter (doc: ProjectMeta, after: ProjectMeta):
await client.update(doc, { parent, path, rank })
}
export class DocumentHiearchyQuery {
queries = {
prjMeta: createQuery(),
prjDoc: createQuery()
}
bundle: DocumentBundle = { ...emptyBundle() }
handleUpdate (data: Partial<DocumentBundle>, callback: (tree: ProjectDocumentTree) => void): void {
this.bundle = { ...this.bundle, ...data }
callback(new ProjectDocumentTree(this.bundle))
}
query (
space: Ref<DocumentSpace>,
project: Ref<Project<DocumentSpace>>,
callback: (tree: ProjectDocumentTree) => void
): void {
project = project ?? documents.ids.NoProject
this.queries.prjMeta.query(
documents.class.ProjectMeta,
{ space, project },
(ProjectMeta) => {
const DocumentMeta = ProjectMeta.map((e) => e.$lookup?.meta).filter((e) => e !== undefined) as DocumentMeta[]
const patch: Partial<DocumentBundle> = { ProjectMeta, DocumentMeta }
this.handleUpdate(patch, callback)
},
{ lookup: { meta: documents.class.DocumentMeta } }
)
this.queries.prjDoc.query(
documents.class.ProjectDocument,
{ space, project },
(ProjectDocument) => {
const ControlledDocument = ProjectDocument.map((e) => e.$lookup?.document as ControlledDocument).filter(
(e) => e !== undefined
)
const patch: Partial<DocumentBundle> = { ProjectDocument, ControlledDocument }
this.handleUpdate(patch, callback)
},
{ lookup: { document: documents.class.ControlledDocument } }
)
}
}
export function createDocumentHierarchyQuery (): DocumentHiearchyQuery {
return new DocumentHiearchyQuery()
}

View File

@ -20,10 +20,12 @@ import {
Doc,
DocumentQuery,
DocumentUpdate,
getCurrentAccount,
Rank,
Ref,
SortingOrder,
Space,
toIdMap,
TxOperations
} from '@hcengineering/core'
import { LexoDecimal, LexoNumeralSystem36, LexoRank } from 'lexorank'
@ -33,6 +35,8 @@ import documents from './plugin'
import attachment, { Attachment } from '@hcengineering/attachment'
import chunter, { ChatMessage } from '@hcengineering/chunter'
import { Employee } from '@hcengineering/contact'
import { makeRank } from '@hcengineering/rank'
import tags, { TagReference } from '@hcengineering/tags'
import {
ChangeControl,
@ -47,7 +51,6 @@ import {
ProjectDocument,
ProjectMeta
} from './types'
import { makeRank } from '@hcengineering/rank'
/**
* @public
@ -138,30 +141,152 @@ export async function deleteProjectDrafts (client: ApplyOperations, source: Ref<
}
}
class ProjectDocumentTree {
rootDocs: ProjectMeta[]
childrenByParent: Map<Ref<DocumentMeta>, ProjectMeta[]>
export function isCollaborator (doc: ControlledDocument, person: Ref<Employee>): boolean {
return (
doc.owner === person ||
doc.coAuthors.includes(person) ||
doc.approvers.includes(person) ||
doc.reviewers.includes(person)
)
}
constructor (pjMeta: ProjectMeta[]) {
this.rootDocs = []
this.childrenByParent = new Map<Ref<DocumentMeta>, Array<ProjectMeta>>()
export function isFolder (doc: ProjectDocument | undefined): boolean {
return doc !== undefined && doc.document === documents.ids.Folder
}
for (const meta of pjMeta) {
const parentId = meta.path[0] ?? documents.ids.NoParent
function extractPresentableStateFromDocumentBundle (bundle: DocumentBundle, prjmeta: ProjectMeta): DocumentBundle {
bundle = { ...bundle }
if (!this.childrenByParent.has(parentId)) {
this.childrenByParent.set(parentId, [])
const person = getCurrentAccount().person as Ref<Employee>
const documentById = toIdMap(bundle.ControlledDocument)
const getSortSequence = (prjdoc: ProjectDocument): number[] => {
const doc = documentById.get(prjdoc.document as Ref<ControlledDocument>)
return doc !== undefined ? [doc.seqNumber, doc.major, doc.minor, doc.createdOn ?? 0] : [0, 0, 0, 0]
}
const prjdoc = bundle.ProjectDocument.filter((prjdoc) => {
if (prjdoc.attachedTo !== prjmeta._id) return false
if (isFolder(prjdoc)) return true
const doc = documentById.get(prjdoc.document as Ref<ControlledDocument>)
const isPublicState = doc?.state === DocumentState.Effective || doc?.state === DocumentState.Archived
return doc !== undefined && (isPublicState || isCollaborator(doc, person))
}).sort((a, b) => {
const s0 = getSortSequence(a)
const s1 = getSortSequence(b)
return s0.reduce((r, v, i) => (r !== 0 ? r : s1[i] - v), 0)
})[0]
const doc = prjdoc !== undefined ? documentById.get(prjdoc.document as Ref<ControlledDocument>) : undefined
bundle.ProjectMeta = [prjmeta]
bundle.ProjectDocument = prjdoc !== undefined ? [prjdoc] : []
bundle.ControlledDocument = doc !== undefined ? [doc] : []
return bundle
}
export class ProjectDocumentTree {
parents: Map<Ref<DocumentMeta>, Ref<DocumentMeta>>
nodesChildren: Map<Ref<DocumentMeta>, DocumentBundle[]>
nodes: Map<Ref<DocumentMeta>, DocumentBundle>
links: Map<Ref<Doc>, Ref<DocumentMeta>>
constructor (bundle?: DocumentBundle) {
bundle = { ...emptyBundle(), ...bundle }
const { bundles, links } = compileBundles(bundle)
this.links = links
this.nodes = new Map()
this.nodesChildren = new Map()
this.parents = new Map()
bundles.sort((a, b) => {
const rankA = a.ProjectMeta[0]?.rank ?? ''
const rankB = b.ProjectMeta[0]?.rank ?? ''
return rankA.localeCompare(rankB)
})
for (const bundle of bundles) {
const prjmeta = bundle.ProjectMeta[0]
if (prjmeta === undefined) continue
const presentable = extractPresentableStateFromDocumentBundle(bundle, prjmeta)
this.nodes.set(prjmeta.meta, presentable)
const parent = prjmeta.path[0] ?? documents.ids.NoParent
this.parents.set(prjmeta.meta, parent)
if (!this.nodesChildren.has(parent)) {
this.nodesChildren.set(parent, [])
}
this.nodesChildren.get(parent)?.push(bundle)
}
this.childrenByParent.get(parentId)?.push(meta)
const nodesForRemoval = new Set<Ref<DocumentMeta>>()
for (const [id, node] of this.nodes) {
const state = node.ControlledDocument[0]?.state
const isRemoved = state === DocumentState.Obsolete || state === DocumentState.Deleted
if (isRemoved) nodesForRemoval.add(id)
}
if (parentId === documents.ids.NoParent) {
this.rootDocs.push(meta)
}
for (const id of this.nodes.keys()) {
if (!nodesForRemoval.has(id)) continue
const blocked = this.descendantsOf(id).some((node) => !nodesForRemoval.has(node))
if (blocked) nodesForRemoval.delete(id)
}
for (const id of nodesForRemoval) {
this.nodes.delete(id)
this.parents.delete(id)
this.nodesChildren.delete(id)
}
for (const [id, children] of this.nodesChildren) {
this.nodesChildren.set(
id,
children.filter((c) => !nodesForRemoval.has(c.ProjectMeta[0].meta))
)
}
}
getDescendants (parent: Ref<DocumentMeta>): Ref<DocumentMeta>[] {
metaOf (ref: Ref<Doc> | undefined): Ref<DocumentMeta> | undefined {
if (ref === undefined) return
return this.links.get(ref)
}
parentChainOf (ref: Ref<DocumentMeta> | undefined): Ref<DocumentMeta>[] {
if (ref === undefined) return []
// Found a bug that can cause path field to contain invalid state,
// until we fix it with migration and a separate fix it's better to use parent.
//
// return this.bundleOf(ref)?.ProjectMeta[0]?.path ?? []
const parents: Ref<DocumentMeta>[] = []
while (this.parentOf(ref) !== documents.ids.NoParent) {
ref = this.parentOf(ref)
parents.push(ref)
}
return parents
}
parentOf (ref: Ref<DocumentMeta> | undefined): Ref<DocumentMeta> {
if (ref === undefined) {
return documents.ids.NoParent
}
return this.parents.get(ref) ?? documents.ids.NoParent
}
bundleOf (ref: Ref<DocumentMeta> | undefined): DocumentBundle | undefined {
if (ref === undefined) return
return this.nodes.get(ref)
}
childrenOf (ref: Ref<DocumentMeta> | undefined): Ref<DocumentMeta>[] {
if (ref === undefined) return []
return this.nodesChildren.get(ref)?.map((p) => p.ProjectMeta[0].meta) ?? []
}
descendantsOf (parent: Ref<DocumentMeta>): Ref<DocumentMeta>[] {
const result: Ref<DocumentMeta>[] = []
const queue: Ref<DocumentMeta>[] = [parent]
@ -169,8 +294,8 @@ class ProjectDocumentTree {
const next = queue.pop()
if (next === undefined) break
const children = this.childrenByParent.get(next) ?? []
const childrenRefs = children.map((p) => p.meta)
const children = this.nodesChildren.get(next) ?? []
const childrenRefs = children.map((p) => p.ProjectMeta[0].meta)
result.push(...childrenRefs)
queue.push(...childrenRefs)
}
@ -184,8 +309,8 @@ export async function findProjectDocsHierarchy (
space: Ref<DocumentSpace>,
project?: Ref<Project<DocumentSpace>>
): Promise<ProjectDocumentTree> {
const pjMeta = await client.findAll(documents.class.ProjectMeta, { space, project })
return new ProjectDocumentTree(pjMeta)
const ProjectMeta = await client.findAll(documents.class.ProjectMeta, { space, project })
return new ProjectDocumentTree({ ...emptyBundle(), ProjectMeta })
}
export interface DocumentBundle {
@ -201,7 +326,7 @@ export interface DocumentBundle {
Attachment: Attachment[]
}
function emptyBundle (): DocumentBundle {
export function emptyBundle (): DocumentBundle {
return {
DocumentMeta: [],
ProjectMeta: [],
@ -216,6 +341,46 @@ function emptyBundle (): DocumentBundle {
}
}
export function compileBundles (all: DocumentBundle): {
bundles: DocumentBundle[]
links: Map<Ref<Doc>, Ref<DocumentMeta>>
} {
const bundles = new Map<Ref<DocumentMeta>, DocumentBundle>(all.DocumentMeta.map((m) => [m._id, { ...emptyBundle() }]))
const links = new Map<Ref<Doc>, Ref<DocumentMeta>>()
const link = (ref: Ref<Doc>, lookup: Ref<Doc>): void => {
const meta = links.get(lookup)
if (meta !== undefined) links.set(ref, meta)
}
const relink = (ref: Ref<Doc>, prop: keyof DocumentBundle, obj: DocumentBundle[typeof prop][0]): void => {
const meta = links.get(ref)
if (meta !== undefined) bundles.get(meta)?.[prop].push(obj as any)
}
for (const m of all.DocumentMeta) links.set(m._id, m._id) // DocumentMeta -> DocumentMeta
for (const m of all.ProjectMeta) links.set(m._id, m.meta) // ProjectMeta -> DocumentMeta
for (const m of all.ProjectDocument) {
link(m._id, m.attachedTo) // ProjectDocument -> ProjectMeta
link(m.document, m.attachedTo) // ControlledDocument -> ProjectMeta
}
for (const m of all.ControlledDocument) link(m.changeControl, m.attachedTo) // ChangeControl -> ControlledDocument
for (const m of all.DocumentRequest) link(m._id, m.attachedTo) // DocumentRequest -> ControlledDocument
for (const m of all.DocumentSnapshot) link(m._id, m.attachedTo) // DocumentSnapshot -> ControlledDocument
for (const m of all.ChatMessage) link(m._id, m.attachedTo) // ChatMessage -> (ControlledDocument | ChatMessage)
for (const m of all.TagReference) link(m._id, m.attachedTo) // TagReference -> ControlledDocument
for (const m of all.Attachment) link(m._id, m.attachedTo) // Attachment -> (ControlledDocument | ChatMessage)
let key: keyof DocumentBundle
for (key in all) {
all[key].forEach((value) => {
relink(value._id, key, value)
})
}
return { bundles: Array.from(bundles.values()), links }
}
export async function findAllDocumentBundles (
client: TxOperations,
ids: Ref<DocumentMeta>[]
@ -293,40 +458,7 @@ export async function findAllDocumentBundles (
...all.ControlledDocument.map((p) => p._id)
])
const bundles = new Map<Ref<DocumentMeta>, DocumentBundle>(all.DocumentMeta.map((m) => [m._id, { ...emptyBundle() }]))
const links = new Map<Ref<Doc>, Ref<DocumentMeta>>()
const link = (ref: Ref<Doc>, lookup: Ref<Doc>): void => {
const meta = links.get(lookup)
if (meta !== undefined) links.set(ref, meta)
}
const relink = (ref: Ref<Doc>, prop: keyof DocumentBundle, obj: DocumentBundle[typeof prop][0]): void => {
const meta = links.get(ref)
if (meta !== undefined) bundles.get(meta)?.[prop].push(obj as any)
}
for (const m of all.DocumentMeta) links.set(m._id, m._id) // DocumentMeta -> DocumentMeta
for (const m of all.ProjectMeta) links.set(m._id, m.meta) // ProjectMeta -> DocumentMeta
for (const m of all.ProjectDocument) {
link(m._id, m.attachedTo) // ProjectDocument -> ProjectMeta
link(m.document, m.attachedTo) // ControlledDocument -> ProjectMeta
}
for (const m of all.ControlledDocument) link(m.changeControl, m.attachedTo) // ChangeControl -> ControlledDocument
for (const m of all.DocumentRequest) link(m._id, m.attachedTo) // DocumentRequest -> ControlledDocument
for (const m of all.DocumentSnapshot) link(m._id, m.attachedTo) // DocumentSnapshot -> ControlledDocument
for (const m of all.ChatMessage) link(m._id, m.attachedTo) // ChatMessage -> (ControlledDocument | ChatMessage)
for (const m of all.TagReference) link(m._id, m.attachedTo) // TagReference -> ControlledDocument
for (const m of all.Attachment) link(m._id, m.attachedTo) // Attachment -> (ControlledDocument | ChatMessage)
let key: keyof DocumentBundle
for (key in all) {
all[key].forEach((value) => {
relink(value._id, key, value)
})
}
return Array.from(bundles.values())
return compileBundles(all).bundles
}
export async function findOneDocumentBundle (
@ -369,7 +501,7 @@ async function _buildDocumentTransferContext (
const docIds = new Set<Ref<DocumentMeta>>(request.sourceDocumentIds)
for (const id of request.sourceDocumentIds) {
sourceTree.getDescendants(id).forEach((d) => docIds.add(d))
sourceTree.descendantsOf(id).forEach((d) => docIds.add(d))
}
const bundles = await findAllDocumentBundles(client, Array.from(docIds))

View File

@ -1094,11 +1094,11 @@ test.describe('QMS. Documents tests', () => {
await documentContentPage.checkDocument(documentDetails)
await documentContentPage.checkDocumentStatus(DocumentStatus.IN_REVIEW)
await expect(documentContentPage.contentLocator.locator('h1:first-child')).toHaveText(overview.heading)
await expect(documentContentPage.contentLocator.locator('h1:first-child + p')).toHaveText(overview.content)
await expect(documentContentPage.contentLocator.locator('h1:nth-of-type(1)')).toHaveText(overview.heading)
await expect(documentContentPage.contentLocator.locator('h1:nth-of-type(1) + p')).toHaveText(overview.content)
await expect(documentContentPage.contentLocator.locator('h1:not(:first-child)')).toHaveText(main.heading)
await expect(documentContentPage.contentLocator.locator('h1:not(:first-child) + p')).toHaveText(main.content)
await expect(documentContentPage.contentLocator.locator('h1:nth-of-type(2)')).toHaveText(main.heading)
await expect(documentContentPage.contentLocator.locator('h1:nth-of-type(2) + p')).toHaveText(main.content)
})
})
})