EZQMS-1234: ability to relocate and reorder controlled documents within the space (#7668)
Some checks are pending
CI / build (push) Waiting to run
CI / svelte-check (push) Blocked by required conditions
CI / formatting (push) Blocked by required conditions
CI / test (push) Blocked by required conditions
CI / uitest (push) Waiting to run
CI / uitest-pg (push) Waiting to run
CI / uitest-qms (push) Waiting to run
CI / docker-build (push) Blocked by required conditions
CI / dist-build (push) Blocked by required conditions

Signed-off-by: Victor Ilyushchenko <alt13ri@gmail.com>
This commit is contained in:
Victor Ilyushchenko 2025-01-15 12:29:30 +03:00 committed by GitHub
parent ff5b57dd47
commit 0c6feb646e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
16 changed files with 562 additions and 151 deletions

View File

@ -58,6 +58,7 @@
"@hcengineering/chunter": "^0.6.20", "@hcengineering/chunter": "^0.6.20",
"@hcengineering/text-editor": "^0.6.0", "@hcengineering/text-editor": "^0.6.0",
"@hcengineering/collaboration": "^0.6.0", "@hcengineering/collaboration": "^0.6.0",
"@hcengineering/time": "^0.6.0" "@hcengineering/time": "^0.6.0",
"@hcengineering/rank": "^0.6.4"
} }
} }

View File

@ -4,12 +4,12 @@
import attachment, { type Attachment } from '@hcengineering/attachment' import attachment, { type Attachment } from '@hcengineering/attachment'
import { import {
YXmlElement,
YXmlText,
YAbstractType,
yXmlElementClone,
loadCollabYdoc, loadCollabYdoc,
saveCollabYdoc saveCollabYdoc,
YAbstractType,
YXmlElement,
yXmlElementClone,
YXmlText
} from '@hcengineering/collaboration' } from '@hcengineering/collaboration'
import { import {
type ChangeControl, type ChangeControl,
@ -17,8 +17,10 @@ import {
createChangeControl, createChangeControl,
createDocumentTemplate, createDocumentTemplate,
type DocumentCategory, type DocumentCategory,
type DocumentMeta,
documentsId, documentsId,
DocumentState DocumentState,
type ProjectMeta
} from '@hcengineering/controlled-documents' } from '@hcengineering/controlled-documents'
import { import {
type Class, type Class,
@ -48,6 +50,7 @@ import { DOMAIN_ATTACHMENT } from '@hcengineering/model-attachment'
import core from '@hcengineering/model-core' import core from '@hcengineering/model-core'
import tags from '@hcengineering/tags' import tags from '@hcengineering/tags'
import { makeRank } from '@hcengineering/rank'
import documents, { DOMAIN_DOCUMENTS } from './index' import documents, { DOMAIN_DOCUMENTS } from './index'
async function createTemplatesSpace (tx: TxOperations): Promise<void> { async function createTemplatesSpace (tx: TxOperations): Promise<void> {
@ -364,6 +367,42 @@ async function migrateDocSections (client: MigrationClient): Promise<void> {
} }
} }
async function migrateProjectMetaRank (client: MigrationClient): Promise<void> {
const projectMeta = await client.find<ProjectMeta>(DOMAIN_DOCUMENTS, {
_class: documents.class.ProjectMeta,
rank: { $exists: false }
})
const docMeta = await client.find<DocumentMeta>(DOMAIN_DOCUMENTS, {
_class: documents.class.ProjectDocument,
_id: { $in: projectMeta.map((p) => p.meta) }
})
const docMetaById = new Map<Ref<DocumentMeta>, DocumentMeta>()
for (const doc of docMeta) {
docMetaById.set(doc._id, doc)
}
projectMeta.sort((a, b) => {
const docA = docMetaById.get(a.meta)
const docB = docMetaById.get(b.meta)
return (docA?.title ?? '').localeCompare(docB?.title ?? '', undefined, { numeric: true })
})
let rank = makeRank(undefined, undefined)
const operations: { filter: MigrationDocumentQuery<ProjectMeta>, update: MigrateUpdate<ProjectMeta> }[] = []
for (const doc of projectMeta) {
operations.push({
filter: { _id: doc._id },
update: { $set: { rank } }
})
rank = makeRank(rank, undefined)
}
await client.bulk(DOMAIN_DOCUMENTS, operations)
}
export const documentsOperation: MigrateOperation = { export const documentsOperation: MigrateOperation = {
async migrate (client: MigrationClient): Promise<void> { async migrate (client: MigrationClient): Promise<void> {
await tryMigrate(client, documentsId, [ await tryMigrate(client, documentsId, [
@ -374,6 +413,10 @@ export const documentsOperation: MigrateOperation = {
{ {
state: 'migrateDocSections', state: 'migrateDocSections',
func: migrateDocSections func: migrateDocSections
},
{
state: 'migrateProjectMetaRank',
func: migrateProjectMetaRank
} }
]) ])
}, },

View File

@ -58,7 +58,8 @@ import {
type Role, type Role,
type TypedSpace, type TypedSpace,
type Account, type Account,
type RolesAssignment type RolesAssignment,
type Rank
} from '@hcengineering/core' } from '@hcengineering/core'
import { import {
ArrOf, ArrOf,
@ -181,6 +182,10 @@ export class TProjectMeta extends TDoc implements ProjectMeta {
@Prop(Collection(documents.class.ProjectDocument), documents.string.Documents) @Prop(Collection(documents.class.ProjectDocument), documents.string.Documents)
documents!: CollectionSize<ProjectDocument> documents!: CollectionSize<ProjectDocument>
@Index(IndexKind.Indexed)
@Hidden()
rank!: Rank
} }
@Model(documents.class.ProjectDocument, core.class.AttachedDoc, DOMAIN_DOCUMENTS) @Model(documents.class.ProjectDocument, core.class.AttachedDoc, DOMAIN_DOCUMENTS)

View File

@ -101,6 +101,7 @@
{draggable} {draggable}
on:dragstart on:dragstart
on:dragover on:dragover
on:dragend
on:drop on:drop
> >
{#if isFold && !empty} {#if isFold && !empty}

View File

@ -70,6 +70,7 @@
"effector": "~22.8.7", "effector": "~22.8.7",
"svelte": "^4.2.19", "svelte": "^4.2.19",
"slugify": "^1.6.6", "slugify": "^1.6.6",
"fast-equals": "^5.0.1" "fast-equals": "^5.0.1",
"@hcengineering/rank": "^0.6.4"
} }
} }

View File

@ -27,7 +27,6 @@
DocumentState DocumentState
} from '@hcengineering/controlled-documents' } from '@hcengineering/controlled-documents'
import { TreeItem } from '@hcengineering/view-resources' import { TreeItem } from '@hcengineering/view-resources'
import { compareDocs } from '../../utils'
export let projectMeta: ProjectMeta[] = [] export let projectMeta: ProjectMeta[] = []
export let childrenByParent: Record<Ref<DocumentMeta>, Array<ProjectMeta>> export let childrenByParent: Record<Ref<DocumentMeta>, Array<ProjectMeta>>
@ -36,13 +35,39 @@
export let getMoreActions: ((obj: Doc, originalEvent?: MouseEvent) => Promise<Action[]>) | undefined = undefined export let getMoreActions: ((obj: Doc, originalEvent?: MouseEvent) => Promise<Action[]>) | undefined = undefined
export let collapsedPrefix: string = '' export let collapsedPrefix: string = ''
export let onDragStart: ((e: DragEvent, object: Ref<DocumentMeta>) => void) | undefined = undefined
export let onDragOver: ((e: DragEvent, object: Ref<DocumentMeta>) => void) | undefined = undefined
export let onDragEnd: ((e: DragEvent, object: Ref<DocumentMeta>) => void) | undefined = undefined
export let onDrop: ((e: DragEvent, object: Ref<DocumentMeta>) => void) | undefined = undefined
export let draggedItem: Ref<DocumentMeta> | undefined = undefined
export let draggedOver: Ref<DocumentMeta> | undefined = undefined
import DropArea from './DropArea.svelte'
const dispatch = createEventDispatcher() const dispatch = createEventDispatcher()
const currentUser = getCurrentAccount() as PersonAccount const currentUser = getCurrentAccount() as PersonAccount
const currentPerson = currentUser.person const currentPerson = currentUser.person
const docsQuery = createQuery()
let docs: WithLookup<ProjectDocument>[] = [] 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( $: docsQuery.query(
documents.class.ProjectDocument, documents.class.ProjectDocument,
{ {
@ -79,12 +104,7 @@
} }
} }
docs.sort((a, b) => { sortDocs(projectMeta)
if (a.$lookup?.document !== undefined && b.$lookup?.document !== undefined) {
return compareDocs(a.$lookup.document, b.$lookup.document)
}
return 0
})
}, },
{ {
lookup: { lookup: {
@ -110,37 +130,62 @@
{#if doc} {#if doc}
{@const children = childrenByParent[doc.attachedTo] ?? []} {@const children = childrenByParent[doc.attachedTo] ?? []}
<TreeItem {@const isDraggedOver = draggedOver === doc.attachedTo}
_id={doc._id} <div class="flex-col relative">
icon={documents.icon.Document} {#if isDraggedOver}
iconProps={{ <DropArea />
fill: 'currentColor' {/if}
}} <TreeItem
title={getDocumentName(doc)} _id={doc._id}
selected={selected === doc._id || selected === prjdoc._id} icon={documents.icon.Document}
isFold iconProps={{
empty={children.length === 0 || children === undefined} fill: 'currentColor'
actions={getMoreActions !== undefined ? () => getDocMoreActions(prjdoc) : undefined} }}
{level} title={getDocumentName(doc)}
{collapsedPrefix} selected={selected === doc._id || selected === prjdoc._id}
shouldTooltip isFold
on:click={() => { empty={children.length === 0 || children === undefined}
dispatch('selected', prjdoc) actions={getMoreActions !== undefined ? () => getDocMoreActions(prjdoc) : undefined}
}} {level}
> {collapsedPrefix}
<svelte:fragment slot="dropbox"> shouldTooltip
{#if children.length} on:click={() => {
<svelte:self dispatch('selected', prjdoc)
projectMeta={children} }}
{childrenByParent} draggable={onDragStart !== undefined}
{selected} on:dragstart={(evt) => {
{collapsedPrefix} onDragStart?.(evt, doc.attachedTo)
{getMoreActions} }}
level={level + 1} on:dragover={(evt) => {
on:selected onDragOver?.(evt, doc.attachedTo)
/> }}
{/if} on:dragend={(evt) => {
</svelte:fragment> onDragEnd?.(evt, doc.attachedTo)
</TreeItem> }}
on:drop={(evt) => {
onDrop?.(evt, doc.attachedTo)
}}
>
<svelte:fragment slot="dropbox">
{#if children.length}
<svelte:self
projectMeta={children}
{childrenByParent}
{selected}
{collapsedPrefix}
{getMoreActions}
level={level + 1}
{onDragStart}
{onDragOver}
{onDragEnd}
{onDrop}
{draggedItem}
{draggedOver}
on:selected
/>
{/if}
</svelte:fragment>
</TreeItem>
</div>
{/if} {/if}
{/each} {/each}

View File

@ -16,7 +16,15 @@
import { WithLookup, type Doc, type Ref, type Space } from '@hcengineering/core' import { WithLookup, type Doc, type Ref, type Space } from '@hcengineering/core'
import { createQuery, getClient } from '@hcengineering/presentation' import { createQuery, getClient } from '@hcengineering/presentation'
import { getResource } from '@hcengineering/platform' import { getResource } from '@hcengineering/platform'
import { type Action, getPlatformColorForTextDef, themeStore, navigate, IconEdit, Label } from '@hcengineering/ui' import {
type Action,
getPlatformColorForTextDef,
themeStore,
navigate,
IconEdit,
Label,
closeTooltip
} from '@hcengineering/ui'
import { getActions as getContributedActions, TreeNode, TreeItem } from '@hcengineering/view-resources' import { getActions as getContributedActions, TreeNode, TreeItem } from '@hcengineering/view-resources'
import { ActionGroup } from '@hcengineering/view' import { ActionGroup } from '@hcengineering/view'
import { import {
@ -40,11 +48,17 @@
getProjectDocsHierarchy, getProjectDocsHierarchy,
isEditableProject, isEditableProject,
createDocument, createDocument,
canCreateChildDocument canCreateChildDocument,
moveDocument,
moveDocumentBefore,
moveDocumentAfter
} from '../../utils' } from '../../utils'
import documents from '../../plugin' import documents from '../../plugin'
import DropArea from './DropArea.svelte'
import DropMarker from './DropMarker.svelte'
export let space: DocumentSpace export let space: DocumentSpace
export let currentSpace: Ref<Space> | undefined export let currentSpace: Ref<Space> | undefined
export let currentFragment: string | undefined export let currentFragment: string | undefined
@ -67,6 +81,7 @@
let project: Ref<Project> = documents.ids.NoProject let project: Ref<Project> = documents.ids.NoProject
$: void selectProject(space) $: void selectProject(space)
let docsByMeta = new Map<Ref<DocumentMeta>, WithLookup<ProjectMeta>>()
let rootDocs: Array<WithLookup<ProjectMeta>> = [] let rootDocs: Array<WithLookup<ProjectMeta>> = []
let childrenByParent: Record<Ref<DocumentMeta>, Array<WithLookup<ProjectMeta>>> = {} let childrenByParent: Record<Ref<DocumentMeta>, Array<WithLookup<ProjectMeta>>> = {}
@ -78,6 +93,7 @@
project project
}, },
(result) => { (result) => {
docsByMeta = new Map(result.map((r) => [r.meta, r]))
;({ rootDocs, childrenByParent } = getProjectDocsHierarchy(result)) ;({ rootDocs, childrenByParent } = getProjectDocsHierarchy(result))
}, },
{ {
@ -116,6 +132,23 @@
selectedControlledDoc = undefined 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> { async function selectProject (space: DocumentSpace): Promise<void> {
project = getCurrentProject(space._id) ?? (await getLatestProjectId(space._id, true)) ?? documents.ids.NoProject project = getCurrentProject(space._id) ?? (await getLatestProjectId(space._id, true)) ?? documents.ids.NoProject
} }
@ -181,75 +214,202 @@
return actions return actions
} }
let parent: HTMLElement
let draggedItem: Ref<DocumentMeta> | undefined = undefined
let draggedOver: Ref<DocumentMeta> | undefined = undefined
let draggedOverPos: 'before' | 'after' | undefined = undefined
let draggedOverTop: number = 0
let cannotDropTo: Ref<DocumentMeta>[] = []
function canDrop (object: Ref<DocumentMeta>, target: Ref<DocumentMeta>): boolean {
if (object === target) return false
if (cannotDropTo.includes(target)) return false
return true
}
function onDragStart (event: DragEvent, object: Ref<DocumentMeta>): void {
// no prevent default to leverage default rendering
// event.preventDefault()
if (event.dataTransfer === null || event.target === null) {
return
}
cannotDropTo = [object, ...getAllDescendants(object)]
event.dataTransfer.effectAllowed = 'move'
event.dataTransfer.dropEffect = 'move'
draggedItem = object
closeTooltip()
}
function getDropPosition (event: DragEvent): { pos: 'before' | 'after' | undefined, top: number } {
const parentRect = parent.getBoundingClientRect()
const targetRect = (event.target as HTMLElement).getBoundingClientRect()
const dropPosition = event.clientY - targetRect.top
const before = dropPosition >= 0 && dropPosition < targetRect.height / 6
const after = dropPosition <= targetRect.height && dropPosition > (5 * targetRect.height) / 6
const pos = before ? 'before' : after ? 'after' : undefined
const top = pos === 'before' ? targetRect.top - parentRect.top - 1 : targetRect.bottom - parentRect.top - 1
return { pos, top }
}
function onDragOver (event: DragEvent, object: Ref<DocumentMeta>): void {
event.preventDefault()
// this is an ugly solution to control drop effect
// we drag and drop elements that are in the depth of components hierarchy
// so we cannot access them directly
if (!(event.target as HTMLElement).draggable) return
if (event.dataTransfer === null || event.target === null || draggedItem === object) {
return
}
if (draggedItem !== undefined && canDrop(draggedItem, object)) {
event.dataTransfer.dropEffect = 'move'
draggedOver = object
const { pos, top } = getDropPosition(event)
draggedOverPos = pos
draggedOverTop = top
} else {
event.dataTransfer.dropEffect = 'none'
}
}
function onDragEnd (event: DragEvent): void {
event.preventDefault()
draggedItem = undefined
draggedOver = undefined
draggedOverPos = undefined
}
function onDrop (event: DragEvent, object: Ref<DocumentMeta>): void {
event.preventDefault()
if (event.dataTransfer === null) {
return
}
if (draggedItem !== undefined && canDrop(draggedItem, object)) {
const doc = docsByMeta.get(draggedItem)
const target = docsByMeta.get(object)
if (doc !== undefined && target !== undefined && doc._id !== target._id) {
if (object === documents.ids.NoParent) {
void moveDocument(doc, doc.space)
} else if (target !== undefined) {
const { pos } = getDropPosition(event)
if (pos === 'before') {
void moveDocumentBefore(doc, target)
} else if (pos === 'after') {
void moveDocumentAfter(doc, target)
} else if (doc.parent !== object) {
void moveDocument(doc, target.space, target)
}
}
}
}
draggedItem = undefined
draggedOver = undefined
}
</script> </script>
<TreeNode <div bind:this={parent} class="flex-col relative">
_id={space?._id} {#if draggedOver === documents.ids.NoParent}
folderIcon <DropArea />
iconProps={{
fill: getPlatformColorForTextDef(space.name, $themeStore.dark).icon
}}
title={space.name}
highlighted={space._id === currentSpace && currentFragment !== undefined && !deselect}
visible={(space._id === currentSpace && currentFragment !== undefined && !deselect) || forciblyСollapsed}
showMenu={pressed}
{forciblyСollapsed}
actions={() => getSpaceActions(space)}
type={'nested'}
>
<svelte:fragment slot="extra">
{#if spaceType?.projects === true}
<ProjectSelector
value={project}
space={space?._id}
maxWidth={'6rem'}
kind={'ghost'}
size={'x-small'}
showDropdownIcon
bind:pressed
on:change={(evt) => {
project = evt.detail
setCurrentProject(space._id, project)
}}
/>
{/if}
</svelte:fragment>
{#if rootDocs.length > 0}
<DocHierarchyLevel
projectMeta={rootDocs}
{childrenByParent}
{selected}
getMoreActions={getDocumentActions}
on:selected={(e) => {
handleDocumentSelected(e.detail)
}}
/>
{:else}
<div class="pseudo-element flex-row-center content-dark-color text-md nowrap">
<Label label={documents.string.NoDocuments} />
</div>
{/if} {/if}
<svelte:fragment slot="visible"> {#if draggedOver && draggedOverPos}
{#if (selected || forciblyСollapsed) && selectedControlledDoc} <DropMarker top={draggedOverTop} />
{@const doc = selectedControlledDoc} {/if}
<TreeItem
_id={doc._id} <TreeNode
icon={documents.icon.Document} _id={space?._id}
iconProps={{ folderIcon
fill: 'currentColor' iconProps={{
fill: getPlatformColorForTextDef(space.name, $themeStore.dark).icon
}}
title={space.name}
highlighted={space._id === currentSpace && currentFragment !== undefined && !deselect}
visible={(space._id === currentSpace && currentFragment !== undefined && !deselect) || forciblyСollapsed}
showMenu={pressed}
{forciblyСollapsed}
actions={() => getSpaceActions(space)}
type={'nested'}
draggable
on:drop={(evt) => {
onDrop(evt, documents.ids.NoParent)
}}
on:dragover={(evt) => {
onDragOver(evt, documents.ids.NoParent)
}}
on:dragstart={(evt) => {
evt.preventDefault()
}}
>
<svelte:fragment slot="extra">
{#if spaceType?.projects === true}
<ProjectSelector
value={project}
space={space?._id}
maxWidth={'6rem'}
kind={'ghost'}
size={'x-small'}
showDropdownIcon
bind:pressed
on:change={(evt) => {
project = evt.detail
setCurrentProject(space._id, project)
}}
/>
{/if}
</svelte:fragment>
{#if rootDocs.length > 0}
<DocHierarchyLevel
projectMeta={rootDocs}
{childrenByParent}
{selected}
getMoreActions={getDocumentActions}
on:selected={(e) => {
handleDocumentSelected(e.detail)
}} }}
title={getDocumentName(doc)} {onDragStart}
actions={() => getDocumentActions(doc)} {onDragEnd}
selected {onDragOver}
isFold {onDrop}
empty {draggedItem}
forciblyСollapsed {draggedOver}
/> />
{:else}
<div class="pseudo-element flex-row-center content-dark-color text-md nowrap">
<Label label={documents.string.NoDocuments} />
</div>
{/if} {/if}
</svelte:fragment>
</TreeNode> <svelte:fragment slot="visible">
{#if (selected || forciblyСollapsed) && selectedControlledDoc}
{@const doc = selectedControlledDoc}
<TreeItem
_id={doc._id}
icon={documents.icon.Document}
iconProps={{
fill: 'currentColor'
}}
title={getDocumentName(doc)}
actions={() => getDocumentActions(doc)}
selected
isFold
empty
forciblyСollapsed
/>
{/if}
</svelte:fragment>
</TreeNode>
</div>
<style lang="scss"> <style lang="scss">
.pseudo-element { .pseudo-element {

View File

@ -0,0 +1,29 @@
<!--
// Copyright © 2024 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.
-->
<div class="drop-area" />
<style lang="scss">
.drop-area {
pointer-events: none;
position: absolute;
left: 0.75rem;
right: 0.75rem;
top: 0;
bottom: 0;
background-color: var(--global-ui-highlight-BackgroundColor);
border-radius: 0.5rem;
}
</style>

View File

@ -0,0 +1,33 @@
<!--
// Copyright © 2024 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">
export let top: number
</script>
<div class="drop-marker" style="top: {top}px;" />
<style lang="scss">
.drop-marker {
pointer-events: none;
position: absolute;
z-index: 100;
height: 0.125rem;
background-color: var(--primary-button-focused);
left: 0.75rem;
right: 0.75rem;
top: 10rem;
}
</style>

View File

@ -11,51 +11,53 @@
// //
// See the License for the specific language governing permissions and // See the License for the specific language governing permissions and
// limitations under the License. // limitations under the License.
import core, {
type Class,
type Doc,
type DocumentQuery,
type Hierarchy,
type Ref,
type Tx,
type TxOperations,
type Space,
type Markup,
type Client,
type WithLookup,
SortingOrder,
getCurrentAccount,
checkPermission
} from '@hcengineering/core'
import { type IntlString, translate } from '@hcengineering/platform'
import { getClient } from '@hcengineering/presentation'
import { type Person, type Employee, type PersonAccount } from '@hcengineering/contact'
import request, { RequestStatus } from '@hcengineering/request'
import { isEmptyMarkup } from '@hcengineering/text'
import { showPopup, getUserTimezone, type Location } from '@hcengineering/ui'
import { type KeyFilter } from '@hcengineering/view'
import chunter from '@hcengineering/chunter' import chunter from '@hcengineering/chunter'
import { type Employee, type Person, type PersonAccount } from '@hcengineering/contact'
import documents, { import documents, {
type ControlledDocument, type ControlledDocument,
type Document, type Document,
type DocumentRequest,
type DocumentTemplate,
type DocumentSpace,
type DocumentCategory, type DocumentCategory,
type DocumentMeta,
type DocumentComment, type DocumentComment,
type DocumentMeta,
type DocumentRequest,
type DocumentSpace,
type DocumentTemplate,
type OrgSpace, type OrgSpace,
type Project, type Project,
type ProjectDocument, type ProjectDocument,
type ProjectMeta, type ProjectMeta,
ControlledDocumentState, ControlledDocumentState,
DocumentState, DocumentState,
getDocumentName getDocumentName,
getFirstRank
} from '@hcengineering/controlled-documents' } from '@hcengineering/controlled-documents'
import { type Request } from '@hcengineering/request' import core, {
type Class,
type Client,
type Doc,
type DocumentQuery,
type Hierarchy,
type Markup,
type QuerySelector,
type Ref,
type Space,
type Tx,
type TxOperations,
type WithLookup,
SortingOrder,
checkPermission,
getCurrentAccount
} from '@hcengineering/core'
import { type IntlString, translate } from '@hcengineering/platform'
import { 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'
import { type KeyFilter } from '@hcengineering/view'
import documentsResources from './plugin' import { makeRank } from '@hcengineering/rank'
import { getProjectDocumentLink } from './navigation' import { getProjectDocumentLink } from './navigation'
import documentsResources from './plugin'
import { wizardOpened } from './stores/wizards/create-document' import { wizardOpened } from './stores/wizards/create-document'
export type TranslatedDocumentStates = Readonly<Record<DocumentState, string>> export type TranslatedDocumentStates = Readonly<Record<DocumentState, string>>
@ -654,3 +656,41 @@ export function formatSignatureDate (date: number): string {
second: 'numeric' second: 'numeric'
}) })
} }
export async function moveDocument (doc: ProjectMeta, space: Ref<Space>, target?: ProjectMeta): Promise<void> {
const client = getClient()
let parent = documents.ids.NoParent
let path: Array<Ref<DocumentMeta>> = []
if (target !== undefined) {
parent = target.meta
path = [target.meta, ...target.path]
}
const prevRank = await getFirstRank(client, space, doc.project, parent)
const rank = makeRank(prevRank, undefined)
await client.update(doc, { parent, path, rank })
}
export async function moveDocumentBefore (doc: ProjectMeta, before: ProjectMeta): Promise<void> {
const client = getClient()
const { space, parent, path } = before
const query = { rank: { $lt: before.rank } as unknown as QuerySelector<ProjectMeta['rank']> }
const lastRank = await getFirstRank(client, space, doc.project, parent, SortingOrder.Descending, query)
const rank = makeRank(lastRank, before.rank)
await client.update(doc, { parent, path, rank })
}
export async function moveDocumentAfter (doc: ProjectMeta, after: ProjectMeta): Promise<void> {
const client = getClient()
const { space, parent, path } = after
const query = { rank: { $gt: after.rank } as unknown as QuerySelector<ProjectMeta['rank']> }
const nextRank = await getFirstRank(client, space, doc.project, parent, SortingOrder.Ascending, query)
const rank = makeRank(after.rank, nextRank)
await client.update(doc, { parent, path, rank })
}

View File

@ -49,7 +49,8 @@
"@hcengineering/training": "^0.1.0", "@hcengineering/training": "^0.1.0",
"lexorank": "~1.0.4", "lexorank": "~1.0.4",
"@hcengineering/activity": "^0.6.0", "@hcengineering/activity": "^0.6.0",
"@hcengineering/chunter": "^0.6.20" "@hcengineering/chunter": "^0.6.20",
"@hcengineering/rank": "^0.6.4"
}, },
"publishConfig": { "publishConfig": {
"@hcengineering:registry": "https://npm.pkg.github.com" "@hcengineering:registry": "https://npm.pkg.github.com"

View File

@ -27,9 +27,10 @@ import {
type ProjectDocument, type ProjectDocument,
DocumentState DocumentState
} from './types' } from './types'
import { makeRank } from '@hcengineering/rank'
import documents from './plugin' import documents from './plugin'
import { TEMPLATE_PREFIX } from './utils' import { getFirstRank, TEMPLATE_PREFIX } from './utils'
async function getParentPath (client: TxOperations, parent: Ref<ProjectDocument>): Promise<Array<Ref<DocumentMeta>>> { async function getParentPath (client: TxOperations, parent: Ref<ProjectDocument>): Promise<Array<Ref<DocumentMeta>>> {
const parentDocObj = await client.findOne(documents.class.ProjectDocument, { const parentDocObj = await client.findOne(documents.class.ProjectDocument, {
@ -175,12 +176,16 @@ export async function createControlledDocMetadata (
path = await getParentPath(client, parent) path = await getParentPath(client, parent)
} }
const parentMeta = path[0] ?? documents.ids.NoParent
const lastRank = await getFirstRank(client, space, projectId, parentMeta)
const projectMetaId = await ops.createDoc(documents.class.ProjectMeta, space, { const projectMetaId = await ops.createDoc(documents.class.ProjectMeta, space, {
project: projectId, project: projectId,
meta: documentMetaId, meta: documentMetaId,
path, path,
parent: path[0] ?? documents.ids.NoParent, parent: parentMeta,
documents: 0 documents: 0,
rank: makeRank(lastRank, undefined)
}) })
const projectDocumentId = await client.addCollection( const projectDocumentId = await client.addCollection(
@ -323,12 +328,16 @@ export async function createDocumentTemplateMetadata (
metaId metaId
) )
const parentMeta = path[0] ?? documents.ids.NoParent
const lastRank = await getFirstRank(client, space, projectId, parentMeta)
const projectMetaId = await ops.createDoc(documents.class.ProjectMeta, space, { const projectMetaId = await ops.createDoc(documents.class.ProjectMeta, space, {
project: projectId, project: projectId,
meta: documentMetaId, meta: documentMetaId,
path, path,
parent: path[0] ?? documents.ids.NoParent, parent: path[0] ?? documents.ids.NoParent,
documents: 0 documents: 0,
rank: makeRank(lastRank, undefined)
}) })
const projectDocumentId = await client.addCollection( const projectDocumentId = await client.addCollection(

View File

@ -16,7 +16,8 @@ import {
type TypedSpace, type TypedSpace,
type Timestamp, type Timestamp,
SpaceType, SpaceType,
SpaceTypeDescriptor SpaceTypeDescriptor,
Rank
} from '@hcengineering/core' } from '@hcengineering/core'
import { type TagReference } from '@hcengineering/tags' import { type TagReference } from '@hcengineering/tags'
import { Request } from '@hcengineering/request' import { Request } from '@hcengineering/request'
@ -91,6 +92,8 @@ export interface ProjectMeta extends Doc {
// head: Ref<HierarchyDocument> // head: Ref<HierarchyDocument>
documents: CollectionSize<ProjectDocument> documents: CollectionSize<ProjectDocument>
rank: Rank
} }
/** /**

View File

@ -12,7 +12,17 @@
// See the License for the specific language governing permissions and // See the License for the specific language governing permissions and
// limitations under the License. // limitations under the License.
// //
import { ApplyOperations, Data, DocumentUpdate, Ref, TxOperations } from '@hcengineering/core' import {
ApplyOperations,
Data,
DocumentQuery,
DocumentUpdate,
Rank,
Ref,
SortingOrder,
Space,
TxOperations
} from '@hcengineering/core'
import { LexoDecimal, LexoNumeralSystem36, LexoRank } from 'lexorank' import { LexoDecimal, LexoNumeralSystem36, LexoRank } from 'lexorank'
import LexoRankBucket from 'lexorank/lib/lexoRank/lexoRankBucket' import LexoRankBucket from 'lexorank/lib/lexoRank/lexoRankBucket'
@ -22,6 +32,7 @@ import {
ChangeControl, ChangeControl,
ControlledDocument, ControlledDocument,
Document, Document,
DocumentMeta,
DocumentSpace, DocumentSpace,
DocumentState, DocumentState,
Project, Project,
@ -143,7 +154,8 @@ export async function copyProjectDocuments (
meta: meta.meta, meta: meta.meta,
path: meta.path, path: meta.path,
parent: meta.parent, parent: meta.parent,
documents: meta.documents documents: meta.documents,
rank: meta.rank
}) })
// copy project docs attached to meta // copy project docs attached to meta
@ -165,6 +177,26 @@ export async function copyProjectDocuments (
} }
} }
/**
* @public
*/
export async function getFirstRank (
client: TxOperations,
space: Ref<Space>,
project: Ref<Project>,
parent: Ref<DocumentMeta>,
sort: SortingOrder = SortingOrder.Descending,
extra: DocumentQuery<ProjectMeta> = {}
): Promise<Rank | undefined> {
const doc = await client.findOne(
documents.class.ProjectMeta,
{ space, project, parent, ...extra },
{ sort: { rank: sort }, projection: { rank: 1 } }
)
return doc?.rank
}
/** /**
* @public * @public
*/ */

View File

@ -108,6 +108,7 @@
on:click on:click
on:dragstart on:dragstart
on:dragover on:dragover
on:dragend
on:drop on:drop
on:toggle={(ev) => { on:toggle={(ev) => {
if (ev.detail !== undefined) collapsed = !ev.detail if (ev.detail !== undefined) collapsed = !ev.detail
@ -176,6 +177,7 @@
on:click on:click
on:dragstart on:dragstart
on:dragover on:dragover
on:dragend
on:drop on:drop
> >
<slot /> <slot />

View File

@ -39,6 +39,7 @@
export let showNotify: boolean = false export let showNotify: boolean = false
export let forciblyСollapsed: boolean = false export let forciblyСollapsed: boolean = false
export let collapsedPrefix: string = '' export let collapsedPrefix: string = ''
export let draggable: boolean = false
</script> </script>
<TreeElement <TreeElement
@ -62,7 +63,12 @@
{showMenu} {showMenu}
{noDivider} {noDivider}
{forciblyСollapsed} {forciblyСollapsed}
{draggable}
on:click on:click
on:dragstart
on:dragover
on:dragend
on:drop
> >
<svelte:fragment slot="extra"><slot name="extra" /></svelte:fragment> <svelte:fragment slot="extra"><slot name="extra" /></svelte:fragment>
<svelte:fragment slot="dropbox"><slot name="dropbox" /></svelte:fragment> <svelte:fragment slot="dropbox"><slot name="dropbox" /></svelte:fragment>