UBERF-6001: Roles management (#4994)

Signed-off-by: Alexey Zinoviev <alexey.zinoviev@xored.com>
This commit is contained in:
Alexey Zinoviev 2024-03-27 12:49:36 +04:00 committed by GitHub
parent bee1986299
commit 418b4b1bbd
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
112 changed files with 3448 additions and 874 deletions

3
.vscode/launch.json vendored
View File

@ -57,6 +57,7 @@
// "RETRANSLATE_TOKEN": ""
},
"runtimeArgs": ["--nolazy", "-r", "ts-node/register"],
"runtimeVersion": "20",
"showAsyncStacks": true,
"sourceMaps": true,
"cwd": "${workspaceRoot}/pods/server",
@ -154,7 +155,7 @@
"name": "Debug tool upgrade",
"type": "node",
"request": "launch",
"args": ["src/index.ts", "upgrade"],
"args": ["src/__start.ts", "upgrade"],
"env": {
"SERVER_SECRET": "secret",
"MINIO_ACCESS_KEY": "minioadmin",

View File

@ -18075,6 +18075,7 @@ packages:
eslint-plugin-n: 15.7.0(eslint@8.56.0)
eslint-plugin-promise: 6.1.1(eslint@8.56.0)
eslint-plugin-svelte: 2.35.1(eslint@8.56.0)(svelte@4.2.12)(ts-node@10.9.2)
fast-equals: 2.0.4
jest: 29.7.0(@types/node@20.11.19)(ts-node@10.9.2)
prettier: 3.2.5
prettier-plugin-svelte: 3.2.2(prettier@3.2.5)(svelte@4.2.12)
@ -18824,6 +18825,7 @@ packages:
eslint-plugin-n: 15.7.0(eslint@8.56.0)
eslint-plugin-promise: 6.1.1(eslint@8.56.0)
eslint-plugin-svelte: 2.35.1(eslint@8.56.0)(svelte@4.2.12)(ts-node@10.9.2)
fast-equals: 2.0.4
jest: 29.7.0(@types/node@20.11.19)(ts-node@10.9.2)
prettier: 3.2.5
prettier-plugin-svelte: 3.2.2(prettier@3.2.5)(svelte@4.2.12)

View File

@ -40,6 +40,7 @@ import { calendarOperation } from '@hcengineering/model-calendar'
import { timeOperation } from '@hcengineering/model-time'
import { activityOperation } from '@hcengineering/model-activity'
import { activityServerOperation } from '@hcengineering/model-server-activity'
import { documentOperation } from '@hcengineering/model-document'
export const migrateOperations: [string, MigrateOperation][] = [
['core', coreOperation],
@ -66,6 +67,7 @@ export const migrateOperations: [string, MigrateOperation][] = [
['inventiry', inventoryOperation],
['time', timeOperation],
['activityServer', activityServerOperation],
['document', documentOperation],
// We should call it after activityServer and chunter
['notification', notificationOperation]
]

View File

@ -505,6 +505,7 @@ export function createModel (builder: Builder): void {
description: board.string.ManageBoardStatuses,
icon: board.icon.Board,
baseClass: board.class.Board,
availablePermissions: [core.permission.DeleteObject],
allowedTaskTypeDescriptors: [board.descriptors.Card]
},
board.descriptors.BoardType

View File

@ -59,6 +59,7 @@ async function createDefaultProjectType (tx: TxOperations): Promise<Ref<ProjectT
descriptor: board.descriptors.BoardType,
description: '',
tasks: [],
roles: 0,
classic: false
},
[

View File

@ -67,7 +67,7 @@ import {
TTypeTimestamp,
TVersion
} from './core'
import { TAccount, TSpace } from './security'
import { TAccount, TSpace, TSpaceType, TSpaceTypeDescriptor, TTypedSpace, TRole, TPermission } from './security'
import { TStatus, TStatusCategory } from './status'
import { TUserStatus } from './transient'
import {
@ -81,6 +81,7 @@ import {
TTxUpdateDoc,
TTxWorkspaceEvent
} from './tx'
import { definePermissions } from './permissions'
export { coreId } from '@hcengineering/core'
export * from './core'
@ -108,6 +109,11 @@ export function createModel (builder: Builder): void {
TTxApplyIf,
TTxWorkspaceEvent,
TSpace,
TTypedSpace,
TSpaceType,
TSpaceTypeDescriptor,
TRole,
TPermission,
TAccount,
TAttribute,
TType,
@ -214,4 +220,6 @@ export function createModel (builder: Builder): void {
builder.mixin(core.class.Space, core.class.Class, core.mixin.FullTextSearchContext, {
childProcessingAllowed: false
})
definePermissions(builder)
}

View File

@ -0,0 +1,48 @@
//
// 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.
//
import { type Builder } from '@hcengineering/model'
import core from './component'
export function definePermissions (builder: Builder): void {
builder.createDoc(
core.class.Permission,
core.space.Model,
{
label: core.string.CreateObject
},
core.permission.CreateObject
)
builder.createDoc(
core.class.Permission,
core.space.Model,
{
label: core.string.UpdateObject
},
core.permission.UpdateObject
)
builder.createDoc(
core.class.Permission,
core.space.Model,
{
label: core.string.DeleteObject,
description: core.string.DeleteObjectDescription
},
core.permission.DeleteObject
)
}

View File

@ -21,11 +21,30 @@ import {
DOMAIN_MODEL,
IndexKind,
type Ref,
type Space
type Space,
type TypedSpace,
type SpaceType,
type SpaceTypeDescriptor,
type Role,
type Class,
type Permission,
type CollectionSize
} from '@hcengineering/core'
import { ArrOf, Hidden, Index, Model, Prop, TypeBoolean, TypeRef, TypeString, UX } from '@hcengineering/model'
import {
ArrOf,
Collection,
Hidden,
Index,
Model,
Prop,
TypeBoolean,
TypeRef,
TypeString,
UX
} from '@hcengineering/model'
import type { Asset, IntlString } from '@hcengineering/platform'
import core from './component'
import { TDoc } from './core'
import { TDoc, TAttachedDoc } from './core'
export const DOMAIN_SPACE = 'space' as Domain
@ -53,6 +72,75 @@ export class TSpace extends TDoc implements Space {
members!: Arr<Ref<Account>>
}
@Model(core.class.TypedSpace, core.class.Space)
@UX(core.string.TypedSpace, undefined, undefined, 'name')
export class TTypedSpace extends TSpace implements TypedSpace {
@Prop(TypeRef(core.class.SpaceType), core.string.SpaceType)
type!: Ref<SpaceType>
}
@Model(core.class.SpaceTypeDescriptor, core.class.Doc, DOMAIN_MODEL)
export class TSpaceTypeDescriptor extends TDoc implements SpaceTypeDescriptor {
name!: IntlString
description!: IntlString
icon!: Asset
baseClass!: Ref<Class<Space>>
availablePermissions!: Ref<Permission>[]
}
@Model(core.class.SpaceType, core.class.Doc, DOMAIN_MODEL)
@UX(core.string.SpaceType, undefined, undefined, 'name')
export class TSpaceType extends TDoc implements SpaceType {
@Prop(TypeString(), core.string.Name)
@Index(IndexKind.FullText)
name!: string
@Prop(TypeString(), core.string.ShortDescription)
shortDescription?: string
@Prop(TypeRef(core.class.SpaceTypeDescriptor), core.string.Descriptor)
descriptor!: Ref<SpaceTypeDescriptor>
@Prop(TypeRef(core.class.Class), core.string.TargetClass)
targetClass!: Ref<Class<Space>>
@Prop(Collection(core.class.Role), core.string.Roles)
roles!: CollectionSize<Role>
}
@Model(core.class.Role, core.class.AttachedDoc, DOMAIN_MODEL)
@UX(core.string.Role, undefined, undefined, 'name')
export class TRole extends TAttachedDoc implements Role {
@Prop(TypeRef(core.class.SpaceType), core.string.AttachedTo)
@Index(IndexKind.Indexed)
@Hidden()
declare attachedTo: Ref<SpaceType>
@Prop(TypeRef(core.class.SpaceType), core.string.AttachedToClass)
@Index(IndexKind.Indexed)
@Hidden()
declare attachedToClass: Ref<Class<SpaceType>>
@Prop(TypeString(), core.string.Collection)
@Hidden()
override collection: 'roles' = 'roles'
@Prop(TypeString(), core.string.Name)
@Index(IndexKind.FullText)
name!: string
@Prop(ArrOf(TypeRef(core.class.Permission)), core.string.Permission)
permissions!: Ref<Permission>[]
}
@Model(core.class.Permission, core.class.Doc, DOMAIN_MODEL)
@UX(core.string.Permission)
export class TPermission extends TDoc implements Permission {
label!: IntlString
description?: IntlString
icon?: Asset
}
@Model(core.class.Account, core.class.Doc, DOMAIN_MODEL)
@UX(core.string.Account)
export class TAccount extends TDoc implements Account {

View File

@ -40,7 +40,7 @@ import {
} from '@hcengineering/model'
import attachment, { TAttachment } from '@hcengineering/model-attachment'
import chunter from '@hcengineering/model-chunter'
import core, { TAttachedDoc, TSpace } from '@hcengineering/model-core'
import core, { TAttachedDoc, TTypedSpace } from '@hcengineering/model-core'
import { createPublicLinkAction } from '@hcengineering/model-guest'
import { generateClassNotificationTypes } from '@hcengineering/model-notification'
import preference, { TPreference } from '@hcengineering/model-preference'
@ -78,6 +78,11 @@ export class TDocument extends TAttachedDoc implements Document {
@Hidden()
declare attachedToClass: Ref<Class<Document>>
@Prop(TypeRef(core.class.Space), core.string.Space)
@Index(IndexKind.Indexed)
@Hidden()
declare space: Ref<Teamspace>
@Prop(TypeString(), core.string.Collection)
@Hidden()
override collection: 'children' = 'children'
@ -129,6 +134,11 @@ export class TDocumentSnapshot extends TAttachedDoc implements DocumentSnapshot
@Prop(TypeRef(core.class.Class), core.string.AttachedToClass)
declare attachedToClass: Ref<Class<Document>>
@Prop(TypeRef(core.class.Space), core.string.Space)
@Index(IndexKind.Indexed)
@Hidden()
declare space: Ref<Teamspace>
@Prop(TypeString(), core.string.Collection)
@Hidden()
override collection: 'snapshots' = 'snapshots'
@ -148,13 +158,26 @@ export class TSavedDocument extends TPreference implements SavedDocument {
declare attachedTo: Ref<Document>
}
@Model(document.class.Teamspace, core.class.Space)
@Model(document.class.Teamspace, core.class.TypedSpace)
@UX(document.string.Teamspace, document.icon.Teamspace, 'Teamspace', 'name')
export class TTeamspace extends TSpace implements Teamspace {}
export class TTeamspace extends TTypedSpace implements Teamspace {}
function defineTeamspace (builder: Builder): void {
builder.createModel(TTeamspace)
builder.createDoc(
core.class.SpaceTypeDescriptor,
core.space.Model,
{
name: document.string.DocumentApplication,
description: document.string.Description,
icon: document.icon.Document,
baseClass: document.class.Teamspace,
availablePermissions: [core.permission.DeleteObject]
},
document.descriptor.TeamspaceType
)
// Navigator
builder.mixin(document.class.Teamspace, core.class.Class, view.mixin.SpacePresenter, {

View File

@ -25,6 +25,8 @@ import {
import { DOMAIN_ATTACHMENT } from '@hcengineering/model-attachment'
import core, { DOMAIN_SPACE } from '@hcengineering/model-core'
import { type Asset } from '@hcengineering/platform'
import { createSpaceType } from '@hcengineering/setting'
import document, { documentId, DOMAIN_DOCUMENT } from './index'
async function createSpace (tx: TxOperations): Promise<void> {
@ -238,6 +240,40 @@ async function setNoParent (client: MigrationClient): Promise<void> {
)
}
async function createDefaultTeamspaceType (tx: TxOperations): Promise<void> {
const exist = await tx.findOne(core.class.SpaceType, { _id: document.spaceType.DefaultTeamspaceType })
const deleted = await tx.findOne(core.class.TxRemoveDoc, {
objectId: document.spaceType.DefaultTeamspaceType
})
if (exist === undefined && deleted === undefined) {
await createSpaceType(
tx,
{
name: 'Default teamspace type',
descriptor: document.descriptor.TeamspaceType,
roles: 0
},
document.spaceType.DefaultTeamspaceType
)
}
}
async function migrateTeamspaces (client: MigrationClient): Promise<void> {
await client.update(
DOMAIN_SPACE,
{
_class: document.class.Teamspace,
type: { $exists: false }
},
{
$set: {
type: document.spaceType.DefaultTeamspaceType
}
}
)
}
export const documentOperation: MigrateOperation = {
async migrate (client: MigrationClient): Promise<void> {
await setNoParent(client)
@ -263,10 +299,12 @@ export const documentOperation: MigrateOperation = {
func: migrateDocumentIcons
}
])
await migrateTeamspaces(client)
},
async upgrade (client: MigrationUpgradeClient): Promise<void> {
const tx = new TxOperations(client, core.account.System)
await createSpace(tx)
await createDefaultTeamspaceType(tx)
}
}

View File

@ -672,6 +672,7 @@ export function createModel (builder: Builder): void {
description: lead.string.ManageFunnelStatuses,
icon: lead.icon.LeadApplication,
baseClass: lead.class.Funnel,
availablePermissions: [core.permission.DeleteObject],
allowedTaskTypeDescriptors: [lead.descriptors.Lead]
},
lead.descriptors.FunnelType

View File

@ -39,6 +39,7 @@ async function createSpace (tx: TxOperations): Promise<void> {
descriptor: lead.descriptors.FunnelType,
description: '',
tasks: [],
roles: 0,
classic: false
},
[

View File

@ -21,7 +21,7 @@ import lead from '@hcengineering/lead-resources/src/plugin'
import { type NotificationGroup, type NotificationType } from '@hcengineering/notification'
import type { IntlString } from '@hcengineering/platform'
import { mergeIds } from '@hcengineering/platform'
import { type TaskTypeDescriptor, type ProjectType } from '@hcengineering/task'
import { type TaskTypeDescriptor } from '@hcengineering/task'
import type { AnyComponent } from '@hcengineering/ui/src/types'
import { type Action, type ActionCategory, type Viewlet } from '@hcengineering/view'
@ -48,9 +48,6 @@ export default mergeIds(leadId, lead, {
space: {
DefaultFunnel: '' as Ref<Funnel>
},
template: {
DefaultFunnel: '' as Ref<ProjectType>
},
viewlet: {
TableCustomer: '' as Ref<Viewlet>,
TableLead: '' as Ref<Viewlet>,

View File

@ -1709,6 +1709,7 @@ export function createModel (builder: Builder): void {
icon: recruit.icon.RecruitApplication,
editor: recruit.component.VacancyTemplateEditor,
baseClass: recruit.class.Vacancy,
availablePermissions: [core.permission.DeleteObject],
allowedTaskTypeDescriptors: [recruit.descriptors.Application]
},
recruit.descriptors.VacancyType

View File

@ -157,6 +157,7 @@ async function createDefaultKanbanTemplate (tx: TxOperations): Promise<Ref<Proje
descriptor: recruit.descriptors.VacancyType,
description: '',
tasks: [],
roles: 0,
classic: false
},
[

View File

@ -30,7 +30,10 @@ import {
type InviteSettings,
type WorkspaceSetting,
type SettingsCategory,
type UserMixin
type UserMixin,
type SpaceTypeEditor,
type SpaceTypeEditorSection,
type SpaceTypeCreator
} from '@hcengineering/setting'
import templates from '@hcengineering/templates'
import setting from './plugin'
@ -105,6 +108,17 @@ export class TWorkspaceSetting extends TDoc implements WorkspaceSetting {
icon?: string
}
@Mixin(setting.mixin.SpaceTypeEditor, core.class.Class)
export class TSpaceTypeEditor extends TClass implements SpaceTypeEditor {
sections!: SpaceTypeEditorSection[]
subEditors?: Record<string, AnyComponent>
}
@Mixin(setting.mixin.SpaceTypeCreator, core.class.Class)
export class TSpaceTypeCreator extends TClass implements SpaceTypeCreator {
extraComponent!: AnyComponent
}
export function createModel (builder: Builder): void {
builder.createModel(
TIntegration,
@ -114,7 +128,9 @@ export function createModel (builder: Builder): void {
TEditable,
TUserMixin,
TInviteSettings,
TWorkspaceSetting
TWorkspaceSetting,
TSpaceTypeEditor,
TSpaceTypeCreator
)
builder.mixin(setting.class.Integration, core.class.Class, notification.mixin.ClassCollaborators, {
@ -560,4 +576,56 @@ export function createModel (builder: Builder): void {
},
setting.ids.IntegrationDisabledNotification
)
builder.createDoc(
setting.class.SettingsCategory,
core.space.Model,
{
name: 'spaceTypes',
label: setting.string.SpaceTypes,
icon: setting.icon.Privacy, // TODO: update icon. Where is it displayed?
component: setting.component.ManageSpaceTypeContent,
extraComponents: {
navigation: setting.component.ManageSpaceTypes,
tools: setting.component.ManageSpaceTypesTools
},
group: 'settings-editor',
secured: false,
order: 6000,
expandable: true
},
setting.ids.ManageSpaces
)
builder.mixin(core.class.SpaceType, core.class.Class, setting.mixin.SpaceTypeEditor, {
sections: [
{
id: 'general',
label: setting.string.General,
component: setting.component.SpaceTypeGeneralSectionEditor,
withoutContainer: true
},
{
id: 'properties',
label: setting.string.Properties,
component: setting.component.SpaceTypePropertiesSectionEditor
},
{
id: 'roles',
label: setting.string.Roles,
component: setting.component.SpaceTypeRolesSectionEditor
}
],
subEditors: {
roles: setting.component.RoleEditor
}
})
builder.mixin(core.class.SpaceTypeDescriptor, core.class.Class, view.mixin.ObjectPresenter, {
presenter: setting.component.SpaceTypeDescriptorPresenter
})
builder.mixin(core.class.Permission, core.class.Class, view.mixin.ObjectPresenter, {
presenter: setting.component.PermissionPresenter
})
}

View File

@ -53,7 +53,15 @@ import {
} from '@hcengineering/model'
import attachment from '@hcengineering/model-attachment'
import chunter from '@hcengineering/model-chunter'
import core, { DOMAIN_SPACE, TAttachedDoc, TClass, TDoc, TSpace } from '@hcengineering/model-core'
import core, {
DOMAIN_SPACE,
TAttachedDoc,
TClass,
TDoc,
TSpaceType,
TSpaceTypeDescriptor,
TTypedSpace
} from '@hcengineering/model-core'
import view, {
classPresenter,
createAction,
@ -173,18 +181,20 @@ export class TProjectTypeClass extends TClass implements ProjectTypeClass {
projectType!: Ref<ProjectType>
}
@Model(task.class.Project, core.class.Space)
export class TProject extends TSpace implements Project {
@Model(task.class.Project, core.class.TypedSpace)
export class TProject extends TTypedSpace implements Project {
@Prop(TypeRef(task.class.ProjectType), task.string.ProjectType)
type!: Ref<ProjectType>
declare type: Ref<ProjectType>
}
@Model(task.class.ProjectType, core.class.Space)
export class TProjectType extends TSpace implements ProjectType {
shortDescription?: string
@Model(task.class.ProjectType, core.class.SpaceType)
export class TProjectType extends TSpaceType implements ProjectType {
@Prop(TypeRef(task.class.ProjectTypeDescriptor), getEmbeddedLabel('Descriptor'))
descriptor!: Ref<ProjectTypeDescriptor>
declare descriptor: Ref<ProjectTypeDescriptor>
@Prop(TypeString(), task.string.Description)
@Index(IndexKind.FullText)
description!: string
@Prop(ArrOf(TypeRef(task.class.TaskType)), getEmbeddedLabel('Tasks'))
tasks!: Ref<TaskType>[]
@ -193,13 +203,13 @@ export class TProjectType extends TSpace implements ProjectType {
statuses!: ProjectStatus[]
@Prop(TypeRef(core.class.Class), getEmbeddedLabel('Target Class'))
targetClass!: Ref<Class<Project>>
declare targetClass: Ref<Class<Project>>
@Prop(TypeBoolean(), getEmbeddedLabel('Classic'))
classic!: boolean
}
@Model(task.class.TaskType, core.class.Doc, DOMAIN_TASK)
@Model(task.class.TaskType, core.class.Doc, DOMAIN_MODEL)
export class TTaskType extends TDoc implements TaskType {
@Prop(TypeString(), getEmbeddedLabel('Name'))
name!: string
@ -232,15 +242,12 @@ export class TTaskType extends TDoc implements TaskType {
statusCategories!: Ref<StatusCategory>[]
}
@Model(task.class.ProjectTypeDescriptor, core.class.Doc, DOMAIN_MODEL)
export class TProjectTypeDescriptor extends TDoc implements ProjectTypeDescriptor {
name!: IntlString
description!: IntlString
icon!: Asset
@Model(task.class.ProjectTypeDescriptor, core.class.SpaceTypeDescriptor, DOMAIN_MODEL)
export class TProjectTypeDescriptor extends TSpaceTypeDescriptor implements ProjectTypeDescriptor {
editor?: AnyComponent
allowedClassic?: boolean
allowedTaskTypeDescriptors?: Ref<TaskTypeDescriptor>[] // if undefined we allow all possible
baseClass!: Ref<Class<Task>>
declare baseClass: Ref<Class<Project>>
}
@Model(task.class.Sequence, core.class.Doc, DOMAIN_KANBAN)
@ -540,25 +547,49 @@ export function createModel (builder: Builder): void {
value: true
})
builder.createDoc(
setting.class.SettingsCategory,
core.space.Model,
{
name: 'statuses',
label: task.string.ManageProjects,
icon: task.icon.ManageTemplates,
component: task.component.ManageProjectsContent,
extraComponents: {
navigation: task.component.ManageProjects,
tools: task.component.ManageProjectsTools
builder.mixin(task.class.ProjectTypeDescriptor, core.class.Class, setting.mixin.SpaceTypeCreator, {
extraComponent: task.component.CreateProjectType
})
builder.mixin(task.class.ProjectType, core.class.Class, setting.mixin.SpaceTypeEditor, {
sections: [
{
id: 'general',
label: setting.string.General,
component: task.component.ProjectTypeGeneralSectionEditor,
withoutContainer: true
},
group: 'settings-editor',
secured: false,
order: 6000,
expandable: true
},
task.ids.ManageProjects
)
{
id: 'properties',
label: setting.string.Properties,
component: setting.component.SpaceTypePropertiesSectionEditor
},
{
id: 'roles',
label: setting.string.Roles,
component: setting.component.SpaceTypeRolesSectionEditor
},
{
id: 'taskTypes',
label: setting.string.TaskTypes,
component: task.component.ProjectTypeTasksTypeSectionEditor
},
{
id: 'automations',
label: setting.string.Automations,
component: task.component.ProjectTypeAutomationsSectionEditor
},
{
id: 'collections',
label: setting.string.Collections,
component: task.component.ProjectTypeCollectionsSectionEditor
}
],
subEditors: {
taskTypes: task.component.TaskTypeEditor,
roles: setting.component.RoleEditor
}
})
createPublicLinkAction(builder, task.class.Task, task.action.PublicLink)
}
@ -589,8 +620,7 @@ export async function fixTaskTypes (
throw new Error('category is not found in model')
}
const projectTypes = await client.find<ProjectType>(DOMAIN_SPACE, {
_class: task.class.ProjectType,
const projectTypes = await client.model.findAll(task.class.ProjectType, {
descriptor
})
const baseClassClass = client.hierarchy.getClass(categoryObj.baseClass)
@ -751,7 +781,7 @@ export async function fixTaskTypes (
parent: t._id,
_id: taskTypeId,
_class: task.class.TaskType,
space: t._id,
space: core.space.Model,
statuses: dStatuses,
modifiedBy: core.account.System,
modifiedOn: Date.now(),

View File

@ -13,7 +13,17 @@
// limitations under the License.
//
import { ClassifierKind, TxOperations, toIdMap, type Class, type Doc, type Ref } from '@hcengineering/core'
import {
ClassifierKind,
TxOperations,
toIdMap,
type Class,
type Doc,
type Ref,
type TxCreateDoc,
generateId,
DOMAIN_TX
} from '@hcengineering/core'
import {
createOrUpdate,
tryMigrate,
@ -25,7 +35,7 @@ import {
import core, { DOMAIN_SPACE } from '@hcengineering/model-core'
import tags from '@hcengineering/model-tags'
import { getEmbeddedLabel } from '@hcengineering/platform'
import { taskId, type TaskType } from '@hcengineering/task'
import { type ProjectType, taskId, type TaskType } from '@hcengineering/task'
import { DOMAIN_TASK } from '.'
import task from './plugin'
@ -167,6 +177,79 @@ async function fixProjectTypeMissingClass (client: MigrationUpgradeClient): Prom
}
}
async function migrateProjectTypes (client: MigrationClient): Promise<void> {
const oldProjectTypes = await client.find<ProjectType>(DOMAIN_SPACE, { _class: task.class.ProjectType })
for (const pt of oldProjectTypes) {
// Create the project type in model instead
const tx: TxCreateDoc<ProjectType> = {
_id: generateId(),
_class: core.class.TxCreateDoc,
space: core.space.Tx,
objectClass: pt._class,
objectSpace: core.space.Model,
objectId: pt._id,
attributes: {
descriptor: pt.descriptor,
tasks: pt.tasks,
statuses: pt.statuses,
targetClass: pt.targetClass,
classic: pt.classic,
name: pt.name,
description: pt.description,
shortDescription: pt.shortDescription,
roles: pt.roles ?? []
},
modifiedOn: pt.modifiedOn,
createdBy: pt.createdBy,
createdOn: pt.createdOn,
modifiedBy: pt.modifiedBy
}
await client.create(DOMAIN_TX, tx)
// Remove old project type from spaces
await client.delete(DOMAIN_SPACE, pt._id)
}
}
async function migrateTaskTypes (client: MigrationClient): Promise<void> {
const oldTaskTypes = await client.find<TaskType>(DOMAIN_TASK, { _class: task.class.TaskType })
for (const tt of oldTaskTypes) {
// Create the task type in model instead
const tx: TxCreateDoc<TaskType> = {
_id: generateId(),
_class: core.class.TxCreateDoc,
space: core.space.Tx,
objectClass: tt._class,
objectSpace: core.space.Model,
objectId: tt._id,
attributes: {
parent: tt.parent,
descriptor: tt.descriptor,
name: tt.name,
kind: tt.kind,
ofClass: tt.ofClass,
targetClass: tt.targetClass,
statuses: tt.statuses,
statusClass: tt.statusClass,
statusCategories: tt.statusCategories,
allowedAsChildOf: tt.allowedAsChildOf,
icon: tt.icon,
color: tt.color
},
modifiedOn: tt.modifiedOn,
createdBy: tt.createdBy,
createdOn: tt.createdOn,
modifiedBy: tt.modifiedBy
}
await client.create(DOMAIN_TX, tx)
// Remove old task type from task
await client.delete(DOMAIN_TASK, tt._id)
}
}
export const taskOperation: MigrateOperation = {
async migrate (client: MigrationClient): Promise<void> {
await tryMigrate(client, taskId, [
@ -201,6 +284,8 @@ export const taskOperation: MigrateOperation = {
}
}
])
await migrateTaskTypes(client)
await migrateProjectTypes(client)
},
async upgrade (client: MigrationUpgradeClient): Promise<void> {
const tx = new TxOperations(client, core.account.System)

View File

@ -61,10 +61,7 @@ export default mergeIds(taskId, task, {
TaskTypePresenter: '' as AnyComponent,
ProjectTypePresenter: '' as AnyComponent,
TaskTypeClassPresenter: '' as AnyComponent,
ProjectTypeClassPresenter: '' as AnyComponent,
ManageProjects: '' as AnyComponent,
ManageProjectsTools: '' as AnyComponent,
ManageProjectsContent: '' as AnyComponent
ProjectTypeClassPresenter: '' as AnyComponent
},
space: {
TasksPublic: '' as Ref<Space>

View File

@ -182,6 +182,7 @@ export function createActions (builder: Builder, issuesId: string, componentsId:
mode: ['context', 'browser'],
group: 'remove'
},
visibilityTester: view.function.CanDeleteObject,
override: [view.action.Delete]
},
tracker.action.DeleteIssue
@ -709,7 +710,8 @@ export function createActions (builder: Builder, issuesId: string, componentsId:
category: tracker.category.Tracker,
input: 'any',
target: tracker.class.Milestone,
context: { mode: ['context', 'browser'], group: 'remove' }
context: { mode: ['context', 'browser'], group: 'remove' },
visibilityTester: view.function.CanDeleteObject
},
tracker.action.DeleteMilestone
)

View File

@ -687,6 +687,7 @@ export function createModel (builder: Builder): void {
description: tracker.string.ManageWorkflowStatuses,
icon: task.icon.Task,
baseClass: tracker.class.Project,
availablePermissions: [core.permission.DeleteObject],
allowedClassic: true,
allowedTaskTypeDescriptors: [tracker.descriptors.Issue]
},

View File

@ -66,6 +66,7 @@ async function createDefaultProject (tx: TxOperations): Promise<void> {
descriptor: tracker.descriptors.ProjectType,
description: '',
tasks: [],
roles: 0,
classic: true
},
[

View File

@ -624,7 +624,8 @@ export function createModel (builder: Builder): void {
category: view.category.General,
input: 'any',
target: core.class.Doc,
context: { mode: ['context', 'browser'], group: 'remove' }
context: { mode: ['context', 'browser'], group: 'remove' },
visibilityTester: view.function.CanDeleteObject
},
view.action.Delete
)

View File

@ -13,8 +13,8 @@
// limitations under the License.
//
import { type Ref } from '@hcengineering/core'
import { type IntlString, mergeIds } from '@hcengineering/platform'
import { type Doc, type Ref } from '@hcengineering/core'
import { type IntlString, mergeIds, type Resource } from '@hcengineering/platform'
import { type AnyComponent } from '@hcengineering/ui/src/types'
import { type FilterFunction, type ViewAction, type ViewCategoryAction, viewId } from '@hcengineering/view'
import { type PresentationMiddlewareFactory } from '@hcengineering/presentation/src/pipeline'
@ -126,7 +126,8 @@ export default mergeIds(viewId, view, {
FilterDateNextMonth: '' as FilterFunction,
FilterDateNotSpecified: '' as FilterFunction,
FilterDateCustom: '' as FilterFunction,
ShowEmptyGroups: '' as ViewCategoryAction
ShowEmptyGroups: '' as ViewCategoryAction,
CanDeleteObject: '' as Resource<(doc?: Doc | Doc[]) => Promise<boolean>>
},
pipeline: {
PresentationMiddleware: '' as Ref<PresentationMiddlewareFactory>,

View File

@ -2,6 +2,8 @@
"string": {
"Id": "Id",
"Space": "Space",
"TypedSpace": "Typed space",
"SpaceType": "Space type",
"Modified": "Modified",
"ModifiedDate": "Modified date",
"ModifiedBy": "Modified by",
@ -10,6 +12,11 @@
"AttachedToClass": "Attached to class",
"Name": "Name",
"Description": "Description",
"ShortDescription": "Short description",
"Descriptor": "Descriptor",
"TargetClass": "Target class",
"Role": "Role",
"Roles": "Roles",
"Private": "Private",
"Archived": "Archived",
"ClassLabel": "Type",
@ -35,6 +42,11 @@
"Status": "Status",
"StatusCategory": "Status category",
"Account": "Account",
"Rank": "Rank"
"Rank": "Rank",
"Permission": "Permission",
"CreateObject": "Create object",
"UpdateObject": "Update object",
"DeleteObject": "Delete object",
"DeleteObjectDescription": "Grants users ability to delete objects in the space"
}
}

View File

@ -2,6 +2,8 @@
"string": {
"Id": "Id",
"Space": "Пространство",
"TypedSpace": "Типизированное пространство",
"SpaceType": "Тип пространства",
"Modified": "Изменено",
"ModifiedDate": "Дата изменения",
"ModifiedBy": "Изменен",
@ -10,6 +12,11 @@
"AttachedToClass": "Прикреплен к классу",
"Name": "Название",
"Description": "Описание",
"ShortDescription": "Короткое описание",
"Descriptor": "Дескриптор",
"TargetClass": "Целевой класс",
"Role": "Роль",
"Roles": "Роли",
"Private": "Личный",
"Archived": "Архивный",
"ClassLabel": "Тип",
@ -35,6 +42,11 @@
"Status": "Статус",
"StatusCategory": "Категория статуса",
"Account": "Аккаунт",
"Rank": "Ранг"
"Rank": "Ранг",
"Permission": "Разрешение",
"CreateObject": "Создавать объект",
"UpdateObject": "Обновлять объект",
"DeleteObject": "Удалять объект",
"DeleteObjectDescription": "Дает пользователям разрешение удалять объекты в пространстве"
}
}

View File

@ -362,6 +362,66 @@ export interface Space extends Doc {
archived: boolean
}
/**
* @public
*
* Space with custom configured type
*/
export interface TypedSpace extends Space {
type: Ref<SpaceType>
}
/**
* @public
*
* Is used to describe "types" for space type
*/
export interface SpaceTypeDescriptor extends Doc {
name: IntlString
description: IntlString
icon: Asset
baseClass: Ref<Class<Space>> // Child class of Space for which the space type can be defined
availablePermissions: Ref<Permission>[]
}
/**
* @public
*
* Customisable space type allowing to configure space roles and permissions within them
*/
export interface SpaceType extends Doc {
name: string
shortDescription?: string
descriptor: Ref<SpaceTypeDescriptor>
targetClass: Ref<Class<Space>> // A dynamic mixin for Spaces to hold custom attributes and roles assignment of the space type
roles: CollectionSize<Role>
}
/**
* @public
* Role defines permissions for employees assigned to this role within the space
*/
export interface Role extends AttachedDoc<SpaceType, 'roles'> {
name: string
permissions: Ref<Permission>[]
}
/**
* @public
* Defines assignment of employees to a role within a space
*/
export type RolesAssignment = Record<Ref<Role>, Ref<Account>[] | undefined>
/**
* @public
* Permission is a basic access control item in the system
*/
export interface Permission extends Doc {
label: IntlString
description?: IntlString
icon?: Asset
}
/**
* @public
*/

View File

@ -38,14 +38,19 @@ import type {
Markup,
MigrationState,
Obj,
Permission,
PluginConfiguration,
Ref,
RefTo,
RelatedDocument,
Role,
Space,
SpaceType,
SpaceTypeDescriptor,
Timestamp,
Type,
TypeAny,
TypedSpace,
UserStatus
} from './classes'
import { CollaborativeDoc } from './collaboration'
@ -93,6 +98,11 @@ export default plugin(coreId, {
TxUpdateDoc: '' as Ref<Class<TxUpdateDoc<Doc>>>,
TxRemoveDoc: '' as Ref<Class<TxRemoveDoc<Doc>>>,
Space: '' as Ref<Class<Space>>,
TypedSpace: '' as Ref<Class<TypedSpace>>,
SpaceTypeDescriptor: '' as Ref<Class<SpaceTypeDescriptor>>,
SpaceType: '' as Ref<Class<SpaceType>>,
Role: '' as Ref<Class<Role>>,
Permission: '' as Ref<Class<Permission>>,
Account: '' as Ref<Class<Account>>,
Type: '' as Ref<Class<Type<any>>>,
TypeString: '' as Ref<Class<Type<string>>>,
@ -157,6 +167,8 @@ export default plugin(coreId, {
string: {
Id: '' as IntlString,
Space: '' as IntlString,
TypedSpace: '' as IntlString,
SpaceType: '' as IntlString,
Modified: '' as IntlString,
ModifiedDate: '' as IntlString,
ModifiedBy: '' as IntlString,
@ -180,6 +192,11 @@ export default plugin(coreId, {
Name: '' as IntlString,
Enum: '' as IntlString,
Description: '' as IntlString,
ShortDescription: '' as IntlString,
Descriptor: '' as IntlString,
TargetClass: '' as IntlString,
Role: '' as IntlString,
Roles: '' as IntlString,
Hyperlink: '' as IntlString,
Private: '' as IntlString,
Object: '' as IntlString,
@ -189,6 +206,16 @@ export default plugin(coreId, {
Status: '' as IntlString,
Account: '' as IntlString,
StatusCategory: '' as IntlString,
Rank: '' as IntlString
Rank: '' as IntlString,
Permission: '' as IntlString,
CreateObject: '' as IntlString,
UpdateObject: '' as IntlString,
DeleteObject: '' as IntlString,
DeleteObjectDescription: '' as IntlString
},
permission: {
CreateObject: '' as Ref<Permission>,
UpdateObject: '' as Ref<Permission>,
DeleteObject: '' as Ref<Permission>
}
})

View File

@ -14,11 +14,26 @@
//
import { deepEqual } from 'fast-equals'
import { Account, AnyAttribute, Class, Doc, DocData, DocIndexState, IndexKind, Obj, Ref, Space } from './classes'
import {
Account,
AnyAttribute,
Class,
Doc,
DocData,
DocIndexState,
IndexKind,
Obj,
Permission,
Ref,
Role,
Space,
TypedSpace
} from './classes'
import core from './component'
import { Hierarchy } from './hierarchy'
import { isPredicate } from './predicate'
import { DocumentQuery, FindResult } from './storage'
import { TxOperations } from './operations'
function toHex (value: number, chars: number): string {
const result = value.toString(16)
@ -542,3 +557,30 @@ export const isEnum =
(token: any): token is T[keyof T] => {
return typeof token === 'string' && Object.values(e as Record<string, any>).includes(token)
}
export async function checkPermission (
client: TxOperations,
_id: Ref<Permission>,
_space: Ref<TypedSpace>
): Promise<boolean> {
const space = await client.findOne(core.class.TypedSpace, { _id: _space })
const type = await client
.getModel()
.findOne(core.class.SpaceType, { _id: space?.type }, { lookup: { _id: { roles: core.class.Role } } })
const mixin = type?.targetClass
if (space === undefined || type === undefined || mixin === undefined) {
return false
}
const me = getCurrentAccount()
const asMixin = client.getHierarchy().as(space, mixin)
const myRoles = type.$lookup?.roles?.filter((role) => (asMixin as any)[role._id]?.includes(me._id)) as Role[]
if (myRoles === undefined) {
return false
}
const myPermissions = new Set(myRoles.flatMap((role) => role.permissions))
return myPermissions.has(_id)
}

View File

@ -19,6 +19,8 @@
import { showPopup, closeTooltip, Label, getIconSize2x, Loading } from '@hcengineering/ui'
import presentation, { PDFViewer, getFileUrl } from '@hcengineering/presentation'
import filesize from 'filesize'
import core from '@hcengineering/core'
import { permissionsStore } from '@hcengineering/view-resources'
import AttachmentName from './AttachmentName.svelte'
@ -35,6 +37,13 @@
const trimFilename = (fname: string): string =>
fname.length > maxLenght ? fname.substr(0, (maxLenght - 1) / 2) + '...' + fname.substr(-(maxLenght - 1) / 2) : fname
$: canRemove =
removable &&
value !== undefined &&
value.readonly !== true &&
($permissionsStore.whitelist.has(value.space) ||
$permissionsStore.ps[value.space]?.has(core.permission.DeleteObject))
function iconLabel (name: string): string {
const parts = `${name}`.split('.')
const ext = parts[parts.length - 1]
@ -156,7 +165,7 @@
>
<Label label={presentation.string.Download} />
</a>
{#if removable && value.readonly !== true}
{#if canRemove}
<span></span>
<!-- svelte-ignore a11y-click-events-have-key-events -->
<!-- svelte-ignore a11y-no-static-element-interactions -->

View File

@ -29,7 +29,9 @@
export let kind: ButtonKind = 'link'
export let size: ButtonSize = 'large'
export let width: string | undefined = undefined
export let includeItems: Ref<Account>[] | undefined = undefined
export let excludeItems: Ref<Account>[] | undefined = undefined
export let emptyLabel: IntlString | undefined = undefined
let timer: any = null
const client = getClient()
@ -56,9 +58,7 @@
})
const excludedQuery = createQuery()
let excluded: Account[] = []
$: if (excludeItems !== undefined && excludeItems.length > 0) {
excludedQuery.query(core.class.Account, { _id: { $in: excludeItems } }, (res) => {
excluded = res
@ -68,21 +68,44 @@
excluded = []
}
const includedQuery = createQuery()
let included: Account[] = []
$: if (includeItems !== undefined && includeItems.length > 0) {
includedQuery.query(core.class.Account, { _id: { $in: includeItems } }, (res) => {
included = res
})
} else {
includedQuery.unsubscribe()
included = []
}
$: employees = Array.from(
(value ?? []).map((it) => $personAccountByIdStore.get(it as Ref<PersonAccount>)?.person)
).filter((it) => it !== undefined) as Ref<Employee>[]
$: docQuery =
excluded.length > 0
? {
_id: { $nin: excluded.map((p) => (p as PersonAccount).person as Ref<Employee>) }
excluded.length === 0 && included.length === 0
? {}
: {
_id: {
...(included.length > 0
? {
$in: included.map((p) => (p as PersonAccount).person as Ref<Employee>)
}
: {}),
...(excluded.length > 0
? {
$nin: excluded.map((p) => (p as PersonAccount).person as Ref<Employee>)
}
: {})
}
}
: {}
</script>
<UserBoxList
items={employees}
{label}
{emptyLabel}
{readonly}
{docQuery}
on:update={onUpdate}

View File

@ -37,7 +37,7 @@
export let justify: 'left' | 'center' = 'center'
export let width: string | undefined = undefined
export let labelDirection: TooltipAlignment | undefined = undefined
export let emptyLabel = plugin.string.Members
export let emptyLabel: IntlString = plugin.string.Members
export let readonly: boolean = false
function filter (items: Ref<Employee>[]): Ref<Employee>[] {

View File

@ -56,6 +56,7 @@
"ReassignToDo": "Change Todo assignee",
"ReassignToDoConfirm": "Do you want to change Todo assignee? The Todo will be removed from the current assignee's planning.",
"Icon": "Icon",
"Color": "Color"
"Color": "Color",
"RoleLabel": "Role: {role}"
}
}

View File

@ -56,6 +56,7 @@
"ReassignToDo": "Изменить исполнителя Todo",
"ReassignToDoConfirm": "Вы хотите изменить исполнителя Todo? Todo будет удалена из планирования текущего исполнителя.",
"Icon": "Иконка",
"Color": "Цвет"
"Color": "Цвет",
"RoleLabel": "Роль: {role}"
}
}

View File

@ -62,6 +62,7 @@
"@hcengineering/time": "^0.6.0",
"@hcengineering/rank": "^0.6.0",
"@tiptap/core": "^2.1.12",
"slugify": "^1.6.6"
"slugify": "^1.6.6",
"fast-equals": "^2.0.3"
}
}

View File

@ -13,9 +13,21 @@
// limitations under the License.
-->
<script lang="ts">
import { deepEqual } from 'fast-equals'
import { AccountArrayEditor } from '@hcengineering/contact-resources'
import core, { Account, Data, DocumentUpdate, Ref, generateId, getCurrentAccount } from '@hcengineering/core'
import { Teamspace } from '@hcengineering/document'
import core, {
Account,
Data,
DocumentUpdate,
RolesAssignment,
Ref,
Role,
SpaceType,
generateId,
getCurrentAccount,
WithLookup
} from '@hcengineering/core'
import document, { Teamspace } from '@hcengineering/document'
import { Asset } from '@hcengineering/platform'
import presentation, { Card, getClient } from '@hcengineering/presentation'
import { StyledTextBox } from '@hcengineering/text-editor'
@ -32,15 +44,16 @@
themeStore
} from '@hcengineering/ui'
import view from '@hcengineering/view'
import { IconPicker } from '@hcengineering/view-resources'
import { IconPicker, SpaceTypeSelector } from '@hcengineering/view-resources'
import { createEventDispatcher } from 'svelte'
import document from '../../plugin'
import documentRes from '../../plugin'
export let teamspace: Teamspace | undefined = undefined
export let namePlaceholder: string = ''
export let descriptionPlaceholder: string = ''
const dispatch = createEventDispatcher()
const client = getClient()
const hierarchy = client.getHierarchy()
@ -52,11 +65,43 @@
let isColorSelected = false
let members: Ref<Account>[] =
teamspace?.members !== undefined ? hierarchy.clone(teamspace.members) : [getCurrentAccount()._id]
const dispatch = createEventDispatcher()
let rolesAssignment: RolesAssignment = {}
$: isNew = teamspace === undefined
let typeId: Ref<SpaceType> | undefined = teamspace?.type || document.spaceType.DefaultTeamspaceType
let spaceType: WithLookup<SpaceType> | undefined
$: void loadSpaceType(typeId)
async function loadSpaceType (id: typeof typeId): Promise<void> {
spaceType =
id !== undefined
? await client
.getModel()
.findOne(core.class.SpaceType, { _id: id }, { lookup: { _id: { roles: core.class.Role } } })
: undefined
if (teamspace === undefined || spaceType?.targetClass === undefined || spaceType?.$lookup?.roles === undefined) {
return
}
rolesAssignment = getRolesAssignment()
}
function getRolesAssignment (): RolesAssignment {
if (teamspace === undefined || spaceType?.targetClass === undefined || spaceType?.$lookup?.roles === undefined) {
return {}
}
const asMixin = hierarchy.as(teamspace, spaceType?.targetClass)
return spaceType.$lookup.roles.reduce<RolesAssignment>((prev, { _id }) => {
prev[_id as Ref<Role>] = (asMixin as any)[_id]
return prev
}, {})
}
async function handleSave (): Promise<void> {
if (isNew) {
await createTeamspace()
@ -65,7 +110,7 @@
}
}
function getTeamspaceData (): Data<Teamspace> {
function getTeamspaceData (): Omit<Data<Teamspace>, 'type'> {
return {
name,
description,
@ -78,7 +123,7 @@
}
async function updateTeamspace (): Promise<void> {
if (teamspace === undefined) {
if (teamspace === undefined || spaceType?.targetClass === undefined) {
return
}
@ -114,14 +159,37 @@
await client.update(teamspace, update)
}
if (!deepEqual(rolesAssignment, getRolesAssignment())) {
await client.updateMixin(
teamspace._id,
document.class.Teamspace,
core.space.Space,
spaceType.targetClass,
rolesAssignment
)
}
close()
}
async function createTeamspace (): Promise<void> {
if (typeId === undefined || spaceType?.targetClass === undefined) {
return
}
const teamspaceId = generateId<Teamspace>()
const teamspaceData = getTeamspaceData()
await client.createDoc(document.class.Teamspace, core.space.Space, { ...teamspaceData }, teamspaceId)
await client.createDoc(document.class.Teamspace, core.space.Space, { ...teamspaceData, type: typeId }, teamspaceId)
// Create space type's mixin with roles assignments
await client.createMixin(
teamspaceId,
document.class.Teamspace,
core.space.Space,
spaceType.targetClass,
rolesAssignment
)
close(teamspaceId)
}
@ -141,13 +209,42 @@
function close (id?: Ref<Teamspace>): void {
dispatch('close', id)
}
function handleTypeChange (evt: CustomEvent<Ref<SpaceType>>): void {
typeId = evt.detail
}
$: roles = (spaceType?.$lookup?.roles ?? []) as Role[]
function handleMembersChanged (newMembers: Ref<Account>[]): void {
// If a member was removed we need to remove it from any roles assignments as well
const newMembersSet = new Set(newMembers)
const removedMembersSet = new Set(members.filter((m) => !newMembersSet.has(m)))
if (removedMembersSet.size > 0 && rolesAssignment !== undefined) {
for (const [key, value] of Object.entries(rolesAssignment)) {
rolesAssignment[key as Ref<Role>] =
value !== undefined ? value.filter((m) => !removedMembersSet.has(m)) : undefined
}
}
members = newMembers
}
function handleRoleAssignmentChanged (roleId: Ref<Role>, newMembers: Ref<Account>[]): void {
if (rolesAssignment === undefined) {
rolesAssignment = {}
}
rolesAssignment[roleId] = newMembers
}
</script>
<Card
label={isNew ? document.string.NewTeamspace : document.string.EditTeamspace}
label={isNew ? documentRes.string.NewTeamspace : documentRes.string.EditTeamspace}
okLabel={isNew ? presentation.string.Create : presentation.string.Save}
okAction={handleSave}
canSave={name.length > 0 && !(members.length === 0 && isPrivate)}
canSave={name.length > 0 && !(members.length === 0 && isPrivate) && typeId !== undefined}
accentHeader
width={'medium'}
gap={'gapV-6'}
@ -157,12 +254,28 @@
<div class="antiGrid">
<div class="antiGrid-row">
<div class="antiGrid-row__header">
<Label label={document.string.TeamspaceTitle} />
<Label label={core.string.SpaceType} />
</div>
<SpaceTypeSelector
disabled={!isNew}
descriptors={[document.descriptor.TeamspaceType]}
type={typeId}
focusIndex={4}
kind="regular"
size="large"
on:change={handleTypeChange}
/>
</div>
<div class="antiGrid-row">
<div class="antiGrid-row__header">
<Label label={documentRes.string.TeamspaceTitle} />
</div>
<div class="padding">
<EditBox
bind:value={name}
placeholder={document.string.TeamspaceTitlePlaceholder}
placeholder={documentRes.string.TeamspaceTitlePlaceholder}
kind={'large-style'}
autoFocus
on:input={() => {
@ -176,14 +289,14 @@
<div class="antiGrid-row">
<div class="antiGrid-row__header topAlign">
<Label label={document.string.Description} />
<Label label={documentRes.string.Description} />
</div>
<div class="padding clear-mins">
<StyledTextBox
alwaysEdit
showButtons={false}
bind:content={description}
placeholder={document.string.TeamspaceDescriptionPlaceholder}
placeholder={documentRes.string.TeamspaceDescriptionPlaceholder}
/>
</div>
</div>
@ -192,7 +305,7 @@
<div class="antiGrid">
<div class="antiGrid-row">
<div class="antiGrid-row__header">
<Label label={document.string.ChooseIcon} />
<Label label={documentRes.string.ChooseIcon} />
</div>
<Button
icon={icon === view.ids.IconWithEmoji ? IconWithEmoji : icon ?? document.icon.Teamspace}
@ -219,15 +332,34 @@
<div class="antiGrid-row">
<div class="antiGrid-row__header">
<Label label={document.string.TeamspaceMembers} />
<Label label={documentRes.string.TeamspaceMembers} />
</div>
<AccountArrayEditor
value={members}
label={document.string.TeamspaceMembers}
onChange={(refs) => (members = refs)}
label={documentRes.string.TeamspaceMembers}
onChange={handleMembersChanged}
kind={'regular'}
size={'large'}
/>
</div>
{#each roles as role}
<div class="antiGrid-row">
<div class="antiGrid-row__header">
<Label label={documentRes.string.RoleLabel} params={{ role: role.name }} />
</div>
<AccountArrayEditor
value={rolesAssignment?.[role._id] ?? []}
label={documentRes.string.TeamspaceMembers}
includeItems={members}
readonly={members.length === 0}
onChange={(refs) => {
handleRoleAssignmentChanged(role._id, refs)
}}
kind={'regular'}
size={'large'}
/>
</div>
{/each}
</div>
</Card>

View File

@ -19,7 +19,6 @@ import {
type DocumentQuery,
type Ref,
type RelatedDocument,
type Space,
type WithLookup,
generateId,
getCurrentAccount
@ -89,7 +88,7 @@ async function createDocument (space: Teamspace): Promise<void> {
await _createDocument(id, space._id, parent)
}
async function _createDocument (id: Ref<Document>, space: Ref<Space>, parent: Ref<Document>): Promise<void> {
async function _createDocument (id: Ref<Document>, space: Ref<Teamspace>, parent: Ref<Document>): Promise<void> {
const client = getClient()
await createEmptyDocument(client, id, space, parent, {})

View File

@ -88,6 +88,8 @@ export default mergeIds(documentId, document, {
ReassignToDoConfirm: '' as IntlString,
Color: '' as IntlString,
Icon: '' as IntlString
Icon: '' as IntlString,
RoleLabel: '' as IntlString
}
})

View File

@ -13,7 +13,7 @@
// limitations under the License.
//
import { Class, Doc, Ref, Space } from '@hcengineering/core'
import type { Class, Doc, Ref, Space, SpaceType, SpaceTypeDescriptor } from '@hcengineering/core'
import { NotificationGroup, NotificationType } from '@hcengineering/notification'
import type { Asset, Plugin, Resource } from '@hcengineering/platform'
import { IntlString, plugin } from '@hcengineering/platform'
@ -77,6 +77,12 @@ export const documentPlugin = plugin(documentId, {
},
viewlet: {
TableBranches: '' as Ref<Viewlet>
},
descriptor: {
TeamspaceType: '' as Ref<SpaceTypeDescriptor>
},
spaceType: {
DefaultTeamspaceType: '' as Ref<SpaceType>
}
})

View File

@ -14,12 +14,12 @@
//
import { Attachment } from '@hcengineering/attachment'
import { AttachedDoc, Class, CollaborativeDoc, Ref, Space } from '@hcengineering/core'
import { AttachedDoc, Class, CollaborativeDoc, Ref, TypedSpace } from '@hcengineering/core'
import { Preference } from '@hcengineering/preference'
import { IconProps } from '@hcengineering/view'
/** @public */
export interface Teamspace extends Space, IconProps {}
export interface Teamspace extends TypedSpace, IconProps {}
/** @public */
export interface Document extends AttachedDoc<Document, 'children', Teamspace>, IconProps {

View File

@ -32,6 +32,8 @@
"UnAssign": "Unassign",
"ConfigLabel": "CRM",
"ConfigDescription": "Extension for Customer relation management",
"EditFunnel": "Edit Funnel"
"EditFunnel": "Edit Funnel",
"FunnelMembers": "Members",
"RoleLabel": "Role: {role}"
}
}

View File

@ -32,6 +32,8 @@
"UnAssign": "Отменить назначение",
"ConfigLabel": "CRM",
"ConfigDescription": "Расширение по работе с клиентами",
"EditFunnel": "Редактировать воронку"
"EditFunnel": "Редактировать воронку",
"FunnelMembers": "Участники",
"RoleLabel": "Роль: {role}"
}
}

View File

@ -56,6 +56,7 @@
"@hcengineering/view": "^0.6.9",
"@hcengineering/view-resources": "^0.6.0",
"@hcengineering/workbench": "^0.6.9",
"svelte": "^4.2.12"
"svelte": "^4.2.12",
"fast-equals": "^2.0.3"
}
}

View File

@ -14,14 +14,25 @@
// limitations under the License.
-->
<script lang="ts">
import { deepEqual } from 'fast-equals'
import { AccountArrayEditor } from '@hcengineering/contact-resources'
import core, { Account, getCurrentAccount, Ref } from '@hcengineering/core'
import { Funnel } from '@hcengineering/lead'
import presentation, { getClient, SpaceCreateCard } from '@hcengineering/presentation'
import core, {
Account,
getCurrentAccount,
Ref,
Role,
RolesAssignment,
SortingOrder,
SpaceType,
WithLookup
} from '@hcengineering/core'
import lead, { Funnel } from '@hcengineering/lead'
import presentation, { createQuery, getClient, SpaceCreateCard } from '@hcengineering/presentation'
import task, { ProjectType } from '@hcengineering/task'
import ui, { Component, EditBox, Grid, Label, ToggleWithLabel } from '@hcengineering/ui'
import { createEventDispatcher } from 'svelte'
import lead from '../plugin'
import leadRes from '../plugin'
export let funnel: Funnel | undefined = undefined
const dispatch = createEventDispatcher()
@ -33,38 +44,112 @@
let name: string = funnel?.name ?? ''
const description: string = funnel?.description ?? ''
let type: Ref<ProjectType> | undefined
let typeId: Ref<ProjectType> | undefined = funnel?.type ?? lead.template.DefaultFunnel
let spaceType: WithLookup<SpaceType> | undefined
let rolesAssignment: RolesAssignment = {}
let isPrivate: boolean = funnel?.private ?? false
let members: Ref<Account>[] =
funnel?.members !== undefined ? hierarchy.clone(funnel.members) : [getCurrentAccount()._id]
$: void loadSpaceType(typeId)
async function loadSpaceType (id: typeof typeId): Promise<void> {
spaceType =
id !== undefined
? await client
.getModel()
.findOne(core.class.SpaceType, { _id: id }, { lookup: { _id: { roles: core.class.Role } } })
: undefined
if (spaceType?.targetClass === undefined || spaceType?.$lookup?.roles === undefined) {
return
}
rolesAssignment = getRolesAssignment()
}
$: roles = (spaceType?.$lookup?.roles ?? []) as Role[]
function getRolesAssignment (): RolesAssignment {
if (funnel === undefined || spaceType?.targetClass === undefined || spaceType?.$lookup?.roles === undefined) {
return {}
}
const asMixin = hierarchy.as(funnel, spaceType?.targetClass)
return spaceType.$lookup.roles.reduce<RolesAssignment>((prev, { _id }) => {
prev[_id as Ref<Role>] = (asMixin as any)[_id]
return prev
}, {})
}
export function canClose (): boolean {
return name === '' && type !== undefined
return name === '' && typeId !== undefined
}
async function createFunnel (): Promise<void> {
if (type === undefined) return
await client.createDoc(lead.class.Funnel, core.space.Space, {
if (typeId === undefined || spaceType?.targetClass === undefined) {
return
}
const funnelId = await client.createDoc(leadRes.class.Funnel, core.space.Space, {
name,
description,
private: isPrivate,
archived: false,
members,
type
type: typeId
})
// Create space type's mixin with roles assignments
await client.createMixin(funnelId, leadRes.class.Funnel, core.space.Space, spaceType.targetClass, rolesAssignment)
}
async function save (): Promise<void> {
if (isNew) {
await createFunnel()
} else if (funnel !== undefined) {
} else if (funnel !== undefined && spaceType?.targetClass !== undefined) {
await client.diffUpdate<Funnel>(funnel, { name, description, members, private: isPrivate }, Date.now())
if (!deepEqual(rolesAssignment, getRolesAssignment())) {
await client.updateMixin(
funnel._id,
leadRes.class.Funnel,
core.space.Space,
spaceType.targetClass,
rolesAssignment
)
}
}
}
function handleMembersChanged (newMembers: Ref<Account>[]): void {
// If a member was removed we need to remove it from any roles assignments as well
const newMembersSet = new Set(newMembers)
const removedMembersSet = new Set(members.filter((m) => !newMembersSet.has(m)))
if (removedMembersSet.size > 0 && rolesAssignment !== undefined) {
for (const [key, value] of Object.entries(rolesAssignment)) {
rolesAssignment[key as Ref<Role>] =
value !== undefined ? value.filter((m) => !removedMembersSet.has(m)) : undefined
}
}
members = newMembers
}
function handleRoleAssignmentChanged (roleId: Ref<Role>, newMembers: Ref<Account>[]): void {
if (rolesAssignment === undefined) {
rolesAssignment = {}
}
rolesAssignment[roleId] = newMembers
}
</script>
<SpaceCreateCard
label={lead.string.CreateFunnel}
label={leadRes.string.CreateFunnel}
okAction={save}
okLabel={!isNew ? ui.string.Save : undefined}
canSave={name.length > 0}
@ -73,7 +158,7 @@
}}
>
<Grid column={1} rowGap={1.5}>
<EditBox label={lead.string.FunnelName} bind:value={name} placeholder={lead.string.FunnelName} autoFocus />
<EditBox label={leadRes.string.FunnelName} bind:value={name} placeholder={leadRes.string.FunnelName} autoFocus />
<ToggleWithLabel
label={presentation.string.MakePrivate}
description={presentation.string.MakePrivateDescription}
@ -82,26 +167,46 @@
<Component
is={task.component.ProjectTypeSelector}
disabled={!isNew}
props={{
categories: [lead.descriptors.FunnelType],
type,
descriptors: [leadRes.descriptors.FunnelType],
type: typeId,
disabled: funnel !== undefined
}}
on:change={(evt) => {
type = evt.detail
typeId = evt.detail
}}
/>
</Grid>
<div class="antiGrid-row mt-4">
<div class="antiGrid-row__header">
<Label label={leadRes.string.Members} />
</div>
<AccountArrayEditor
value={members}
label={leadRes.string.Members}
onChange={handleMembersChanged}
kind={'regular'}
size={'large'}
/>
</div>
{#each roles as role}
<div class="antiGrid-row">
<div class="antiGrid-row__header">
<Label label={lead.string.Members} />
<Label label={leadRes.string.RoleLabel} params={{ role: role.name }} />
</div>
<AccountArrayEditor
value={members}
label={lead.string.Members}
onChange={(refs) => (members = refs)}
value={rolesAssignment?.[role._id] ?? []}
label={leadRes.string.FunnelMembers}
includeItems={members}
readonly={members.length === 0}
onChange={(refs) => {
handleRoleAssignmentChanged(role._id, refs)
}}
kind={'regular'}
size={'large'}
/>
</div>
</Grid>
{/each}
</SpaceCreateCard>

View File

@ -42,7 +42,9 @@ export default mergeIds(leadId, lead, {
FunnelPlaceholder: '' as IntlString,
Members: '' as IntlString,
Assignee: '' as IntlString,
UnAssign: '' as IntlString
UnAssign: '' as IntlString,
FunnelMembers: '' as IntlString,
RoleLabel: '' as IntlString
},
component: {
CreateCustomer: '' as AnyComponent,

View File

@ -19,7 +19,7 @@ import type { Attribute, Class, Doc, Ref, Status, Timestamp } from '@hcengineeri
import { Mixin } from '@hcengineering/core'
import type { Asset, IntlString, Plugin } from '@hcengineering/platform'
import { plugin } from '@hcengineering/platform'
import type { Project, ProjectTypeDescriptor, Task, TaskType } from '@hcengineering/task'
import type { Project, ProjectType, ProjectTypeDescriptor, Task, TaskType } from '@hcengineering/task'
/**
* @public
@ -87,6 +87,9 @@ const lead = plugin(leadId, {
},
taskType: {
Lead: '' as Ref<TaskType>
},
template: {
DefaultFunnel: '' as Ref<ProjectType>
}
})

View File

@ -15,8 +15,19 @@
<script lang="ts">
import { AttachmentStyledBox } from '@hcengineering/attachment-resources'
import contact, { Organization } from '@hcengineering/contact'
import { UserBox } from '@hcengineering/contact-resources'
import core, { Data, fillDefaults, FindResult, generateId, Ref, SortingOrder } from '@hcengineering/core'
import { AccountArrayEditor, UserBox } from '@hcengineering/contact-resources'
import core, {
Account,
Class,
Data,
fillDefaults,
FindResult,
generateId,
Ref,
Role,
RolesAssignment,
SortingOrder
} from '@hcengineering/core'
import { Card, createQuery, getClient, InlineAttributeBar, MessageBox } from '@hcengineering/presentation'
import { Vacancy as VacancyClass } from '@hcengineering/recruit'
import tags from '@hcengineering/tags'
@ -36,6 +47,7 @@
import Company from './icons/Company.svelte'
import Vacancy from './icons/Vacancy.svelte'
import { selectedTypeStore, typeStore } from '@hcengineering/task-resources'
import { getEmbeddedLabel } from '@hcengineering/platform'
const dispatch = createEventDispatcher()
@ -48,7 +60,6 @@
let appliedTemplateId: Ref<ProjectType> | undefined
let objectId: Ref<VacancyClass> = generateId()
let issueTemplates: FindResult<IssueTemplate> = []
let fullDescription: string = ''
export let company: Ref<Organization> | undefined
@ -78,7 +89,7 @@
fillDefaults(hierarchy, vacancyData, recruit.class.Vacancy)
$: typeId &&
templateQ.query(task.class.ProjectType, { _id: typeId }, (result) => {
const { _class, _id, description, ...templateData } = result[0]
const { _class, _id, description, targetClass, ...templateData } = result[0]
vacancyData = { ...(templateData as unknown as Data<VacancyClass>), fullDescription: description }
if (appliedTemplateId !== typeId) {
fullDescription = description ?? ''
@ -92,6 +103,34 @@
issueTemplates = result
})
let rolesAssignment: RolesAssignment | undefined
let roles: Role[] = []
const rolesQuery = createQuery()
$: if (typeType !== undefined) {
rolesQuery.query(
core.class.Role,
{ attachedTo: typeType._id },
(res) => {
roles = res
if (rolesAssignment === undefined) {
rolesAssignment = roles.reduce<RolesAssignment>((prev, { _id }) => {
prev[_id] = []
return prev
}, {})
}
},
{
sort: {
name: SortingOrder.Ascending
}
}
)
} else {
rolesQuery.unsubscribe()
}
async function saveIssue (
id: Ref<VacancyClass>,
space: Ref<Project>,
@ -196,8 +235,14 @@
await descriptionBox.createAttachments()
// Add vacancy mixin
await client.createMixin(objectId, recruit.class.Vacancy, core.space.Space, typeType.targetClass, {})
// Add vacancy mixin with roles assignment
await client.createMixin(
objectId,
recruit.class.Vacancy,
core.space.Space,
typeType.targetClass,
rolesAssignment ?? {}
)
objectId = generateId()
@ -228,6 +273,14 @@
}
)
}
function handleRoleAssignmentChanged (roleId: Ref<Role>, newMembers: Ref<Account>[]): void {
if (rolesAssignment === undefined) {
rolesAssignment = {}
}
rolesAssignment[roleId] = newMembers
}
</script>
<FocusHandler {manager} />
@ -304,9 +357,22 @@
_class={recruit.class.Vacancy}
object={vacancyData}
toClass={core.class.Space}
ignoreKeys={['fullDescription', 'company']}
ignoreKeys={['fullDescription', 'company', 'type']}
extraProps={{ showNavigate: false }}
/>
{#each roles as role}
<AccountArrayEditor
value={rolesAssignment?.[role._id] ?? []}
label={getEmbeddedLabel(role.name)}
emptyLabel={getEmbeddedLabel(role.name)}
onChange={(refs) => {
handleRoleAssignmentChanged(role._id, refs)
}}
kind={'regular'}
size={'large'}
/>
{/each}
</svelte:fragment>
<svelte:fragment slot="footer">
<Button

View File

@ -137,7 +137,7 @@
<svelte:fragment slot="attributes" let:direction={dir}>
{#if dir === 'column'}
<DocAttributeBar {object} {mixins} ignoreKeys={['name', 'fullDescription', 'private', 'archived']} />
<DocAttributeBar {object} {mixins} ignoreKeys={['name', 'fullDescription', 'private', 'archived', 'type']} />
{/if}
</svelte:fragment>

View File

@ -100,6 +100,15 @@
"Automations": "Automations",
"Collections": "Collections",
"ClassColon": "Class:",
"Branding": "Branding"
"Branding": "Branding",
"SpaceTypes": "Space types",
"NewSpaceType": "New space type",
"SpaceTypeTitle": "Space type title",
"General": "General",
"Description": "Description",
"CountSpaces": "{count, plural, =0 {No spaces} =1 {# space} other {# spaces}}",
"Roles": "Roles",
"RoleName": "Role name",
"Permissions": "Permissions"
}
}

View File

@ -101,6 +101,15 @@
"Automations": "Автоматизация",
"Collections": "Коллекции",
"ClassColon": "Класс:",
"Branding": "Брендинг"
"Branding": "Брендинг",
"SpaceTypes": "Типы пространств",
"NewSpaceType": "Новый тип пространства",
"SpaceTypeTitle": "Название типа пространства",
"General": "Общее",
"Description": "Описание",
"CountSpaces": "{count, plural, =0 {Нет пространств} =1 {# пространство} =2 {# пространства} =3 {# пространства} =4 {# пространства} other {# пространств}}",
"Roles": "Роли",
"RoleName": "Название роли",
"Permissions": "Разрешения"
}
}
}

View File

@ -0,0 +1,44 @@
<!--
// Copyright © 2020, 2021 Anticrm Platform Contributors.
// Copyright © 2021 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 size: 'small' | 'medium' | 'large'
const fill: string = 'currentColor'
</script>
<svg class="svg-{size}" {fill} viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
<path
d="M10.2857 5.44444C10.2857 6.79447 9.26237 7.88889 8 7.88889C6.73764 7.88889 5.71429 6.79447 5.71429 5.44444C5.71429 4.09441 6.73764 3 8 3C9.26237 3 10.2857 4.09441 10.2857 5.44444Z"
/>
<path
d="M3.42857 11.9603C3.42857 10.9748 3.98128 10.081 4.85831 9.7786C5.79546 9.45545 7.02325 9.11111 8 9.11111C8.97675 9.11111 10.2045 9.45545 11.1417 9.77859C12.0187 10.081 12.5714 10.9748 12.5714 11.9603V12.7778C12.5714 13.4528 12.0598 14 11.4286 14H4.57143C3.94025 14 3.42857 13.4528 3.42857 12.7778V11.9603Z"
/>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M4.63272 4.75066C4.59249 4.97531 4.57143 5.20725 4.57143 5.44444C4.57143 6.00206 4.68782 6.5306 4.89604 7.00381C4.59565 7.53433 4.05083 7.88889 3.42857 7.88889C2.4818 7.88889 1.71429 7.06808 1.71429 6.05556C1.71429 5.04303 2.4818 4.22222 3.42857 4.22222C3.89788 4.22222 4.32315 4.42391 4.63272 4.75066Z"
/>
<path
d="M2.28571 12.7778V11.9603C2.28571 10.8522 2.76028 9.77952 3.59669 9.11537C3.5397 9.11257 3.48361 9.11111 3.42857 9.11111C2.69601 9.11111 1.77516 9.36937 1.0723 9.61172C0.414531 9.83853 0 10.5089 0 11.248V11.8611C0 12.3674 0.383756 12.7778 0.857143 12.7778H2.28571Z"
/>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M11.3716 4.75066C11.4118 4.97531 11.4329 5.20725 11.4329 5.44444C11.4329 6.00206 11.3166 6.5306 11.1086 7.00381C11.4087 7.53433 11.953 7.88889 12.5747 7.88889C13.5205 7.88889 14.2873 7.06808 14.2873 6.05556C14.2873 5.04303 13.5205 4.22222 12.5747 4.22222C12.1058 4.22222 11.6809 4.42391 11.3716 4.75066Z"
/>
<path
d="M13.7164 12.7778V11.9603C13.7164 10.8522 13.2423 9.77952 12.4067 9.11537C12.4636 9.11257 12.5197 9.11111 12.5747 9.11111C13.3065 9.11111 14.2265 9.36937 14.9287 9.61172C15.5859 9.83853 16 10.5089 16 11.248V11.8611C16 12.3674 15.6166 12.7778 15.1437 12.7778H13.7164Z"
/>
</svg>

View File

@ -0,0 +1,28 @@
<!--
// Copyright © 2020, 2021 Anticrm Platform Contributors.
// Copyright © 2021 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 size: 'small' | 'medium' | 'large'
const fill: string = 'currentColor'
</script>
<svg class="svg-{size}" {fill} viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
<path
d="M8 3.99988C7.50555 3.99988 7.0222 4.1465 6.61108 4.4212C6.19995 4.69591 5.87952 5.08635 5.6903 5.54317C5.50108 5.99998 5.45158 6.50265 5.54804 6.9876C5.6445 7.47256 5.88261 7.91801 6.23224 8.26764C6.58187 8.61728 7.02733 8.85538 7.51228 8.95184C7.99723 9.0483 8.4999 8.9988 8.95671 8.80958C9.41353 8.62036 9.80397 8.29993 10.0787 7.8888C10.3534 7.47768 10.5 6.99433 10.5 6.49988C10.5 5.83684 10.2366 5.20095 9.76777 4.73211C9.29893 4.26327 8.66304 3.99988 8 3.99988ZM8 7.99988C7.70333 7.99988 7.41332 7.9119 7.16665 7.74708C6.91997 7.58226 6.72771 7.34799 6.61418 7.0739C6.50065 6.79981 6.47095 6.49821 6.52882 6.20724C6.5867 5.91627 6.72956 5.649 6.93934 5.43922C7.14912 5.22944 7.4164 5.08658 7.70737 5.0287C7.99834 4.97082 8.29994 5.00053 8.57403 5.11406C8.84812 5.22759 9.08239 5.41985 9.24721 5.66652C9.41203 5.9132 9.5 6.20321 9.5 6.49988C9.49955 6.89756 9.34137 7.27883 9.06017 7.56004C8.77896 7.84125 8.39769 7.99943 8 7.99988Z"
/>
<path
d="M8 0.999878C6.61553 0.999878 5.26216 1.41042 4.11101 2.17959C2.95987 2.94876 2.06266 4.04201 1.53285 5.32109C1.00303 6.60018 0.86441 8.00764 1.13451 9.36551C1.4046 10.7234 2.07129 11.9707 3.05026 12.9496C4.02922 13.9286 5.2765 14.5953 6.63437 14.8654C7.99224 15.1355 9.3997 14.9968 10.6788 14.467C11.9579 13.9372 13.0511 13.04 13.8203 11.8889C14.5895 10.7377 15 9.38435 15 7.99988C14.9979 6.144 14.2597 4.36474 12.9474 3.05244C11.6351 1.74014 9.85588 1.00197 8 0.999878ZM5 13.1881V12.4999C5.00044 12.1022 5.15862 11.7209 5.43983 11.4397C5.72104 11.1585 6.10231 11.0003 6.5 10.9999H9.5C9.89769 11.0003 10.279 11.1585 10.5602 11.4397C10.8414 11.7209 10.9996 12.1022 11 12.4999V13.1881C10.0896 13.7197 9.05426 13.9999 8 13.9999C6.94574 13.9999 5.91042 13.7197 5 13.1881ZM11.9963 12.4628C11.9863 11.8069 11.7191 11.1812 11.2521 10.7205C10.7852 10.2598 10.156 10.001 9.5 9.99988H6.5C5.84405 10.001 5.2148 10.2598 4.74786 10.7205C4.28093 11.1812 4.01369 11.8069 4.00375 12.4628C3.09703 11.6532 2.45762 10.5872 2.17017 9.40611C1.88272 8.22501 1.9608 6.98445 2.39407 5.84871C2.82734 4.71297 3.59536 3.73561 4.59644 3.04606C5.59751 2.35651 6.78442 1.98729 8 1.98729C9.21558 1.98729 10.4025 2.35651 11.4036 3.04606C12.4046 3.73561 13.1727 4.71297 13.6059 5.84871C14.0392 6.98445 14.1173 8.22501 13.8298 9.40611C13.5424 10.5872 12.903 11.6532 11.9963 12.4628Z"
/>
</svg>

View File

@ -0,0 +1,28 @@
<!--
// 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">
import { Permission } from '@hcengineering/core'
import { Label } from '@hcengineering/ui'
export let value: Permission
export let inline: boolean = false
</script>
{#if value}
<div class="flex-presenter" class:inline-presenter={inline}>
<Label label={value.label} />
</div>
{/if}

View File

@ -0,0 +1,28 @@
<!--
// 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">
import { SpaceTypeDescriptor } from '@hcengineering/core'
import { Label } from '@hcengineering/ui'
export let value: SpaceTypeDescriptor
export let inline: boolean = false
</script>
{#if value}
<div class="flex-presenter" class:inline-presenter={inline}>
<Label label={value.name} />
</div>
{/if}

View File

@ -0,0 +1,127 @@
<!--
// 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">
import { createEventDispatcher } from 'svelte'
import core, { Class, Ref, SpaceTypeDescriptor, generateId, SpaceType, Data } from '@hcengineering/core'
import { Card, getClient, hasResource } from '@hcengineering/presentation'
import { AnySvelteComponent, EditBox } from '@hcengineering/ui'
import { Resource, getResource } from '@hcengineering/platform'
import { ObjectBox } from '@hcengineering/view-resources'
import setting, { SpaceTypeCreator, createSpaceType } from '@hcengineering/setting'
import settingRes from '../../plugin'
const dispatch = createEventDispatcher()
const client = getClient()
const hierarchy = client.getHierarchy()
let name: string = ''
let descriptor: SpaceTypeDescriptor | undefined = undefined
let handleTypeCreated: (() => Promise<void>) | undefined
async function createType (): Promise<void> {
if (descriptor === undefined) {
return
}
if (handleTypeCreated !== undefined) {
await handleTypeCreated()
} else {
const data: Omit<Data<SpaceType>, 'targetClass'> = {
name,
descriptor: descriptor._id,
roles: 0
}
await createSpaceType(client, data, generateId())
}
dispatch('close')
}
const descriptors = client
.getModel()
.findAllSync(core.class.SpaceTypeDescriptor, {})
.filter((descriptor) => hasResource(descriptor._id as any as Resource<any>))
descriptor = descriptors[0]
$: typeCreator =
descriptor !== undefined
? hierarchy.classHierarchyMixin<Class<SpaceTypeDescriptor>, SpaceTypeCreator>(
descriptor._class,
setting.mixin.SpaceTypeCreator
)
: undefined
let extraComponent: AnySvelteComponent | undefined
$: loadExtraComponent(typeCreator)
async function loadExtraComponent (tc: SpaceTypeCreator | undefined): Promise<void> {
if (tc === undefined) {
extraComponent = undefined
handleTypeCreated = undefined
return
}
extraComponent = await getResource(tc.extraComponent)
}
function handleDescriptorSelected (evt: CustomEvent<Ref<SpaceTypeDescriptor>>): void {
descriptor = descriptors.find((it) => it._id === evt.detail)
}
$: canSave = name.trim().length > 0 && descriptor !== undefined
</script>
<Card
label={settingRes.string.NewSpaceType}
{canSave}
okAction={createType}
on:close={() => {
dispatch('close')
}}
on:changeContent
>
<svelte:fragment slot="header">
<ObjectBox
_class={core.class.SpaceTypeDescriptor}
value={descriptor?._id}
on:change={handleDescriptorSelected}
kind="regular"
size="small"
label={core.string.SpaceType}
searchField="name"
showNavigate={false}
focusIndex={20000}
/>
</svelte:fragment>
<div class="flex-col flex-gap-2">
<EditBox
bind:value={name}
placeholder={core.string.SpaceType}
kind="large-style"
focusIndex={1}
autoFocus
fullSize
/>
{#if extraComponent !== undefined}
<svelte:component this={extraComponent} {name} {descriptor} bind:handleTypeCreated />
{/if}
</div>
</Card>

View File

@ -0,0 +1,187 @@
<!--
// Copyright © 2020, 2021 Anticrm Platform Contributors.
// Copyright © 2021, 2022, 2023 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 { createEventDispatcher, onDestroy } from 'svelte'
import core, { Class, Doc, IdMap, Ref, SpaceType, WithLookup, toIdMap } from '@hcengineering/core'
import {
Location,
resolvedLocationStore,
resizeObserver,
Breadcrumbs,
ButtonIcon,
Header,
IconCopy,
IconDelete,
IconMoreV,
AnySvelteComponent,
navigate,
getCurrentResolvedLocation
} from '@hcengineering/ui'
import { createQuery, getClient } from '@hcengineering/presentation'
import { showMenu } from '@hcengineering/view-resources'
import setting, { SpaceTypeEditor } from '@hcengineering/setting'
import { Asset, getResource } from '@hcengineering/platform'
import SpaceTypeEditorComponent from './editor/SpaceTypeEditor.svelte'
import { clearSettingsStore } from '../../store'
export let visibleNav: boolean = true
const dispatch = createEventDispatcher()
const client = getClient()
const hierarchy = client.getHierarchy()
let visibleSecondNav: boolean = true
let type: WithLookup<SpaceType> | undefined
let selectedTypeId: Ref<SpaceType> | undefined
let typesMap: IdMap<SpaceType> | undefined
let selectedSubEditorId: string | undefined
let selectedSubObjectId: Ref<Doc> | undefined
let subItemName: string | undefined
let subItemIcon: Asset | undefined
onDestroy(resolvedLocationStore.subscribe(handleLocationChanged))
function handleLocationChanged ({ path }: Location): void {
selectedTypeId = path[4] as Ref<SpaceType>
if (path.length === 7) {
selectSubItem(path[5], path[6] as Ref<Doc>)
} else {
selectSubItem(undefined, undefined)
}
}
function selectSubItem (editorId: string | undefined, objId: Ref<Doc> | undefined): void {
selectedSubEditorId = editorId
selectedSubObjectId = objId
}
const typesQuery = createQuery()
typesQuery.query(
core.class.SpaceType,
{},
(res) => {
typesMap = toIdMap(res)
},
{
lookup: {
descriptor: core.class.SpaceTypeDescriptor
}
}
)
$: type = selectedTypeId !== undefined && typesMap !== undefined ? typesMap.get(selectedTypeId) : undefined
$: descriptor = type?.$lookup?.descriptor
$: editorDescriptor =
type !== undefined
? hierarchy.classHierarchyMixin<Class<SpaceType>, SpaceTypeEditor>(type._class, setting.mixin.SpaceTypeEditor)
: undefined
$: subEditorRes =
selectedSubEditorId !== undefined && editorDescriptor !== undefined
? editorDescriptor?.subEditors?.[selectedSubEditorId]
: undefined
let subEditor: AnySvelteComponent | undefined
$: if (subEditorRes !== undefined) {
void getResource(subEditorRes).then((res) => (subEditor = res))
} else {
subEditor = undefined
}
let bcItems: Array<{ title: string, icon?: Asset }> = []
$: {
bcItems = []
if (type !== undefined) {
bcItems.push({ title: type.name, icon: descriptor?.icon })
if (selectedSubObjectId) {
bcItems.push({ title: subItemName ?? selectedSubObjectId, icon: subItemIcon })
}
}
}
function handleCrumbSelected (event: CustomEvent): void {
if (event.detail === 0) {
const loc = getCurrentResolvedLocation()
loc.path.length = 5
clearSettingsStore()
navigate(loc)
}
}
</script>
<div
class="hulyComponent"
use:resizeObserver={(element) => {
visibleSecondNav = element.clientWidth > 720
}}
>
{#if type !== undefined && descriptor !== undefined}
<Header minimize={!visibleNav} on:resize={(event) => dispatch('change', event.detail)}>
<ButtonIcon
icon={IconCopy}
size={'small'}
kind={'secondary'}
disabled
on:click={(ev) => {
// TODO: copy space type
}}
/>
<ButtonIcon
icon={IconDelete}
size={'small'}
kind={'secondary'}
disabled
on:click={(ev) => {
// TODO: delete space type
}}
/>
<ButtonIcon
icon={IconMoreV}
size={'small'}
kind={'secondary'}
on:click={(ev) => {
showMenu(ev, { object: type })
}}
/>
<Breadcrumbs
items={bcItems}
size="large"
selected={selectedSubObjectId ? 1 : 0}
on:select={handleCrumbSelected}
/>
</Header>
{#if editorDescriptor !== undefined}
{#if subEditor === undefined}
{#key type._id}
<SpaceTypeEditorComponent {type} {descriptor} {editorDescriptor} {visibleSecondNav} on:change />
{/key}
{:else}
<svelte:component
this={subEditor}
bind:name={subItemName}
bind:icon={subItemIcon}
spaceType={type}
{descriptor}
objectId={selectedSubObjectId}
/>
{/if}
{/if}
{/if}
</div>

View File

@ -14,27 +14,38 @@
// limitations under the License.
-->
<script lang="ts">
import { IdMap, Ref, WithLookup, toIdMap } from '@hcengineering/core'
import task, { ProjectType } from '@hcengineering/task'
import { Location, getCurrentResolvedLocation, navigate, resolvedLocationStore } from '@hcengineering/ui'
import { createQuery, hasResource } from '@hcengineering/presentation'
import { onDestroy } from 'svelte'
import Types from './Types.svelte'
import core, { Ref, SpaceType, WithLookup } from '@hcengineering/core'
import { Location, getCurrentResolvedLocation, navigate, resolvedLocationStore } from '@hcengineering/ui'
import { createQuery, hasResource } from '@hcengineering/presentation'
import { Resource } from '@hcengineering/platform'
import { clearSettingsStore } from '@hcengineering/setting-resources'
export let kind: 'navigation' | 'tools' | undefined
import { clearSettingsStore } from '../../store'
import SpaceTypes from './SpaceTypes.svelte'
export let categoryName: string
let type: WithLookup<ProjectType> | undefined
let typeId: Ref<ProjectType> | undefined
onDestroy(
resolvedLocationStore.subscribe((loc) => {
void (async (loc: Location): Promise<void> => {
typeId = loc.path[4] as Ref<ProjectType>
})(loc)
})
let selectedTypeId: Ref<WithLookup<SpaceType>> | undefined
onDestroy(resolvedLocationStore.subscribe(handleLocationChanged))
function handleLocationChanged (loc: Location): void {
selectedTypeId = loc.path[4] as Ref<SpaceType>
}
let types: WithLookup<SpaceType>[] = []
const typesQuery = createQuery()
$: typesQuery.query(
core.class.SpaceType,
{},
(result) => {
types = result.filter((p) => hasResource(p.descriptor as any as Resource<any>))
},
{
lookup: {
descriptor: core.class.SpaceTypeDescriptor
}
}
)
function selectProjectType (id: string): void {
@ -46,31 +57,9 @@
navigate(loc)
}
let types: WithLookup<ProjectType>[] = []
let typeMap: IdMap<WithLookup<ProjectType>> = new Map()
const query = createQuery()
$: query.query(
task.class.ProjectType,
{ archived: false },
(result) => {
types = result.filter((p) => hasResource(p.descriptor as any as Resource<any>))
},
{
lookup: {
descriptor: task.class.ProjectTypeDescriptor
}
}
)
$: typeMap = toIdMap(types)
$: type = typeId !== undefined ? typeMap.get(typeId) : undefined
function handleTypeChange (event: CustomEvent): void {
selectProjectType(event.detail)
}
</script>
<Types
{type}
{typeId}
{types}
on:change={(evt) => {
selectProjectType(evt.detail)
}}
/>
<SpaceTypes {selectedTypeId} {types} on:change={handleTypeChange} />

View File

@ -1,6 +1,6 @@
<!--
// Copyright © 2020, 2021 Anticrm Platform Contributors.
// Copyright © 2021, 2022, 2023 Hardcore Engineering Inc.
// Copyright © 2021, 2022, 2023, 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
@ -15,11 +15,11 @@
-->
<script lang="ts">
import { Button, IconAdd, showPopup } from '@hcengineering/ui'
import CreateProjectType from './CreateProjectType.svelte'
import CreateSpaceType from './CreateSpaceType.svelte'
function open () {
showPopup(CreateProjectType, {}, 'top')
function handleAdd (): void {
showPopup(CreateSpaceType, {}, 'top')
}
</script>
<Button id="new-project-type" icon={IconAdd} kind={'link'} size="small" on:click={open} />
<Button id="new-space-type" icon={IconAdd} kind="link" size="small" on:click={handleAdd} />

View File

@ -0,0 +1,130 @@
<!--
// 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">
import { AttributeEditor, createQuery, getClient } from '@hcengineering/presentation'
import core, { Permission, Ref, Role, SpaceType, SpaceTypeDescriptor, WithLookup } from '@hcengineering/core'
import { ButtonIcon, Icon, IconEdit, IconSettings, Label, Scroller, showPopup } from '@hcengineering/ui'
import { ObjectBoxPopup } from '@hcengineering/view-resources'
import PersonIcon from '../icons/Person.svelte'
import settingRes from '../../plugin'
export let spaceType: SpaceType
export let descriptor: SpaceTypeDescriptor
export let objectId: Ref<Role>
export let name: string | undefined
const client = getClient()
let role: Role | undefined
const roleQuery = createQuery()
$: roleQuery.query(core.class.Role, { _id: objectId }, (res) => {
;[role] = res
})
$: name = role?.name
let permissions: Permission[] = []
const permissionsQuery = createQuery()
$: if (role !== undefined) {
permissionsQuery.query(core.class.Permission, { _id: { $in: role.permissions } }, (res) => {
permissions = res
})
}
function handleEditPermissions (evt: Event): void {
if (role === undefined || descriptor === undefined) {
return
}
showPopup(
ObjectBoxPopup,
{
_class: core.class.Permission,
docQuery: { _id: { $in: descriptor.availablePermissions } },
multiSelect: true,
allowDeselect: true,
selectedObjects: role.permissions
},
evt.target as HTMLElement,
undefined,
async (result) => {
if (role === undefined) {
return
}
await client.updateCollection(
core.class.Role,
core.space.Model,
role._id,
spaceType._id,
spaceType._class,
'roles',
{
permissions: result
}
)
}
)
}
</script>
{#if role !== undefined}
<div class="hulyComponent-content__container columns">
<div class="hulyComponent-content__column content">
<Scroller align={'center'} padding={'var(--spacing-3)'} bottomPadding={'var(--spacing-3)'}>
<div class="hulyComponent-content gap">
<div class="hulyComponent-content__column-group mt-4">
<div class="hulyComponent-content__header mb-6">
<ButtonIcon icon={PersonIcon} size="large" iconProps={{ size: 'small' }} kind="secondary" />
<AttributeEditor _class={core.class.Role} object={role} key="name" editKind="modern-ghost-large" />
</div>
<div class="hulyTableAttr-container">
<div class="hulyTableAttr-header font-medium-12">
<IconSettings size="small" />
<span><Label label={settingRes.string.Permissions} /></span>
<ButtonIcon kind="primary" icon={IconEdit} size="small" on:click={handleEditPermissions} />
</div>
{#if permissions.length > 0}
<div class="hulyTableAttr-content task">
{#each permissions as permission}
<div class="hulyTableAttr-content__row">
{#if permission.icon !== undefined}
<div class="hulyTableAttr-content__row-icon-wrapper">
<Icon icon={permission.icon} size="small" />
</div>
{/if}
<div class="hulyTableAttr-content__row-label font-medium-14">
<Label label={permission.label} />
</div>
{#if permission.description !== undefined}
<div class="hulyTableAttr-content__row-label grow dark font-regular-14">
<Label label={permission.description} />
</div>
{/if}
</div>
{/each}
</div>
{/if}
</div>
</div>
</div>
</Scroller>
</div>
</div>
{/if}

View File

@ -1,6 +1,5 @@
<!--
// Copyright © 2020, 2021 Anticrm Platform Contributors.
// Copyright © 2021 Hardcore Engineering Inc.
// 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
@ -14,47 +13,47 @@
// limitations under the License.
-->
<script lang="ts">
import { Ref, WithLookup } from '@hcengineering/core'
import { ProjectType } from '@hcengineering/task'
import { Ref, SpaceType, WithLookup } from '@hcengineering/core'
import { Icon, Label, IconOpenedArrow } from '@hcengineering/ui'
import { createEventDispatcher } from 'svelte'
export let type: ProjectType | undefined
export let typeId: Ref<ProjectType> | undefined
export let types: WithLookup<ProjectType>[] = []
export let selectedTypeId: Ref<SpaceType> | undefined
export let types: WithLookup<SpaceType>[] = []
const dispatch = createEventDispatcher()
function select (item: ProjectType): void {
typeId = item._id
dispatch('change', typeId)
function handleSelected (type: SpaceType): void {
selectedTypeId = type._id
dispatch('change', selectedTypeId)
}
</script>
{#each types as typeItem}
{#each types as type}
{@const descriptor = type.$lookup?.descriptor}
<button
class="hulyTaskNavLink-container font-regular-14"
class:selected={typeItem._id === typeId}
class:selected={type._id === selectedTypeId}
on:click={() => {
select(typeItem)
handleSelected(type)
}}
>
<div class="hulyTaskNavLink-avatar">
{#if typeItem.$lookup?.descriptor?.icon}
{#if descriptor?.icon}
<div class="hulyTaskNavLink-icon">
<Icon icon={typeItem.$lookup?.descriptor?.icon} size={'small'} fill={'currentColor'} />
<Icon icon={descriptor?.icon} size="small" fill="currentColor" />
</div>
{/if}
</div>
{#if typeItem.$lookup?.descriptor}
{#if descriptor}
<div class="hulyTaskNavLink-content">
<span class="hulyTaskNavLink-content__title">{typeItem.name}</span>
<span class="hulyTaskNavLink-content__title">{type.name}</span>
<span class="hulyTaskNavLink-content__descriptor">
<Label label={typeItem.$lookup?.descriptor.name} />
<Label label={descriptor.name} />
</span>
</div>
{/if}
{#if typeItem._id === typeId}
{#if type._id === selectedTypeId}
<div class="hulyTaskNavLink-icon right">
<IconOpenedArrow size={'small'} />
</div>

View File

@ -0,0 +1,81 @@
<!--
// 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">
import presentation, { getClient } from '@hcengineering/presentation'
import setting from '@hcengineering/setting'
import { Modal, ModernEditbox } from '@hcengineering/ui'
import core, { AttachedData, Attribute, PropertyType, Ref, Role, SpaceType } from '@hcengineering/core'
import { ArrOf, TypeRef } from '@hcengineering/model'
import { getEmbeddedLabel } from '@hcengineering/platform'
import settingRes from '../../../plugin'
import { clearSettingsStore } from '../../../store'
const client = getClient()
export let type: SpaceType
const roleData: AttachedData<Role> = {
name: '',
permissions: []
}
async function handleRoleCreated (): Promise<void> {
const name = roleData.name.trim()
if (name === '') {
return
}
const roleId = await client.addCollection(
core.class.Role,
core.space.Model,
type._id,
type._class,
'roles',
roleData
)
// Create role as an attribute of space type's mixin
await client.createDoc(
core.class.Attribute,
core.space.Model,
{
name: roleId,
attributeOf: type.targetClass,
type: ArrOf(TypeRef(core.class.Account)),
label: getEmbeddedLabel(`Role: ${name}`),
editor: setting.component.RoleAssignmentEditor
},
`role-${roleId}` as Ref<Attribute<PropertyType>>
)
}
$: canSave = roleData.name.trim() !== ''
</script>
<Modal
label={settingRes.string.Role}
type="type-aside"
okAction={handleRoleCreated}
{canSave}
okLabel={presentation.string.Create}
on:changeContent
onCancel={() => {
clearSettingsStore()
}}
>
<div class="hulyModal-content__titleGroup">
<ModernEditbox bind:value={roleData.name} label={settingRes.string.RoleName} size="large" kind="ghost" autoFocus />
</div>
</Modal>

View File

@ -0,0 +1,106 @@
<!--
// 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">
import { getClient } from '@hcengineering/presentation'
import core, { SpaceType, SpaceTypeDescriptor } from '@hcengineering/core'
import type { IntlString } from '@hcengineering/platform'
import { SpaceTypeEditor } from '@hcengineering/setting'
import {
ButtonIcon,
Component,
IconDescription,
NavItem,
Scroller,
Separator,
defineSeparators,
secondNavSeparators
} from '@hcengineering/ui'
export let type: SpaceType
export let descriptor: SpaceTypeDescriptor | undefined
export let editorDescriptor: SpaceTypeEditor
export let visibleSecondNav: boolean = true
const client = getClient()
$: if (descriptor === undefined) {
void loadDescriptor()
}
async function loadDescriptor (): Promise<void> {
descriptor = await client.findOne(core.class.SpaceTypeDescriptor, { _id: type.descriptor })
}
const navigator: {
id: string
label: IntlString
}[] =
editorDescriptor !== undefined
? editorDescriptor.sections.map(({ id, label }) => ({
id,
label
}))
: []
const sectionRefs: Record<string, HTMLElement | undefined> = {}
defineSeparators('spaceTypeEditor', secondNavSeparators)
</script>
{#if type !== undefined && descriptor !== undefined}
<div class="hulyComponent-content__container columns">
{#if visibleSecondNav}
<div class="hulyComponent-content__column">
<div class="hulyComponent-content__navHeader">
<div class="hulyComponent-content__navHeader-menu">
<ButtonIcon kind="tertiary" icon={IconDescription} size="small" inheritColor />
</div>
</div>
{#each navigator as navItem, i (navItem.id)}
<NavItem
type="type-anchor-link"
label={navItem.label}
on:click={() => {
sectionRefs[navItem.id]?.scrollIntoView()
}}
/>
{/each}
</div>
<Separator name="spaceTypeEditor" index={0} color="transparent" />
{/if}
{#if editorDescriptor !== undefined}
<div class="hulyComponent-content__column content">
<Scroller align="center" padding="var(--spacing-3)" bottomPadding="var(--spacing-3)">
<div class="hulyComponent-content gap">
{#each editorDescriptor.sections as section}
<div bind:this={sectionRefs[section.id]} class:hulyTableAttr-container={!section.withoutContainer}>
<Component
is={section.component}
props={{
type,
descriptor
}}
/>
</div>
{/each}
</div>
</Scroller>
</div>
{/if}
</div>
{/if}
<style lang="scss">
</style>

View File

@ -0,0 +1,92 @@
<!--
// 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">
import core, { type SpaceType, type SpaceTypeDescriptor } from '@hcengineering/core'
import { ButtonIcon, IconSquareExpand, ModernButton, ModernEditbox, TextArea } from '@hcengineering/ui'
import { createQuery, getClient } from '@hcengineering/presentation'
import settingRes from '../../../plugin'
export let type: SpaceType | undefined
export let descriptor: SpaceTypeDescriptor | undefined
const client = getClient()
let shortDescription = type?.shortDescription ?? ''
let spacesCount: number = 0
const spacesCountQuery = createQuery()
$: if (type !== undefined) {
spacesCountQuery.query(
core.class.TypedSpace,
{ type: type._id },
(res) => {
spacesCount = res.length
},
{
projection: { _id: 1 }
}
)
} else {
spacesCountQuery.unsubscribe()
}
async function attributeUpdated<T extends keyof SpaceType> (field: T, value: SpaceType[T]): Promise<void> {
if (type === undefined || type[field] === value) {
return
}
await client.update(type, { [field]: value })
}
</script>
{#if descriptor !== undefined}
<div class="hulyComponent-content__column-group">
<div class="hulyComponent-content__header">
<div class="flex gap-1">
<ButtonIcon icon={descriptor.icon} size={'large'} kind={'secondary'} />
<ModernEditbox
kind="ghost"
size="large"
label={settingRes.string.SpaceTypeTitle}
value={type?.name ?? ''}
on:blur={(evt) => {
attributeUpdated('name', evt.detail)
}}
/>
</div>
<ModernButton
icon={IconSquareExpand}
label={settingRes.string.CountSpaces}
labelParams={{ count: spacesCount }}
disabled={spacesCount === 0}
kind="tertiary"
size="medium"
hasMenu
/>
</div>
<TextArea
placeholder={settingRes.string.Description}
width="100%"
height="4.5rem"
margin="var(--spacing-2) 0"
noFocusBorder
bind:value={shortDescription}
on:change={() => {
attributeUpdated('shortDescription', shortDescription)
}}
/>
<slot name="extra" />
</div>
{/if}

View File

@ -0,0 +1,26 @@
<!--
// 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">
import { type SpaceType, type SpaceTypeDescriptor } from '@hcengineering/core'
import ClassAttributes from '../../ClassAttributes.svelte'
export let type: SpaceType | undefined
export let descriptor: SpaceTypeDescriptor | undefined
</script>
{#if type !== undefined && descriptor !== undefined}
<ClassAttributes ofClass={descriptor.baseClass} _class={type.targetClass} showHierarchy />
{/if}

View File

@ -0,0 +1,91 @@
<!--
// 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">
import core, { Role, SortingOrder, SpaceType, SpaceTypeDescriptor } from '@hcengineering/core'
import { ButtonIcon, IconAdd, Label, getCurrentResolvedLocation, navigate } from '@hcengineering/ui'
import { createQuery } from '@hcengineering/presentation'
import MembersIcon from '../../icons/Members.svelte'
import PersonIcon from '../../icons/Person.svelte'
import CreateRole from './CreateRole.svelte'
import { clearSettingsStore, settingsStore } from '../../../store'
import settingRes from '../../../plugin'
export let type: SpaceType | undefined
export let descriptor: SpaceTypeDescriptor | undefined
let roles: Role[] = []
const rolesQuery = createQuery()
$: if (type !== undefined) {
rolesQuery.query(
core.class.Role,
{ attachedTo: type._id },
(res) => {
roles = res
},
{ sort: { _id: SortingOrder.Ascending } }
)
}
function handleRoleSelected (id: string | undefined): void {
const loc = getCurrentResolvedLocation()
if (id !== undefined) {
loc.path[5] = 'roles'
loc.path[6] = id
loc.path.length = 7
} else {
loc.path.length = 5
}
clearSettingsStore()
navigate(loc)
}
</script>
{#if descriptor !== undefined}
<div class="hulyTableAttr-header font-medium-12">
<MembersIcon size="small" />
<span><Label label={settingRes.string.Roles} /></span>
<ButtonIcon
kind="primary"
icon={IconAdd}
size="small"
on:click={(ev) => {
$settingsStore = { id: 'createRole', component: CreateRole, props: { type, descriptor } }
}}
/>
</div>
{#if roles.length}
<div class="hulyTableAttr-content task">
{#each roles as role}
<button
class="hulyTableAttr-content__row justify-start"
on:click|stopPropagation={() => {
handleRoleSelected(role._id)
}}
>
<div class="hulyTableAttr-content__row-icon-wrapper">
<PersonIcon size="small" />
</div>
{#if role.name !== ''}
<div class="hulyTableAttr-content__row-label font-medium-14">
{role.name}
</div>
{/if}
</button>
{/each}
</div>
{/if}
{/if}

View File

@ -0,0 +1,42 @@
<!--
// 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">
import { AccountArrayEditor } from '@hcengineering/contact-resources'
import { Account, Ref, TypedSpace } from '@hcengineering/core'
import { IntlString } from '@hcengineering/platform'
import { ButtonKind, ButtonSize } from '@hcengineering/ui'
export let object: TypedSpace
export let label: IntlString
export let value: Ref<Account>[]
export let onChange: ((refs: Ref<Account>[]) => void) | undefined
export let readonly = false
export let kind: ButtonKind = 'link'
export let size: ButtonSize = 'large'
export let width: string | undefined = undefined
$: members = object?.members ?? []
</script>
<AccountArrayEditor
{value}
{label}
readonly={readonly || (object.private && members.length === 0)}
includeItems={object.private ? members : undefined}
{onChange}
{size}
width={width ?? 'min-content'}
{kind}
/>

View File

@ -49,8 +49,18 @@ import IntegrationPanel from './components/IntegrationPanel.svelte'
import { getOwnerFirstName, getOwnerLastName, getOwnerPosition, getValue, filterDescendants } from './utils'
import ClassAttributes from './components/ClassAttributes.svelte'
import ClassAttributesList from './components/ClassAttributesList.svelte'
import ManageSpaceTypes from './components/spaceTypes/ManageSpaceTypes.svelte'
import ManageSpaceTypesTools from './components/spaceTypes/ManageSpaceTypesTools.svelte'
import ManageSpaceTypeContent from './components/spaceTypes/ManageSpaceTypeContent.svelte'
import PermissionPresenter from './components/presenters/PermissionPresenter.svelte'
import SpaceTypeDescriptorPresenter from './components/presenters/SpaceTypeDescriptorPresenter.svelte'
import SpaceTypeGeneralSectionEditor from './components/spaceTypes/editor/SpaceTypeGeneralSectionEditor.svelte'
import SpaceTypePropertiesSectionEditor from './components/spaceTypes/editor/SpaceTypePropertiesSectionEditor.svelte'
import SpaceTypeRolesSectionEditor from './components/spaceTypes/editor/SpaceTypeRolesSectionEditor.svelte'
import RoleEditor from './components/spaceTypes/RoleEditor.svelte'
import RoleAssignmentEditor from './components/typeEditors/RoleAssignmentEditor.svelte'
export { ClassSetting, filterDescendants, ClassAttributes, ClassAttributesList }
export { ClassSetting, filterDescendants, ClassAttributes, ClassAttributesList, SpaceTypeGeneralSectionEditor }
export * from './store'
async function DeleteMixin (object: Mixin<Class<Doc>>): Promise<void> {
@ -106,7 +116,17 @@ export default async (): Promise<Resources> => ({
CreateMixin,
InviteSetting,
IntegrationPanel,
Configure
Configure,
ManageSpaceTypes,
ManageSpaceTypesTools,
ManageSpaceTypeContent,
PermissionPresenter,
SpaceTypeDescriptorPresenter,
SpaceTypeGeneralSectionEditor,
SpaceTypePropertiesSectionEditor,
SpaceTypeRolesSectionEditor,
RoleEditor,
RoleAssignmentEditor
},
actionImpl: {
DeleteMixin

View File

@ -20,7 +20,10 @@ import { type AnyComponent } from '@hcengineering/ui'
export default mergeIds(settingId, setting, {
component: {
EditEnum: '' as AnyComponent
EditEnum: '' as AnyComponent,
ManageSpaceTypes: '' as AnyComponent,
ManageSpaceTypesTools: '' as AnyComponent,
ManageSpaceTypeContent: '' as AnyComponent
},
string: {
IntegrationDisabled: '' as IntlString,
@ -88,6 +91,12 @@ export default mergeIds(settingId, setting, {
ConfigBeta: '' as IntlString,
ClassSettingHint: '' as IntlString,
ClassProperties: '' as IntlString,
ClassColon: '' as IntlString
ClassColon: '' as IntlString,
NewSpaceType: '' as IntlString,
SpaceTypeTitle: '' as IntlString,
Description: '' as IntlString,
CountSpaces: '' as IntlString,
RoleName: '' as IntlString,
Permissions: '' as IntlString
}
})

View File

@ -19,6 +19,11 @@ import { Asset, IntlString, plugin, Resource } from '@hcengineering/platform'
import { AnyComponent } from '@hcengineering/ui'
import { TemplateFieldCategory, TemplateField } from '@hcengineering/templates'
import { SpaceTypeEditor, SpaceTypeCreator } from './spaceTypeEditor'
export * from './spaceTypeEditor'
export * from './utils'
/**
* @public
*/
@ -122,11 +127,14 @@ export default plugin(settingId, {
ClassSetting: '' as Ref<Doc>,
Owners: '' as Ref<Doc>,
InviteSettings: '' as Ref<Doc>,
WorkspaceSetting: '' as Ref<Doc>
WorkspaceSetting: '' as Ref<Doc>,
ManageSpaces: '' as Ref<Doc>
},
mixin: {
Editable: '' as Ref<Mixin<Editable>>,
UserMixin: '' as Ref<Mixin<UserMixin>>
UserMixin: '' as Ref<Mixin<UserMixin>>,
SpaceTypeEditor: '' as Ref<Mixin<SpaceTypeEditor>>,
SpaceTypeCreator: '' as Ref<Mixin<SpaceTypeCreator>>
},
space: {
Setting: '' as Ref<Space>
@ -149,7 +157,14 @@ export default plugin(settingId, {
Support: '' as AnyComponent,
Privacy: '' as AnyComponent,
Terms: '' as AnyComponent,
ClassSetting: '' as AnyComponent
ClassSetting: '' as AnyComponent,
PermissionPresenter: '' as AnyComponent,
SpaceTypeDescriptorPresenter: '' as AnyComponent,
SpaceTypeGeneralSectionEditor: '' as AnyComponent,
SpaceTypePropertiesSectionEditor: '' as AnyComponent,
SpaceTypeRolesSectionEditor: '' as AnyComponent,
RoleEditor: '' as AnyComponent,
RoleAssignmentEditor: '' as AnyComponent
},
string: {
Settings: '' as IntlString,
@ -182,10 +197,13 @@ export default plugin(settingId, {
Owners: '' as IntlString,
Configure: '' as IntlString,
InviteSettings: '' as IntlString,
General: '' as IntlString,
Properties: '' as IntlString,
TaskTypes: '' as IntlString,
Automations: '' as IntlString,
Collections: '' as IntlString
Collections: '' as IntlString,
SpaceTypes: '' as IntlString,
Roles: '' as IntlString
},
icon: {
AccountSettings: '' as Asset,

View File

@ -0,0 +1,49 @@
//
// 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.
//
import type { Class, SpaceType, SpaceTypeDescriptor } from '@hcengineering/core'
import type { IntlString } from '@hcengineering/platform'
import type { AnyComponent } from '@hcengineering/ui'
/**
* @public
*
* A mixin describing various configurations of a space type editor
*/
export interface SpaceTypeEditor extends Class<SpaceType> {
sections: SpaceTypeEditorSection[]
subEditors?: Record<string, AnyComponent>
}
/**
* @public
*
* Describes one space type editor section
*/
export interface SpaceTypeEditorSection {
id: string
label: IntlString
component: AnyComponent
withoutContainer?: boolean
}
/**
* @public
*
* A mixin for extensions during space type creation
*/
export interface SpaceTypeCreator extends Class<SpaceTypeDescriptor> {
extraComponent: AnyComponent
}

View File

@ -0,0 +1,55 @@
//
// 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.
//
import core, { Class, ClassifierKind, Data, Doc, Ref, SpaceType, TxOperations, generateId } from '@hcengineering/core'
import { getEmbeddedLabel } from '@hcengineering/platform'
export async function createSpaceType (
client: TxOperations,
data: Omit<Data<SpaceType>, 'targetClass'>,
_id: Ref<SpaceType>
): Promise<Ref<SpaceType>> {
const descriptorObj = client.getModel().findObject(data.descriptor)
if (descriptorObj === undefined) {
throw new Error('Descriptor is not found in the model')
}
const baseClassClazz = client.getHierarchy().getClass(descriptorObj.baseClass)
const spaceTypeMixinId: Ref<Class<Doc>> = generateId()
await client.createDoc(
core.class.Mixin,
core.space.Model,
{
extends: descriptorObj.baseClass,
kind: ClassifierKind.MIXIN,
label: getEmbeddedLabel(data.name),
icon: baseClassClazz.icon
},
spaceTypeMixinId
)
return await client.createDoc(
core.class.SpaceType,
core.space.Model,
{
shortDescription: data.shortDescription,
descriptor: data.descriptor,
roles: data.roles,
name: data.name,
targetClass: spaceTypeMixinId
},
_id
)
}

View File

@ -97,6 +97,8 @@
"Color": "Color",
"Identifier": "Identifier",
"RenameStatus": "Rename a status to new name",
"UpdateTasksStatusRequest": "Status is used with {total} tasks, it will require update of all of them. Please approve."
"UpdateTasksStatusRequest": "Status is used with {total} tasks, it will require update of all of them. Please approve.",
"TaskTypes": "Task types",
"Collections": "Collections"
}
}

View File

@ -97,6 +97,8 @@
"Color": "Цвет",
"Identifier": "Идентификатор",
"RenameStatus": "Переименование статуса в новое имя",
"UpdateTasksStatusRequest": "Статус сейчас используется в {total} задачах, потребуется обновление всеи их. Пожалуйста подтвердите."
"UpdateTasksStatusRequest": "Статус сейчас используется в {total} задачах, потребуется обновление всеи их. Пожалуйста подтвердите.",
"TaskTypes": "Типы задач",
"Collections": "Коллекции"
}
}

View File

@ -14,72 +14,45 @@
-->
<script lang="ts">
import { Ref, generateId } from '@hcengineering/core'
import { Card, getClient, hasResource } from '@hcengineering/presentation'
import { SpaceTypeDescriptor, generateId } from '@hcengineering/core'
import { getClient } from '@hcengineering/presentation'
import { ProjectTypeDescriptor, createProjectType } from '@hcengineering/task'
import { DropdownLabelsIntl, EditBox, ToggleWithLabel } from '@hcengineering/ui'
import { ToggleWithLabel } from '@hcengineering/ui'
import { createEventDispatcher } from 'svelte'
import task from '../../plugin'
import { Resource } from '@hcengineering/platform'
const client = getClient()
let name: string = ''
let classic: boolean = true
export let descriptor: SpaceTypeDescriptor
export let name: string = ''
export const handleTypeCreated: () => Promise<void> = createType
let descriptor: ProjectTypeDescriptor | undefined = undefined
let classic: boolean = true
$: projDescriptor = descriptor as ProjectTypeDescriptor
const dispatch = createEventDispatcher()
async function createType (): Promise<void> {
if (descriptor === undefined) {
if (projDescriptor === undefined) {
return
}
await createProjectType(
client,
{
name,
descriptor: descriptor._id,
descriptor: projDescriptor._id,
description: '',
tasks: [],
classic: descriptor.allowedClassic === true ? classic : false
roles: 0,
classic: projDescriptor.allowedClassic === true ? classic : false
},
[],
generateId()
)
dispatch('close')
}
const descriptors = client
.getModel()
.findAllSync(task.class.ProjectTypeDescriptor, {})
.filter((p) => hasResource(p._id as any as Resource<any>))
const items = descriptors.map((it) => ({
label: it.name,
id: it._id
}))
function selectType (evt: CustomEvent<Ref<ProjectTypeDescriptor>>): void {
descriptor = descriptors.find((it) => it._id === evt.detail)
}
descriptor = descriptors[0]
</script>
<Card
label={task.string.CreateProjectType}
canSave={name.trim().length > 0 && descriptor !== undefined}
okAction={createType}
on:close={() => {
dispatch('close')
}}
on:changeContent
>
<div class="flex-col flex-gap-2">
<EditBox bind:value={name} placeholder={task.string.ProjectType} />
<DropdownLabelsIntl {items} on:selected={selectType} />
{#if descriptor?.allowedClassic === true}
<ToggleWithLabel label={task.string.ClassicProject} bind:on={classic} />
{/if}
</div>
</Card>
{#if projDescriptor?.allowedClassic === true}
<ToggleWithLabel label={task.string.ClassicProject} bind:on={classic} />
{/if}

View File

@ -1,51 +0,0 @@
<!--
// Copyright © 2020, 2021 Anticrm Platform Contributors.
// Copyright © 2021, 2022, 2023 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 { Ref, WithLookup } from '@hcengineering/core'
import { ProjectType } from '@hcengineering/task'
import { Location, resolvedLocationStore, resizeObserver } from '@hcengineering/ui'
import { onDestroy } from 'svelte'
import { typeStore } from '../../'
import ProjectEditor from './ProjectEditor.svelte'
export let visibleNav: boolean = true
let visibleSecondNav: boolean = true
let type: WithLookup<ProjectType> | undefined
let typeId: Ref<ProjectType> | undefined
onDestroy(
resolvedLocationStore.subscribe((loc) => {
void (async (loc: Location): Promise<void> => {
typeId = loc.path[4] as Ref<ProjectType>
})(loc)
})
)
$: type = typeId !== undefined ? $typeStore.get(typeId) : undefined
</script>
<div
class="hulyComponent"
use:resizeObserver={(element) => {
visibleSecondNav = element.clientWidth > 720
}}
>
{#if type !== undefined}
<ProjectEditor {type} descriptor={type.$lookup?.descriptor} {visibleNav} {visibleSecondNav} on:change />
{/if}
</div>

View File

@ -1,353 +0,0 @@
<!--
// Copyright © 2020, 2021 Anticrm Platform Contributors.
// Copyright © 2021, 2022, 2023 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 { ComponentExtensions, createQuery, getClient } from '@hcengineering/presentation'
import task, { Project, ProjectType, ProjectTypeDescriptor, Task, TaskType } from '@hcengineering/task'
import { createEventDispatcher, onDestroy } from 'svelte'
import { Ref, SortingOrder } from '@hcengineering/core'
import type { IntlString } from '@hcengineering/platform'
import setting from '@hcengineering/setting'
import { ClassAttributes, clearSettingsStore, settingsStore } from '@hcengineering/setting-resources'
import {
Breadcrumbs,
ButtonIcon,
Component,
Header,
IconAdd,
IconCopy,
IconDelete,
IconDescription,
IconFolder,
IconMoreV,
IconSquareExpand,
Label,
Location,
ModernButton,
ModernEditbox,
NavItem,
Scroller,
Separator,
TextArea,
defineSeparators,
getCurrentResolvedLocation,
navigate,
resolvedLocationStore,
secondNavSeparators
} from '@hcengineering/ui'
import { showMenu } from '@hcengineering/view-resources'
import plugin from '../../plugin'
import IconLayers from '../icons/Layers.svelte'
import CreateTaskType from '../taskTypes/CreateTaskType.svelte'
import TaskTypeEditor from '../taskTypes/TaskTypeEditor.svelte'
import TaskTypeIcon from '../taskTypes/TaskTypeIcon.svelte'
import TaskTypeKindEditor from '../taskTypes/TaskTypeKindEditor.svelte'
export let type: ProjectType
export let descriptor: ProjectTypeDescriptor | undefined
export let visibleNav: boolean = true
export let visibleSecondNav: boolean = true
const dispatch = createEventDispatcher()
const client = getClient()
const query = createQuery()
const projectQuery = createQuery()
$: if (descriptor === undefined) {
void client.findOne(task.class.ProjectTypeDescriptor, { _id: type.descriptor }).then((res) => {
descriptor = res
})
}
let taskTypes: TaskType[] = []
let projects: Project[] = []
$: query.query(
task.class.TaskType,
{ _id: { $in: type?.tasks ?? [] } },
(res) => {
taskTypes = res
},
{ sort: { _id: SortingOrder.Ascending } }
)
async function onShortDescriptionChange (value: string): Promise<void> {
if (type !== undefined) {
await client.diffUpdate(type, { shortDescription: value })
}
}
$: projectQuery.query(task.class.Project, { type: type._id }, (res) => {
projects = res
})
const statsQuery = createQuery()
let tasks: Task[] = []
$: statsQuery.query(
task.class.Task,
{ kind: { $in: taskTypes.map((it) => it._id) } },
(res) => {
tasks = res
},
{
projection: {
_id: 1,
_class: 1,
space: 1,
status: 1,
kind: 1
}
}
)
// $: spaceCounter = tasks.reduce(
// (map, task) => map.set(task.space, (map.get(task.space) ?? 0) + 1),
// new Map<Ref<Space>, number>()
// )
$: taskTypeCounter = tasks.reduce(
(map, task) => map.set(task.kind, (map.get(task.kind) ?? 0) + 1),
new Map<Ref<TaskType>, number>()
)
let selectedTaskTypeId: Ref<TaskType> | undefined
$: selectedTaskType = taskTypes.find((it) => it._id === selectedTaskTypeId)
onDestroy(
resolvedLocationStore.subscribe((loc) => {
void (async (loc: Location): Promise<void> => {
selectedTaskTypeId = loc.path[5] as Ref<TaskType>
})(loc)
})
)
function selectTaskType (id: string | undefined): void {
const loc = getCurrentResolvedLocation()
if (id !== undefined) {
loc.path[5] = id
loc.path.length = 6
} else {
loc.path.length = 5
}
selectedTaskTypeId = id as Ref<TaskType>
clearSettingsStore()
navigate(loc)
}
$: items =
selectedTaskType !== undefined
? [
{ label: plugin.string.ProjectType, icon: descriptor?.icon },
{ title: selectedTaskType.name, icon: selectedTaskType.icon }
]
: [{ label: plugin.string.ProjectType, icon: descriptor?.icon }]
let scroller: Scroller
const navigator: {
id: string
label: IntlString
element?: HTMLElement
}[] = [
{ id: 'properties', label: setting.string.Properties },
{ id: 'tasktypes', label: setting.string.TaskTypes },
{ id: 'automations', label: setting.string.Automations },
{ id: 'collections', label: setting.string.Collections }
]
defineSeparators('typeSettings', secondNavSeparators)
</script>
{#if type !== undefined && descriptor !== undefined}
<Header minimize={!visibleNav} on:resize={(event) => dispatch('change', event.detail)}>
<ButtonIcon
icon={IconCopy}
size={'small'}
kind={'secondary'}
disabled
on:click={(ev) => {
// Do copy of type
}}
/>
<ButtonIcon
icon={IconDelete}
size={'small'}
kind={'secondary'}
disabled
on:click={(ev) => {
// Ask for delete
}}
/>
<ButtonIcon
icon={IconMoreV}
size={'small'}
kind={'secondary'}
on:click={(ev) => {
showMenu(ev, { object: type })
}}
/>
<Breadcrumbs
{items}
size={'large'}
selected={selectedTaskType !== undefined ? 1 : 0}
on:select={(event) => {
if (event.detail === 0) selectTaskType(undefined)
}}
>
<!-- afterLabel={plugin.string.Published} -->
<!-- <span slot="afterLabel">{dateStr}</span> -->
</Breadcrumbs>
<!-- <svelte:fragment slot="actions">
<div class="hulyHeader-buttonsGroup__label font-regular-12">
<Label label={plugin.string.LastSave} />
<span>{dateStr}</span>
</div>
<ModernButton kind={'secondary'} label={ui.string.SaveDraft} size={'small'} />
<ModernButton kind={'primary'} icon={IconSend} label={ui.string.Publish} size={'small'} />
</svelte:fragment> -->
</Header>
<div class="hulyComponent-content__container columns">
{#if selectedTaskTypeId === undefined && visibleSecondNav}
<div class="hulyComponent-content__column">
<div class="hulyComponent-content__navHeader">
<div class="hulyComponent-content__navHeader-menu">
<ButtonIcon kind={'tertiary'} icon={IconDescription} size={'small'} inheritColor />
</div>
</div>
{#each navigator as navItem, i (navItem.id)}
<NavItem
type={'type-anchor-link'}
label={navItem.label}
on:click={() => {
if (i === 0) scroller.scroll(0)
else navItem.element?.scrollIntoView()
}}
/>
{/each}
</div>
<Separator name={'typeSettings'} index={0} color={'transparent'} />
{/if}
<div class="hulyComponent-content__column content">
<Scroller bind:this={scroller} align={'center'} padding={'var(--spacing-3)'} bottomPadding={'var(--spacing-3)'}>
<div class="hulyComponent-content gap">
{#if selectedTaskType === undefined}
<div class="hulyComponent-content__column-group">
<div class="hulyComponent-content__header">
<ButtonIcon icon={descriptor.icon} size={'large'} kind={'secondary'} />
<ModernButton
icon={IconSquareExpand}
label={plugin.string.CountProjects}
labelParams={{ count: projects.length }}
disabled={projects.length === 0}
kind={'tertiary'}
size={'medium'}
hasMenu
/>
</div>
<ModernEditbox
kind={'ghost'}
size={'large'}
label={plugin.string.ProjectTypeTitle}
value={type?.name ?? ''}
on:blur={(evt) => {
if (type !== undefined) {
void client.diffUpdate(type, { name: evt.detail })
}
}}
/>
<TextArea
placeholder={plugin.string.Description}
width={'100%'}
height={'4.5rem'}
margin={'var(--spacing-1) var(--spacing-2)'}
noFocusBorder
bind:value={type.shortDescription}
on:change={() => onShortDescriptionChange(type?.shortDescription ?? '')}
/>
{#if descriptor?.editor}
<Component is={descriptor.editor} props={{ type }} />
{/if}
</div>
<ClassAttributes ofClass={descriptor.baseClass} _class={type.targetClass} showHierarchy />
<div bind:this={navigator[1].element} class="hulyTableAttr-container">
<div class="hulyTableAttr-header font-medium-12">
<IconLayers size={'small'} />
<span><Label label={setting.string.TaskTypes} /></span>
<ButtonIcon
kind={'primary'}
icon={IconAdd}
size={'small'}
on:click={(ev) => {
$settingsStore = { id: 'createTaskType', component: CreateTaskType, props: { type, descriptor } }
// showPopup(CreateTaskType, { type, descriptor }, 'top')
}}
/>
</div>
{#if taskTypes.length}
<div class="hulyTableAttr-content task">
{#each taskTypes as taskType}
<button
class="hulyTableAttr-content__row"
on:click|stopPropagation={() => {
selectTaskType(taskType._id)
}}
>
<div class="hulyTableAttr-content__row-icon-wrapper">
<TaskTypeIcon value={taskType} size={'small'} />
</div>
{#if taskType.name}
<div class="hulyTableAttr-content__row-label font-medium-14">
{taskType.name}
</div>
{/if}
<div class="hulyTableAttr-content__row-label grow dark font-regular-14">
<TaskTypeKindEditor readonly kind={taskType.kind} />
</div>
</button>
{/each}
</div>
{/if}
</div>
<div bind:this={navigator[2].element} class="flex-col gapV-8">
<ComponentExtensions extension={task.extensions.ProjectEditorExtension} props={{ type }} />
</div>
<div bind:this={navigator[3].element} class="hulyTableAttr-container">
<div class="hulyTableAttr-header font-medium-12">
<IconFolder size={'small'} />
<span><Label label={setting.string.Collections} /></span>
<ButtonIcon kind={'primary'} icon={IconAdd} size={'small'} on:click={() => {}} />
</div>
</div>
{:else}
<TaskTypeEditor taskType={selectedTaskType} projectType={type} {taskTypes} {taskTypeCounter} />
{/if}
</div>
</Scroller>
</div>
</div>
{/if}
<style lang="scss">
.gap {
gap: var(--spacing-4);
}
</style>

View File

@ -0,0 +1,26 @@
<!--
// 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">
import { ComponentExtensions } from '@hcengineering/presentation'
import { ProjectType, ProjectTypeDescriptor } from '@hcengineering/task'
import task from '../../plugin'
export let type: ProjectType | undefined
export let descriptor: ProjectTypeDescriptor | undefined
</script>
{#if descriptor !== undefined && type !== undefined}
<ComponentExtensions extension={task.extensions.ProjectEditorExtension} props={{ type }} />
{/if}

View File

@ -0,0 +1,30 @@
<!--
// 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">
import { ButtonIcon, IconAdd, IconFolder, Label } from '@hcengineering/ui'
import { ProjectType, ProjectTypeDescriptor } from '@hcengineering/task'
import task from '../../plugin'
export let type: ProjectType | undefined
export let descriptor: ProjectTypeDescriptor | undefined
</script>
{#if descriptor !== undefined}
<div class="hulyTableAttr-header font-medium-12">
<IconFolder size="small" />
<span><Label label={task.string.Collections} /></span>
<ButtonIcon kind="primary" icon={IconAdd} size="small" on:click={() => {}} />
</div>
{/if}

View File

@ -0,0 +1,30 @@
<!--
// 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">
import { type ProjectType, type ProjectTypeDescriptor } from '@hcengineering/task'
import { SpaceTypeGeneralSectionEditor } from '@hcengineering/setting-resources'
import { Component } from '@hcengineering/ui'
export let type: ProjectType | undefined
export let descriptor: ProjectTypeDescriptor | undefined
</script>
<SpaceTypeGeneralSectionEditor {type} {descriptor}>
<svelte:fragment slot="extra">
{#if descriptor?.editor}
<Component is={descriptor.editor} props={{ type }} />
{/if}
</svelte:fragment>
</SpaceTypeGeneralSectionEditor>

View File

@ -31,14 +31,9 @@
let types: ProjectType[] = []
const typesQ = createQuery()
const query = disabled
? {
descriptor: { $in: descriptors }
}
: {
descriptor: { $in: descriptors },
archived: false
}
const query = {
descriptor: { $in: descriptors }
}
$: typesQ.query(task.class.ProjectType, query, (result) => {
types = result
})

View File

@ -0,0 +1,94 @@
<!--
// 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">
import { SortingOrder } from '@hcengineering/core'
import { ButtonIcon, IconAdd, Label, getCurrentResolvedLocation, navigate } from '@hcengineering/ui'
import { createQuery } from '@hcengineering/presentation'
import { ProjectType, ProjectTypeDescriptor, TaskType } from '@hcengineering/task'
import { clearSettingsStore, settingsStore } from '@hcengineering/setting-resources'
import IconLayers from '../icons/Layers.svelte'
import TaskTypeIcon from '../taskTypes/TaskTypeIcon.svelte'
import TaskTypeKindEditor from '../taskTypes/TaskTypeKindEditor.svelte'
import CreateTaskType from '../taskTypes/CreateTaskType.svelte'
import task from '../../plugin'
export let type: ProjectType | undefined
export let descriptor: ProjectTypeDescriptor | undefined
let taskTypes: TaskType[] = []
const taskTypesQuery = createQuery()
$: taskTypesQuery.query(
task.class.TaskType,
{ _id: { $in: type?.tasks ?? [] } },
(res) => {
taskTypes = res
},
{ sort: { _id: SortingOrder.Ascending } }
)
function handleTaskTypeSelected (id: string | undefined): void {
const loc = getCurrentResolvedLocation()
if (id !== undefined) {
loc.path[5] = 'taskTypes'
loc.path[6] = id
loc.path.length = 7
} else {
loc.path.length = 5
}
clearSettingsStore()
navigate(loc)
}
</script>
{#if descriptor !== undefined}
<div class="hulyTableAttr-header font-medium-12">
<IconLayers size={'small'} />
<span><Label label={task.string.TaskTypes} /></span>
<ButtonIcon
kind="primary"
icon={IconAdd}
size="small"
on:click={(ev) => {
$settingsStore = { id: 'createTaskType', component: CreateTaskType, props: { type, descriptor } }
}}
/>
</div>
{#if taskTypes.length}
<div class="hulyTableAttr-content task">
{#each taskTypes as taskType}
<button
class="hulyTableAttr-content__row"
on:click|stopPropagation={() => {
handleTaskTypeSelected(taskType._id)
}}
>
<div class="hulyTableAttr-content__row-icon-wrapper">
<TaskTypeIcon value={taskType} size={'small'} />
</div>
{#if taskType.name}
<div class="hulyTableAttr-content__row-label font-medium-14">
{taskType.name}
</div>
{/if}
<div class="hulyTableAttr-content__row-label grow dark font-regular-14">
<TaskTypeKindEditor readonly kind={taskType.kind} />
</div>
</button>
{/each}
</div>
{/if}
{/if}

View File

@ -143,7 +143,7 @@
icon: ofClassClass.icon
})
await client.createDoc(task.class.TaskType, type._id, _taskType, taskTypeId)
await client.createDoc(task.class.TaskType, core.space.Model, _taskType, taskTypeId)
}
if (!type.tasks.includes(taskTypeId)) {

View File

@ -1,6 +1,6 @@
<!--
// Copyright © 2020, 2021 Anticrm Platform Contributors.
// Copyright © 2021, 2022, 2023 Hardcore Engineering Inc.
// Copyright © 2021, 2022, 2023, 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
@ -14,11 +14,20 @@
// limitations under the License.
-->
<script lang="ts">
import { AttributeEditor, getClient } from '@hcengineering/presentation'
import { AttributeEditor, createQuery, getClient } from '@hcengineering/presentation'
import task, { ProjectType, TaskType, calculateStatuses, findStatusAttr } from '@hcengineering/task'
import { Ref, Status } from '@hcengineering/core'
import { Ref, SortingOrder, Status } from '@hcengineering/core'
import { Asset, getEmbeddedLabel } from '@hcengineering/platform'
import { Label, showPopup, ButtonIcon, ModernButton, IconSquareExpand, IconAdd, Icon } from '@hcengineering/ui'
import {
Label,
showPopup,
ButtonIcon,
ModernButton,
IconSquareExpand,
IconAdd,
Icon,
Scroller
} from '@hcengineering/ui'
import { IconPicker, statusStore } from '@hcengineering/view-resources'
import { ClassAttributes, settingsStore } from '@hcengineering/setting-resources'
import { taskTypeStore } from '../..'
@ -28,31 +37,67 @@
import TaskTypeIcon from './TaskTypeIcon.svelte'
import plugin from '../../plugin'
export let projectType: ProjectType
export let taskType: TaskType
export let taskTypeCounter: Map<Ref<TaskType>, number>
export let taskTypes: TaskType[]
export let spaceType: ProjectType
export let objectId: Ref<TaskType>
export let name: string | undefined
export let icon: Asset | undefined
const client = getClient()
$: descriptor = client.getModel().findAllSync(task.class.TaskTypeDescriptor, { _id: taskType?.descriptor })
let taskTypes: TaskType[] = []
const taskTypesQuery = createQuery()
$: taskTypesQuery.query(
task.class.TaskType,
{ _id: { $in: spaceType?.tasks ?? [] } },
(res) => {
taskTypes = res
},
{ sort: { _id: SortingOrder.Ascending } }
)
$: states = taskType.statuses.map((p) => $statusStore.byId.get(p)).filter((p) => p !== undefined) as Status[]
$: taskType = taskTypes.find((tt) => tt._id === objectId)
$: name = taskType?.name
$: icon = taskType?.icon
$: descriptor = client.getModel().findAllSync(task.class.TaskTypeDescriptor, { _id: taskType?.descriptor })
$: states = (taskType?.statuses.map((p) => $statusStore.byId.get(p)).filter((p) => p !== undefined) as Status[]) ?? []
let tasksCounter: number = 0
const tasksCounterQuery = createQuery()
$: if (taskType !== undefined) {
tasksCounterQuery.query(
task.class.Task,
{ kind: taskType._id },
(res) => {
tasksCounter = res.length
},
{
projection: {
_id: 1
}
}
)
}
function selectIcon (el: MouseEvent): void {
const icons: Asset[] = [descriptor[0].icon]
showPopup(
IconPicker,
{ icon: taskType?.icon, color: taskType?.color, icons, showColor: false },
el.target as HTMLElement,
async (result) => {
if (result !== undefined && result !== null) {
if (result !== undefined && result !== null && taskType !== undefined) {
await client.update(taskType, { color: result.color, icon: result.icon })
}
}
)
}
function handleAddStatus (): void {
if (taskType === undefined) {
return
}
const icons: Asset[] = []
const attr = findStatusAttr(getClient().getHierarchy(), taskType.ofClass)
$settingsStore = {
@ -63,7 +108,7 @@
taskType,
_class: taskType.statusClass,
category: task.statusCategory.Active,
type: projectType,
type: spaceType,
ofAttribute: attr._id,
icon: undefined,
color: 0,
@ -73,93 +118,120 @@
}
</script>
<div class="hulyComponent-content__column-group mt-4">
<div class="hulyComponent-content__header mb-6">
<div class="flex-row-center gap-1-5">
<TaskTypeKindEditor
kind={taskType.kind}
on:change={(evt) => {
void client.diffUpdate(taskType, { kind: evt.detail })
}}
/>
<ButtonIcon
icon={TaskTypeIcon}
iconProps={{ value: taskType }}
size={'large'}
kind={'secondary'}
on:click={selectIcon}
/>
{#if taskType !== undefined}
<div class="hulyComponent-content__container columns">
<div class="hulyComponent-content__column content">
<Scroller align={'center'} padding={'var(--spacing-3)'} bottomPadding={'var(--spacing-3)'}>
<div class="hulyComponent-content gap">
<div class="hulyComponent-content__column-group mt-4">
<div class="hulyComponent-content__header mb-6">
<div class="flex-row-center gap-1-5">
<TaskTypeKindEditor
kind={taskType.kind}
on:change={(evt) => {
if (taskType === undefined) {
return
}
void client.diffUpdate(taskType, { kind: evt.detail })
}}
/>
<ButtonIcon
icon={TaskTypeIcon}
iconProps={{ value: taskType }}
size={'large'}
kind={'secondary'}
on:click={selectIcon}
/>
</div>
<ModernButton
icon={IconSquareExpand}
label={plugin.string.CountTasks}
labelParams={{ count: tasksCounter }}
disabled={tasksCounter === 0}
kind={'tertiary'}
size={'medium'}
hasMenu
/>
</div>
<AttributeEditor
_class={task.class.TaskType}
object={taskType}
key="name"
editKind={'modern-ghost-large'}
/>
<div class="flex-row-center mt-4 ml-4 mr-4 gap-4">
<div class="flex-no-shrink trans-title uppercase">
<Label label={getEmbeddedLabel('Parent type restrictions')} />
</div>
{#if taskType.kind === 'subtask' || taskType.kind === 'both'}
<TaskTypeRefEditor
label={getEmbeddedLabel('Allowed parents')}
value={taskType.allowedAsChildOf}
types={taskTypes.filter((it) => it.kind === 'task' || it.kind === 'both')}
onChange={(evt) => {
if (taskType === undefined) {
return
}
void client.diffUpdate(taskType, { allowedAsChildOf: evt })
}}
/>
{/if}
</div>
</div>
<div class="hulyTableAttr-container">
<div class="hulyTableAttr-header font-medium-12">
<Icon icon={task.icon.ManageTemplates} size={'small'} />
<span><Label label={plugin.string.ProcessStates} /></span>
<ButtonIcon kind={'primary'} icon={IconAdd} size={'small'} on:click={handleAddStatus} />
</div>
<StatesProjectEditor
{taskType}
type={spaceType}
{states}
on:delete={async (evt) => {
if (taskType === undefined) {
return
}
const index = taskType.statuses.findIndex((p) => p === evt.detail.state._id)
taskType.statuses.splice(index, 1)
await client.update(taskType, {
statuses: taskType.statuses
})
await client.update(spaceType, {
statuses: calculateStatuses(spaceType, $taskTypeStore, [
{ taskTypeId: taskType._id, statuses: taskType.statuses }
])
})
}}
on:move={async (evt) => {
if (taskType === undefined) {
return
}
const index = taskType.statuses.findIndex((p) => p === evt.detail.stateID)
const state = taskType.statuses.splice(index, 1)[0]
const statuses = [
...taskType.statuses.slice(0, evt.detail.position),
state,
...taskType.statuses.slice(evt.detail.position)
]
await client.update(taskType, {
statuses
})
await client.update(spaceType, {
statuses: calculateStatuses(spaceType, $taskTypeStore, [{ taskTypeId: taskType._id, statuses }])
})
}}
/>
</div>
<ClassAttributes ofClass={taskType.ofClass} _class={taskType.targetClass} showHierarchy />
</div>
</Scroller>
</div>
<ModernButton
icon={IconSquareExpand}
label={plugin.string.CountTasks}
labelParams={{ count: taskTypeCounter.get(taskType._id) ?? 0 }}
disabled={taskTypeCounter.get(taskType._id) === undefined}
kind={'tertiary'}
size={'medium'}
hasMenu
/>
</div>
<AttributeEditor _class={task.class.TaskType} object={taskType} key="name" editKind={'modern-ghost-large'} />
<div class="flex-row-center mt-4 ml-4 mr-4 gap-4">
<div class="flex-no-shrink trans-title uppercase">
<Label label={getEmbeddedLabel('Parent type restrictions')} />
</div>
{#if taskType.kind === 'subtask' || taskType.kind === 'both'}
<TaskTypeRefEditor
label={getEmbeddedLabel('Allowed parents')}
value={taskType.allowedAsChildOf}
types={taskTypes.filter((it) => it.kind === 'task' || it.kind === 'both')}
onChange={(evt) => {
void client.diffUpdate(taskType, { allowedAsChildOf: evt })
}}
/>
{/if}
</div>
</div>
<div class="hulyTableAttr-container">
<div class="hulyTableAttr-header font-medium-12">
<Icon icon={task.icon.ManageTemplates} size={'small'} />
<span><Label label={plugin.string.ProcessStates} /></span>
<ButtonIcon kind={'primary'} icon={IconAdd} size={'small'} on:click={handleAddStatus} />
</div>
<StatesProjectEditor
{taskType}
type={projectType}
{states}
on:delete={async (evt) => {
const index = taskType.statuses.findIndex((p) => p === evt.detail.state._id)
taskType.statuses.splice(index, 1)
await client.update(taskType, {
statuses: taskType.statuses
})
await client.update(projectType, {
statuses: calculateStatuses(projectType, $taskTypeStore, [
{ taskTypeId: taskType._id, statuses: taskType.statuses }
])
})
}}
on:move={async (evt) => {
const index = taskType.statuses.findIndex((p) => p === evt.detail.stateID)
const state = taskType.statuses.splice(index, 1)[0]
const statuses = [
...taskType.statuses.slice(0, evt.detail.position),
state,
...taskType.statuses.slice(evt.detail.position)
]
await client.update(taskType, {
statuses
})
await client.update(projectType, {
statuses: calculateStatuses(projectType, $taskTypeStore, [{ taskTypeId: taskType._id, statuses }])
})
}}
/>
</div>
<ClassAttributes ofClass={taskType.ofClass} _class={taskType.targetClass} showHierarchy />
{/if}

View File

@ -53,9 +53,7 @@ import TaskPresenter from './components/TaskPresenter.svelte'
import TemplatesIcon from './components/TemplatesIcon.svelte'
import TypesView from './components/TypesView.svelte'
import KanbanView from './components/kanban/KanbanView.svelte'
import ProjectEditor from './components/projectTypes/ProjectEditor.svelte'
import ProjectTypePresenter from './components/projectTypes/ProjectTypePresenter.svelte'
import ProjectTypeSelector from './components/projectTypes/ProjectTypeSelector.svelte'
import CreateStatePopup from './components/state/CreateStatePopup.svelte'
import StateEditor from './components/state/StateEditor.svelte'
import StateIconPresenter from './components/state/StateIconPresenter.svelte'
@ -67,9 +65,13 @@ import TaskKindSelector from './components/taskTypes/TaskKindSelector.svelte'
import TaskTypeClassPresenter from './components/taskTypes/TaskTypeClassPresenter.svelte'
import TaskTypePresenter from './components/taskTypes/TaskTypePresenter.svelte'
import ManageProjects from './components/projectTypes/ManageProjects.svelte'
import ManageProjectsContent from './components/projectTypes/ManageProjectsContent.svelte'
import ManageProjectsTools from './components/projectTypes/ManageProjectsTools.svelte'
import ProjectTypeSelector from './components/projectTypes/ProjectTypeSelector.svelte'
import CreateProjectType from './components/projectTypes/CreateProjectType.svelte'
import ProjectTypeGeneralSectionEditor from './components/projectTypes/ProjectTypeGeneralSectionEditor.svelte'
import ProjectTypeTasksTypeSectionEditor from './components/projectTypes/ProjectTypeTasksTypeSectionEditor.svelte'
import ProjectTypeAutomationsSectionEditor from './components/projectTypes/ProjectTypeAutomationsSectionEditor.svelte'
import ProjectTypeCollectionsSectionEditor from './components/projectTypes/ProjectTypeCollectionsSectionEditor.svelte'
import TaskTypeEditor from './components/taskTypes/TaskTypeEditor.svelte'
export { default as AssigneePresenter } from './components/AssigneePresenter.svelte'
export { default as TypeSelector } from './components/TypeSelector.svelte'
@ -112,8 +114,6 @@ export default async (): Promise<Resources> => ({
StateEditor,
StatusTableView,
TaskHeader,
ProjectEditor,
ProjectTypeSelector,
AssignedTasks,
StateRefPresenter,
DueDateEditor,
@ -126,10 +126,14 @@ export default async (): Promise<Resources> => ({
TaskTypePresenter,
TaskTypeClassPresenter,
ProjectTypeClassPresenter,
ManageProjects,
ManageProjectsTools,
ManageProjectsContent,
ProjectTypePresenter
ProjectTypePresenter,
ProjectTypeSelector,
CreateProjectType,
ProjectTypeGeneralSectionEditor,
ProjectTypeTasksTypeSectionEditor,
ProjectTypeAutomationsSectionEditor,
ProjectTypeCollectionsSectionEditor,
TaskTypeEditor
},
actionImpl: {
EditStatuses: editStatuses,

View File

@ -91,7 +91,9 @@ export default mergeIds(taskId, task, {
Color: '' as IntlString,
RenameStatus: '' as IntlString,
UpdateTasksStatusRequest: '' as IntlString
UpdateTasksStatusRequest: '' as IntlString,
TaskTypes: '' as IntlString,
Collections: '' as IntlString
},
status: {
AssigneeRequired: '' as IntlString
@ -101,7 +103,13 @@ export default mergeIds(taskId, task, {
StatusFilter: '' as AnyComponent,
TodoStatePresenter: '' as AnyComponent,
AssignedTasks: '' as AnyComponent,
DueDateEditor: '' as AnyComponent
DueDateEditor: '' as AnyComponent,
CreateProjectType: '' as AnyComponent,
ProjectTypeGeneralSectionEditor: '' as AnyComponent,
ProjectTypeTasksTypeSectionEditor: '' as AnyComponent,
ProjectTypeAutomationsSectionEditor: '' as AnyComponent,
ProjectTypeCollectionsSectionEditor: '' as AnyComponent,
TaskTypeEditor: '' as AnyComponent
},
function: {
GetAllStates: '' as GetAllValuesFunc,

View File

@ -25,7 +25,10 @@ import {
Space,
Status,
StatusCategory,
Timestamp
Timestamp,
SpaceType,
SpaceTypeDescriptor,
TypedSpace
} from '@hcengineering/core'
import { NotificationType } from '@hcengineering/notification'
import type { Asset, IntlString, Plugin, Resource } from '@hcengineering/platform'
@ -41,7 +44,7 @@ export interface DocWithRank extends Doc {
rank: Rank
}
export interface Project extends Space {
export interface Project extends TypedSpace {
type: Ref<ProjectType>
}
@ -149,15 +152,16 @@ export interface TaskTypeClass extends Class<TaskType> {
export interface ProjectTypeClass extends Class<ProjectType> {
projectType: Ref<ProjectType>
}
/**
* @public
*
* Define a user customized project type.
*/
export interface ProjectType extends Space {
shortDescription?: string
export interface ProjectType extends SpaceType {
descriptor: Ref<ProjectTypeDescriptor>
tasks: Ref<TaskType>[]
description: string
// Color and extra options per project type.
// All statuses per project has same color.
@ -173,14 +177,11 @@ export interface ProjectType extends Space {
/**
* @public
*/
export interface ProjectTypeDescriptor extends Doc {
name: IntlString
description: IntlString
icon: Asset
editor?: AnyComponent
export interface ProjectTypeDescriptor extends SpaceTypeDescriptor {
allowedClassic?: boolean
allowedTaskTypeDescriptors?: Ref<TaskTypeDescriptor>[] // if undefined we allow all possible
baseClass: Ref<Class<Task>>
baseClass: Ref<Class<Project>>
editor?: AnyComponent
}
/**
@ -292,7 +293,6 @@ const task = plugin(taskId, {
Lost: '' as Ref<StatusCategory>
},
component: {
ProjectEditor: '' as AnyComponent,
ProjectTypeSelector: '' as AnyComponent,
CreateStatePopup: '' as AnyComponent
},

View File

@ -168,7 +168,7 @@ export type TaskTypeWithFactory = Omit<Data<TaskType>, 'statuses' | 'parent' | '
factory: Data<Status>[]
} & Partial<Pick<TaskType, 'targetClass'>>
type ProjectData = Omit<Data<ProjectType>, 'statuses' | 'private' | 'members' | 'archived' | 'targetClass'>
type ProjectData = Omit<Data<ProjectType>, 'statuses' | 'targetClass'>
async function createStates (
client: TxOperations,
@ -212,16 +212,14 @@ export async function createProjectType (
const targetProjectClassId: Ref<Class<Doc>> = generateId()
const tmpl = await client.createDoc(
task.class.ProjectType,
core.space.Space,
core.space.Model,
{
description: data.description,
shortDescription: data.shortDescription,
descriptor: data.descriptor,
roles: 0,
tasks: _tasks,
name: data.name,
private: false,
members: [],
archived: false,
statuses: calculateStatuses({ tasks: _tasks, statuses: [] }, tasksData, []),
targetClass: targetProjectClassId,
classic: data.classic
@ -229,6 +227,7 @@ export async function createProjectType (
_id
)
// Mixin to hold custom fields of this project type
await client.createDoc(
core.class.Mixin,
core.space.Model,
@ -241,6 +240,7 @@ export async function createProjectType (
targetProjectClassId
)
// TODO: not needed ???
await client.createMixin(targetProjectClassId, core.class.Mixin, core.space.Model, task.mixin.ProjectTypeClass, {
projectType: _id
})
@ -344,7 +344,7 @@ async function createTaskTypes (
projectType: _id
})
}
await client.createDoc(task.class.TaskType, _id, tdata as Data<TaskType>, taskId)
await client.createDoc(task.class.TaskType, core.space.Model, tdata as Data<TaskType>, taskId)
tasksData.set(taskId, tdata as Data<TaskType>)
_tasks.push(taskId)
}

View File

@ -286,7 +286,8 @@
"MapRelatedIssues": "Configure Related issue default projects",
"DefaultIssueStatus": "Default issue status",
"IssueStatus": "Status",
"Extensions": "Extensions"
"Extensions": "Extensions",
"RoleLabel": "Role: {role}"
},
"status": {}
}

View File

@ -286,7 +286,8 @@
"MapRelatedIssues": "Настроить проекты по умолчанию для связанных задач",
"DefaultIssueStatus": "Статус по умолчанию",
"IssueStatus": "Cтатус",
"Extensions": "Дополнительно"
"Extensions": "Дополнительно",
"RoleLabel": "Роль: {role}"
},
"status": {}
}

View File

@ -13,9 +13,20 @@
// limitations under the License.
-->
<script lang="ts">
import { deepEqual } from 'fast-equals'
import { Employee } from '@hcengineering/contact'
import { AccountArrayEditor, AssigneeBox } from '@hcengineering/contact-resources'
import core, { Account, Data, DocumentUpdate, Ref, generateId, getCurrentAccount } from '@hcengineering/core'
import core, {
Account,
Data,
DocumentUpdate,
RolesAssignment,
Ref,
Role,
SortingOrder,
generateId,
getCurrentAccount
} from '@hcengineering/core'
import { Asset } from '@hcengineering/platform'
import presentation, { Card, createQuery, getClient } from '@hcengineering/presentation'
import task, { ProjectType } from '@hcengineering/task'
@ -25,11 +36,9 @@
Button,
Component,
EditBox,
IconEdit,
IconWithEmoji,
Label,
Toggle,
eventToHTMLElement,
getColorNumberByText,
getPlatformColorDef,
getPlatformColorForTextDef,
@ -38,14 +47,12 @@
} from '@hcengineering/ui'
import view from '@hcengineering/view'
import { IconPicker } from '@hcengineering/view-resources'
import { typeStore, taskTypeStore } from '@hcengineering/task-resources'
import { createEventDispatcher } from 'svelte'
import tracker from '../../plugin'
import StatusSelector from '../issues/StatusSelector.svelte'
import ChangeIdentity from './ChangeIdentity.svelte'
import { typeStore, taskTypeStore } from '@hcengineering/task-resources'
export let project: Project | undefined = undefined
export let namePlaceholder: string = ''
export let descriptionPlaceholder: string = ''
@ -65,6 +72,7 @@
let projectsIdentifiers = new Set<string>()
let isSaving = false
let defaultStatus: Ref<IssueStatus> | undefined = project?.defaultIssueStatus
let rolesAssignment: RolesAssignment | undefined
let changeIdentityRef: HTMLElement
@ -99,8 +107,22 @@
}
}
function getRolesAssignment (): RolesAssignment {
if (project === undefined || typeType?.targetClass === undefined || roles === undefined) {
return {}
}
const asMixin = hierarchy.as(project, typeType?.targetClass)
return roles.reduce<RolesAssignment>((prev, { _id }) => {
prev[_id] = (asMixin as any)[_id]
return prev
}, {})
}
async function updateProject (): Promise<void> {
if (!project) {
if (!project || typeType?.targetClass === undefined) {
return
}
@ -147,11 +169,20 @@
isSaving = false
}
if (rolesAssignment && !deepEqual(rolesAssignment, getRolesAssignment())) {
await client.updateMixin(
project._id,
tracker.class.Project,
core.space.Space,
typeType.targetClass,
rolesAssignment
)
}
close()
}
let typeId: Ref<ProjectType> | undefined = project?.type
$: typeType = typeId !== undefined ? $typeStore.get(typeId) : undefined
$: if (defaultStatus === undefined && typeType !== undefined) {
@ -171,8 +202,14 @@
const succeeded = await ops.commit()
if (succeeded) {
// Add vacancy mixin
await client.createMixin(projectId, tracker.class.Project, core.space.Space, typeType.targetClass, {})
// Add space type's mixin with roles assignments
await client.createMixin(
projectId,
tracker.class.Project,
core.space.Space,
typeType.targetClass,
rolesAssignment ?? {}
)
close(projectId)
} else {
@ -207,6 +244,52 @@
}
$: identifier = identifier.toLocaleUpperCase().replaceAll('-', '_').replaceAll(' ', '_').substring(0, 5)
let roles: Role[] = []
const rolesQuery = createQuery()
$: if (typeType !== undefined) {
rolesQuery.query(
core.class.Role,
{ attachedTo: typeType._id },
(res) => {
roles = res
if (rolesAssignment === undefined && typeType !== undefined) {
rolesAssignment = getRolesAssignment()
}
},
{
sort: {
name: SortingOrder.Ascending
}
}
)
} else {
rolesQuery.unsubscribe()
}
function handleMembersChanged (newMembers: Ref<Account>[]): void {
// If a member was removed we need to remove it from any roles assignments as well
const newMembersSet = new Set(newMembers)
const removedMembersSet = new Set(members.filter((m) => !newMembersSet.has(m)))
if (removedMembersSet.size > 0 && rolesAssignment !== undefined) {
for (const [key, value] of Object.entries(rolesAssignment)) {
rolesAssignment[key as Ref<Role>] =
value !== undefined ? value.filter((m) => !removedMembersSet.has(m)) : undefined
}
}
members = newMembers
}
function handleRoleAssignmentChanged (roleId: Ref<Role>, newMembers: Ref<Account>[]): void {
if (rolesAssignment === undefined) {
rolesAssignment = {}
}
rolesAssignment[roleId] = newMembers
}
</script>
<Card
@ -326,19 +409,6 @@
<Toggle bind:on={isPrivate} disabled={!isPrivate && members.length === 0} />
</div>
<div class="antiGrid-row">
<div class="antiGrid-row__header">
<Label label={tracker.string.Members} />
</div>
<AccountArrayEditor
value={members}
label={tracker.string.Members}
onChange={(refs) => (members = refs)}
kind={'regular'}
size={'large'}
/>
</div>
<div class="antiGrid-row">
<div class="antiGrid-row__header">
<Label label={tracker.string.DefaultAssignee} />
@ -369,6 +439,38 @@
size={'large'}
/>
</div>
<div class="antiGrid-row">
<div class="antiGrid-row__header">
<Label label={tracker.string.Members} />
</div>
<AccountArrayEditor
value={members}
label={tracker.string.Members}
onChange={handleMembersChanged}
kind={'regular'}
size={'large'}
/>
</div>
{#each roles as role}
<div class="antiGrid-row">
<div class="antiGrid-row__header">
<Label label={tracker.string.RoleLabel} params={{ role: role.name }} />
</div>
<AccountArrayEditor
value={rolesAssignment?.[role._id] ?? []}
label={tracker.string.Members}
includeItems={members}
readonly={members.length === 0}
onChange={(refs) => {
handleRoleAssignmentChanged(role._id, refs)
}}
kind={'regular'}
size={'large'}
/>
</div>
{/each}
</div>
</Card>

View File

@ -299,7 +299,8 @@ export default mergeIds(trackerId, tracker, {
UnsetParent: '' as IntlString,
PreviousAssigned: '' as IntlString,
EditRelatedTargets: '' as IntlString,
RelatedIssueTargetDescription: '' as IntlString
RelatedIssueTargetDescription: '' as IntlString,
RoleLabel: '' as IntlString
},
component: {
NopeComponent: '' as AnyComponent,

View File

@ -0,0 +1,59 @@
<!--
// Copyright © 2020, 2021 Anticrm Platform Contributors.
//
// 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 { createEventDispatcher } from 'svelte'
import core, { type Ref, type SpaceType, type SpaceTypeDescriptor } from '@hcengineering/core'
import { createQuery } from '@hcengineering/presentation'
import { type DropdownTextItem, ButtonKind, ButtonSize, DropdownLabels } from '@hcengineering/ui'
import view from '@hcengineering/view'
export let descriptors: Ref<SpaceTypeDescriptor>[]
export let type: Ref<SpaceType> | undefined = undefined
export let kind: ButtonKind = 'no-border'
export let size: ButtonSize = 'small'
export let focusIndex = -1
export let disabled: boolean = false
let types: SpaceType[] = []
const typesQ = createQuery()
const query = {
descriptor: { $in: descriptors }
}
$: typesQ.query(core.class.SpaceType, query, (result) => {
types = result
})
let items: DropdownTextItem[] = []
$: items = types.map((x) => ({ id: x._id, label: x.name }))
let selectedItem: string | undefined = type
const dispatch = createEventDispatcher()
$: {
type = selectedItem === undefined ? undefined : (selectedItem as Ref<SpaceType>)
dispatch('change', type)
}
</script>
<DropdownLabels
{focusIndex}
{items}
{kind}
{size}
{disabled}
icon={view.icon.Setting}
bind:selected={selectedItem}
label={core.string.SpaceType}
/>

View File

@ -79,6 +79,7 @@ import SearchSelector from './components/SearchSelector.svelte'
import SpaceHeader from './components/SpaceHeader.svelte'
import SpacePresenter from './components/SpacePresenter.svelte'
import SpaceRefPresenter from './components/SpaceRefPresenter.svelte'
import SpaceTypeSelector from './components/SpaceTypeSelector.svelte'
import StatusPresenter from './components/status/StatusPresenter.svelte'
import StatusRefPresenter from './components/status/StatusRefPresenter.svelte'
import StringEditor from './components/StringEditor.svelte'
@ -117,6 +118,7 @@ import {
import { IndexedDocumentPreview } from '@hcengineering/presentation'
import { AggregationMiddleware, AnalyticsMiddleware } from './middleware'
import { showEmptyGroups } from './viewOptions'
import { canDeleteObject } from './visibilityTester'
export { getActions, getContextActions, invokeAction, showMenu } from './actions'
export { default as ActionButton } from './components/ActionButton.svelte'
export { default as ActionHandler } from './components/ActionHandler.svelte'
@ -133,11 +135,13 @@ export { default as MarkupPreviewPopup } from './components/MarkupPreviewPopup.s
export { default as ContextMenu } from './components/Menu.svelte'
export { default as NavLink } from './components/navigator/NavLink.svelte'
export { default as ObjectBox } from './components/ObjectBox.svelte'
export { default as ObjectBoxPopup } from './components/ObjectBoxPopup.svelte'
export { default as ObjectPresenter } from './components/ObjectPresenter.svelte'
export { default as ObjectSearchBox } from './components/ObjectSearchBox.svelte'
export { default as ParentsNavigator } from './components/ParentsNavigator.svelte'
export { default as StatusPresenter } from './components/status/StatusPresenter.svelte'
export { default as StatusRefPresenter } from './components/status/StatusRefPresenter.svelte'
export { default as SpaceTypeSelector } from './components/SpaceTypeSelector.svelte'
export { default as TableBrowser } from './components/TableBrowser.svelte'
export { default as ValueSelector } from './components/ValueSelector.svelte'
export { default as ViewletSelector } from './components/ViewletSelector.svelte'
@ -255,6 +259,7 @@ export default async (): Promise<Resources> => ({
DividerPresenter,
IndexedDocumentPreview,
SpaceRefPresenter,
SpaceTypeSelector,
EnumArrayEditor,
EnumPresenter,
StatusPresenter,
@ -293,6 +298,7 @@ export default async (): Promise<Resources> => ({
// eslint-disable-next-line @typescript-eslint/unbound-method
CreateDocMiddleware: AggregationMiddleware.create,
// eslint-disable-next-line @typescript-eslint/unbound-method
AnalyticsMiddleware: AnalyticsMiddleware.create
AnalyticsMiddleware: AnalyticsMiddleware.create,
CanDeleteObject: canDeleteObject
}
})

View File

@ -29,7 +29,8 @@ export default mergeIds(viewId, view, {
TimestampFilter: '' as AnyComponent,
FilterTypePopup: '' as AnyComponent,
ProxyPresenter: '' as AnyComponent,
ArrayEditor: '' as AnyComponent
ArrayEditor: '' as AnyComponent,
SpaceTypeSelector: '' as AnyComponent
},
string: {
Contains: '' as IntlString,

View File

@ -47,7 +47,9 @@ import core, {
type TxMixin,
type TxOperations,
type TxUpdateDoc,
type TypeAny
type TypeAny,
type TypedSpace,
type Permission
} from '@hcengineering/core'
import { type Restrictions } from '@hcengineering/guest'
import type { Asset, IntlString } from '@hcengineering/platform'
@ -60,7 +62,8 @@ import {
hasResource,
type KeyedAttribute,
getFiltredKeys,
isAdminUser
isAdminUser,
createQuery
} from '@hcengineering/presentation'
import {
ErrorPresenter,
@ -1327,3 +1330,47 @@ async function getAttrEditor (key: KeyedAttribute, hierarchy: Hierarchy): Promis
return undefined
}
}
type PermissionsBySpace = Record<Ref<Space>, Set<Ref<Permission>>>
interface PermissionsStore {
ps: PermissionsBySpace
whitelist: Set<Ref<Space>>
}
export const permissionsStore = writable<PermissionsStore>({
ps: {},
whitelist: new Set()
})
const permissionsQuery = createQuery(true)
permissionsQuery.query(core.class.Space, {}, (res) => {
const whitelistedSpaces = new Set<Ref<Space>>()
const permissionsBySpace: PermissionsBySpace = {}
const client = getClient()
const hierarchy = client.getHierarchy()
const me = getCurrentAccount()
for (const s of res) {
if (hierarchy.isDerived(s._class, core.class.TypedSpace)) {
const type = client.getModel().findAllSync(core.class.SpaceType, { _id: (s as TypedSpace).type })[0]
const mixin = type?.targetClass
if (mixin === undefined) {
permissionsBySpace[s._id] = new Set()
continue
}
const asMixin = hierarchy.as(s, mixin)
const roles = client.getModel().findAllSync(core.class.Role, { attachedTo: type._id })
const myRoles = roles.filter((r) => (asMixin as any)[r._id].includes(me._id))
permissionsBySpace[s._id] = new Set(myRoles.flatMap((r) => r.permissions))
} else {
whitelistedSpaces.add(s._id)
}
}
permissionsStore.set({
ps: permissionsBySpace,
whitelist: whitelistedSpaces
})
})

View File

@ -0,0 +1,42 @@
//
// 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.
//
import core, { checkPermission, type Space, type Doc, type TypedSpace } from '@hcengineering/core'
import { getClient } from '@hcengineering/presentation'
function isTypedSpace (space: Space): space is TypedSpace {
return getClient().getHierarchy().isDerived(space._class, core.class.TypedSpace)
}
export async function canDeleteObject (doc?: Doc | Doc[]): Promise<boolean> {
if (doc === undefined) {
return false
}
const client = getClient()
const targets = Array.isArray(doc) ? doc : [doc]
// Note: allow deleting objects in NOT typed spaces for now
const targetSpaces = (await client.findAll(core.class.Space, { _id: { $in: targets.map((t) => t.space) } })).filter(
isTypedSpace
)
return (
await Promise.all(
Array.from(new Set(targetSpaces.map((t) => t._id))).map(
async (s) => await checkPermission(client, core.permission.DeleteObject, s)
)
)
).every((r) => r)
}

View File

@ -32,7 +32,8 @@ import {
ModifiedMiddleware,
PrivateMiddleware,
QueryJoinMiddleware,
SpaceSecurityMiddleware
SpaceSecurityMiddleware,
SpacePermissionsMiddleware
} from '@hcengineering/middleware'
import { createMongoAdapter, createMongoTxAdapter } from '@hcengineering/mongo'
import { OpenAIEmbeddingsStage, openAIId, openAIPluginImpl } from '@hcengineering/openai'
@ -229,6 +230,7 @@ export function start (
ModifiedMiddleware.create,
PrivateMiddleware.create,
SpaceSecurityMiddleware.create,
SpacePermissionsMiddleware.create,
ConfigurationMiddleware.create,
QueryJoinMiddleware.create // Should be last one
]

View File

@ -19,3 +19,4 @@ export * from './modified'
export * from './private'
export * from './queryJoin'
export * from './spaceSecurity'
export * from './spacePermissions'

View File

@ -0,0 +1,346 @@
//
// 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.
//
import core, {
Account,
AttachedDoc,
Class,
Doc,
MeasureContext,
Permission,
Ref,
Role,
RolesAssignment,
ServerStorage,
Space,
SpaceType,
Tx,
TxApplyIf,
TxCUD,
TxCollectionCUD,
TxCreateDoc,
TxMixin,
TxProcessor,
TxRemoveDoc,
TxUpdateDoc,
TypedSpace
} from '@hcengineering/core'
import platform, { PlatformError, Severity, Status } from '@hcengineering/platform'
import { BroadcastFunc, Middleware, SessionContext, TxMiddlewareResult } from '@hcengineering/server-core'
import { BaseMiddleware } from './base'
import { getUser } from './utils'
/**
* @public
*/
export class SpacePermissionsMiddleware extends BaseMiddleware implements Middleware {
private spaceMeasureCtx!: MeasureContext
private whitelistSpaces = new Set<Ref<Space>>()
private assignmentBySpace: Record<Ref<Space>, RolesAssignment> = {}
private permissionsBySpace: Record<Ref<Space>, Record<Ref<Account>, Set<Ref<Permission>>>> = {}
private typeBySpace: Record<Ref<Space>, Ref<SpaceType>> = {}
static async create (
ctx: MeasureContext,
broadcast: BroadcastFunc,
storage: ServerStorage,
next?: Middleware
): Promise<SpacePermissionsMiddleware> {
const res = new SpacePermissionsMiddleware(storage, next)
res.spaceMeasureCtx = ctx.newChild('space permisisons', {})
await res.init(res.spaceMeasureCtx)
return res
}
private async init (ctx: MeasureContext): Promise<void> {
const spaces: Space[] = await this.storage.findAll(ctx, core.class.Space, {})
for (const space of spaces) {
if (this.isTypedSpace(space)) {
await this.addRestrictedSpace(space)
}
}
this.whitelistSpaces = new Set(spaces.filter((s) => !this.isTypedSpaceClass(s._class)).map((p) => p._id))
}
private async getRoles (spaceTypeId: Ref<SpaceType>): Promise<Role[]> {
return await this.storage.modelDb.findAll(core.class.Role, { attachedTo: spaceTypeId })
}
private async setPermissions (spaceId: Ref<Space>, roles: Role[], assignment: RolesAssignment): Promise<void> {
for (const role of roles) {
const roleMembers: Ref<Account>[] = assignment[role._id] ?? []
for (const member of roleMembers) {
if (this.permissionsBySpace[spaceId][member] === undefined) {
this.permissionsBySpace[spaceId][member] = new Set()
}
for (const permission of role.permissions) {
this.permissionsBySpace[spaceId][member].add(permission)
}
}
}
}
private async addRestrictedSpace (space: TypedSpace): Promise<void> {
this.permissionsBySpace[space._id] = {}
const spaceType = await this.storage.modelDb.findOne(core.class.SpaceType, { _id: space.type })
if (spaceType === undefined) {
return
}
this.typeBySpace[space._id] = space.type
const asMixin: RolesAssignment = this.storage.hierarchy.as(
space,
spaceType.targetClass
) as unknown as RolesAssignment
this.assignmentBySpace[space._id] = asMixin
await this.setPermissions(space._id, await this.getRoles(spaceType._id), asMixin)
}
private isTypedSpaceClass (_class: Ref<Class<Space>>): boolean {
const h = this.storage.hierarchy
return h.isDerived(_class, core.class.TypedSpace)
}
private isTypedSpace (space: Space): space is TypedSpace {
return this.isTypedSpaceClass(space._class)
}
/**
* @private
*
* Throws if the required permission is missing in the space for the given context
*/
private async needPermission (ctx: SessionContext, space: Ref<TypedSpace>, id: Ref<Permission>): Promise<void> {
const account = await getUser(this.storage, ctx)
const permissions = this.permissionsBySpace[space]?.[account._id] ?? new Set()
if (!permissions.has(id)) {
throw new PlatformError(new Status(Severity.ERROR, platform.status.Forbidden, {}))
}
}
private async handleCreate (tx: TxCUD<Space>): Promise<void> {
const createTx = tx as TxCreateDoc<Space>
if (!this.storage.hierarchy.isDerived(createTx.objectClass, core.class.Space)) return
if (this.isTypedSpaceClass(createTx.objectClass)) {
const res = TxProcessor.buildDoc2Doc<TypedSpace>([createTx])
if (res !== undefined) {
await this.addRestrictedSpace(res)
}
} else {
this.whitelistSpaces.add(createTx.objectId)
}
}
private async handleMixin (tx: TxCUD<Space>): Promise<void> {
if (!this.isTypedSpaceClass(tx.objectClass)) {
return
}
const spaceId = tx.objectId
const spaceTypeId = this.typeBySpace[spaceId]
if (spaceTypeId === undefined) {
return
}
const spaceType = await this.storage.modelDb.findOne(core.class.SpaceType, { _id: spaceTypeId })
if (spaceType === undefined) {
return
}
const mixinDoc = tx as TxMixin<Space, Space>
if (mixinDoc.mixin !== spaceType.targetClass) {
return
}
// Note: currently the whole assignment is always included into the mixin update
// so we can just rebuild the permissions
const assignment: RolesAssignment = mixinDoc.attributes as RolesAssignment
this.assignmentBySpace[spaceId] = assignment
this.permissionsBySpace[tx.objectId] = {}
await this.setPermissions(spaceId, await this.getRoles(spaceType._id), assignment)
}
private handleRemove (tx: TxCUD<Space>): void {
const removeTx = tx as TxRemoveDoc<Space>
if (!this.storage.hierarchy.isDerived(removeTx.objectClass, core.class.Space)) return
if (removeTx._class !== core.class.TxCreateDoc) return
// eslint-disable-next-line @typescript-eslint/no-dynamic-delete
delete this.permissionsBySpace[tx.objectId]
// eslint-disable-next-line @typescript-eslint/no-dynamic-delete
delete this.assignmentBySpace[tx.objectId]
// eslint-disable-next-line @typescript-eslint/no-dynamic-delete
delete this.typeBySpace[tx.objectId]
this.whitelistSpaces.delete(tx.objectId)
}
private isSpaceTxCUD (tx: TxCUD<Doc>): tx is TxCUD<Space> {
return this.storage.hierarchy.isDerived(tx.objectClass, core.class.Space)
}
private isTxCollectionCUD (tx: TxCUD<Doc>): tx is TxCollectionCUD<Doc, AttachedDoc> {
return this.storage.hierarchy.isDerived(tx._class, core.class.TxCollectionCUD)
}
private isRoleTxCUD (tx: TxCUD<Doc>): tx is TxCUD<Role> {
return this.storage.hierarchy.isDerived(tx.objectClass, core.class.Role)
}
private async handlePermissionsUpdatesFromRoleTx (ctx: SessionContext, tx: TxCUD<Doc>): Promise<void> {
if (!this.isTxCollectionCUD(tx)) {
return
}
const h = this.storage.hierarchy
const actualTx = TxProcessor.extractTx(tx)
if (!h.isDerived(actualTx._class, core.class.TxCUD)) {
return
}
const actualCudTx = actualTx as TxCUD<Doc>
if (!this.isRoleTxCUD(actualCudTx)) {
return
}
// We are only interested in updates of the existing roles because:
// When role is created it always has empty set of permissions
// And it's not currently possible to delete a role
if (actualCudTx._class !== core.class.TxUpdateDoc) {
return
}
const updateTx = actualCudTx as TxUpdateDoc<Role>
if (updateTx.operations.permissions === undefined) {
return
}
// Find affected spaces
const targetSpaceTypeId = tx.objectId
const affectedSpacesIds = Object.entries(this.typeBySpace)
.filter(([, typeId]) => typeId === targetSpaceTypeId)
.map(([spaceId]) => spaceId) as Ref<TypedSpace>[]
for (const spaceId of affectedSpacesIds) {
const spaceTypeId = this.typeBySpace[spaceId]
if (spaceTypeId === undefined) {
return
}
const assignment: RolesAssignment = this.assignmentBySpace[spaceId]
const roles = await this.getRoles(spaceTypeId)
const targetRole = roles.find((r) => r._id === updateTx.objectId)
if (targetRole === undefined) {
continue
}
targetRole.permissions = updateTx.operations.permissions
this.permissionsBySpace[spaceId] = {}
await this.setPermissions(spaceId, roles, assignment)
}
}
private async handlePermissionsUpdatesFromTx (ctx: SessionContext, tx: TxCUD<Doc>): Promise<void> {
if (this.isSpaceTxCUD(tx)) {
if (tx._class === core.class.TxCreateDoc) {
await this.handleCreate(tx)
// } else if (tx._class === core.class.TxUpdateDoc) {
// Roles assignment in spaces are managed through the space type mixin
// so nothing to handle here
} else if (tx._class === core.class.TxMixin) {
await this.handleMixin(tx)
} else if (tx._class === core.class.TxRemoveDoc) {
this.handleRemove(tx)
}
}
await this.handlePermissionsUpdatesFromRoleTx(ctx, tx)
}
private async processPermissionsUpdatesFromTx (ctx: SessionContext, tx: Tx): Promise<void> {
const h = this.storage.hierarchy
if (!h.isDerived(tx._class, core.class.TxCUD)) {
return
}
const cudTx = tx as TxCUD<Doc>
await this.handlePermissionsUpdatesFromTx(ctx, cudTx)
}
async tx (ctx: SessionContext, tx: Tx): Promise<TxMiddlewareResult> {
await this.processPermissionsUpdatesFromTx(ctx, tx)
await this.checkPermissions(ctx, tx)
const res = await this.provideTx(ctx, tx)
for (const tx of res[1]) {
await this.processPermissionsUpdatesFromTx(ctx, tx)
}
return res
}
handleBroadcast (tx: Tx[], targets?: string[]): Tx[] {
return this.provideHandleBroadcast(tx, targets)
}
protected async checkPermissions (ctx: SessionContext, tx: Tx): Promise<void> {
if (tx._class === core.class.TxApplyIf) {
const applyTx = tx as TxApplyIf
await Promise.all(applyTx.txes.map((t) => this.checkPermissions(ctx, t)))
return
}
if (tx._class === core.class.TxCollectionCUD) {
const actualTx = TxProcessor.extractTx(tx)
await this.checkPermissions(ctx, actualTx)
}
const cudTx = tx as TxCUD<Doc>
const h = this.storage.hierarchy
const isSpace = h.isDerived(cudTx.objectClass, core.class.Space)
// NOTE: in assumption that we want to control permissions for space itself on that space level
// and not on the system's spaces space level for now
const targetSpaceId = (isSpace ? cudTx.objectId : cudTx.objectSpace) as Ref<Space>
if (this.whitelistSpaces.has(targetSpaceId)) {
return
}
// NOTE: move this checking logic later to be defined in some server plugins?
// so they can contribute checks into the middleware for their custom permissions?
if (tx._class === core.class.TxRemoveDoc) {
await this.needPermission(ctx, targetSpaceId as Ref<TypedSpace>, core.permission.DeleteObject)
}
}
}

View File

@ -23,6 +23,10 @@ export class CommonPage {
await page.locator('div[class$="opup"] span[class*="label"]', { hasText: point }).click()
}
async checkDropdownHasNo (page: Page, item: string): Promise<void> {
await expect(page.locator('div.selectPopup span[class^="lines"]', { hasText: item })).not.toBeVisible()
}
async fillToDropdown (page: Page, input: string): Promise<void> {
await page.locator('div.popup input.search').fill(input)
await page.locator('div.popup button#channel-ok').click()

Some files were not shown because too many files have changed in this diff Show More