mirror of
https://github.com/hcengineering/platform.git
synced 2025-01-22 11:26:58 +00:00
EZQMS-1234: means for transferring controlled documents between spaces (#7691)
* EZQMS-1234: transfer of controlled documents between spaces Signed-off-by: Victor Ilyushchenko <alt13ri@gmail.com> * ff Signed-off-by: Victor Ilyushchenko <alt13ri@gmail.com> * updated translation Signed-off-by: Victor Ilyushchenko <alt13ri@gmail.com> --------- Signed-off-by: Victor Ilyushchenko <alt13ri@gmail.com>
This commit is contained in:
parent
8f31c6ec61
commit
8829859d1e
@ -676,6 +676,24 @@ export function createModel (builder: Builder): void {
|
||||
provider: documents.function.DocumentIdentifierProvider
|
||||
})
|
||||
|
||||
createAction(
|
||||
builder,
|
||||
{
|
||||
action: documents.actionImpl.TransferDocument,
|
||||
label: documents.string.Transfer,
|
||||
icon: view.icon.Move,
|
||||
input: 'any',
|
||||
category: view.category.General,
|
||||
target: documents.class.ProjectDocument,
|
||||
visibilityTester: documents.function.CanTransferDocument,
|
||||
context: {
|
||||
mode: ['context', 'browser'],
|
||||
group: 'copy'
|
||||
}
|
||||
},
|
||||
documents.action.TransferDocument
|
||||
)
|
||||
|
||||
createAction(
|
||||
builder,
|
||||
{
|
||||
|
@ -61,8 +61,10 @@ export default mergeIds(documentsId, documents, {
|
||||
CreateChildTemplate: '' as ViewAction,
|
||||
CreateDocument: '' as ViewAction,
|
||||
CreateTemplate: '' as ViewAction,
|
||||
TransferTemplate: '' as ViewAction,
|
||||
DeleteDocument: '' as ViewAction,
|
||||
ArchiveDocument: '' as ViewAction,
|
||||
TransferDocument: '' as ViewAction,
|
||||
EditDocSpace: '' as ViewAction
|
||||
},
|
||||
viewlet: {
|
||||
|
@ -129,7 +129,12 @@
|
||||
"Copy": "kopírovat",
|
||||
|
||||
"ConfigLabel": "Řízené dokumenty",
|
||||
"ConfigDescription": "Rozšíření pro správu řízených dokumentů"
|
||||
"ConfigDescription": "Rozšíření pro správu řízených dokumentů",
|
||||
|
||||
"Transfer": "Přenos",
|
||||
"TransferWarning": "Někteří členové týmu mohou po této akci ztratit možnost prohlížet nebo upravovat tento dokument.",
|
||||
"TransferDocuments": "Přenos řízených dokumentů",
|
||||
"TransferDocumentsHint": "Dokumenty, které mají být přeneseny do vybraného prostoru:"
|
||||
},
|
||||
"controlledDocStates": {
|
||||
"Empty": "",
|
||||
|
@ -293,7 +293,12 @@
|
||||
"DeleteDocumentCategoryPermission": "Dokumentenkategorie löschen",
|
||||
"DeleteDocumentCategoryDescription": "Gewährt Benutzern die Möglichkeit, eine Dokumentenkategorie zu löschen",
|
||||
"ConfigLabel": "Kontrollierte Dokumente",
|
||||
"ConfigDescription": "Erweiterung zur Verwaltung kontrollierter Dokumente"
|
||||
"ConfigDescription": "Erweiterung zur Verwaltung kontrollierter Dokumente",
|
||||
|
||||
"Transfer": "Übertragung",
|
||||
"TransferWarning": "Einige Teammitglieder können dieses Dokument nach dieser Aktion möglicherweise nicht mehr anzeigen oder bearbeiten.",
|
||||
"TransferDocuments": "Übertragung kontrollierter Dokumente",
|
||||
"TransferDocumentsHint": "Dokumente, die in den ausgewählten Bereich übertragen werden sollen:"
|
||||
},
|
||||
"controlledDocStates": {
|
||||
"Empty": "",
|
||||
|
@ -295,7 +295,12 @@
|
||||
"DeleteDocumentCategoryPermission": "Delete document category",
|
||||
"DeleteDocumentCategoryDescription": "Grants users ability to delete a document category",
|
||||
"ConfigLabel": "Controlled Documents",
|
||||
"ConfigDescription": "Extension to manage controlled documents"
|
||||
"ConfigDescription": "Extension to manage controlled documents",
|
||||
|
||||
"Transfer": "Transfer",
|
||||
"TransferWarning": "Some team members may lose the ability to view or edit this document after this action.",
|
||||
"TransferDocuments": "Transfer controlled documents",
|
||||
"TransferDocumentsHint": "Documents to be transferred to the selected space:"
|
||||
},
|
||||
"controlledDocStates": {
|
||||
"Empty": "",
|
||||
|
@ -253,7 +253,12 @@
|
||||
"DeleteDocumentCategoryPermission": "Supprimer la catégorie de document",
|
||||
"DeleteDocumentCategoryDescription": "Accorde aux utilisateurs la capacité de supprimer une catégorie de document",
|
||||
"ConfigLabel": "Documents contrôlés",
|
||||
"ConfigDescription": "Extension pour gérer les documents contrôlés"
|
||||
"ConfigDescription": "Extension pour gérer les documents contrôlés",
|
||||
|
||||
"Transfer": "Transfert",
|
||||
"TransferWarning": "Certains membres de l'équipe peuvent perdre la possibilité de visualiser ou de modifier ce document après cette action.",
|
||||
"TransferDocuments": "Transférer des documents contrôlés",
|
||||
"TransferDocumentsHint": "Documents à transférer dans l'espace sélectionné:"
|
||||
},
|
||||
"controlledDocStates": {
|
||||
"Empty": "",
|
||||
|
@ -251,7 +251,12 @@
|
||||
"DeleteDocumentCategoryPermission": "Elimina categoria documento",
|
||||
"DeleteDocumentCategoryDescription": "Concede agli utenti la possibilità di eliminare una categoria di documento",
|
||||
"ConfigLabel": "Documenti controllati",
|
||||
"ConfigDescription": "Estensione per gestire documenti controllati"
|
||||
"ConfigDescription": "Estensione per gestire documenti controllati",
|
||||
|
||||
"Transfer": "Trasferimento",
|
||||
"TransferWarning": "Alcuni membri del team potrebbero perdere la possibilità di visualizzare o modificare il documento dopo questa azione.",
|
||||
"TransferDocuments": "Trasferimento di documenti controllati",
|
||||
"TransferDocumentsHint": "Documenti da trasferire nello spazio selezionato:"
|
||||
},
|
||||
"controlledDocStates": {
|
||||
"Empty": "",
|
||||
|
@ -295,7 +295,12 @@
|
||||
"DeleteDocumentCategoryPermission": "Удалять категорию",
|
||||
"DeleteDocumentCategoryDescription": "Предоставляет пользователям разрешение удалять категорию",
|
||||
"ConfigLabel": "Управляемые Документы",
|
||||
"ConfigDescription": "Расширение для управления управляемыми документами"
|
||||
"ConfigDescription": "Расширение для управления управляемыми документами",
|
||||
|
||||
"Transfer": "Трансфер",
|
||||
"TransferWarning": "После этого действия некоторые члены команды могут потерять возможность просматривать или редактировать этот документ.",
|
||||
"TransferDocuments": "Трансфер управляемых документов",
|
||||
"TransferDocumentsHint": "Документы, которые будут перенесены в выбранное пространство:"
|
||||
},
|
||||
"controlledDocStates": {
|
||||
"Empty": "",
|
||||
|
@ -292,7 +292,12 @@
|
||||
"DeleteDocumentCategoryPermission": "删除文档类别",
|
||||
"DeleteDocumentCategoryDescription": "授予用户删除文档类别的权限",
|
||||
"ConfigLabel": "受控文档",
|
||||
"ConfigDescription": "用于管理受控文档的扩展"
|
||||
"ConfigDescription": "用于管理受控文档的扩展",
|
||||
|
||||
"Transfer": "转让",
|
||||
"TransferWarning": "执行此操作后,某些团队成员可能会失去查看或编辑此文档的能力",
|
||||
"TransferDocuments": "移交受控文件",
|
||||
"TransferDocumentsHint": "要转移到所选空间的文件:"
|
||||
},
|
||||
"controlledDocStates": {
|
||||
"Empty": "",
|
||||
|
@ -0,0 +1,284 @@
|
||||
<!--
|
||||
// Copyright © 2025 Hardcore Engineering Inc.
|
||||
//
|
||||
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License. You may
|
||||
// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
//
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
-->
|
||||
|
||||
<script lang="ts">
|
||||
import documents, {
|
||||
canTransferDocuments,
|
||||
DocumentMeta,
|
||||
DocumentTransferRequest,
|
||||
listDocumentsAffectedByTransfer,
|
||||
transferDocuments,
|
||||
type DocumentSpace,
|
||||
type DocumentSpaceType,
|
||||
type Project,
|
||||
type ProjectDocument
|
||||
} from '@hcengineering/controlled-documents'
|
||||
import { type Doc, type Ref, type Space } from '@hcengineering/core'
|
||||
import presentation, { getClient, SpaceSelector } from '@hcengineering/presentation'
|
||||
import { Button, Label } from '@hcengineering/ui'
|
||||
import { permissionsStore } from '@hcengineering/view-resources'
|
||||
import { createEventDispatcher } from 'svelte'
|
||||
|
||||
import documentsRes from '../../../plugin'
|
||||
import { getLatestProjectId } from '../../../utils'
|
||||
import DocumentParentSelector from '../../hierarchy/DocumentParentSelector.svelte'
|
||||
import ProjectSelector from '../../project/ProjectSelector.svelte'
|
||||
|
||||
import Info from '../../icons/Info.svelte'
|
||||
|
||||
export let sourceDocumentIds: Ref<DocumentMeta>[] = []
|
||||
export let sourceSpaceId: Ref<DocumentSpace> | undefined
|
||||
export let sourceProjectId: Ref<Project<DocumentSpace>> | undefined
|
||||
|
||||
let targetSpaceId: Ref<DocumentSpace> | undefined
|
||||
let targetParentId: Ref<DocumentMeta> | undefined
|
||||
|
||||
let targetSpace: DocumentSpace | undefined
|
||||
$: void fetchSpace(targetSpaceId)
|
||||
|
||||
let targetSpaceType: DocumentSpaceType | undefined
|
||||
$: void fetchSpaceType(targetSpace?.type)
|
||||
|
||||
let targetProjectId: Ref<Project> | undefined
|
||||
$: void selectProject(targetSpaceId)
|
||||
|
||||
let targetParentDocumentId: Ref<ProjectDocument> | undefined
|
||||
|
||||
let affectedDocs: DocumentMeta[] = []
|
||||
let canTransfer = false
|
||||
|
||||
$: request =
|
||||
sourceSpaceId !== undefined && targetSpaceId !== undefined
|
||||
? ({
|
||||
sourceDocumentIds,
|
||||
sourceSpaceId,
|
||||
sourceProjectId,
|
||||
targetSpaceId,
|
||||
targetProjectId,
|
||||
targetParentId
|
||||
} satisfies DocumentTransferRequest)
|
||||
: undefined
|
||||
|
||||
const client = getClient()
|
||||
const hierarchy = client.getHierarchy()
|
||||
|
||||
const dispatch = createEventDispatcher()
|
||||
|
||||
async function transfer (): Promise<void> {
|
||||
if (request !== undefined) {
|
||||
await transferDocuments(client, request)
|
||||
dispatch('close')
|
||||
}
|
||||
}
|
||||
|
||||
$: if (request !== undefined) {
|
||||
void listDocumentsAffectedByTransfer(client, request).then((result) => {
|
||||
affectedDocs = result
|
||||
})
|
||||
}
|
||||
|
||||
$: if (request !== undefined) {
|
||||
void canTransferDocuments(client, request).then((value) => {
|
||||
canTransfer = value
|
||||
})
|
||||
} else {
|
||||
canTransfer = false
|
||||
}
|
||||
|
||||
async function selectProject (spaceRef: Ref<DocumentSpace> | undefined): Promise<void> {
|
||||
targetProjectId = spaceRef !== undefined ? await getLatestProjectId(spaceRef) : undefined
|
||||
}
|
||||
|
||||
async function fetchSpace (id: Ref<DocumentSpace> | undefined): Promise<void> {
|
||||
targetSpace = id === undefined ? undefined : await client.findOne(documents.class.DocumentSpace, { _id: id })
|
||||
}
|
||||
|
||||
async function fetchSpaceType (id: Ref<DocumentSpaceType> | undefined): Promise<void> {
|
||||
targetSpaceType =
|
||||
id === undefined ? undefined : await client.findOne(documents.class.DocumentSpaceType, { _id: id }, {})
|
||||
}
|
||||
|
||||
async function handleParentSelected (doc: Doc): Promise<void> {
|
||||
if (hierarchy.isDerived(doc._class, documents.class.DocumentSpace)) {
|
||||
targetParentDocumentId = undefined
|
||||
targetParentId = undefined
|
||||
} else if (hierarchy.isDerived(doc._class, documents.class.ProjectDocument)) {
|
||||
const pjDoc = doc as ProjectDocument
|
||||
targetParentDocumentId = pjDoc._id
|
||||
const pjMeta = await client.findOne(documents.class.ProjectMeta, { _id: pjDoc.attachedTo })
|
||||
if (targetParentDocumentId === pjDoc._id) targetParentId = pjMeta?.meta
|
||||
}
|
||||
}
|
||||
|
||||
function handleProjectSelected (value: Ref<Project> | undefined): void {
|
||||
targetProjectId = value
|
||||
}
|
||||
|
||||
let haveTemplateObjects: boolean = false
|
||||
$: void checkForTemplateObjects(affectedDocs)
|
||||
|
||||
async function checkForTemplateObjects (docs: DocumentMeta[]): Promise<void> {
|
||||
const cdocs = await client.findAll(documents.class.ControlledDocument, {
|
||||
attachedTo: { $in: docs.map((d) => d._id) }
|
||||
})
|
||||
haveTemplateObjects = cdocs.some((doc) => hierarchy.hasMixin(doc, documents.mixin.DocumentTemplate))
|
||||
}
|
||||
|
||||
const externalSpaces = hierarchy.getDescendants(documents.class.ExternalSpace)
|
||||
|
||||
$: hasParentSelector = targetSpaceId !== documents.space.UnsortedTemplates
|
||||
$: permissionRestrictedSpaces = Object.entries($permissionsStore.ps)
|
||||
.filter(([, pss]) => !pss.has(documents.permission.CreateDocument))
|
||||
.map(([s]) => s) as Ref<Space>[]
|
||||
$: restrictedSpaces =
|
||||
sourceSpaceId !== undefined ? permissionRestrictedSpaces.concat(sourceSpaceId) : permissionRestrictedSpaces
|
||||
|
||||
$: spaceQuery = haveTemplateObjects
|
||||
? { _id: { $nin: restrictedSpaces }, archived: false, _class: { $nin: externalSpaces } }
|
||||
: { _id: { $nin: restrictedSpaces }, archived: false }
|
||||
</script>
|
||||
|
||||
<div class="popup">
|
||||
<div class="bottom-divider">
|
||||
<div class="text-xl pr-6 pl-6 pt-4 pb-4 primary-text-color">
|
||||
<Label label={documents.string.TransferDocuments} />
|
||||
</div>
|
||||
</div>
|
||||
<div class="p-6 bottom-divider popup-body">
|
||||
<div class="sectionTitle"><Label label={documentsRes.string.Space} /></div>
|
||||
<div class="flex-row-center flex-no-shrink flex-gap-4">
|
||||
<div class="space">
|
||||
<SpaceSelector
|
||||
_class={documents.class.DocumentSpace}
|
||||
query={spaceQuery}
|
||||
bind:space={targetSpaceId}
|
||||
label={documentsRes.string.Space}
|
||||
width="100%"
|
||||
justify="left"
|
||||
autoSelect={true}
|
||||
/>
|
||||
</div>
|
||||
{#if targetSpace && targetSpaceType && targetSpaceType.projects}
|
||||
<div class="space">
|
||||
<ProjectSelector
|
||||
value={targetProjectId}
|
||||
space={targetSpace._id}
|
||||
kind={'no-border'}
|
||||
size={'small'}
|
||||
justify="left"
|
||||
showReadonly={false}
|
||||
on:change={(e) => {
|
||||
handleProjectSelected(e.detail)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="parentText pt-4"><Label label={documents.string.TransferDocumentsHint} /></div>
|
||||
<ol class="docList">
|
||||
{#each affectedDocs as object}
|
||||
<li>{object.title}</li>
|
||||
{/each}
|
||||
</ol>
|
||||
{#if hasParentSelector}
|
||||
<div class="sectionTitle"><Label label={documents.string.Parent} /></div>
|
||||
<div class="parentText"><Label label={documentsRes.string.SelectParent} /></div>
|
||||
<div class="parentSelector">
|
||||
{#if targetSpace}
|
||||
<DocumentParentSelector
|
||||
space={targetSpace}
|
||||
project={targetProjectId}
|
||||
selected={targetParentDocumentId}
|
||||
collapsedPrefix="locationStep"
|
||||
on:selected={(e) => {
|
||||
void handleParentSelected(e.detail)
|
||||
}}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="flex items-center flex-between pr-6 pl-6 pt-4 pb-4">
|
||||
<div class="flex flex-gap-2 items-center max-w-120 p-1 text-xs pr-4">
|
||||
<div class="warning-sign">
|
||||
<Info size="small" />
|
||||
</div>
|
||||
<Label label={documents.string.TransferWarning} />
|
||||
</div>
|
||||
<div class="flex justify-end items-center flex-gap-2">
|
||||
<Button kind="regular" label={presentation.string.Cancel} on:click={() => dispatch('close')} />
|
||||
<Button
|
||||
kind={canTransfer ? 'primary' : 'ghost'}
|
||||
disabled={!canTransfer}
|
||||
label={documents.string.Transfer}
|
||||
on:click={transfer}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
.popup {
|
||||
width: 58.25rem;
|
||||
border-radius: 1.25rem;
|
||||
background-color: var(--theme-dialog-background-color);
|
||||
}
|
||||
|
||||
.docList li {
|
||||
color: var(--global-primary-TextColor);
|
||||
}
|
||||
|
||||
.popup-body {
|
||||
height: 60vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.hint {
|
||||
color: var(--theme-dark-color);
|
||||
}
|
||||
|
||||
.warning-sign {
|
||||
color: var(--theme-docs-warning-icon-color);
|
||||
}
|
||||
|
||||
.primary-text-color {
|
||||
color: var(--theme-text-primary-color);
|
||||
}
|
||||
|
||||
.sectionTitle {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
line-height: 1.25rem;
|
||||
|
||||
&:not(:first-child) {
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
.space {
|
||||
width: 12.5rem;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.parentText {
|
||||
font-size: 0.6875rem;
|
||||
line-height: 1rem;
|
||||
}
|
||||
|
||||
.parentSelector {
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
</style>
|
@ -28,7 +28,9 @@ import {
|
||||
type Document,
|
||||
type DocumentSpace,
|
||||
DocumentState,
|
||||
type DocumentMeta
|
||||
type DocumentMeta,
|
||||
type ProjectDocument,
|
||||
type Project
|
||||
} from '@hcengineering/controlled-documents'
|
||||
import { type Resources } from '@hcengineering/platform'
|
||||
import { type ObjectSearchResult, getClient, MessageBox } from '@hcengineering/presentation'
|
||||
@ -101,6 +103,7 @@ import {
|
||||
createTemplate
|
||||
} from './utils'
|
||||
import { comment, isCommentVisible } from './text'
|
||||
import TransferDocumentPopup from './components/document/popups/TransferDocumentPopup.svelte'
|
||||
|
||||
export { DocumentStatusTag, DocumentTitle, DocumentVersionPresenter, StatePresenter }
|
||||
|
||||
@ -207,6 +210,46 @@ async function canArchiveDocument (obj?: Doc | Doc[]): Promise<boolean> {
|
||||
).then((res) => res.every((r) => r))
|
||||
}
|
||||
|
||||
async function canTransferDocument (obj?: Doc | Doc[]): Promise<boolean> {
|
||||
if (obj == null) {
|
||||
return false
|
||||
}
|
||||
|
||||
const objs = (Array.isArray(obj) ? obj : [obj]) as Document[]
|
||||
const spaces = new Set(objs.map((doc) => doc.space))
|
||||
|
||||
return await Promise.all(
|
||||
Array.from(spaces).map(
|
||||
async (space) => await checkPermission(getClient(), documents.permission.ArchiveDocument, space)
|
||||
)
|
||||
).then((res) => res.every((r) => r))
|
||||
}
|
||||
|
||||
async function transferDocuments (selection: Document | Document[]): Promise<void> {
|
||||
const objects = Array.isArray(selection) ? selection : [selection]
|
||||
|
||||
const client = getClient()
|
||||
const h = client.getHierarchy()
|
||||
|
||||
let sourceDocumentIds: Array<Ref<DocumentMeta>> = []
|
||||
let sourceSpaceId: Ref<DocumentSpace> | undefined
|
||||
let sourceProjectId: Ref<Project<DocumentSpace>> | undefined
|
||||
|
||||
if (objects.length < 1) return
|
||||
if (h.isDerived(objects[0]._class, documents.class.ProjectDocument)) {
|
||||
const pjDocs = objects as unknown as ProjectDocument[]
|
||||
const pjMeta = await client.findAll(documents.class.ProjectMeta, { _id: { $in: pjDocs.map((d) => d.attachedTo) } })
|
||||
const docMeta = await client.findAll(documents.class.DocumentMeta, { _id: { $in: pjMeta.map((d) => d.meta) } })
|
||||
sourceDocumentIds = docMeta.map((d) => d._id)
|
||||
sourceSpaceId = pjDocs[0].space
|
||||
sourceProjectId = pjDocs[0].project
|
||||
}
|
||||
|
||||
if (sourceDocumentIds.length < 1) return
|
||||
|
||||
showPopup(TransferDocumentPopup, { sourceDocumentIds, sourceSpaceId, sourceProjectId })
|
||||
}
|
||||
|
||||
async function isLatestDraftDoc (obj?: Doc | Doc[]): Promise<boolean> {
|
||||
if (obj == null) {
|
||||
return false
|
||||
@ -322,6 +365,7 @@ export default async (): Promise<Resources> => ({
|
||||
GetDocumentMetaLinkFragment: getDocumentMetaLinkFragment,
|
||||
CanDeleteDocument: canDeleteDocument,
|
||||
CanArchiveDocument: canArchiveDocument,
|
||||
CanTransferDocument: canTransferDocument,
|
||||
DocumentIdentifierProvider: documentIdentifierProvider,
|
||||
ControlledDocumentTitleProvider: getControlledDocumentTitle,
|
||||
Comment: comment,
|
||||
@ -334,6 +378,7 @@ export default async (): Promise<Resources> => ({
|
||||
CreateTemplate: createTemplate,
|
||||
DeleteDocument: deleteDocuments,
|
||||
ArchiveDocument: archiveDocuments,
|
||||
TransferDocument: transferDocuments,
|
||||
EditDocSpace: editDocSpace
|
||||
},
|
||||
resolver: {
|
||||
|
@ -238,6 +238,7 @@ export default mergeIds(documentsId, documents, {
|
||||
GetDocumentMetaLinkFragment: '' as Resource<(doc: Doc, props: Record<string, any>) => Promise<Location>>,
|
||||
CanDeleteDocument: '' as Resource<(doc?: Doc | Doc[]) => Promise<boolean>>,
|
||||
CanArchiveDocument: '' as Resource<(doc?: Doc | Doc[]) => Promise<boolean>>,
|
||||
CanTransferDocument: '' as Resource<(doc?: Doc | Doc[]) => Promise<boolean>>,
|
||||
ControlledDocumentTitleProvider: '' as Resource<(client: Client, ref: Ref<Doc>, doc?: Doc) => Promise<string>>
|
||||
}
|
||||
})
|
||||
|
@ -118,6 +118,7 @@ export const documentsPlugin = plugin(documentsId, {
|
||||
DeleteDocument: '' as Ref<Action>,
|
||||
ArchiveDocument: '' as Ref<Action>,
|
||||
EditDocSpace: '' as Ref<Action>,
|
||||
TransferDocument: '' as Ref<Action>,
|
||||
Print: '' as Ref<Action<Doc, { signed: boolean }>>
|
||||
},
|
||||
function: {
|
||||
@ -259,7 +260,12 @@ export const documentsPlugin = plugin(documentsId, {
|
||||
DeleteDocumentCategoryPermission: '' as IntlString,
|
||||
DeleteDocumentCategoryDescription: '' as IntlString,
|
||||
ConfigLabel: '' as IntlString,
|
||||
ConfigDescription: '' as IntlString
|
||||
ConfigDescription: '' as IntlString,
|
||||
|
||||
Transfer: '' as IntlString,
|
||||
TransferWarning: '' as IntlString,
|
||||
TransferDocuments: '' as IntlString,
|
||||
TransferDocumentsHint: '' as IntlString
|
||||
},
|
||||
ids: {
|
||||
NoParent: '' as Ref<DocumentMeta>,
|
||||
|
@ -14,7 +14,10 @@
|
||||
//
|
||||
import {
|
||||
ApplyOperations,
|
||||
checkPermission,
|
||||
Class,
|
||||
Data,
|
||||
Doc,
|
||||
DocumentQuery,
|
||||
DocumentUpdate,
|
||||
Rank,
|
||||
@ -28,17 +31,23 @@ import LexoRankBucket from 'lexorank/lib/lexoRank/lexoRankBucket'
|
||||
|
||||
import documents from './plugin'
|
||||
|
||||
import attachment, { Attachment } from '@hcengineering/attachment'
|
||||
import chunter, { ChatMessage } from '@hcengineering/chunter'
|
||||
import tags, { TagReference } from '@hcengineering/tags'
|
||||
import {
|
||||
ChangeControl,
|
||||
ControlledDocument,
|
||||
Document,
|
||||
DocumentMeta,
|
||||
DocumentRequest,
|
||||
DocumentSnapshot,
|
||||
DocumentSpace,
|
||||
DocumentState,
|
||||
Project,
|
||||
ProjectDocument,
|
||||
ProjectMeta
|
||||
} from './types'
|
||||
import { makeRank } from '@hcengineering/rank'
|
||||
|
||||
/**
|
||||
* @public
|
||||
@ -129,6 +138,364 @@ export async function deleteProjectDrafts (client: ApplyOperations, source: Ref<
|
||||
}
|
||||
}
|
||||
|
||||
class ProjectDocumentTree {
|
||||
rootDocs: ProjectMeta[]
|
||||
childrenByParent: Map<Ref<DocumentMeta>, ProjectMeta[]>
|
||||
|
||||
constructor (pjMeta: ProjectMeta[]) {
|
||||
this.rootDocs = []
|
||||
this.childrenByParent = new Map<Ref<DocumentMeta>, Array<ProjectMeta>>()
|
||||
|
||||
for (const meta of pjMeta) {
|
||||
const parentId = meta.path[0] ?? documents.ids.NoParent
|
||||
|
||||
if (!this.childrenByParent.has(parentId)) {
|
||||
this.childrenByParent.set(parentId, [])
|
||||
}
|
||||
|
||||
this.childrenByParent.get(parentId)?.push(meta)
|
||||
|
||||
if (parentId === documents.ids.NoParent) {
|
||||
this.rootDocs.push(meta)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
getDescendants (parent: Ref<DocumentMeta>): Ref<DocumentMeta>[] {
|
||||
const result: Ref<DocumentMeta>[] = []
|
||||
const queue: Ref<DocumentMeta>[] = [parent]
|
||||
|
||||
while (queue.length > 0) {
|
||||
const next = queue.pop()
|
||||
if (next === undefined) break
|
||||
|
||||
const children = this.childrenByParent.get(next) ?? []
|
||||
const childrenRefs = children.map((p) => p.meta)
|
||||
result.push(...childrenRefs)
|
||||
queue.push(...childrenRefs)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
export async function findProjectDocsHierarchy (
|
||||
client: TxOperations,
|
||||
space: Ref<DocumentSpace>,
|
||||
project?: Ref<Project<DocumentSpace>>
|
||||
): Promise<ProjectDocumentTree> {
|
||||
const pjMeta = await client.findAll(documents.class.ProjectMeta, { space, project })
|
||||
return new ProjectDocumentTree(pjMeta)
|
||||
}
|
||||
|
||||
export interface DocumentBundle {
|
||||
DocumentMeta: DocumentMeta[]
|
||||
ProjectMeta: ProjectMeta[]
|
||||
ProjectDocument: ProjectDocument[]
|
||||
ControlledDocument: ControlledDocument[]
|
||||
ChangeControl: ChangeControl[]
|
||||
DocumentRequest: DocumentRequest[]
|
||||
DocumentSnapshot: DocumentSnapshot[]
|
||||
ChatMessage: ChatMessage[]
|
||||
TagReference: TagReference[]
|
||||
Attachment: Attachment[]
|
||||
}
|
||||
|
||||
function emptyBundle (): DocumentBundle {
|
||||
return {
|
||||
DocumentMeta: [],
|
||||
ProjectMeta: [],
|
||||
ProjectDocument: [],
|
||||
ControlledDocument: [],
|
||||
ChangeControl: [],
|
||||
DocumentRequest: [],
|
||||
DocumentSnapshot: [],
|
||||
ChatMessage: [],
|
||||
TagReference: [],
|
||||
Attachment: []
|
||||
}
|
||||
}
|
||||
|
||||
export async function findAllDocumentBundles (
|
||||
client: TxOperations,
|
||||
ids: Ref<DocumentMeta>[]
|
||||
): Promise<DocumentBundle[]> {
|
||||
const all: DocumentBundle = { ...emptyBundle() }
|
||||
|
||||
async function crawl<T extends Doc, P extends keyof T> (
|
||||
_class: Ref<Class<T>>,
|
||||
bkey: keyof DocumentBundle,
|
||||
prop: P,
|
||||
ids: T[P][]
|
||||
): Promise<T[]> {
|
||||
const data = await client.findAll(_class, { [prop]: { $in: ids } } as any)
|
||||
all[bkey].push(...(data as any))
|
||||
return data
|
||||
}
|
||||
|
||||
await crawl(documents.class.DocumentMeta, 'DocumentMeta', '_id', ids)
|
||||
await crawl(
|
||||
documents.class.ProjectMeta,
|
||||
'ProjectMeta',
|
||||
'meta',
|
||||
all.DocumentMeta.map((m) => m._id)
|
||||
)
|
||||
await crawl(
|
||||
documents.class.ProjectDocument,
|
||||
'ProjectDocument',
|
||||
'attachedTo',
|
||||
all.ProjectMeta.map((m) => m._id)
|
||||
)
|
||||
await crawl(
|
||||
documents.class.ControlledDocument,
|
||||
'ControlledDocument',
|
||||
'attachedTo',
|
||||
all.DocumentMeta.map((m) => m._id)
|
||||
)
|
||||
await crawl(
|
||||
documents.class.ChangeControl,
|
||||
'ChangeControl',
|
||||
'_id',
|
||||
all.ControlledDocument.map((p) => p.changeControl)
|
||||
)
|
||||
await crawl(
|
||||
documents.class.DocumentRequest,
|
||||
'DocumentRequest',
|
||||
'attachedTo',
|
||||
all.ControlledDocument.map((p) => p._id)
|
||||
)
|
||||
await crawl(
|
||||
documents.class.DocumentSnapshot,
|
||||
'DocumentSnapshot',
|
||||
'attachedTo',
|
||||
all.ControlledDocument.map((p) => p._id)
|
||||
)
|
||||
await crawl(
|
||||
documents.class.DocumentComment,
|
||||
'ChatMessage',
|
||||
'attachedTo',
|
||||
all.ControlledDocument.map((p) => p._id)
|
||||
)
|
||||
await crawl(
|
||||
chunter.class.ThreadMessage,
|
||||
'ChatMessage',
|
||||
'attachedTo',
|
||||
all.ChatMessage.map((p) => p._id)
|
||||
)
|
||||
await crawl(
|
||||
tags.class.TagReference,
|
||||
'TagReference',
|
||||
'attachedTo',
|
||||
all.ControlledDocument.map((p) => p._id)
|
||||
)
|
||||
await crawl(attachment.class.Attachment, 'Attachment', 'attachedTo', [
|
||||
...all.ChatMessage.map((p) => p._id),
|
||||
...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())
|
||||
}
|
||||
|
||||
export async function findOneDocumentBundle (
|
||||
client: TxOperations,
|
||||
id: Ref<DocumentMeta>
|
||||
): Promise<DocumentBundle | undefined> {
|
||||
const bundles = await findAllDocumentBundles(client, [id])
|
||||
return bundles[0]
|
||||
}
|
||||
|
||||
export interface DocumentTransferRequest {
|
||||
sourceDocumentIds: Ref<DocumentMeta>[]
|
||||
sourceSpaceId: Ref<DocumentSpace>
|
||||
sourceProjectId?: Ref<Project<DocumentSpace>>
|
||||
|
||||
targetSpaceId: Ref<DocumentSpace>
|
||||
targetParentId?: Ref<DocumentMeta>
|
||||
targetProjectId?: Ref<Project<DocumentSpace>>
|
||||
}
|
||||
|
||||
interface DocumentTransferContext {
|
||||
request: DocumentTransferRequest
|
||||
bundles: DocumentBundle[]
|
||||
|
||||
sourceTree: ProjectDocumentTree
|
||||
targetTree: ProjectDocumentTree
|
||||
|
||||
sourceSpace: DocumentSpace
|
||||
targetSpace: DocumentSpace
|
||||
|
||||
targetParentBundle?: DocumentBundle
|
||||
}
|
||||
|
||||
async function _buildDocumentTransferContext (
|
||||
client: TxOperations,
|
||||
request: DocumentTransferRequest
|
||||
): Promise<DocumentTransferContext | undefined> {
|
||||
const sourceTree = await findProjectDocsHierarchy(client, request.sourceSpaceId, request.sourceProjectId)
|
||||
const targetTree = await findProjectDocsHierarchy(client, request.targetSpaceId, request.targetProjectId)
|
||||
|
||||
const docIds = new Set<Ref<DocumentMeta>>(request.sourceDocumentIds)
|
||||
for (const id of request.sourceDocumentIds) {
|
||||
sourceTree.getDescendants(id).forEach((d) => docIds.add(d))
|
||||
}
|
||||
|
||||
const bundles = await findAllDocumentBundles(client, Array.from(docIds))
|
||||
const targetParentBundle =
|
||||
request.targetParentId !== undefined ? await findOneDocumentBundle(client, request.targetParentId) : undefined
|
||||
|
||||
const sourceSpace = await client.findOne(documents.class.DocumentSpace, { _id: request.sourceSpaceId })
|
||||
const targetSpace = await client.findOne(documents.class.DocumentSpace, { _id: request.targetSpaceId })
|
||||
|
||||
if (sourceSpace === undefined || targetSpace === undefined) return
|
||||
|
||||
return {
|
||||
request,
|
||||
bundles,
|
||||
sourceTree,
|
||||
targetTree,
|
||||
sourceSpace,
|
||||
targetSpace,
|
||||
targetParentBundle
|
||||
}
|
||||
}
|
||||
|
||||
export async function listDocumentsAffectedByTransfer (
|
||||
client: TxOperations,
|
||||
req: DocumentTransferRequest
|
||||
): Promise<DocumentMeta[]> {
|
||||
const cx = await _buildDocumentTransferContext(client, req)
|
||||
return cx?.bundles.map((b) => b.DocumentMeta[0]) ?? []
|
||||
}
|
||||
|
||||
/**
|
||||
* @public
|
||||
*/
|
||||
export async function canTransferDocuments (client: TxOperations, req: DocumentTransferRequest): Promise<boolean> {
|
||||
const cx = await _buildDocumentTransferContext(client, req)
|
||||
return cx !== undefined ? await _transferDocuments(client, cx, 'check') : false
|
||||
}
|
||||
|
||||
/**
|
||||
* @public
|
||||
*/
|
||||
export async function transferDocuments (client: TxOperations, req: DocumentTransferRequest): Promise<boolean> {
|
||||
const cx = await _buildDocumentTransferContext(client, req)
|
||||
return cx !== undefined ? await _transferDocuments(client, cx) : false
|
||||
}
|
||||
|
||||
async function _transferDocuments (
|
||||
client: TxOperations,
|
||||
cx: DocumentTransferContext,
|
||||
mode: 'default' | 'check' = 'default'
|
||||
): Promise<boolean> {
|
||||
if (cx.bundles.length < 1) return false
|
||||
if (cx.targetSpace._id === cx.sourceSpace._id) return false
|
||||
|
||||
const hierarchy = client.getHierarchy()
|
||||
|
||||
const canArchiveInSourceSpace = await checkPermission(
|
||||
client,
|
||||
documents.permission.ArchiveDocument,
|
||||
cx.request.sourceSpaceId
|
||||
)
|
||||
const canCreateInTargetSpace = await checkPermission(
|
||||
client,
|
||||
documents.permission.CreateDocument,
|
||||
cx.request.targetSpaceId
|
||||
)
|
||||
|
||||
if (!canArchiveInSourceSpace || !canCreateInTargetSpace) return false
|
||||
|
||||
for (const bundle of cx.bundles) {
|
||||
if (bundle.DocumentMeta.length !== 1) return false
|
||||
if (bundle.ProjectMeta.length !== 1) return false
|
||||
if (bundle.DocumentMeta[0].space !== cx.request.sourceSpaceId) return false
|
||||
if (bundle.ControlledDocument.length < 1) return false
|
||||
|
||||
const isTemplate = hierarchy.hasMixin(bundle.ControlledDocument[0], documents.mixin.DocumentTemplate)
|
||||
if (isTemplate && hierarchy.isDerived(cx.targetSpace._class, documents.class.ExternalSpace)) return false
|
||||
}
|
||||
|
||||
const roots = new Set(cx.request.sourceDocumentIds)
|
||||
const updates = new Map<Doc, Partial<Doc>>()
|
||||
|
||||
function update<T extends Doc> (document: T, update: Partial<T>): void {
|
||||
updates.set(document, { ...updates.get(document), ...update })
|
||||
}
|
||||
|
||||
const parentMeta = cx.targetParentBundle?.ProjectMeta[0]
|
||||
const project = cx.request.targetProjectId ?? documents.ids.NoProject
|
||||
|
||||
if (cx.targetParentBundle !== undefined && parentMeta === undefined) return false
|
||||
|
||||
let lastRank: Rank | undefined
|
||||
if (parentMeta !== undefined) {
|
||||
lastRank = await getFirstRank(client, cx.targetSpace._id, project, parentMeta.meta)
|
||||
}
|
||||
|
||||
for (const bundle of cx.bundles) {
|
||||
const projectMeta = bundle.ProjectMeta[0]
|
||||
|
||||
if (roots.has(projectMeta.meta)) {
|
||||
const path = parentMeta?.path !== undefined ? [parentMeta.meta, ...parentMeta.path] : []
|
||||
const parent = path[0] ?? documents.ids.NoParent
|
||||
const rank = makeRank(lastRank, undefined)
|
||||
update(projectMeta, { parent, path, rank })
|
||||
}
|
||||
|
||||
let key: keyof DocumentBundle
|
||||
for (key in bundle) {
|
||||
bundle[key].forEach((doc) => {
|
||||
update(doc, { space: cx.targetSpace._id })
|
||||
})
|
||||
}
|
||||
|
||||
for (const m of bundle.ProjectMeta) update(m, { project })
|
||||
for (const m of bundle.ProjectDocument) update(m, { project })
|
||||
}
|
||||
|
||||
if (mode === 'check') return true
|
||||
|
||||
const ops = client.apply()
|
||||
for (const u of updates) await ops.update(u[0], u[1])
|
||||
|
||||
const commit = await ops.commit()
|
||||
return commit.result
|
||||
}
|
||||
|
||||
/**
|
||||
* @public
|
||||
*/
|
||||
|
Loading…
Reference in New Issue
Block a user