EZQMS-1393: implemented folders in controlled documents (#7803)

* EZQMS-1393: implemented folders in controlled documents

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

* formatting

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

---------

Signed-off-by: Victor Ilyushchenko <alt13ri@gmail.com>
This commit is contained in:
Victor Ilyushchenko
2025-01-28 10:02:13 +03:00
committed by GitHub
parent f8a8c94bc2
commit 40931c81ca
21 changed files with 633 additions and 83 deletions
@@ -0,0 +1,83 @@
<!--
//
// 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, {
createNewFolder,
DocumentMeta,
type DocumentSpace,
type Project,
type ProjectDocument
} from '@hcengineering/controlled-documents'
import core, { Ref } from '@hcengineering/core'
import { Card, getClient } from '@hcengineering/presentation'
import { EditBox, FocusHandler, createFocusManager } from '@hcengineering/ui'
import view from '@hcengineering/view'
import { createEventDispatcher } from 'svelte'
export function canClose (): boolean {
return name === ''
}
export let folder: DocumentMeta | undefined
export let name: string = ''
export let space: Ref<DocumentSpace> | undefined
export let project: Ref<Project<DocumentSpace>> | undefined
export let parent: Ref<ProjectDocument> | undefined
const dispatch = createEventDispatcher()
const client = getClient()
$: canSave = getTitle(name).length > 0 && (folder !== undefined || space !== undefined)
function getTitle (value: string): string {
return value.trim()
}
async function create (): Promise<void> {
const title = getTitle(name)
if (title.length < 1) {
return
}
if (folder !== undefined) {
await client.update(folder, { title })
} else if (space !== undefined) {
await createNewFolder(client, space, project ?? documents.ids.NoProject, parent, title)
}
dispatch('close')
}
const manager = createFocusManager()
</script>
<FocusHandler {manager} />
<Card
label={folder ? documents.string.RenameFolder : documents.string.CreateFolder}
okAction={create}
accentHeader
{canSave}
on:close={() => {
dispatch('close')
}}
on:changeContent
>
<div class="flex-row-center clear-mins">
<EditBox placeholder={core.string.Name} bind:value={name} autoFocus focusIndex={1} />
</div>
</Card>
@@ -14,7 +14,7 @@
-->
<script lang="ts">
import { createEventDispatcher } from 'svelte'
import { type Ref, type Doc, SortingOrder, getCurrentAccount, WithLookup } from '@hcengineering/core'
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'
@@ -80,6 +80,11 @@
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
@@ -120,29 +125,45 @@
}
)
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}
{@const title = doc ? getDocumentName(doc) : meta?.title ?? ''}
{@const docid = doc?._id ?? prjdoc._id}
{@const isFolder = prjdoc.document === documents.ids.Folder}
{#if doc}
{@const children = childrenByParent[doc.attachedTo] ?? []}
{@const isDraggedOver = draggedOver === doc.attachedTo}
{#if metaid}
{@const children = childrenByParent[metaid] ?? []}
{@const isDraggedOver = draggedOver === metaid}
<div class="flex-col relative">
{#if isDraggedOver}
<DropArea />
{/if}
<TreeItem
_id={doc._id}
icon={documents.icon.Document}
_id={docid}
icon={isFolder ? documents.icon.Folder : documents.icon.Document}
iconProps={{
fill: 'currentColor'
}}
title={getDocumentName(doc)}
selected={selected === doc._id || selected === prjdoc._id}
{title}
selected={selected === docid || selected === prjdoc._id}
isFold
empty={children.length === 0 || children === undefined}
actions={getMoreActions !== undefined ? () => getDocMoreActions(prjdoc) : undefined}
@@ -154,16 +175,16 @@
}}
draggable={onDragStart !== undefined}
on:dragstart={(evt) => {
onDragStart?.(evt, doc.attachedTo)
onDragStart?.(evt, metaid)
}}
on:dragover={(evt) => {
onDragOver?.(evt, doc.attachedTo)
onDragOver?.(evt, metaid)
}}
on:dragend={(evt) => {
onDragEnd?.(evt, doc.attachedTo)
onDragEnd?.(evt, metaid)
}}
on:drop={(evt) => {
onDrop?.(evt, doc.attachedTo)
onDrop?.(evt, metaid)
}}
>
<svelte:fragment slot="dropbox">
@@ -51,7 +51,9 @@
canCreateChildDocument,
moveDocument,
moveDocumentBefore,
moveDocumentAfter
moveDocumentAfter,
canCreateChildFolder,
createFolder
} from '../../utils'
import documents from '../../plugin'
@@ -178,6 +180,21 @@
})
}
if (
spaceType?.projects === true &&
(await isEditableProject(project)) &&
(await canCreateChildFolder(space, true))
) {
actions.push({
icon: documents.icon.Folder,
label: documents.string.CreateFolder,
group: 'create',
action: async () => {
await createFolder(space)
}
})
}
return orderActions(actions)
}
@@ -12,54 +12,54 @@
// See the License for the specific language governing permissions and
// limitations under the License.
//
import { type PersonAccount } from '@hcengineering/contact'
import {
type Document,
type DocumentMeta,
type DocumentSpace,
DocumentState,
type Project,
type ProjectDocument
} from '@hcengineering/controlled-documents'
import {
checkPermission,
type Class,
type Client,
type Doc,
type DocumentQuery,
getCurrentAccount,
type Ref,
type RelatedDocument,
SortingOrder,
type WithLookup,
type Doc,
getCurrentAccount,
checkPermission
type WithLookup
} from '@hcengineering/core'
import {
type Document,
type DocumentSpace,
DocumentState,
type DocumentMeta,
type ProjectDocument,
type Project
} from '@hcengineering/controlled-documents'
import { type Resources } from '@hcengineering/platform'
import { type ObjectSearchResult, getClient, MessageBox } from '@hcengineering/presentation'
import { getClient, MessageBox, type ObjectSearchResult } from '@hcengineering/presentation'
import { showPopup } from '@hcengineering/ui'
import { type PersonAccount } from '@hcengineering/contact'
import CreateDocument from './components/CreateDocument.svelte'
import DeleteCategoryPopup from './components/category/popups/DeleteCategoryPopup.svelte'
import QmsDocumentWizard from './components/create-doc/QmsDocumentWizard.svelte'
import QmsTemplateWizard from './components/create-doc/QmsTemplateWizard.svelte'
import DocumentStatusTag from './components/document/common/DocumentStatusTag.svelte'
import DocumentSpacePresenter from './components/hierarchy/DocumentSpacePresenter.svelte'
import DocumentPresenter from './components/document/presenters/DocumentPresenter.svelte'
import StatePresenter from './components/document/presenters/StatePresenter.svelte'
import StateFilterValuePresenter from './components/document/presenters/StateFilterValuePresenter.svelte'
import TitlePresenter from './components/document/presenters/TitlePresenter.svelte'
import OwnerPresenter from './components/document/presenters/OwnerPresenter.svelte'
import AddCommentPopup from './components/document/popups/AddCommentPopup.svelte'
import DocumentCommentsPopup from './components/document/popups/DocumentCommentsPopup.svelte'
import ChangeOwnerPopup from './components/document/popups/ChangeOwnerPopup.svelte'
import DeleteCategoryPopup from './components/category/popups/DeleteCategoryPopup.svelte'
import DocumentCommentsPopup from './components/document/popups/DocumentCommentsPopup.svelte'
import DocumentPresenter from './components/document/presenters/DocumentPresenter.svelte'
import OwnerPresenter from './components/document/presenters/OwnerPresenter.svelte'
import StateFilterValuePresenter from './components/document/presenters/StateFilterValuePresenter.svelte'
import StatePresenter from './components/document/presenters/StatePresenter.svelte'
import TitlePresenter from './components/document/presenters/TitlePresenter.svelte'
import DocumentSpacePresenter from './components/hierarchy/DocumentSpacePresenter.svelte'
import CategoryPresenter from './components/category/presenters/CategoryPresenter.svelte'
import DocumentItem from './components/DocumentItem.svelte'
import Documents from './components/Documents.svelte'
import DocumentsContainer from './components/DocumentsContainer.svelte'
import MyDocuments from './components/MyDocuments.svelte'
import EditDoc from './components/EditDoc.svelte'
import EditProjectDoc from './components/EditProjectDoc.svelte'
import DocumentItem from './components/DocumentItem.svelte'
import MyDocuments from './components/MyDocuments.svelte'
import NewDocumentHeader from './components/NewDocumentHeader.svelte'
import CategoryPresenter from './components/category/presenters/CategoryPresenter.svelte'
import DocumentIcon from './components/icons/DocumentIcon.svelte'
import DocumentTemplates from './components/DocumentTemplates.svelte'
@@ -67,43 +67,53 @@ import DocumentTemplates from './components/DocumentTemplates.svelte'
import Categories from './components/Categories.svelte'
import EditDocumentCategory from './components/EditDocumentCategory.svelte'
import DocumentMetaPresenter from './components/DocumentMetaPresenter.svelte'
import CreateDocumentSpaceType from './components/docspace/CreateDocumentSpaceType.svelte'
import CreateDocumentsSpace from './components/docspace/CreateDocumentsSpace.svelte'
import DocumentTitle from './components/document/DocumentTitle.svelte'
import EditDocContent from './components/document/EditDocContent.svelte'
import ControlledStateFilterValuePresenter from './components/document/presenters/ControlledStateFilterValuePresenter.svelte'
import DocumentVersionPresenter from './components/document/presenters/DocumentVersionPresenter.svelte'
import DocumentReviewRequest from './components/requests/DocumentReviewRequest.svelte'
import DocumentReviewRequestPresenter from './components/requests/DocumentReviewRequestPresenter.svelte'
import DocumentApprovalRequest from './components/requests/DocumentApprovalRequest.svelte'
import DocumentApprovalRequestPresenter from './components/requests/DocumentApprovalRequestPresenter.svelte'
import ControlledStateFilterValuePresenter from './components/document/presenters/ControlledStateFilterValuePresenter.svelte'
import DocumentMetaPresenter from './components/DocumentMetaPresenter.svelte'
import CreateDocumentsSpace from './components/docspace/CreateDocumentsSpace.svelte'
import CreateDocumentSpaceType from './components/docspace/CreateDocumentSpaceType.svelte'
import DocumentReviewRequest from './components/requests/DocumentReviewRequest.svelte'
import DocumentReviewRequestPresenter from './components/requests/DocumentReviewRequestPresenter.svelte'
import Projects from './components/project/Projects.svelte'
import ProjectPresenter from './components/project/ProjectPresenter.svelte'
import ProjectRefPresenter from './components/project/ProjectRefPresenter.svelte'
import Projects from './components/project/Projects.svelte'
import { getPrintBaseURL } from '@hcengineering/print'
import CreateFolder from './components/create-doc/CreateFolder.svelte'
import TransferDocumentPopup from './components/document/popups/TransferDocumentPopup.svelte'
import { resolveLocation } from './navigation'
import documents from './plugin'
import './styles/_colors.scss'
import { resolveLocation } from './navigation'
import { comment, isCommentVisible } from './text'
import {
getVisibleFilters,
sortDocumentStates,
getAllDocumentStates,
canChangeDocumentOwner,
canDeleteDocumentCategory,
canCreateChildTemplate,
canCreateChildDocument,
documentIdentifierProvider,
getControlledDocumentTitle,
getDocumentMetaLinkFragment,
canCreateChildFolder,
canCreateChildTemplate,
canDeleteDocumentCategory,
canDeleteFolder,
canRenameFolder,
createChildDocument,
createChildFolder,
createChildTemplate,
createDocument,
createTemplate
createFolder,
createTemplate,
deleteFolder,
documentIdentifierProvider,
getAllDocumentStates,
getControlledDocumentTitle,
getDocumentMetaLinkFragment,
getVisibleFilters,
isFolder,
renameFolder,
sortDocumentStates
} from './utils'
import { comment, isCommentVisible } from './text'
import TransferDocumentPopup from './components/document/popups/TransferDocumentPopup.svelte'
export { DocumentStatusTag, DocumentTitle, DocumentVersionPresenter, StatePresenter }
@@ -210,6 +220,37 @@ async function canArchiveDocument (obj?: Doc | Doc[]): Promise<boolean> {
).then((res) => res.every((r) => r))
}
async function canOpenDocument (obj?: ProjectDocument | ProjectDocument[]): Promise<boolean> {
if (obj == null) {
return false
}
const h = getClient().getHierarchy()
const objs = Array.isArray(obj) ? obj : [obj]
return !objs.some((d) => isFolder(h, d))
}
async function canPrintDocument (obj?: Document | Document[] | ProjectDocument | ProjectDocument[]): Promise<boolean> {
if (obj == null) {
return false
}
const h = getClient().getHierarchy()
const objs = Array.isArray(obj) ? obj : [obj]
if (objs.some((d) => isFolder(h, d))) return false
let printURL = ''
try {
printURL = getPrintBaseURL()
} catch (err) {
// do nothing
}
return printURL?.length > 0
}
async function canTransferDocument (obj?: Doc | Doc[]): Promise<boolean> {
if (obj == null) {
return false
@@ -339,7 +380,8 @@ export default async (): Promise<Resources> => ({
Projects,
ProjectPresenter,
ProjectRefPresenter,
DocumentIcon
DocumentIcon,
CreateFolder
},
completion: {
DocumentMetaQuery: async (
@@ -356,8 +398,12 @@ export default async (): Promise<Resources> => ({
CanChangeDocumentOwner: canChangeDocumentOwner,
CanCreateTemplate: canCreateChildTemplate,
CanCreateDocument: canCreateChildDocument,
CanCreateFolder: canCreateChildFolder,
CanCreateChildTemplate: canCreateChildTemplate,
CanCreateChildDocument: canCreateChildDocument,
CanCreateChildFolder: canCreateChildFolder,
CanRenameFolder: canRenameFolder,
CanDeleteFolder: canDeleteFolder,
CanDeleteDocumentCategory: canDeleteDocumentCategory,
GetVisibleFilters: getVisibleFilters,
DocumentStateSort: sortDocumentStates,
@@ -366,6 +412,8 @@ export default async (): Promise<Resources> => ({
CanDeleteDocument: canDeleteDocument,
CanArchiveDocument: canArchiveDocument,
CanTransferDocument: canTransferDocument,
CanOpenDocument: canOpenDocument,
CanPrintDocument: canPrintDocument,
DocumentIdentifierProvider: documentIdentifierProvider,
ControlledDocumentTitleProvider: getControlledDocumentTitle,
Comment: comment,
@@ -374,8 +422,12 @@ export default async (): Promise<Resources> => ({
actionImpl: {
CreateChildDocument: createChildDocument,
CreateChildTemplate: createChildTemplate,
CreateChildFolder: createChildFolder,
RenameFolder: renameFolder,
DeleteFolder: deleteFolder,
CreateDocument: createDocument,
CreateTemplate: createTemplate,
CreateFolder: createFolder,
DeleteDocument: deleteDocuments,
ArchiveDocument: archiveDocuments,
TransferDocument: transferDocuments,
@@ -226,8 +226,12 @@ export default mergeIds(documentsId, documents, {
function: {
CanCreateTemplate: '' as Resource<ViewActionAvailabilityFunction<DocumentSpace>>,
CanCreateDocument: '' as Resource<ViewActionAvailabilityFunction<DocumentSpace>>,
CanCreateFolder: '' as Resource<ViewActionAvailabilityFunction<DocumentSpace>>,
CanCreateChildTemplate: '' as Resource<ViewActionAvailabilityFunction<Document>>,
CanCreateChildDocument: '' as Resource<ViewActionAvailabilityFunction<Document>>,
CanCreateChildFolder: '' as Resource<ViewActionAvailabilityFunction<Document>>,
CanRenameFolder: '' as Resource<ViewActionAvailabilityFunction<Document>>,
CanDeleteFolder: '' as Resource<ViewActionAvailabilityFunction<Document>>,
CheckIsDocumentCreationDisabled: '' as Resource<() => Promise<boolean>>,
CheckAreTemplatesDisabled: '' as Resource<() => Promise<boolean>>,
CheckAreDomainsDisabled: '' as Resource<() => Promise<boolean>>,
@@ -238,6 +242,8 @@ 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>>,
CanOpenDocument: '' as Resource<(doc?: Doc | Doc[]) => Promise<boolean>>,
CanPrintDocument: '' 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>>
}
@@ -119,6 +119,11 @@ export function isProjectDocument (hierarchy: Hierarchy, doc: Doc): doc is Proje
return hierarchy.isDerived(doc._class, documents.class.ProjectDocument)
}
export function isFolder (hierarchy: Hierarchy, doc: Doc): doc is ProjectDocument {
if (!isProjectDocument(hierarchy, doc)) return false
return doc.document === documents.ids.Folder
}
export async function getVisibleFilters (filters: KeyFilter[], space?: Ref<Space>): Promise<KeyFilter[]> {
// Removes the "Space" filter if a specific space is provided
return space === undefined ? filters : filters.filter((f) => f.key !== 'space')
@@ -461,6 +466,112 @@ export async function canCreateChildDocument (
return true
}
export async function canCreateChildFolder (
doc?: Document | Document[] | DocumentSpace | DocumentSpace[] | ProjectDocument | ProjectDocument[],
includeProjects = false
): Promise<boolean> {
if (doc === null || doc === undefined) {
return false
}
if (Array.isArray(doc)) {
return false
}
const client = getClient()
const hierarchy = client.getHierarchy()
const spaceId: Ref<DocumentSpace> = isSpace(hierarchy, doc) ? doc._id : doc.space
const canCreateDocument = await checkPermission(client, documents.permission.CreateDocument, spaceId)
if (!canCreateDocument) {
return false
}
if (isSpace(hierarchy, doc)) {
const spaceType = await client.findOne(documents.class.DocumentSpaceType, { _id: doc.type })
return includeProjects || spaceType?.projects !== true
}
if (isProjectDocument(hierarchy, doc)) {
return await isEditableProject(doc.project)
}
return true
}
export async function canRenameFolder (
doc?: Document | Document[] | DocumentSpace | DocumentSpace[] | ProjectDocument | ProjectDocument[],
includeProjects = false
): Promise<boolean> {
if (doc === null || doc === undefined) {
return false
}
if (Array.isArray(doc)) {
return false
}
const client = getClient()
const hierarchy = client.getHierarchy()
const spaceId: Ref<DocumentSpace> = isSpace(hierarchy, doc) ? doc._id : doc.space
const canCreateDocument = await checkPermission(client, documents.permission.CreateDocument, spaceId)
if (!canCreateDocument) {
return false
}
if (isSpace(hierarchy, doc)) {
const spaceType = await client.findOne(documents.class.DocumentSpaceType, { _id: doc.type })
return includeProjects || spaceType?.projects !== true
}
if (!isFolder(hierarchy, doc)) {
return false
}
return await isEditableProject(doc.project)
}
export async function canDeleteFolder (obj?: Doc | Doc[]): Promise<boolean> {
if (obj == null) {
return false
}
const client = getClient()
const hierarchy = client.getHierarchy()
const objs = (Array.isArray(obj) ? obj : [obj]) as Document[]
const isFolders = objs.every((doc) => isFolder(hierarchy, doc))
if (!isFolders) {
return false
}
const folders = objs as unknown as ProjectDocument[]
const pjMeta = await client.findAll(documents.class.ProjectMeta, { _id: { $in: folders.map((f) => f.attachedTo) } })
const directChildren = await client.findAll(documents.class.ProjectMeta, {
parent: { $in: pjMeta.map((p) => p.meta) }
})
if (directChildren.length > 0) {
return false
}
const currentUser = getCurrentAccount() as PersonAccount
const isOwner = objs.every((doc) => doc.owner === currentUser.person)
if (isOwner) {
return true
}
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))
}
export async function canDeleteDocumentCategory (doc?: Doc | Doc[]): Promise<boolean> {
if (doc === null || doc === undefined) {
return false
@@ -627,6 +738,54 @@ export async function createChildTemplate (doc: ProjectDocument): Promise<void>
showPopup(documents.component.QmsTemplateWizard, {})
}
export async function createChildFolder (doc: ProjectDocument): Promise<void> {
const props = {
space: doc.space,
project: doc.project,
parent: doc._id
}
showPopup(documents.component.CreateFolder, props)
}
export async function renameFolder (doc: ProjectDocument): Promise<void> {
const client = getClient()
const pjmeta = await client.findOne(documents.class.ProjectMeta, { _id: doc.attachedTo })
if (pjmeta === undefined) return
const meta = await client.findOne(documents.class.DocumentMeta, { _id: pjmeta.meta })
if (meta === undefined) return
const props = {
folder: meta,
name: meta.title
}
showPopup(documents.component.CreateFolder, props)
}
export async function deleteFolder (obj: ProjectDocument | ProjectDocument[]): Promise<void> {
const client = getClient()
if (!(await canDeleteFolder(obj))) {
return
}
const objs = Array.isArray(obj) ? obj : [obj]
const pjmeta = await client.findAll(documents.class.ProjectMeta, { _id: { $in: objs.map((p) => p.attachedTo) } })
const meta = await client.findAll(documents.class.DocumentMeta, { _id: { $in: pjmeta.map((p) => p.meta) } })
const docsToRemove = [...objs, ...pjmeta, ...meta]
const ops = client.apply()
for (const doc of docsToRemove) {
await ops.remove(doc)
}
await ops.commit()
}
export async function createDocument (space: DocumentSpace): Promise<void> {
const project = await getLatestProjectId(space._id)
wizardOpened({
@@ -642,6 +801,15 @@ export async function createTemplate (space: OrgSpace): Promise<void> {
showPopup(documents.component.QmsTemplateWizard, {})
}
export async function createFolder (space: DocumentSpace): Promise<void> {
const project = await getLatestProjectId(space._id)
const props = {
space: space._id,
project: project ?? documents.ids.NoProject
}
showPopup(documents.component.CreateFolder, props)
}
export function formatSignatureDate (date: number): string {
const timeZone: string = getUserTimezone()