TSK-943:General Status support (#2842)

Signed-off-by: Andrey Sobolev <haiodo@gmail.com>
This commit is contained in:
Andrey Sobolev 2023-04-04 13:11:49 +07:00 committed by GitHub
parent 17ccbb1512
commit 585d82320e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
110 changed files with 2816 additions and 1819 deletions

View File

@ -28,7 +28,8 @@ export async function createUpdateSpaceKanban (
await ctx.with('find-or-update', {}, (ctx) =>
findOrUpdate(ctx, client, spaceId, task.class.State, sid, {
title: st.name,
ofAttribute: task.attribute.State,
name: st.name,
color: st.color,
rank
})
@ -37,8 +38,8 @@ export async function createUpdateSpaceKanban (
}
const doneStates = [
{ class: task.class.WonState, title: 'Won' },
{ class: task.class.LostState, title: 'Lost' }
{ class: task.class.WonState, name: 'Won' },
{ class: task.class.LostState, name: 'Lost' }
]
const doneStateRanks = genRanks(doneStates.length)
for (const st of doneStates) {
@ -49,10 +50,11 @@ export async function createUpdateSpaceKanban (
break
}
const sid = ('generated-' + spaceId + '.done-state.' + st.title.toLowerCase().replace(' ', '_')) as Ref<DoneState>
const sid = `generated-${spaceId}.done-state.${st.name.toLowerCase().replace(' ', '_')}` as Ref<DoneState>
await ctx.with('gen-done-state', {}, (ctx) =>
findOrUpdate(ctx, client, spaceId, st.class, sid, {
title: st.title,
ofAttribute: task.attribute.DoneState,
name: st.name,
rank
})
)

View File

@ -264,7 +264,8 @@ export function createModel (builder: Builder): void {
task.class.WonState,
core.space.Model,
{
title: board.string.Completed,
ofAttribute: task.attribute.DoneState,
name: board.string.Completed,
rank: '0'
},
board.state.Completed

View File

@ -13,26 +13,11 @@
// limitations under the License.
//
import { Board, Card } from '@hcengineering/board'
import {
AttachedDoc,
Class,
Doc,
DOMAIN_TX,
generateId,
Ref,
TxCollectionCUD,
TxCreateDoc,
TxCUD,
TxOperations,
TxProcessor,
TxUpdateDoc
} from '@hcengineering/core'
import { Ref, TxOperations } from '@hcengineering/core'
import { createOrUpdate, MigrateOperation, MigrationClient, MigrationUpgradeClient } from '@hcengineering/model'
import core from '@hcengineering/model-core'
import { DOMAIN_TAGS } from '@hcengineering/model-tags'
import { createKanbanTemplate, createSequence, DOMAIN_TASK } from '@hcengineering/model-task'
import tags, { TagElement, TagReference } from '@hcengineering/tags'
import { createKanbanTemplate, createSequence } from '@hcengineering/model-task'
import tags from '@hcengineering/tags'
import task, { createKanban, KanbanTemplate } from '@hcengineering/task'
import board from './plugin'
@ -77,12 +62,12 @@ async function createSpace (tx: TxOperations): Promise<void> {
async function createDefaultKanbanTemplate (tx: TxOperations): Promise<Ref<KanbanTemplate>> {
const defaultKanban = {
states: [
{ color: 9, title: 'To do' },
{ color: 9, title: 'Done' }
{ color: 9, name: 'To do' },
{ color: 9, name: 'Done' }
],
doneStates: [
{ isWon: true, title: 'Won' },
{ isWon: false, title: 'Lost' }
{ isWon: true, name: 'Won' },
{ isWon: false, name: 'Lost' }
]
}
@ -123,74 +108,7 @@ async function createDefaults (tx: TxOperations): Promise<void> {
)
}
interface CardLabel extends AttachedDoc {
title: string
color: number
isHidden?: boolean
}
async function migrateLabels (client: MigrationClient): Promise<void> {
const objectClass = 'board:class:CardLabel' as Ref<Class<Doc>>
const txes = await client.find<TxCUD<CardLabel>>(DOMAIN_TX, { objectClass }, { sort: { modifiedOn: 1 } })
const collectionTxes = await client.find<TxCollectionCUD<Board, CardLabel>>(
DOMAIN_TX,
{ 'tx.objectClass': objectClass },
{ sort: { modifiedOn: 1 } }
)
await Promise.all([...txes, ...collectionTxes].map(({ _id }) => client.delete<Doc>(DOMAIN_TX, _id)))
const removed = txes.filter(({ _class }) => _class === core.class.TxRemoveDoc).map(({ objectId }) => objectId)
const createTxes = txes.filter(
({ _class, objectId }) => _class === core.class.TxCreateDoc && !removed.includes(objectId)
) as unknown as TxCreateDoc<CardLabel>[]
const cardLabels = createTxes.map((createTx) => {
const cardLabel = TxProcessor.createDoc2Doc(createTx)
const updateTxes = collectionTxes
.map(({ tx }) => tx)
.filter(
({ _class, objectId }) => _class === core.class.TxUpdateDoc && objectId === createTx.objectId
) as unknown as TxUpdateDoc<CardLabel>[]
return updateTxes.reduce((label, updateTx) => TxProcessor.updateDoc2Doc(label, updateTx), cardLabel)
})
await Promise.all(
cardLabels.map((cardLabel) =>
client.create<TagElement>(DOMAIN_TAGS, {
_class: tags.class.TagElement,
space: tags.space.Tags,
targetClass: board.class.Card,
category: board.category.Other,
_id: cardLabel._id as unknown as Ref<TagElement>,
modifiedBy: cardLabel.modifiedBy,
modifiedOn: cardLabel.modifiedOn,
title: cardLabel.title,
color: cardLabel.color,
description: ''
})
)
)
const cards = (await client.find<Card>(DOMAIN_TASK, { _class: board.class.Card })).filter((card) =>
Array.isArray(card.labels)
)
for (const card of cards) {
const labelRefs = card.labels as unknown as Array<Ref<CardLabel>>
await client.update<Card>(DOMAIN_TASK, { _id: card._id }, { labels: labelRefs.length })
for (const labelRef of labelRefs) {
const cardLabel = cardLabels.find(({ _id }) => _id === labelRef)
if (cardLabel === undefined) continue
await client.create<TagReference>(DOMAIN_TAGS, {
_class: tags.class.TagReference,
attachedToClass: board.class.Card,
_id: generateId(),
attachedTo: card._id,
space: card.space,
tag: cardLabel._id as unknown as Ref<TagElement>,
title: cardLabel.title,
color: cardLabel.color,
modifiedBy: cardLabel.modifiedBy,
modifiedOn: cardLabel.modifiedOn,
collection: 'labels'
})
}
}
}
async function migrateLabels (client: MigrationClient): Promise<void> {}
export const boardOperation: MigrateOperation = {
async migrate (client: MigrationClient): Promise<void> {
await Promise.all([migrateLabels(client)])

View File

@ -60,6 +60,7 @@ import {
TVersion
} from './core'
import { TAccount, TSpace } from './security'
import { TStatus, TStatusCategory } from './status'
import { TUserStatus } from './transient'
import { TTx, TTxApplyIf, TTxCollectionCUD, TTxCreateDoc, TTxCUD, TTxMixin, TTxRemoveDoc, TTxUpdateDoc } from './tx'
@ -67,6 +68,7 @@ export * from './core'
export { coreOperation } from './migration'
export * from './security'
export * from './tx'
export * from './status'
export { core as default }
export function createModel (builder: Builder): void {
@ -114,7 +116,9 @@ export function createModel (builder: Builder): void {
TFullTextSearchContext,
TConfiguration,
TConfigurationElement,
TIndexConfiguration
TIndexConfiguration,
TStatus,
TStatusCategory
)
builder.createDoc(

57
models/core/src/status.ts Normal file
View File

@ -0,0 +1,57 @@
//
// Copyright © 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.
//
import { Attribute, DOMAIN_STATUS, DOMAIN_MODEL, Ref, Status, StatusCategory } from '@hcengineering/core'
import { Model, Prop, TypeRef, TypeString, UX } from '@hcengineering/model'
import { Asset, IntlString } from '@hcengineering/platform'
import core from './component'
import { TDoc } from './core'
// S T A T U S
@Model(core.class.Status, core.class.Doc, DOMAIN_STATUS)
@UX(core.string.Status)
export class TStatus extends TDoc implements Status {
// We attach to attribute, so we could distinguish between
ofAttribute!: Ref<Attribute<Status>>
@Prop(TypeRef(core.class.StatusCategory), core.string.StatusCategory)
category!: Ref<StatusCategory>
@Prop(TypeString(), core.string.Name)
name!: string
// @Prop(TypeNumber(), core.string.Color)
color!: number
@Prop(TypeString(), core.string.Description)
description!: string
// @Prop(TypeString(), core.string.Rank)
rank!: string
}
@Model(core.class.StatusCategory, core.class.Doc, DOMAIN_MODEL)
@UX(core.string.StatusCategory)
export class TStatusCategory extends TDoc implements StatusCategory {
// We attach to attribute, so we could distinguish between
ofAttribute!: Ref<Attribute<Status>>
icon!: Asset
label!: IntlString
color!: number
defaultStatusName!: string
order!: number
}

View File

@ -15,7 +15,7 @@
// To help typescript locate view plugin properly
import type { Employee } from '@hcengineering/contact'
import { Doc, FindOptions, IndexKind, Ref } from '@hcengineering/core'
import { FindOptions, IndexKind, Ref, SortingOrder } from '@hcengineering/core'
import { Customer, Funnel, Lead, leadId } from '@hcengineering/lead'
import {
Builder,
@ -33,10 +33,11 @@ import attachment from '@hcengineering/model-attachment'
import chunter from '@hcengineering/model-chunter'
import contact, { TContact } from '@hcengineering/model-contact'
import core from '@hcengineering/model-core'
import task, { actionTemplates, TSpaceWithStates, TTask } from '@hcengineering/model-task'
import view, { actionTemplates as viewTemplates, createAction } from '@hcengineering/model-view'
import task, { TSpaceWithStates, TTask, actionTemplates } from '@hcengineering/model-task'
import view, { createAction, actionTemplates as viewTemplates } from '@hcengineering/model-view'
import workbench from '@hcengineering/model-workbench'
import setting from '@hcengineering/setting'
import { ViewOptionsModel } from '@hcengineering/view'
import lead from './plugin'
@Model(lead.class.Funnel, task.class.SpaceWithStates)
@ -256,6 +257,31 @@ export function createModel (builder: Builder): void {
},
lead.viewlet.ListLead
)
const leadViewOptions: ViewOptionsModel = {
groupBy: ['state', 'assignee'],
orderBy: [
['state', SortingOrder.Ascending],
['modifiedOn', SortingOrder.Descending],
['dueDate', SortingOrder.Descending],
['rank', SortingOrder.Ascending]
],
other: [
{
key: 'shouldShowAll',
type: 'toggle',
defaultValue: false,
actionTarget: 'category',
action: view.function.ShowEmptyGroups,
label: view.string.ShowEmptyGroups
}
]
}
const lookupLeadOptions: FindOptions<Lead> = {
lookup: {
attachedTo: lead.mixin.Customer
}
}
builder.createDoc(
view.class.Viewlet,
@ -264,11 +290,11 @@ export function createModel (builder: Builder): void {
attachTo: lead.class.Lead,
descriptor: task.viewlet.Kanban,
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
options: {
lookup: {
attachedTo: lead.mixin.Customer
}
} as FindOptions<Doc>, // TODO: fix
viewOptions: {
...leadViewOptions,
groupDepth: 1
},
options: lookupLeadOptions,
config: []
},
lead.viewlet.KanbanLead

View File

@ -62,16 +62,16 @@ async function createSpace (tx: TxOperations): Promise<void> {
async function createDefaultKanbanTemplate (tx: TxOperations): Promise<Ref<KanbanTemplate>> {
const defaultKanban = {
states: [
{ color: 9, title: 'Incoming' },
{ color: 10, title: 'Negotation' },
{ color: 1, title: 'Offer preparing' },
{ color: 0, title: 'Make a decision' },
{ color: 11, title: 'Contract conclusion' },
{ color: 9, title: 'Done' }
{ color: 9, name: 'Incoming' },
{ color: 10, name: 'Negotation' },
{ color: 1, name: 'Offer preparing' },
{ color: 0, name: 'Make a decision' },
{ color: 11, name: 'Contract conclusion' },
{ color: 9, name: 'Done' }
],
doneStates: [
{ isWon: true, title: 'Won' },
{ isWon: false, title: 'Lost' }
{ isWon: true, name: 'Won' },
{ isWon: false, name: 'Lost' }
]
}

View File

@ -14,7 +14,7 @@
//
import type { Employee, Organization } from '@hcengineering/contact'
import { Doc, FindOptions, IndexKind, Lookup, Ref, Timestamp } from '@hcengineering/core'
import { IndexKind, Lookup, Ref, SortingOrder, Timestamp } from '@hcengineering/core'
import {
Builder,
Collection,
@ -41,9 +41,9 @@ import presentation from '@hcengineering/model-presentation'
import tags from '@hcengineering/model-tags'
import task, { actionTemplates, DOMAIN_TASK, TSpaceWithStates, TTask } from '@hcengineering/model-task'
import tracker from '@hcengineering/model-tracker'
import notification from '@hcengineering/notification'
import view, { actionTemplates as viewTemplates, createAction } from '@hcengineering/model-view'
import workbench, { Application, createNavigateAction } from '@hcengineering/model-workbench'
import notification from '@hcengineering/notification'
import { getEmbeddedLabel, IntlString } from '@hcengineering/platform'
import {
Applicant,
@ -55,7 +55,7 @@ import {
VacancyList
} from '@hcengineering/recruit'
import setting from '@hcengineering/setting'
import { KeyBinding } from '@hcengineering/view'
import { KeyBinding, ViewOptionsModel } from '@hcengineering/view'
import recruit from './plugin'
import { createReviewModel, reviewTableConfig, reviewTableOptions } from './review'
import { TOpinion, TReview } from './review-model'
@ -281,6 +281,7 @@ export function createModel (builder: Builder): void {
label: recruit.string.Applications,
createLabel: recruit.string.ApplicationCreateLabel,
createComponent: recruit.component.CreateApplication,
descriptors: [view.viewlet.Table, task.viewlet.Kanban, recruit.viewlet.ApplicantDashboard],
baseQuery: {
doneState: null
}
@ -408,7 +409,8 @@ export function createModel (builder: Builder): void {
{
attachTo: recruit.class.Applicant,
descriptor: view.viewlet.Table,
config: ['', 'attachedTo', 'state', 'doneState', 'modifiedOn']
config: ['', 'attachedTo', 'state', 'doneState', 'modifiedOn'],
variant: 'short'
},
recruit.viewlet.VacancyApplicationsShort
)
@ -588,6 +590,27 @@ export function createModel (builder: Builder): void {
}
}
const applicantViewOptions: ViewOptionsModel = {
groupBy: ['state', 'doneState', 'assignee'],
orderBy: [
['state', SortingOrder.Ascending],
['doneState', SortingOrder.Ascending],
['modifiedOn', SortingOrder.Descending],
['dueDate', SortingOrder.Descending],
['rank', SortingOrder.Ascending]
],
other: [
{
key: 'shouldShowAll',
type: 'toggle',
defaultValue: false,
actionTarget: 'category',
action: view.function.ShowEmptyGroups,
label: view.string.ShowEmptyGroups
}
]
}
builder.createDoc(
view.class.Viewlet,
core.space.Model,
@ -595,9 +618,13 @@ export function createModel (builder: Builder): void {
attachTo: recruit.class.Applicant,
descriptor: task.viewlet.Kanban,
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
viewOptions: {
...applicantViewOptions,
groupDepth: 1
},
options: {
lookup: applicantKanbanLookup
} as FindOptions<Doc>, // TODO: fix
},
config: []
},
recruit.viewlet.ApplicantKanban

View File

@ -232,14 +232,14 @@ async function createDefaults (tx: TxOperations): Promise<void> {
async function createDefaultKanbanTemplate (tx: TxOperations): Promise<Ref<KanbanTemplate>> {
const defaultKanban = {
states: [
{ color: 9, title: 'HR Interview' },
{ color: 10, title: 'Technical Interview' },
{ color: 1, title: 'Test task' },
{ color: 0, title: 'Offer' }
{ color: 9, name: 'HR Interview' },
{ color: 10, name: 'Technical Interview' },
{ color: 1, name: 'Test task' },
{ color: 0, name: 'Offer' }
],
doneStates: [
{ isWon: true, title: 'Won' },
{ isWon: false, title: 'Lost' }
{ isWon: true, name: 'Won' },
{ isWon: false, name: 'Lost' }
]
}

View File

@ -15,17 +15,4 @@
import { Builder } from '@hcengineering/model'
import core from '@hcengineering/core'
import serverTask from '@hcengineering/server-task'
import task from '@hcengineering/task'
import serverNotification from '@hcengineering/server-notification'
export function createModel (builder: Builder): void {
builder.mixin(task.class.Issue, core.class.Class, serverNotification.mixin.HTMLPresenter, {
presenter: serverTask.function.IssueHTMLPresenter
})
builder.mixin(task.class.Issue, core.class.Class, serverNotification.mixin.TextPresenter, {
presenter: serverTask.function.IssueTextPresenter
})
}
export function createModel (builder: Builder): void {}

View File

@ -15,7 +15,7 @@
import type { Employee } from '@hcengineering/contact'
import contact from '@hcengineering/contact'
import { Arr, Class, Doc, Domain, FindOptions, IndexKind, Ref, Space, Timestamp } from '@hcengineering/core'
import { Arr, Attribute, Class, Doc, Domain, IndexKind, Ref, Space, Status, Timestamp } from '@hcengineering/core'
import {
Builder,
Collection,
@ -32,25 +32,20 @@ import {
TypeString,
UX
} from '@hcengineering/model'
import attachment from '@hcengineering/model-attachment'
import chunter from '@hcengineering/model-chunter'
import core, { TAttachedDoc, TClass, TDoc, TSpace } from '@hcengineering/model-core'
import core, { TAttachedDoc, TClass, TDoc, TSpace, TStatus } from '@hcengineering/model-core'
import view, { actionTemplates as viewTemplates, createAction, template } from '@hcengineering/model-view'
import notification from '@hcengineering/notification'
import { IntlString } from '@hcengineering/platform'
import tags from '@hcengineering/tags'
import {
DOMAIN_STATE,
DoneState,
DoneStateTemplate,
Issue,
Kanban,
KanbanCard,
KanbanTemplate,
KanbanTemplateSpace,
LostState,
LostStateTemplate,
Project,
Sequence,
State,
StateTemplate,
@ -68,25 +63,15 @@ export { default } from './plugin'
export const DOMAIN_TASK = 'task' as Domain
export const DOMAIN_KANBAN = 'kanban' as Domain
@Model(task.class.State, core.class.Doc, DOMAIN_STATE, [task.interface.DocWithRank])
@Model(task.class.State, core.class.Status)
@UX(task.string.TaskState, task.icon.TaskState, undefined, 'rank')
export class TState extends TDoc implements State {
@Prop(TypeString(), task.string.TaskStateTitle)
title!: string
color!: number
declare rank: string
export class TState extends TStatus implements State {
isArchived!: boolean
}
@Model(task.class.DoneState, core.class.Doc, DOMAIN_STATE, [task.interface.DocWithRank])
@UX(task.string.TaskStateDone, task.icon.TaskState, undefined, 'title')
export class TDoneState extends TDoc implements DoneState {
@Prop(TypeString(), task.string.TaskStateTitle)
title!: string
declare rank: string
}
@Model(task.class.DoneState, core.class.Status)
@UX(task.string.TaskStateDone, task.icon.TaskState, undefined, 'name')
export class TDoneState extends TStatus implements DoneState {}
@Model(task.class.WonState, task.class.DoneState)
export class TWonState extends TDoneState implements WonState {}
@ -99,13 +84,13 @@ export class TLostState extends TDoneState implements LostState {}
*
* No domain is specified, since pure Tasks could not exists
*/
@Model(task.class.Task, core.class.AttachedDoc, DOMAIN_TASK, [task.interface.DocWithRank])
@Model(task.class.Task, core.class.AttachedDoc, DOMAIN_TASK)
@UX(task.string.Task, task.icon.Task, task.string.Task)
export class TTask extends TAttachedDoc implements Task {
@Prop(TypeRef(task.class.State), task.string.TaskState)
@Prop(TypeRef(task.class.State), task.string.TaskState, { _id: task.attribute.State })
state!: Ref<State>
@Prop(TypeRef(task.class.DoneState), task.string.TaskStateDone)
@Prop(TypeRef(task.class.DoneState), task.string.TaskStateDone, { _id: task.attribute.DoneState })
doneState!: Ref<DoneState> | null
@Prop(TypeString(), task.string.TaskNumber)
@ -124,14 +109,11 @@ export class TTask extends TAttachedDoc implements Task {
declare rank: string
// @Prop(Collection(task.class.TodoItem), task.string.Todos)
// todoItems!: number
@Prop(Collection(tags.class.TagReference, task.string.TaskLabels), task.string.TaskLabels)
labels!: number
}
@Model(task.class.TodoItem, core.class.AttachedDoc, DOMAIN_TASK, [task.interface.DocWithRank])
@Model(task.class.TodoItem, core.class.AttachedDoc, DOMAIN_TASK)
@UX(task.string.Todo)
export class TTodoItem extends TAttachedDoc implements TodoItem {
@Prop(TypeMarkup(), task.string.TodoName, task.icon.Task)
@ -153,38 +135,6 @@ export class TTodoItem extends TAttachedDoc implements TodoItem {
declare rank: string
}
@Model(task.class.SpaceWithStates, core.class.Space)
export class TSpaceWithStates extends TSpace {}
@Model(task.class.Project, task.class.SpaceWithStates)
@UX(task.string.ProjectName, task.icon.Task)
export class TProject extends TSpaceWithStates implements Project {}
@Model(task.class.Issue, task.class.Task, DOMAIN_TASK)
@UX(task.string.Task, task.icon.Task, task.string.Task, 'number')
export class TIssue extends TTask implements Issue {
// We need to declare, to provide property with label
@Prop(TypeRef(core.class.Doc), task.string.TaskParent)
declare attachedTo: Ref<Doc>
@Prop(TypeString(), task.string.IssueName)
@Index(IndexKind.FullText)
name!: string
@Prop(TypeMarkup(), task.string.Description)
@Index(IndexKind.FullText)
description!: string
@Prop(Collection(chunter.class.Comment), task.string.TaskComments)
comments!: number
@Prop(Collection(attachment.class.Attachment), attachment.string.Attachments, { shortLabel: attachment.string.Files })
attachments!: number
@Prop(TypeRef(contact.class.Employee), task.string.TaskAssignee)
declare assignee: Ref<Employee> | null
}
@Mixin(task.mixin.KanbanCard, core.class.Class)
export class TKanbanCard extends TClass implements KanbanCard {
card!: AnyComponent
@ -197,6 +147,9 @@ export class TKanban extends TDoc implements Kanban {
attachedTo!: Ref<Space>
}
@Model(task.class.SpaceWithStates, core.class.Space)
export class TSpaceWithStates extends TSpace {}
@Model(task.class.KanbanTemplateSpace, core.class.Space)
export class TKanbanTemplateSpace extends TSpace implements KanbanTemplateSpace {
name!: IntlString
@ -205,10 +158,13 @@ export class TKanbanTemplateSpace extends TSpace implements KanbanTemplateSpace
editor!: AnyComponent
}
@Model(task.class.StateTemplate, core.class.AttachedDoc, DOMAIN_KANBAN, [task.interface.DocWithRank])
@Model(task.class.StateTemplate, core.class.AttachedDoc, DOMAIN_KANBAN)
export class TStateTemplate extends TAttachedDoc implements StateTemplate {
// We attach to attribute, so we could distinguish between
ofAttribute!: Ref<Attribute<Status>>
@Prop(TypeString(), task.string.StateTemplateTitle)
title!: string
name!: string
@Prop(TypeString(), task.string.StateTemplateColor)
color!: number
@ -216,10 +172,13 @@ export class TStateTemplate extends TAttachedDoc implements StateTemplate {
declare rank: string
}
@Model(task.class.DoneStateTemplate, core.class.AttachedDoc, DOMAIN_KANBAN, [task.interface.DocWithRank])
@Model(task.class.DoneStateTemplate, core.class.AttachedDoc, DOMAIN_KANBAN)
export class TDoneStateTemplate extends TAttachedDoc implements DoneStateTemplate {
// We attach to attribute, so we could distinguish between
ofAttribute!: Ref<Attribute<Status>>
@Prop(TypeString(), task.string.StateTemplateTitle)
title!: string
name!: string
declare rank: string
}
@ -334,10 +293,8 @@ export function createModel (builder: Builder): void {
TKanbanTemplate,
TSequence,
TTask,
TSpaceWithStates,
TProject,
TIssue,
TTodoItem
TTodoItem,
TSpaceWithStates
)
builder.createDoc(
@ -351,33 +308,14 @@ export function createModel (builder: Builder): void {
task.viewlet.StatusTable
)
builder.createDoc(
view.class.Viewlet,
core.space.Model,
{
attachTo: task.class.Issue,
descriptor: task.viewlet.StatusTable,
config: ['', 'name', 'assignee', 'state', 'doneState', 'attachments', 'comments', 'modifiedOn']
},
task.viewlet.TableIssue
)
builder.mixin(task.class.Task, core.class.Class, view.mixin.ObjectPresenter, {
presenter: view.component.ObjectPresenter
})
builder.mixin(task.class.Issue, core.class.Class, view.mixin.ObjectPresenter, {
presenter: task.component.TaskPresenter
})
builder.mixin(task.class.KanbanTemplate, core.class.Class, view.mixin.ObjectPresenter, {
presenter: task.component.KanbanTemplatePresenter
})
builder.mixin(task.class.Issue, core.class.Class, view.mixin.ObjectEditor, {
editor: task.component.EditIssue
})
builder.mixin(task.class.Task, core.class.Class, view.mixin.ObjectEditorHeader, {
editor: task.component.TaskHeader
})
@ -387,30 +325,6 @@ export function createModel (builder: Builder): void {
fields: ['assignee']
})
builder.createDoc(
view.class.Viewlet,
core.space.Model,
{
attachTo: task.class.Issue,
descriptor: task.viewlet.Kanban,
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
options: {
lookup: {
assignee: contact.class.Employee,
_id: {
todoItems: task.class.TodoItem
}
}
} as FindOptions<Doc>,
config: []
},
task.viewlet.KanbanIssue
)
builder.mixin(task.class.Issue, core.class.Class, task.mixin.KanbanCard, {
card: task.component.KanbanCard
})
builder.createDoc(
view.class.ActionCategory,
core.space.Model,

View File

@ -13,13 +13,18 @@
// limitations under the License.
//
import { Class, Doc, Ref, Space, TxOperations } from '@hcengineering/core'
import { Class, Doc, Domain, Ref, Space, TxOperations, DOMAIN_STATUS } from '@hcengineering/core'
import { createOrUpdate, MigrateOperation, MigrationClient, MigrationUpgradeClient } from '@hcengineering/model'
import core from '@hcengineering/model-core'
import { KanbanTemplate, StateTemplate, DoneStateTemplate, genRanks, createKanban } from '@hcengineering/task'
import { DOMAIN_TASK } from '.'
import task from './plugin'
import tags from '@hcengineering/model-tags'
import { createKanban, DoneStateTemplate, genRanks, KanbanTemplate, StateTemplate } from '@hcengineering/task'
import { DOMAIN_TASK, DOMAIN_KANBAN } from '.'
import task from './plugin'
/**
* @public
*/
export const DOMAIN_STATE = 'state' as Domain
/**
* @public
@ -30,8 +35,8 @@ export interface KanbanTemplateData {
title: KanbanTemplate['title']
description?: string
shortDescription?: string
states: Pick<StateTemplate, 'title' | 'color'>[]
doneStates: (Pick<DoneStateTemplate, 'title'> & { isWon: boolean })[]
states: Pick<StateTemplate, 'name' | 'color'>[]
doneStates: (Pick<DoneStateTemplate, 'name'> & { isWon: boolean })[]
}
/**
@ -79,8 +84,9 @@ export async function createKanbanTemplate (
task.class.KanbanTemplate,
'doneStatesC',
{
ofAttribute: task.attribute.DoneState,
rank: doneStateRanks[i],
title: st.title
name: st.name
}
)
)
@ -90,8 +96,9 @@ export async function createKanbanTemplate (
await Promise.all(
data.states.map((st, i) =>
client.addCollection(task.class.StateTemplate, data.space, data.kanbanId, task.class.KanbanTemplate, 'statesC', {
ofAttribute: task.attribute.State,
rank: stateRanks[i],
title: st.title,
name: st.name,
color: st.color
})
)
@ -100,26 +107,6 @@ export async function createKanbanTemplate (
return tmpl
}
async function createDefaultProject (tx: TxOperations): Promise<void> {
const createTx = await tx.findOne(core.class.TxCreateDoc, {
objectId: task.space.TasksPublic
})
if (createTx === undefined) {
await tx.createDoc(
task.class.Project,
core.space.Space,
{
name: 'public',
description: 'Public tasks',
private: false,
archived: false,
members: []
},
task.space.TasksPublic
)
}
}
async function createDefaultSequence (tx: TxOperations): Promise<void> {
const current = await tx.findOne(core.class.Space, {
_id: task.space.Sequence
@ -143,15 +130,15 @@ async function createDefaultSequence (tx: TxOperations): Promise<void> {
async function createDefaultKanbanTemplate (tx: TxOperations): Promise<Ref<KanbanTemplate>> {
const defaultKanban = {
states: [
{ color: 9, title: 'Open' },
{ color: 10, title: 'In Progress' },
{ color: 1, title: 'Under review' },
{ color: 0, title: 'Done' },
{ color: 11, title: 'Invalid' }
{ color: 9, name: 'Open' },
{ color: 10, name: 'In Progress' },
{ color: 1, name: 'Under review' },
{ color: 0, name: 'Done' },
{ color: 11, name: 'Invalid' }
],
doneStates: [
{ isWon: true, title: 'Won' },
{ isWon: false, title: 'Lost' }
{ isWon: true, name: 'Won' },
{ isWon: false, name: 'Lost' }
]
}
@ -199,8 +186,6 @@ async function createSpace (tx: TxOperations): Promise<void> {
async function createDefaults (tx: TxOperations): Promise<void> {
await createSpace(tx)
await createDefaultSequence(tx)
await createDefaultProject(tx)
await createSequence(tx, task.class.Issue)
await createDefaultKanban(tx)
}
@ -219,6 +204,37 @@ async function migrateTodoItems (client: MigrationClient): Promise<void> {
export const taskOperation: MigrateOperation = {
async migrate (client: MigrationClient): Promise<void> {
await Promise.all([migrateTodoItems(client)])
const stateClasses = client.hierarchy.getDescendants(task.class.State)
const doneStateClasses = client.hierarchy.getDescendants(task.class.DoneState)
const stateTemplateClasses = client.hierarchy.getDescendants(task.class.StateTemplate)
const doneStateTemplatesClasses = client.hierarchy.getDescendants(task.class.DoneStateTemplate)
await client.move(DOMAIN_STATE, { _class: { $in: [...stateClasses, ...doneStateClasses] } }, DOMAIN_STATUS)
await client.update(
DOMAIN_STATUS,
{ _class: { $in: stateClasses }, ofAttribute: { $exists: false } },
{ ofAttribute: task.attribute.State }
)
await client.update(
DOMAIN_STATUS,
{ _class: { $in: doneStateClasses }, ofAttribute: { $exists: false } },
{ ofAttribute: task.attribute.DoneState }
)
await client.update(
DOMAIN_STATUS,
{ _class: { $in: [...stateClasses, ...doneStateClasses] }, title: { $exists: true } },
{ $rename: { title: 'name' } }
)
await client.update(
DOMAIN_KANBAN,
{ _class: { $in: [...stateTemplateClasses, ...doneStateTemplatesClasses] }, title: { $exists: true } },
{ $rename: { title: 'name' } }
)
},
async upgrade (client: MigrationUpgradeClient): Promise<void> {
const tx = new TxOperations(client, core.account.System)

View File

@ -43,7 +43,6 @@ export default mergeIds(taskId, task, {
},
component: {
ProjectView: '' as AnyComponent,
CreateProject: '' as AnyComponent,
EditIssue: '' as AnyComponent,
TaskPresenter: '' as AnyComponent,
KanbanTemplatePresenter: '' as AnyComponent,

View File

@ -47,30 +47,29 @@ import {
} from '@hcengineering/model'
import attachment from '@hcengineering/model-attachment'
import chunter from '@hcengineering/model-chunter'
import core, { DOMAIN_SPACE, TAttachedDoc, TDoc, TSpace, TType } from '@hcengineering/model-core'
import core, { DOMAIN_SPACE, TAttachedDoc, TDoc, TSpace, TStatus, TType } from '@hcengineering/model-core'
import view, { actionTemplates, classPresenter, createAction } from '@hcengineering/model-view'
import workbench, { createNavigateAction } from '@hcengineering/model-workbench'
import notification from '@hcengineering/notification'
import { Asset, IntlString } from '@hcengineering/platform'
import { IntlString } from '@hcengineering/platform'
import setting from '@hcengineering/setting'
import tags, { TagElement } from '@hcengineering/tags'
import task from '@hcengineering/task'
import {
Component,
ComponentStatus,
Issue,
IssueChildInfo,
IssueParentInfo,
IssuePriority,
IssueStatus,
IssueStatusCategory,
IssueTemplate,
IssueTemplateChild,
Component,
ComponentStatus,
Project,
Scrum,
ScrumRecord,
Sprint,
SprintStatus,
Project,
TimeReportDayType,
TimeSpendReport,
trackerId
@ -89,34 +88,8 @@ export const DOMAIN_TRACKER = 'tracker' as Domain
/**
* @public
*/
@Model(tracker.class.IssueStatus, core.class.AttachedDoc, DOMAIN_TRACKER)
export class TIssueStatus extends TAttachedDoc implements IssueStatus {
@Index(IndexKind.Indexed)
name!: string
description?: string
color?: number
@Prop(TypeRef(tracker.class.IssueStatusCategory), tracker.string.StatusCategory)
@Index(IndexKind.Indexed)
category!: Ref<IssueStatusCategory>
@Prop(TypeString(), tracker.string.Rank)
@Hidden()
rank!: string
}
/**
* @public
*/
@Model(tracker.class.IssueStatusCategory, core.class.Doc, DOMAIN_MODEL)
export class TIssueStatusCategory extends TDoc implements IssueStatusCategory {
label!: IntlString
icon!: Asset
color!: number
defaultStatusName!: string
order!: number
}
@Model(tracker.class.IssueStatus, core.class.Status)
export class TIssueStatus extends TStatus implements IssueStatus {}
/**
* @public
@ -207,7 +180,7 @@ export class TIssue extends TAttachedDoc implements Issue {
@Index(IndexKind.FullText)
description!: Markup
@Prop(TypeRef(tracker.class.IssueStatus), tracker.string.Status)
@Prop(TypeRef(tracker.class.IssueStatus), tracker.string.Status, { _id: tracker.attribute.IssueStatus })
@Index(IndexKind.Indexed)
status!: Ref<IssueStatus>
@ -498,7 +471,6 @@ export function createModel (builder: Builder): void {
TIssue,
TIssueTemplate,
TIssueStatus,
TIssueStatusCategory,
TTypeIssuePriority,
TTypeComponentStatus,
TSprint,
@ -773,9 +745,10 @@ export function createModel (builder: Builder): void {
)
builder.createDoc(
tracker.class.IssueStatusCategory,
core.class.StatusCategory,
core.space.Model,
{
ofAttribute: tracker.attribute.IssueStatus,
label: tracker.string.CategoryBacklog,
icon: tracker.icon.CategoryBacklog,
color: 12,
@ -786,9 +759,10 @@ export function createModel (builder: Builder): void {
)
builder.createDoc(
tracker.class.IssueStatusCategory,
core.class.StatusCategory,
core.space.Model,
{
ofAttribute: tracker.attribute.IssueStatus,
label: tracker.string.CategoryUnstarted,
icon: tracker.icon.CategoryUnstarted,
color: 13,
@ -799,9 +773,10 @@ export function createModel (builder: Builder): void {
)
builder.createDoc(
tracker.class.IssueStatusCategory,
core.class.StatusCategory,
core.space.Model,
{
ofAttribute: tracker.attribute.IssueStatus,
label: tracker.string.CategoryStarted,
icon: tracker.icon.CategoryStarted,
color: 14,
@ -812,9 +787,10 @@ export function createModel (builder: Builder): void {
)
builder.createDoc(
tracker.class.IssueStatusCategory,
core.class.StatusCategory,
core.space.Model,
{
ofAttribute: tracker.attribute.IssueStatus,
label: tracker.string.CategoryCompleted,
icon: tracker.icon.CategoryCompleted,
color: 15,
@ -825,9 +801,10 @@ export function createModel (builder: Builder): void {
)
builder.createDoc(
tracker.class.IssueStatusCategory,
core.class.StatusCategory,
core.space.Model,
{
ofAttribute: tracker.attribute.IssueStatus,
label: tracker.string.CategoryCanceled,
icon: tracker.icon.CategoryCanceled,
color: 16,
@ -870,6 +847,14 @@ export function createModel (builder: Builder): void {
presenters: [tracker.component.IssueStatistics]
})
builder.mixin(tracker.class.IssueStatus, core.class.Class, view.mixin.ObjectPresenter, {
presenter: tracker.component.StatusPresenter
})
builder.mixin(tracker.class.IssueStatus, core.class.Class, view.mixin.AttributePresenter, {
presenter: tracker.component.StatusRefPresenter
})
builder.mixin(tracker.class.IssueStatus, core.class.Class, view.mixin.SortFuncs, {
func: tracker.function.IssueStatusSort
})
@ -894,14 +879,6 @@ export function createModel (builder: Builder): void {
component: view.component.ValueFilter
})
builder.mixin(tracker.class.IssueStatus, core.class.Class, view.mixin.ObjectPresenter, {
presenter: tracker.component.StatusPresenter
})
builder.mixin(tracker.class.IssueStatus, core.class.Class, view.mixin.AttributePresenter, {
presenter: tracker.component.StatusRefPresenter
})
builder.mixin(tracker.class.TypeIssuePriority, core.class.Class, view.mixin.AttributePresenter, {
presenter: tracker.component.PriorityRefPresenter
})
@ -947,10 +924,6 @@ export function createModel (builder: Builder): void {
fields: ['assignee']
})
builder.mixin(tracker.class.IssueStatus, core.class.Class, view.mixin.AllValuesFunc, {
func: tracker.function.GetAllStatuses
})
builder.mixin(tracker.class.TypeIssuePriority, core.class.Class, view.mixin.AllValuesFunc, {
func: tracker.function.GetAllPriority
})
@ -1342,7 +1315,7 @@ export function createModel (builder: Builder): void {
const statusOptions: FindOptions<IssueStatus> = {
lookup: {
category: tracker.class.IssueStatusCategory
category: core.class.StatusCategory
},
sort: { rank: SortingOrder.Ascending }
}
@ -1377,6 +1350,9 @@ export function createModel (builder: Builder): void {
attribute: 'status',
_class: tracker.class.IssueStatus,
placeholder: tracker.string.SetStatus,
query: {
ofAttribute: tracker.attribute.IssueStatus
},
fillQuery: {
space: 'space'
},

View File

@ -21,11 +21,13 @@ import core, {
generateId,
Ref,
SortingOrder,
StatusCategory,
TxCollectionCUD,
TxCreateDoc,
TxOperations,
TxResult,
TxUpdateDoc
TxUpdateDoc,
DOMAIN_STATUS
} from '@hcengineering/core'
import { createOrUpdate, MigrateOperation, MigrationClient, MigrationUpgradeClient } from '@hcengineering/model'
import { DOMAIN_SPACE } from '@hcengineering/model-core'
@ -35,7 +37,6 @@ import {
genRanks,
Issue,
IssueStatus,
IssueStatusCategory,
IssueTemplate,
IssueTemplateChild,
Project,
@ -55,9 +56,9 @@ enum DeprecatedIssueStatus {
interface CreateProjectIssueStatusesArgs {
tx: TxOperations
projectId: Ref<Project>
categories: IssueStatusCategory[]
categories: StatusCategory[]
defaultStatusId?: Ref<IssueStatus>
defaultCategoryId?: Ref<IssueStatusCategory>
defaultCategoryId?: Ref<StatusCategory>
}
const categoryByDeprecatedIssueStatus = {
@ -81,15 +82,14 @@ async function createProjectIssueStatuses ({
const { _id: category, defaultStatusName } = statusCategory
const rank = issueStatusRanks[i]
await tx.addCollection(
tracker.class.IssueStatus,
attachedTo,
attachedTo,
tracker.class.Project,
'issueStatuses',
{ name: defaultStatusName, category, rank },
category === defaultCategoryId ? defaultStatusId : undefined
)
if (defaultStatusName !== undefined) {
await tx.createDoc(
tracker.class.IssueStatus,
attachedTo,
{ ofAttribute: tracker.attribute.IssueStatus, name: defaultStatusName, category, rank },
category === defaultCategoryId ? defaultStatusId : undefined
)
}
}
}
@ -105,11 +105,7 @@ async function createDefaultProject (tx: TxOperations): Promise<void> {
// Create new if not deleted by customers.
if (current === undefined && currentDeleted === undefined) {
const defaultStatusId: Ref<IssueStatus> = generateId()
const categories = await tx.findAll(
tracker.class.IssueStatusCategory,
{},
{ sort: { order: SortingOrder.Ascending } }
)
const categories = await tx.findAll(core.class.StatusCategory, {}, { sort: { order: SortingOrder.Ascending } })
await tx.createDoc<Project>(
tracker.class.Project,
@ -137,7 +133,7 @@ async function fixProjectIssueStatusesOrder (tx: TxOperations, project: Project)
const statuses = await tx.findAll(
tracker.class.IssueStatus,
{ attachedTo: project._id },
{ lookup: { category: tracker.class.IssueStatusCategory } }
{ lookup: { category: core.class.StatusCategory } }
)
statuses.sort((a, b) => (a.$lookup?.category?.order ?? 0) - (b.$lookup?.category?.order ?? 0))
const issueStatusRanks = genRanks(statuses.length)
@ -170,11 +166,7 @@ async function upgradeProjectIssueStatuses (tx: TxOperations): Promise<void> {
const projects = await tx.findAll(tracker.class.Project, { issueStatuses: undefined })
if (projects.length > 0) {
const categories = await tx.findAll(
tracker.class.IssueStatusCategory,
{},
{ sort: { order: SortingOrder.Ascending } }
)
const categories = await tx.findAll(core.class.StatusCategory, {}, { sort: { order: SortingOrder.Ascending } })
for (const project of projects) {
const defaultStatusId: Ref<IssueStatus> = generateId()
@ -754,6 +746,22 @@ export const trackerOperation: MigrateOperation = {
await fillRank(client)
await renameProject(client)
await setCreate(client)
// Move all status objects into status domain
await client.move(
DOMAIN_TRACKER,
{
_class: tracker.class.IssueStatus
},
DOMAIN_STATUS
)
await client.update(
DOMAIN_STATUS,
{ _class: tracker.class.IssueStatus, ofAttribute: { $exists: false } },
{
ofAttribute: tracker.attribute.IssueStatus
}
)
},
async upgrade (client: MigrationUpgradeClient): Promise<void> {
const tx = new TxOperations(client, core.account.System)

View File

@ -64,7 +64,8 @@ import type {
ViewOptions,
AllValuesFunc,
LinkProvider,
ObjectPanel
ObjectPanel,
AllValuesFuncGetter
} from '@hcengineering/view'
import view from './plugin'
@ -220,9 +221,7 @@ export class TSortFuncs extends TClass implements ClassSortFuncs {
@Mixin(view.mixin.AllValuesFunc, core.class.Class)
export class TAllValuesFunc extends TClass implements AllValuesFunc {
func!: Resource<
(space: Ref<Space> | undefined, onUpdate: () => void, queryId: Ref<Doc>) => Promise<any[] | undefined>
>
func!: Resource<AllValuesFuncGetter>
}
@Model(view.class.ViewletPreference, preference.class.Preference)
@ -774,6 +773,18 @@ export function createModel (builder: Builder): void {
},
view.action.Open
)
builder.mixin(core.class.Status, core.class.Class, view.mixin.SortFuncs, {
func: view.function.StatusSort
})
builder.mixin(core.class.Status, core.class.Class, view.mixin.ObjectPresenter, {
presenter: view.component.StatusPresenter
})
builder.mixin(core.class.Status, core.class.Class, view.mixin.AttributePresenter, {
presenter: view.component.StatusRefPresenter
})
}
export default view

View File

@ -67,7 +67,9 @@ export default mergeIds(viewId, view, {
ListView: '' as AnyComponent,
IndexedDocumentPreview: '' as AnyComponent,
SpaceRefPresenter: '' as AnyComponent,
EnumPresenter: '' as AnyComponent
EnumPresenter: '' as AnyComponent,
StatusPresenter: '' as AnyComponent,
StatusRefPresenter: '' as AnyComponent
},
string: {
Table: '' as IntlString,

View File

@ -23,13 +23,17 @@ import type {
BlobData,
Class,
Collection,
Configuration,
ConfigurationElement,
Doc,
DocIndexState,
FullTextSearchContext,
Enum,
EnumOf,
FullTextData,
FullTextSearchContext,
Hyperlink,
IndexingConfiguration,
IndexStageState,
Interface,
Obj,
PluginConfiguration,
@ -39,12 +43,9 @@ import type {
Space,
Timestamp,
Type,
UserStatus,
Configuration,
ConfigurationElement,
IndexStageState,
IndexingConfiguration
UserStatus
} from './classes'
import { Status, StatusCategory } from './status'
import type {
Tx,
TxApplyIf,
@ -54,7 +55,8 @@ import type {
TxMixin,
TxModelUpgrade,
TxRemoveDoc,
TxUpdateDoc
TxUpdateDoc,
TxWorkspaceEvent
} from './tx'
/**
@ -78,6 +80,7 @@ export default plugin(coreId, {
Attribute: '' as Ref<Class<AnyAttribute>>,
Tx: '' as Ref<Class<Tx>>,
TxModelUpgrade: '' as Ref<Class<TxModelUpgrade>>,
TxWorkspaceEvent: '' as Ref<Class<TxWorkspaceEvent>>,
TxApplyIf: '' as Ref<Class<TxApplyIf>>,
TxCUD: '' as Ref<Class<TxCUD<Doc>>>,
TxCreateDoc: '' as Ref<Class<TxCreateDoc<Doc>>>,
@ -111,7 +114,10 @@ export default plugin(coreId, {
DocIndexState: '' as Ref<Class<DocIndexState>>,
IndexStageState: '' as Ref<Class<IndexStageState>>,
Configuration: '' as Ref<Class<Configuration>>
Configuration: '' as Ref<Class<Configuration>>,
Status: '' as Ref<Class<Status>>,
StatusCategory: '' as Ref<Class<StatusCategory>>
},
mixin: {
FullTextSearchContext: '' as Ref<Mixin<FullTextSearchContext>>,
@ -161,6 +167,8 @@ export default plugin(coreId, {
Private: '' as IntlString,
Object: '' as IntlString,
System: '' as IntlString,
CreatedBy: '' as IntlString
CreatedBy: '' as IntlString,
Status: '' as IntlString,
StatusCategory: '' as IntlString
}
})

View File

@ -31,6 +31,7 @@ export * from './storage'
export * from './tx'
export * from './utils'
export * from './backup'
export * from './status'
addStringsLoader(coreId, async (lang: string) => {
return await import(`./lang/${lang}.json`)

View File

@ -28,6 +28,8 @@
"Hyperlink": "URL",
"Object": "Object",
"System": "System",
"CreatedBy": "Reporter"
"CreatedBy": "Reporter",
"Status": "Status",
"StatusCategory": "Status category"
}
}

View File

@ -28,6 +28,8 @@
"Hyperlink": "URL",
"Object": "Объект",
"System": "Система",
"CreatedBy": "Автор"
"CreatedBy": "Автор",
"Status": "Статус",
"StatusCategory": "Категория статуса"
}
}

View File

@ -4,7 +4,7 @@ import core from './component'
import { Hierarchy } from './hierarchy'
import { getObjectValue } from './objvalue'
import { createPredicates, isPredicate } from './predicate'
import { SortingOrder, SortingQuery, Storage } from './storage'
import { SortQuerySelector, SortingOrder, SortingQuery, SortingRules, Storage } from './storage'
/**
* @public
@ -70,7 +70,30 @@ export async function resultSort<T extends Doc> (
result.sort(sortFunc)
}
function getSortingResult (aValue: any, bValue: any, order: SortingOrder): number {
function mapSortingValue (order: SortingOrder | SortingRules<any>, val: any): any {
if (typeof order !== 'object') {
return val
}
for (const r of order.cases) {
if (typeof r.query === 'object') {
const q: SortQuerySelector<any> = r.query
if (q.$in?.includes(val) ?? false) {
return r.index
}
if (q.$nin !== undefined && !q.$nin.includes(val)) {
return r.index
}
if (q.$ne !== undefined && q.$ne !== val) {
return r.index
}
}
if (r.query === val) {
return r.index
}
}
}
function getSortingResult (aValue: any, bValue: any, order: SortingOrder | SortingRules<any>): number {
let res = 0
if (typeof aValue === 'undefined') {
return typeof bValue === 'undefined' ? 0 : -1
@ -78,12 +101,19 @@ function getSortingResult (aValue: any, bValue: any, order: SortingOrder): numbe
if (typeof bValue === 'undefined') {
return 1
}
const orderOrder = typeof order === 'object' ? order.order : order
if (Array.isArray(aValue) && Array.isArray(bValue)) {
res = (aValue.sort((a, b) => (a - b) * order)[0] ?? 0) - (bValue.sort((a, b) => (a - b) * order)[0] ?? 0)
res =
(aValue.map((it) => mapSortingValue(order, it)).sort((a, b) => (a - b) * orderOrder)[0] ?? 0) -
(bValue.map((it) => mapSortingValue(order, it)).sort((a, b) => (a - b) * orderOrder)[0] ?? 0)
} else {
res = typeof aValue === 'string' ? aValue.localeCompare(bValue) : aValue - bValue
const aaValue = mapSortingValue(order, aValue)
const bbValue = mapSortingValue(order, bValue)
res = typeof aaValue === 'string' ? aaValue.localeCompare(bbValue) : aaValue - bbValue
}
return res * order
return res * orderOrder
}
async function getEnums<T extends Doc> (

View File

@ -0,0 +1,91 @@
//
// Copyright © 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.
//
import { Asset, IntlString } from '@hcengineering/platform'
import { Attribute, Doc, Domain, Ref } from './classes'
import { WithLookup } from './storage'
import { IdMap, toIdMap } from './utils'
/**
* @public
*/
export interface StatusCategory extends Doc {
ofAttribute: Ref<Attribute<Status>>
icon: Asset
label: IntlString
color: number
defaultStatusName?: string
order: number // category order
}
/**
* @public
*/
export const DOMAIN_STATUS = 'status' as Domain
/**
* @public
*
* Status is attached to attribute, and if user attribute will be removed, all status values will be remove as well.
*/
export interface Status extends Doc {
// We attach to attribute, so we could distinguish between
ofAttribute: Ref<Attribute<Status>>
// Optional category.
category?: Ref<StatusCategory>
// Status with case insensitivity name match will be assumed same.
name: string
// Optional color
color?: number
// Optional description
description?: string
// Lexorank rank for ordering.
rank: string
}
/**
* @public
*/
export class StatusValue {
constructor (readonly name: string, readonly color: number | undefined, readonly values: WithLookup<Status>[]) {}
}
/**
* @public
*
* Allow to query for status keys/values.
*/
export class StatusManager {
byId: IdMap<WithLookup<Status>>
constructor (readonly statuses: WithLookup<Status>[]) {
this.byId = toIdMap(statuses)
}
get (ref: Ref<Status>): WithLookup<Status> | undefined {
return this.byId.get(ref)
}
filter (predicate: (value: WithLookup<Status>) => boolean): WithLookup<Status>[] {
return this.statuses.filter(predicate)
}
}
/**
* @public
*/
export type CategoryType = number | string | undefined | Ref<Doc> | StatusValue

View File

@ -123,15 +123,41 @@ export type FindOptions<T extends Doc> = {
projection?: Projection<T>
}
/**
* @public
*/
// eslint-disable-next-line @typescript-eslint/consistent-type-definitions
export type SortQuerySelector<T> = {
$in?: T[]
$nin?: T[]
$ne?: T
}
/**
* @public
*/
export type SortRuleQueryType<T> = (T extends Array<infer U> ? U | U[] : T) | SortQuerySelector<T>
/**
* @public
*/
export interface SortingRules<T> {
order: SortingOrder
default?: string | number
cases: {
query: SortRuleQueryType<T>
index: string | number
}[]
}
/**
* @public
*/
export type SortingQuery<T extends Doc> = {
[P in keyof T]?: SortingOrder
[P in keyof T]?: SortingOrder | SortingRules<T[P]>
} & {
// support nested queries e.g. 'user.friends.name'
// this will mark all unrecognized properties as any (including nested queries)
[key: string]: SortingOrder
[key: string]: SortingOrder | SortingRules<any>
}
/**

View File

@ -13,6 +13,7 @@
// limitations under the License.
//
import justClone from 'just-clone'
import type { KeysByType } from 'simplytyped'
import type {
Account,
@ -34,7 +35,6 @@ import { _getOperator } from './operator'
import { _toDoc } from './proxy'
import type { DocumentQuery, TxResult } from './storage'
import { generateId } from './utils'
import justClone from 'just-clone'
/**
* @public
@ -43,10 +43,34 @@ export interface Tx extends Doc {
objectSpace: Ref<Space> // space where transaction will operate
}
/**
* @public
*/
export enum WorkspaceEvent {
UpgradeScheduled,
Upgrade,
IndexingUpdate
}
/**
* Event to be send by server during model upgrade procedure.
* @public
*/
export interface TxWorkspaceEvent extends Tx {
event: WorkspaceEvent
params: any
}
/**
* @public
*/
export interface IndexingUpdateEvent {
_class: Ref<Class<Doc>>[]
}
/**
* @public
*/
export interface TxModelUpgrade extends Tx {}
/**

View File

@ -13,69 +13,41 @@
// limitations under the License.
-->
<script lang="ts">
import { Class, Doc, DocumentQuery, DocumentUpdate, FindOptions, Ref, SortingOrder } from '@hcengineering/core'
import { createQuery, getClient } from '@hcengineering/presentation'
import { getPlatformColor, ScrollBox, Scroller } from '@hcengineering/ui'
import { CategoryType, Doc, DocumentUpdate, Ref } from '@hcengineering/core'
import { getClient } from '@hcengineering/presentation'
import { ScrollBox, Scroller } from '@hcengineering/ui'
import { createEventDispatcher } from 'svelte'
import { CardDragEvent, Item, StateType, TypeState } from '../types'
import { CardDragEvent, Item } from '../types'
import { calcRank } from '../utils'
import KanbanRow from './KanbanRow.svelte'
export let _class: Ref<Class<Item>>
export let options: FindOptions<Item> | undefined = undefined
export let states: TypeState[] = []
export let query: DocumentQuery<Item> = {}
export let fieldName: string
export let categories: CategoryType[] = []
export let objects: Item[] = []
export let groupByDocs: Record<string | number, Item[]>
export let getGroupByValues: (groupByDocs: Record<string | number, Item[]>, category: CategoryType) => Item[]
export let setGroupByValues: (
groupByDocs: Record<string | number, Item[]>,
category: CategoryType,
docs: Item[]
) => void
export let selection: number | undefined = undefined
export let checked: Doc[] = []
export let dontUpdateRank: boolean = false
export let getUpdateProps: (doc: Doc, state: CategoryType) => DocumentUpdate<Item> | undefined
const dispatch = createEventDispatcher()
let objects: Item[] = []
const objsQ = createQuery()
$: objsQ.query(
_class,
query,
(result) => {
objects = result
fillStateObjects(result, fieldName)
dispatch('content', objects)
},
{
sort: { rank: SortingOrder.Ascending },
...options
}
)
function fieldNameChange (fieldName: string) {
fillStateObjects(objects, fieldName)
}
$: fieldNameChange(fieldName)
function fillStateObjects (objects: Item[], fieldName: string): void {
objectByState.clear()
for (const object of objects) {
const arr = objectByState.get((object as any)[fieldName]) ?? []
arr.push(object)
objectByState.set((object as any)[fieldName], arr)
}
objectByState = objectByState
}
async function move (state: StateType) {
async function move (state: CategoryType) {
if (dragCard === undefined) {
return
}
let updates: DocumentUpdate<Item> = {}
let updates = getUpdateProps(dragCard, state)
if (dragCardInitialState !== state) {
updates = {
...updates,
[fieldName]: state
}
if (updates === undefined) {
panelDragLeave(undefined, dragCardState)
return
}
if (!dontUpdateRank && dragCardInitialRank !== dragCard.rank) {
@ -95,35 +67,61 @@
let dragCard: Item | undefined
let dragCardInitialRank: string | undefined
let dragCardInitialState: StateType
let objectByState: Map<StateType, Item[]> = new Map<StateType, Item[]>()
let dragCardInitialState: CategoryType
let dragCardInitialPosition: number | undefined
let dragCardState: CategoryType | undefined
let isDragging = false
async function updateDone (query: DocumentUpdate<Item>): Promise<void> {
async function updateDone (updateValue: DocumentUpdate<Item>): Promise<void> {
isDragging = false
if (dragCard === undefined) {
return
}
await client.update(dragCard, query)
await client.update(dragCard, updateValue)
}
function panelDragOver (event: Event, state: TypeState): void {
event.preventDefault()
const card = dragCard as any
if (card !== undefined && card[fieldName] !== state._id) {
const oldArr = objectByState.get(card[fieldName]) ?? []
const index = oldArr.findIndex((p) => p._id === card._id)
function panelDragOver (event: Event | undefined, state: CategoryType): void {
event?.preventDefault()
if (dragCard !== undefined && dragCardState !== state) {
const updates = getUpdateProps(dragCard, state)
if (updates === undefined) {
return
}
const oldArr = getGroupByValues(groupByDocs, dragCardState)
const index = oldArr.findIndex((p) => p._id === dragCard?._id)
if (index !== -1) {
oldArr.splice(index, 1)
objectByState.set(card[fieldName], oldArr)
setGroupByValues(groupByDocs, dragCardState, oldArr)
}
card[fieldName] = state._id
const arr = objectByState.get(card[fieldName]) ?? []
arr.push(card)
objectByState.set(card[fieldName], arr)
objectByState = objectByState
dragCardState = state
const arr = getGroupByValues(groupByDocs, state) ?? []
arr.push(dragCard)
setGroupByValues(groupByDocs, state, arr)
groupByDocs = groupByDocs
}
}
function panelDragLeave (event: Event | undefined, state: CategoryType): void {
event?.preventDefault()
if (dragCard !== undefined && state !== dragCardInitialState) {
// We need to restore original position
const oldArr = getGroupByValues(groupByDocs, state)
const index = oldArr.findIndex((p) => p._id === dragCard?._id)
if (index !== -1) {
oldArr.splice(index, 1)
setGroupByValues(groupByDocs, state, oldArr)
}
if (dragCardInitialPosition !== undefined) {
const newArr = getGroupByValues(groupByDocs, dragCardInitialState)
newArr.splice(dragCardInitialPosition, 0, dragCard)
setGroupByValues(groupByDocs, dragCardInitialPosition, newArr)
}
groupByDocs = groupByDocs
}
}
@ -137,10 +135,14 @@
return false
}
function cardDragOver (evt: CardDragEvent, object: Item): void {
function cardDragOver (evt: CardDragEvent, object: Item, state: CategoryType): void {
if (dragCard !== undefined && !dontUpdateRank) {
const updates = getUpdateProps(dragCard, state)
if (updates === undefined) {
return
}
if (object._id !== dragCard._id) {
let arr = objectByState.get((object as any)[fieldName]) ?? []
let arr = getGroupByValues(groupByDocs, state) ?? []
const dragCardIndex = arr.findIndex((p) => p._id === dragCard?._id)
const targetIndex = arr.findIndex((p) => p._id === object._id)
if (
@ -150,15 +152,15 @@
) {
arr.splice(dragCardIndex, 1)
arr = [...arr.slice(0, targetIndex), dragCard, ...arr.slice(targetIndex)]
objectByState.set((object as any)[fieldName], arr)
objectByState = objectByState
setGroupByValues(groupByDocs, state, arr)
groupByDocs = groupByDocs
}
}
}
}
function cardDrop (evt: CardDragEvent, object: Item): void {
function cardDrop (evt: CardDragEvent, object: Item, state: CategoryType): void {
if (!dontUpdateRank && dragCard !== undefined) {
const arr = objectByState.get((object as any)[fieldName]) ?? []
const arr = getGroupByValues(groupByDocs, state) ?? []
const s = arr.findIndex((p) => p._id === dragCard?._id)
if (s !== -1) {
const newRank = calcRank(arr[s - 1], arr[s + 1])
@ -167,9 +169,12 @@
}
isDragging = false
}
function onDragStart (object: Item, state: TypeState): void {
dragCardInitialState = state._id
function onDragStart (object: Item, state: CategoryType): void {
dragCardInitialState = state
dragCardState = state
dragCardInitialRank = object.rank
const items = getGroupByValues(groupByDocs, state) ?? []
dragCardInitialPosition = items.findIndex((p) => p._id === object._id)
dragCard = object
isDragging = true
dispatch('obj-focus', object)
@ -184,19 +189,31 @@
const stateRefs: HTMLElement[] = []
const stateRows: KanbanRow[] = []
$: stateRefs.length = states.length
$: stateRows.length = states.length
$: stateRefs.length = categories.length
$: stateRows.length = categories.length
function scrollInto (statePos: number, obj: Item): void {
stateRefs[statePos]?.scrollIntoView({ behavior: 'auto', block: 'nearest' })
stateRows[statePos]?.scroll(obj)
}
function getState (doc: Item): number {
let pos = 0
for (const st of categories) {
const stateObjs = getGroupByValues(groupByDocs, st) ?? []
if (stateObjs.findIndex((it) => it._id === doc._id) !== -1) {
return pos
}
pos++
}
return -1
}
export function select (offset: 1 | -1 | 0, of?: Doc, dir?: 'vertical' | 'horizontal'): void {
let pos = (of !== undefined ? objects.findIndex((it) => it._id === of._id) : selection) ?? -1
if (pos === -1) {
for (const st of states) {
const stateObjs = objectByState.get(st) ?? []
for (const st of categories) {
const stateObjs = getGroupByValues(groupByDocs, st) ?? []
if (stateObjs.length > 0) {
pos = objects.findIndex((it) => it._id === stateObjs[0]._id)
break
@ -215,12 +232,12 @@
if (obj === undefined) {
return
}
const fState = (obj as any)[fieldName]
let objState = states.findIndex((it) => it._id === fState)
let objState = getState(obj)
if (objState === -1) {
return
}
const stateObjs = objectByState.get(states[objState]) ?? []
const stateObjs = getGroupByValues(groupByDocs, categories.indexOf(objState)) ?? []
const statePos = stateObjs.findIndex((it) => it._id === obj._id)
if (statePos === undefined) {
return
@ -235,7 +252,7 @@
} else {
while (objState > 0) {
objState--
const nstateObjs = objectByState.get(states[objState]) ?? []
const nstateObjs = getGroupByValues(groupByDocs, categories[objState]) ?? []
if (nstateObjs.length > 0) {
const obj = nstateObjs[statePos] ?? nstateObjs[nstateObjs.length - 1]
scrollInto(objState, obj)
@ -252,9 +269,9 @@
dispatch('obj-focus', obj)
return
} else {
while (objState < states.length - 1) {
while (objState < categories.length - 1) {
objState++
const nstateObjs = objectByState.get(states[objState]) ?? []
const nstateObjs = getGroupByValues(groupByDocs, categories[objState]) ?? []
if (nstateObjs.length > 0) {
const obj = nstateObjs[statePos] ?? nstateObjs[nstateObjs.length - 1]
scrollInto(objState, obj)
@ -265,7 +282,7 @@
}
}
if (offset === 0) {
scrollInto(objState, obj)
// scrollInto(objState, obj)
dispatch('obj-focus', obj)
}
}
@ -288,29 +305,20 @@
<div class="kanban-container top-divider">
<ScrollBox>
<div class="kanban-content">
{#each states as state, si (state._id)}
{@const stateObjects = objectByState.get(state._id) ?? []}
{#each categories as state, si (state)}
{@const stateObjects = getGroupByValues(groupByDocs, state)}
<div
class="panel-container step-lr75"
bind:this={stateRefs[si]}
on:dragover={(event) => panelDragOver(event, state)}
on:drop={() => {
move(state._id)
move(state)
isDragging = false
}}
>
{#if $$slots.header !== undefined}
<slot name="header" state={toAny(state)} count={stateObjects.length} />
{:else}
<div class="header">
<div class="bar" style="background-color: {getPlatformColor(state.color)}" />
<div class="flex-between label">
<div>
<span class="lines-limit-2">{state.title}</span>
</div>
</div>
</div>
{/if}
<Scroller padding={'.5rem 0'} on:dragover on:drop>
<slot name="beforeCard" {state} />
@ -324,8 +332,8 @@
{selection}
{checkedSet}
{state}
{cardDragOver}
{cardDrop}
cardDragOver={(evt, obj) => cardDragOver(evt, obj, state)}
cardDrop={(evt, obj) => cardDrop(evt, obj, state)}
{onDragStart}
{showMenu}
>

View File

@ -13,10 +13,11 @@
// limitations under the License.
-->
<script lang="ts">
import { Doc, Ref } from '@hcengineering/core'
import { CategoryType, Doc, Ref } from '@hcengineering/core'
import ui, { Button, IconMoreH } from '@hcengineering/ui'
import { createEventDispatcher } from 'svelte'
import { slide } from 'svelte/transition'
import { CardDragEvent, Item, TypeState } from '../types'
import { CardDragEvent, Item } from '../types'
export let stateObjects: Item[]
export let isDragging: boolean
@ -24,11 +25,11 @@
export let objects: Item[]
export let selection: number | undefined = undefined
export let checkedSet: Set<Ref<Doc>>
export let state: TypeState
export let state: CategoryType
export let cardDragOver: (evt: CardDragEvent, object: Item) => void
export let cardDrop: (evt: CardDragEvent, object: Item) => void
export let onDragStart: (object: Item, state: TypeState) => void
export let onDragStart: (object: Item, state: CategoryType) => void
export let showMenu: (evt: MouseEvent, object: Item) => void
const dispatch = createEventDispatcher()
@ -48,9 +49,13 @@
stateRefs[pos]?.scrollIntoView({ behavior: 'auto', block: 'nearest' })
}
}
let limit = 50
$: limitedObjects = stateObjects.slice(0, limit)
</script>
{#each stateObjects as object, i}
{#each limitedObjects as object, i (object._id)}
{@const dragged = isDragging && object._id === dragCard?._id}
<div
bind:this={stateRefs[i]}
@ -80,6 +85,23 @@
</div>
</div>
{/each}
{#if stateObjects.length > limitedObjects.length}
<div class="step-tb75">
<div class="card-container h-18 flex-row-center flex-between p-4">
<span class="p-1">
{limitedObjects.length}/{stateObjects.length}
</span>
<Button
size={'small'}
icon={IconMoreH}
label={ui.string.ShowMore}
on:click={() => {
limit = limit + 20
}}
/>
</div>
</div>
{/if}
<style lang="scss">
.card-container {

View File

@ -22,7 +22,7 @@ export interface TypeState {
/**
* @public
*/
export type Item = DocWithRank & { state: StateType, doneState: StateType | null }
export type Item = DocWithRank
/**
* @public

View File

@ -131,7 +131,7 @@ export function Prop (type: Type<PropertyType>, label: IntlString, extra: Partia
modifiedBy: core.account.System,
modifiedOn: Date.now(),
objectSpace: core.space.Model,
objectId: propertyKey as Ref<Attribute<PropertyType>>,
objectId: extra._id ?? (propertyKey as Ref<Attribute<PropertyType>>),
objectClass: core.class.Attribute,
attributes: {
...extra,
@ -240,7 +240,8 @@ function generateIds (objectId: Ref<Doc>, txes: TxCreateDoc<Attribute<PropertyTy
return txes.map((tx) => {
const withId = {
...tx,
objectId: `${objectId}_${tx.objectId}`
// Do not override custom attribute id if specified
objectId: tx.objectId !== tx.attributes.name ? tx.objectId : `${objectId}_${tx.objectId}`
}
withId.attributes.attributeOf = objectId as Ref<Class<Obj>>
return withId

View File

@ -0,0 +1,234 @@
import {
Class,
Client,
Doc,
DocumentQuery,
FindOptions,
FindResult,
Hierarchy,
ModelDb,
Ref,
Tx,
TxResult,
WithLookup
} from '@hcengineering/core'
/**
* @public
*/
export interface PresentationMiddleware {
next?: PresentationMiddleware
tx: (tx: Tx) => Promise<TxResult>
notifyTx: (tx: Tx) => Promise<void>
findAll: <T extends Doc>(
_class: Ref<Class<T>>,
query: DocumentQuery<T>,
options?: FindOptions<T>
) => Promise<FindResult<T>>
findOne: <T extends Doc>(
_class: Ref<Class<T>>,
query: DocumentQuery<T>,
options?: FindOptions<T>
) => Promise<WithLookup<T> | undefined>
subscribe: <T extends Doc>(
_class: Ref<Class<T>>,
query: DocumentQuery<T>,
options: FindOptions<T> | undefined,
refresh: () => void
) => Promise<{
unsubscribe: () => void
query?: DocumentQuery<T>
options?: FindOptions<T>
}>
close: () => Promise<void>
}
/**
* @public
*/
export type PresentationMiddlewareCreator = (client: Client, next?: PresentationMiddleware) => PresentationMiddleware
/**
* @public
*/
export interface PresentationPipeline extends Client, Exclude<PresentationMiddleware, 'next'> {
close: () => Promise<void>
}
/**
* @public
*/
export class PresentationPipelineImpl implements PresentationPipeline {
private head: PresentationMiddleware | undefined
private constructor (readonly client: Client) {}
getHierarchy (): Hierarchy {
return this.client.getHierarchy()
}
getModel (): ModelDb {
return this.client.getModel()
}
async notifyTx (tx: Tx): Promise<void> {
await this.head?.notifyTx(tx)
}
static create (client: Client, constructors: PresentationMiddlewareCreator[]): PresentationPipeline {
const pipeline = new PresentationPipelineImpl(client)
pipeline.head = pipeline.buildChain(constructors)
return pipeline
}
private buildChain (constructors: PresentationMiddlewareCreator[]): PresentationMiddleware | undefined {
let current: PresentationMiddleware | undefined
for (let index = constructors.length - 1; index >= 0; index--) {
const element = constructors[index]
current = element(this.client, current)
}
return current
}
async findAll<T extends Doc>(
_class: Ref<Class<T>>,
query: DocumentQuery<T>,
options?: FindOptions<T>
): Promise<FindResult<T>> {
return this.head !== undefined
? await this.head.findAll(_class, query, options)
: await this.client.findAll(_class, query, options)
}
async findOne<T extends Doc>(
_class: Ref<Class<T>>,
query: DocumentQuery<T>,
options?: FindOptions<T>
): Promise<WithLookup<T> | undefined> {
return this.head !== undefined
? await this.head.findOne(_class, query, options)
: await this.client.findOne(_class, query, options)
}
async subscribe<T extends Doc>(
_class: Ref<Class<T>>,
query: DocumentQuery<T>,
options: FindOptions<T> | undefined,
refresh: () => void
): Promise<{
unsubscribe: () => void
query?: DocumentQuery<T>
options?: FindOptions<T>
}> {
return this.head !== undefined
? await this.head.subscribe(_class, query, options, refresh)
: { unsubscribe: () => {} }
}
async tx (tx: Tx): Promise<TxResult> {
if (this.head === undefined) {
return await this.client.tx(tx)
} else {
return await this.head.tx(tx)
}
}
async close (): Promise<void> {
await this.head?.close()
}
}
/**
* @public
*/
export abstract class BasePresentationMiddleware {
constructor (protected readonly client: Client, readonly next?: PresentationMiddleware) {}
async provideNotifyTx (tx: Tx): Promise<void> {
await this.next?.notifyTx(tx)
}
async provideClose (): Promise<void> {
await this.next?.close()
}
async findAll<T extends Doc>(
_class: Ref<Class<T>>,
query: DocumentQuery<T>,
options?: FindOptions<T>
): Promise<FindResult<T>> {
return await this.provideFindAll(_class, query, options)
}
async findOne<T extends Doc>(
_class: Ref<Class<T>>,
query: DocumentQuery<T>,
options?: FindOptions<T>
): Promise<WithLookup<T> | undefined> {
return await this.provideFindOne(_class, query, options)
}
async subscribe<T extends Doc>(
_class: Ref<Class<T>>,
query: DocumentQuery<T>,
options: FindOptions<T> | undefined,
refresh: () => void
): Promise<{
unsubscribe: () => void
query?: DocumentQuery<T>
options?: FindOptions<T>
}> {
return await this.provideSubscribe(_class, query, options, refresh)
}
protected async provideTx (tx: Tx): Promise<TxResult> {
if (this.next !== undefined) {
return await this.next.tx(tx)
}
return await this.client.tx(tx)
}
protected async provideFindAll<T extends Doc>(
_class: Ref<Class<T>>,
query: DocumentQuery<T>,
options?: FindOptions<T>
): Promise<FindResult<T>> {
if (this.next !== undefined) {
return await this.next.findAll(_class, query, options)
}
return await this.client.findAll(_class, query, options)
}
protected async provideFindOne<T extends Doc>(
_class: Ref<Class<T>>,
query: DocumentQuery<T>,
options?: FindOptions<T>
): Promise<WithLookup<T> | undefined> {
if (this.next !== undefined) {
return await this.next.findOne(_class, query, options)
}
return await this.client.findOne(_class, query, options)
}
protected async provideSubscribe<T extends Doc>(
_class: Ref<Class<T>>,
query: DocumentQuery<T>,
options: FindOptions<T> | undefined,
refresh: () => void
): Promise<{
unsubscribe: () => void
query?: DocumentQuery<T>
options?: FindOptions<T>
}> {
if (this.next !== undefined) {
return await this.next.subscribe(_class, query, options, refresh)
}
return { unsubscribe: () => {} }
}
}

View File

@ -0,0 +1,299 @@
//
// Copyright © 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.
//
import core, {
AnyAttribute,
Attribute,
Class,
Client,
Doc,
DocumentQuery,
FindOptions,
FindResult,
generateId,
Hierarchy,
Ref,
RefTo,
SortingOrder,
SortingRules,
Status,
StatusManager,
Tx,
TxResult
} from '@hcengineering/core'
import { LiveQuery } from '@hcengineering/query'
import { writable } from 'svelte/store'
import { BasePresentationMiddleware, PresentationMiddleware } from './pipeline'
// Issue status live query
export const statusStore = writable<StatusManager>(new StatusManager([]))
interface StatusSubscriber<T extends Doc = Doc> {
attributes: Array<Ref<AnyAttribute>>
_class: Ref<Class<T>>
query: DocumentQuery<T>
options?: FindOptions<T>
refresh: () => void
}
/**
* @public
*/
export class StatusMiddleware extends BasePresentationMiddleware implements PresentationMiddleware {
mgr: StatusManager | Promise<StatusManager> | undefined
status: Status[] | undefined
statusQuery: (() => void) | undefined
lq: LiveQuery
subscribers: Map<string, StatusSubscriber> = new Map()
private constructor (client: Client, next?: PresentationMiddleware) {
super(client, next)
this.lq = new LiveQuery(client)
}
async notifyTx (tx: Tx): Promise<void> {
await this.lq.tx(tx)
await this.provideNotifyTx(tx)
}
async close (): Promise<void> {
this.statusQuery?.()
return await this.provideClose()
}
async getManager (): Promise<StatusManager> {
if (this.mgr !== undefined) {
if (this.mgr instanceof Promise) {
this.mgr = await this.mgr
}
return this.mgr
}
this.mgr = new Promise<StatusManager>((resolve) => {
this.statusQuery = this.lq.query(
core.class.Status,
{},
(res) => {
const first = this.status === undefined
this.status = res
this.mgr = new StatusManager(res)
statusStore.set(this.mgr)
if (!first) {
this.refreshSubscribers()
}
resolve(this.mgr)
},
{
lookup: {
category: core.class.StatusCategory
},
sort: {
rank: SortingOrder.Ascending
}
}
)
})
return await this.mgr
}
private refreshSubscribers (): void {
for (const s of this.subscribers.values()) {
// TODO: Do something more smart and track if used status field is changed.
s.refresh()
}
}
async subscribe<T extends Doc>(
_class: Ref<Class<T>>,
query: DocumentQuery<T>,
options: FindOptions<T> | undefined,
refresh: () => void
): Promise<{
unsubscribe: () => void
query?: DocumentQuery<T>
options?: FindOptions<T>
}> {
const ret = await this.provideSubscribe(_class, query, options, refresh)
const h = this.client.getHierarchy()
const id = generateId()
const s: StatusSubscriber<T> = {
_class,
query,
refresh,
options,
attributes: []
}
const statusFields: Array<Attribute<Status>> = []
const allAttrs = h.getAllAttributes(_class)
const updatedQuery: DocumentQuery<T> = { ...(ret.query ?? query) }
const finalOptions = { ...(ret.options ?? options ?? {}) }
await this.updateQueryOptions<T>(allAttrs, h, statusFields, updatedQuery, finalOptions)
if (statusFields.length > 0) {
this.subscribers.set(id, s)
return {
unsubscribe: () => {
ret.unsubscribe()
this.subscribers.delete(id)
},
query: updatedQuery,
options: finalOptions
}
}
return { unsubscribe: (await ret).unsubscribe }
}
static create (client: Client, next?: PresentationMiddleware): StatusMiddleware {
return new StatusMiddleware(client, next)
}
async findAll<T extends Doc>(
_class: Ref<Class<T>>,
query: DocumentQuery<T>,
options?: FindOptions<T> | undefined
): Promise<FindResult<T>> {
const statusFields: Array<Attribute<Status>> = []
const h = this.client.getHierarchy()
const allAttrs = h.getAllAttributes(_class)
const finalOptions = options ?? {}
await this.updateQueryOptions<T>(allAttrs, h, statusFields, query, finalOptions)
const result = await this.provideFindAll(_class, query, finalOptions)
// We need to add $
if (statusFields.length > 0) {
// We need to update $lookup for status fields and provide $status group fields.
for (const attr of statusFields) {
for (const r of result) {
const resultDoc = Hierarchy.toDoc(r)
if (resultDoc.$lookup === undefined) {
resultDoc.$lookup = {}
}
// TODO: Check for mixin?
const stateValue = (r as any)[attr.name]
const status = (await this.getManager()).byId.get(stateValue)
if (status !== undefined) {
;(resultDoc.$lookup as any)[attr.name] = status
}
}
}
}
return result
}
private async updateQueryOptions<T extends Doc>(
allAttrs: Map<string, AnyAttribute>,
h: Hierarchy,
statusFields: Array<Attribute<Status>>,
query: DocumentQuery<T>,
finalOptions: FindOptions<T>
): Promise<void> {
for (const attr of allAttrs.values()) {
try {
if (attr.type._class === core.class.RefTo && h.isDerived((attr.type as RefTo<Doc>).to, core.class.Status)) {
const mgr = await this.getManager()
let target: Array<Ref<Status>> = []
statusFields.push(attr)
const v = (query as any)[attr.name]
if (v !== undefined) {
// Only add filter if we have filer inside.
if (v?.$in !== undefined) {
target.push(...v.$in)
} else {
target.push(v)
}
// Find all similar name statues for same attribute name.
for (const sid of [...target]) {
const s = mgr.byId.get(sid)
if (s !== undefined) {
const statuses = mgr.statuses.filter(
(it) =>
it.ofAttribute === attr._id &&
it.name.toLowerCase().trim() === s.name.toLowerCase().trim() &&
it._id !== s._id
)
if (statuses !== undefined) {
target.push(...statuses.map((it) => it._id))
}
}
}
target = target.filter((it, idx, arr) => arr.indexOf(it) === idx)
;(query as any)[attr.name] = { $in: target }
if (finalOptions.lookup !== undefined) {
// Remove lookups by status field
if ((finalOptions.lookup as any)[attr.name] !== undefined) {
const { [attr.name]: _, ...newLookup } = finalOptions.lookup as any
finalOptions.lookup = newLookup
}
}
}
// Update sorting if defined.
this.updateCustomSorting<T>(finalOptions, attr, mgr)
}
} catch (err: any) {
console.error(err)
}
}
}
private updateCustomSorting<T extends Doc>(
finalOptions: FindOptions<T>,
attr: AnyAttribute,
mgr: StatusManager
): void {
const attrSort = finalOptions.sort?.[attr.name]
if (attrSort !== undefined && typeof attrSort !== 'object') {
// Fill custom sorting.
const statuses = mgr.statuses.filter((it) => it.ofAttribute === attr._id)
statuses.sort((a, b) => {
let ret = 0
if (a.category !== undefined && b.category !== undefined) {
ret = (a.$lookup?.category?.order ?? 0) - (b.$lookup?.category?.order ?? 0)
}
if (ret === 0) {
if (a.name.toLowerCase().trim() === b.name.toLowerCase().trim()) {
return 0
}
ret = a.rank.localeCompare(b.rank)
}
return ret
})
if (finalOptions.sort === undefined) {
finalOptions.sort = {}
}
const rules: SortingRules<any> = {
order: attrSort,
cases: statuses.map((it, idx) => ({ query: it._id, index: idx })),
default: statuses.length + 1
}
;(finalOptions.sort as any)[attr.name] = rules
}
}
async tx (tx: Tx): Promise<TxResult> {
return await this.provideTx(tx)
}
}

View File

@ -43,10 +43,14 @@ import view, { AttributeEditor } from '@hcengineering/view'
import { deepEqual } from 'fast-equals'
import { onDestroy } from 'svelte'
import { KeyedAttribute } from '..'
import { PresentationPipeline, PresentationPipelineImpl } from './pipeline'
import plugin from './plugin'
import { StatusMiddleware, statusStore } from './status'
export { statusStore }
let liveQuery: LQ
let client: TxOperations
let pipeline: PresentationPipeline
const txListeners: Array<(tx: Tx) => void> = []
@ -68,7 +72,7 @@ export function removeTxListener (l: (tx: Tx) => void): void {
}
class UIClient extends TxOperations implements Client {
constructor (client: Client, private readonly liveQuery: LQ) {
constructor (client: Client, private readonly liveQuery: Client) {
super(client, getCurrentAccount()._id)
}
@ -89,7 +93,7 @@ class UIClient extends TxOperations implements Client {
}
override async tx (tx: Tx): Promise<TxResult> {
return await super.tx(tx)
return await this.client.tx(tx)
}
}
@ -103,28 +107,36 @@ export function getClient (): TxOperations {
/**
* @public
*/
export function setClient (_client: Client): void {
export async function setClient (_client: Client): Promise<void> {
if (liveQuery !== undefined) {
void liveQuery.close()
await liveQuery.close()
}
if (pipeline !== undefined) {
await pipeline.close()
}
pipeline = PresentationPipelineImpl.create(_client, [StatusMiddleware.create])
const needRefresh = liveQuery !== undefined
liveQuery = new LQ(_client)
client = new UIClient(_client, liveQuery)
liveQuery = new LQ(pipeline)
client = new UIClient(pipeline, liveQuery)
_client.notify = (tx: Tx) => {
pipeline.notifyTx(tx).catch((err) => console.log(err))
liveQuery.tx(tx).catch((err) => console.log(err))
txListeners.forEach((it) => it(tx))
}
if (needRefresh) {
refreshClient()
await refreshClient()
}
}
/**
* @public
*/
export function refreshClient (): void {
void liveQuery?.refreshConnect()
export async function refreshClient (): Promise<void> {
await liveQuery?.refreshConnect()
for (const q of globalQueries) {
q.refreshClient()
}
@ -140,10 +152,12 @@ export class LiveQuery {
private oldQuery: DocumentQuery<Doc> | undefined
private oldOptions: FindOptions<Doc> | undefined
private oldCallback: ((result: FindResult<any>) => void) | undefined
private reqId = 0
unsubscribe = () => {}
clientRecreated = false
constructor (dontDestroy: boolean = false) {
if (!dontDestroy) {
constructor (noDestroy: boolean = false) {
if (!noDestroy) {
onDestroy(() => {
this.unsubscribe()
})
@ -158,43 +172,58 @@ export class LiveQuery {
callback: (result: FindResult<T>) => void,
options?: FindOptions<T>
): boolean {
if (!this.needUpdate(_class, query, callback, options)) {
if (!this.needUpdate(_class, query, callback, options) && !this.clientRecreated) {
return false
}
return this.doQuery<T>(_class, query, callback, options)
// One time refresh in case of client recreation
this.clientRecreated = false
void this.doQuery<T>(_class, query, callback, options)
return true
}
private doQuery<T extends Doc>(
private async doQuery<T extends Doc>(
_class: Ref<Class<T>>,
query: DocumentQuery<T>,
callback: (result: FindResult<T>) => void,
options: FindOptions<T> | undefined
): boolean {
): Promise<void> {
const id = ++this.reqId
const piplineQuery = await pipeline.subscribe(_class, query, options, () => {
// Refresh query if pipeline decide it is required.
this.refreshClient()
})
if (id !== this.reqId) {
// If we have one more request after this one, no need to do something.
piplineQuery.unsubscribe()
return
}
this.unsubscribe()
this.oldCallback = callback
this.oldClass = _class
this.oldOptions = options
this.oldQuery = query
const unsub = liveQuery.query(_class, query, callback, options)
const unsub = liveQuery.query(_class, piplineQuery.query ?? query, callback, piplineQuery.options ?? options)
this.unsubscribe = () => {
unsub()
piplineQuery.unsubscribe()
this.oldCallback = undefined
this.oldClass = undefined
this.oldOptions = undefined
this.oldQuery = undefined
this.unsubscribe = () => {}
}
return true
}
refreshClient (): void {
this.clientRecreated = true
if (this.oldClass !== undefined && this.oldQuery !== undefined && this.oldCallback !== undefined) {
const _class = this.oldClass
const query = this.oldQuery
const callback = this.oldCallback
const options = this.oldOptions
this.doQuery(_class, query, callback, options)
void this.doQuery(_class, query, callback, options)
}
}

View File

@ -24,8 +24,10 @@ import core, {
FindOptions,
findProperty,
FindResult,
generateId,
getObjectValue,
Hierarchy,
IndexingUpdateEvent,
Lookup,
LookupData,
matchQuery,
@ -43,7 +45,9 @@ import core, {
TxRemoveDoc,
TxResult,
TxUpdateDoc,
WithLookup
TxWorkspaceEvent,
WithLookup,
WorkspaceEvent
} from '@hcengineering/core'
import { deepEqual } from 'fast-equals'
@ -57,7 +61,7 @@ interface Query {
result: Doc[] | Promise<Doc[]>
options?: FindOptions<Doc>
total: number
callbacks: Callback[]
callbacks: Map<string, Callback>
}
/**
@ -138,12 +142,10 @@ export class LiveQuery extends TxProcessor implements Client {
options?: FindOptions<T>
): Query {
const callback: () => void = () => {}
const q = this.createQuery(_class, query, callback, options)
const index = q.callbacks.indexOf(callback as (result: Doc[]) => void)
if (index !== -1) {
q.callbacks.splice(index, 1)
}
if (q.callbacks.length === 0) {
const callbackId = generateId()
const q = this.createQuery(_class, query, { callback, callbackId }, options)
q.callbacks.delete(callbackId)
if (q.callbacks.size === 0) {
this.queue.push(q)
}
return q
@ -213,7 +215,7 @@ export class LiveQuery extends TxProcessor implements Client {
}
private removeFromQueue (q: Query): boolean {
if (q.callbacks.length === 0) {
if (q.callbacks.size === 0) {
const queueIndex = this.queue.indexOf(q)
if (queueIndex !== -1) {
this.queue.splice(queueIndex, 1)
@ -223,8 +225,14 @@ export class LiveQuery extends TxProcessor implements Client {
return false
}
private pushCallback (q: Query, callback: (result: Doc[]) => void): void {
q.callbacks.push(callback)
private pushCallback (
q: Query,
callback: {
callback: (result: Doc[]) => void
callbackId: string
}
): void {
q.callbacks.set(callback.callbackId, callback.callback)
setTimeout(async () => {
if (q !== undefined) {
await this.callback(q)
@ -235,7 +243,10 @@ export class LiveQuery extends TxProcessor implements Client {
private getQuery<T extends Doc>(
_class: Ref<Class<T>>,
query: DocumentQuery<T>,
callback: (result: Doc[]) => void,
callback: {
callback: (result: Doc[]) => void
callbackId: string
},
options?: FindOptions<T>
): Query | undefined {
const current = this.findQuery(_class, query, options)
@ -250,7 +261,7 @@ export class LiveQuery extends TxProcessor implements Client {
private createQuery<T extends Doc>(
_class: Ref<Class<T>>,
query: DocumentQuery<T>,
callback: (result: FindResult<T>) => void,
callback: { callback: (result: FindResult<T>) => void, callbackId: string },
options?: FindOptions<T>
): Query {
const queries = this.queries.get(_class) ?? []
@ -261,8 +272,9 @@ export class LiveQuery extends TxProcessor implements Client {
result,
total: 0,
options: options as FindOptions<Doc>,
callbacks: [callback as (result: Doc[]) => void]
callbacks: new Map()
}
q.callbacks.set(callback.callbackId, callback.callback as unknown as Callback)
queries.push(q)
result
.then(async (result) => {
@ -303,16 +315,14 @@ export class LiveQuery extends TxProcessor implements Client {
modifiedOn: 1
}
}
const callbackId = generateId()
const q =
this.getQuery(_class, query, callback as (result: Doc[]) => void, options) ??
this.createQuery(_class, query, callback, options)
this.getQuery(_class, query, { callback: callback as (result: Doc[]) => void, callbackId }, options) ??
this.createQuery(_class, query, { callback, callbackId }, options)
return () => {
const index = q.callbacks.indexOf(callback as (result: Doc[]) => void)
if (index !== -1) {
q.callbacks.splice(index, 1)
}
if (q.callbacks.length === 0) {
q.callbacks.delete(callbackId)
if (q.callbacks.size === 0) {
this.queue.push(q)
}
}
@ -329,12 +339,16 @@ export class LiveQuery extends TxProcessor implements Client {
return true
} else {
const pos = q.result.findIndex((p) => p._id === _id)
q.result.splice(pos, 1)
q.total--
if (pos !== -1) {
q.result.splice(pos, 1)
q.total--
}
}
} else {
const pos = q.result.findIndex((p) => p._id === _id)
q.result[pos] = match
if (pos !== -1) {
q.result[pos] = match
}
}
return false
}
@ -772,7 +786,7 @@ export class LiveQuery extends TxProcessor implements Client {
q.result = await q.result
}
const result = q.result
q.callbacks.forEach((callback) => {
Array.from(q.callbacks.values()).forEach((callback) => {
callback(toFindResult(this.clone(result), q.total))
})
}
@ -938,9 +952,42 @@ export class LiveQuery extends TxProcessor implements Client {
}
async tx (tx: Tx): Promise<TxResult> {
if (tx._class === core.class.TxWorkspaceEvent) {
await this.checkUpdateFulltextQueries(tx)
return {}
}
return await super.tx(tx)
}
private async checkUpdateFulltextQueries (tx: Tx): Promise<void> {
const evt = tx as TxWorkspaceEvent
if (evt.event === WorkspaceEvent.IndexingUpdate) {
const indexingParam = evt.params as IndexingUpdateEvent
for (const q of [...this.queue]) {
if (indexingParam._class.includes(q._class) && q.query.$search !== undefined) {
if (!(await this.removeFromQueue(q))) {
try {
await this.refresh(q)
} catch (err) {
console.error(err)
}
}
}
}
for (const v of this.queries.values()) {
for (const q of v) {
if (indexingParam._class.includes(q._class) && q.query.$search !== undefined) {
try {
await this.refresh(q)
} catch (err) {
console.error(err)
}
}
}
}
}
}
private async __updateLookup (q: Query, updatedDoc: WithLookup<Doc>, ops: any): Promise<void> {
for (const key in ops) {
if (!key.startsWith('$')) {

View File

@ -15,19 +15,32 @@
-->
<script lang="ts">
import board, { Card } from '@hcengineering/board'
import { Class, Doc, DocumentQuery, FindOptions, Ref, SortingOrder, WithLookup } from '@hcengineering/core'
import {
CategoryType,
Class,
Doc,
DocumentQuery,
DocumentUpdate,
FindOptions,
Ref,
SortingOrder,
WithLookup
} from '@hcengineering/core'
import { Kanban as KanbanUI } from '@hcengineering/kanban'
import { createQuery, getClient } from '@hcengineering/presentation'
import type { Kanban, SpaceWithStates, State } from '@hcengineering/task'
import type { DocWithRank, Kanban, SpaceWithStates, State } from '@hcengineering/task'
import task, { calcRank } from '@hcengineering/task'
import { getEventPositionElement, showPopup } from '@hcengineering/ui'
import {
ActionContext,
ContextMenu,
focusStore,
getGroupByValues,
groupBy,
ListSelectionProvider,
SelectDirection,
selectionStore
selectionStore,
setGroupByValues
} from '@hcengineering/view-resources'
import { onMount } from 'svelte'
import AddCard from './add-card/AddCard.svelte'
@ -72,7 +85,8 @@
async function addItem (title: any) {
const lastOne = await client.findOne(task.class.State, {}, { sort: { rank: SortingOrder.Descending } })
await client.createDoc(task.class.State, space, {
title,
name: title,
ofAttribute: task.attribute.State,
color: 9,
rank: calcRank(lastOne, undefined)
})
@ -97,6 +111,33 @@
}
$: resultQuery = { ...query, doneState: null, isArchived: { $nin: [true] }, space }
const cardQuery = createQuery()
let cards: DocWithRank[] = []
$: cardQuery.query<DocWithRank>(
_class,
resultQuery,
(result) => {
cards = result
},
{
...options
}
)
$: groupByDocs = groupBy(cards, 'state')
const getUpdateProps = (doc: Doc, category: CategoryType): DocumentUpdate<DocWithRank> | undefined => {
const groupValue =
typeof category === 'object' ? category.values.find((it) => it.space === doc.space)?._id : category
if (groupValue === undefined) {
return undefined
}
return {
state: groupValue,
space: doc.space
} as any
}
</script>
<ActionContext
@ -106,17 +147,18 @@
/>
<KanbanUI
bind:this={kanbanUI}
{_class}
{options}
query={resultQuery}
{states}
fieldName={'state'}
objects={cards}
getGroupByValues={(groupByDocs, category) => getGroupByValues(groupByDocs, category)}
{setGroupByValues}
categories={states.map((it) => it._id)}
on:content={(evt) => {
listProvider.update(evt.detail)
}}
on:obj-focus={(evt) => {
listProvider.updateFocus(evt.detail)
}}
{groupByDocs}
{getUpdateProps}
checked={$selectionStore ?? []}
on:check={(evt) => {
listProvider.updateSelection(evt.detail.docs, evt.detail.value)
@ -137,10 +179,16 @@
</svelte:fragment>
<svelte:fragment slot="header" let:state>
<ListHeader {state} />
{@const st = states.find((it) => it._id === state)}
{#if st}
<ListHeader state={st} />
{/if}
</svelte:fragment>
<svelte:fragment slot="afterCard" let:state={targetState}>
<AddCard {space} state={targetState} />
{@const st = states.find((it) => it._id === targetState)}
{#if st}
<AddCard {space} state={st} />
{/if}
</svelte:fragment>
</KanbanUI>

View File

@ -1,6 +1,13 @@
<script lang="ts">
import { State } from '@hcengineering/task'
import { Button, getEventPositionElement, getPlatformColor, IconMoreV, showPopup } from '@hcengineering/ui'
import {
Button,
getEventPositionElement,
getPlatformColor,
getPlatformColorForText,
IconMoreV,
showPopup
} from '@hcengineering/ui'
import { ContextMenu } from '@hcengineering/view-resources'
export let state: State
@ -10,12 +17,17 @@
}
</script>
<div class="flex-col h-16">
<div class="h-2 border-radius-1" style="background-color: {getPlatformColor(state.color)}" />
<div class="flex-between h-full font-medium pr-2 pl-4">
<span class="lines-limit-2">{state.title}</span>
<div class="flex">
<Button icon={IconMoreV} kind="transparent" on:click={showMenu} />
{#if state}
<div class="flex-col h-16">
<div
class="h-2 border-radius-1"
style="background-color: {state.color ? getPlatformColor(state.color) : getPlatformColorForText(state.name)}"
/>
<div class="flex-between h-full font-medium pr-2 pl-4">
<span class="lines-limit-2">{state.name}</span>
<div class="flex">
<Button icon={IconMoreV} kind="transparent" on:click={showMenu} />
</div>
</div>
</div>
</div>
{/if}

View File

@ -29,7 +29,9 @@ import core, {
Tx,
TxApplyIf,
TxHandler,
TxResult
TxResult,
TxWorkspaceEvent,
WorkspaceEvent
} from '@hcengineering/core'
import {
getMetadata,
@ -167,7 +169,10 @@ class Connection implements ClientConnection {
}
} else {
const tx = resp.result as Tx
if (tx?._class === core.class.TxModelUpgrade) {
if (
(tx?._class === core.class.TxWorkspaceEvent && (tx as TxWorkspaceEvent).event === WorkspaceEvent.Upgrade) ||
tx?._class === core.class.TxModelUpgrade
) {
console.log('Processing upgrade')
websocket.send(
serialize({

View File

@ -15,18 +15,18 @@
<script lang="ts">
import { CalendarMode } from '@hcengineering/calendar-resources'
import { Employee, EmployeeAccount } from '@hcengineering/contact'
import contact from '@hcengineering/contact-resources/src/plugin'
import { DocumentQuery, getCurrentAccount, Ref } from '@hcengineering/core'
import { Department, fromTzDate, Request, RequestType, Staff } from '@hcengineering/hr'
import { createQuery, getClient } from '@hcengineering/presentation'
import tracker, { Issue } from '@hcengineering/tracker'
import { Label } from '@hcengineering/ui'
import { groupBy } from '@hcengineering/view-resources'
import hr from '../plugin'
import { EmployeeReports, getEndDate, getStartDate } from '../utils'
import MonthTableView from './schedule/MonthTableView.svelte'
import MonthView from './schedule/MonthView.svelte'
import YearView from './schedule/YearView.svelte'
import { groupBy } from '@hcengineering/view-resources'
import contact from '@hcengineering/contact-resources/src/plugin'
export let department: Ref<Department>
export let descendants: Map<Ref<Department>, Department[]>

View File

@ -17,13 +17,13 @@
import chunter from '@hcengineering/chunter'
import type { Contact, Employee, Person } from '@hcengineering/contact'
import contact from '@hcengineering/contact'
import { ExpandRightDouble } from '@hcengineering/contact-resources'
import { EmployeeBox, ExpandRightDouble, UserBox } from '@hcengineering/contact-resources'
import {
Account,
Class,
Client,
fillDefaults,
Doc,
fillDefaults,
FindOptions,
generateId,
Markup,
@ -39,7 +39,6 @@
InlineAttributeBar,
SpaceSelect
} from '@hcengineering/presentation'
import { EmployeeBox, UserBox } from '@hcengineering/contact-resources'
import type { Applicant, Candidate, Vacancy } from '@hcengineering/recruit'
import task, { calcRank, State } from '@hcengineering/task'
import ui, {
@ -48,6 +47,7 @@
createFocusManager,
deviceOptionsStore as deviceInfo,
FocusHandler,
getColorNumberByText,
getPlatformColor,
Label,
showPopup,
@ -245,7 +245,7 @@
}
$: states = rawStates.map((s) => {
return { id: s._id, label: s.title, color: s.color }
return { id: s._id, label: s.name, color: s.color ?? getColorNumberByText(s.name) }
})
const manager = createFocusManager()
@ -414,8 +414,13 @@
>
<div slot="content" class="flex-row-center" class:empty={!selectedState}>
{#if selectedState}
<div class="color" style="background-color: {getPlatformColor(selectedState.color)}" />
<span class="label overflow-label">{selectedState.title}</span>
<div
class="color"
style="background-color: {getPlatformColor(
selectedState.color ?? getColorNumberByText(selectedState.name)
)}"
/>
<span class="label overflow-label">{selectedState.name}</span>
{:else}
<div class="color" />
<span class="label overflow-label"><Label label={presentation.string.NotSelected} /></span>

View File

@ -26,6 +26,7 @@
createFocusManager,
deviceOptionsStore as deviceInfo,
FocusHandler,
getColorNumberByText,
getPlatformColor,
Label,
ListView,
@ -100,7 +101,7 @@
}
$: states = rawStates.map((s) => {
return { id: s._id, label: s.title, color: s.color }
return { id: s._id, label: s.name, color: s.color ?? getColorNumberByText(s.name) }
})
const manager = createFocusManager()

View File

@ -64,7 +64,8 @@
task.class.KanbanTemplate,
'statesC',
{
title: 'New State',
ofAttribute: task.attribute.State,
name: 'New State',
color: 9,
rank: [...genRanks(1)][0]
}
@ -74,12 +75,12 @@
const doneStates = [
{
class: task.class.WonStateTemplate,
title: 'Won',
name: 'Won',
rank: ranks[0]
},
{
class: task.class.LostStateTemplate,
title: 'Lost',
name: 'Lost',
rank: ranks[1]
}
]
@ -93,7 +94,8 @@
task.class.KanbanTemplate,
'doneStatesC',
{
title: ds.title,
ofAttribute: task.attribute.DoneState,
name: ds.name,
rank: ds.rank
}
)

View File

@ -13,10 +13,9 @@
// limitations under the License.
-->
<script lang="ts">
import core, { Class, Doc, Ref, RefTo } from '@hcengineering/core'
import core, { Class, Doc, DOMAIN_STATUS, Ref, RefTo } from '@hcengineering/core'
import { TypeRef } from '@hcengineering/model'
import { getClient } from '@hcengineering/presentation'
import { DOMAIN_STATE } from '@hcengineering/task'
import { DropdownLabelsIntl, Label } from '@hcengineering/ui'
import view from '@hcengineering/view-resources/src/plugin'
import { createEventDispatcher } from 'svelte'
@ -35,7 +34,7 @@
return (
hierarchy.hasMixin(p, view.mixin.AttributeEditor) &&
p.label !== undefined &&
hierarchy.getDomain(p._id) !== DOMAIN_STATE
hierarchy.getDomain(p._id) !== DOMAIN_STATUS
)
})
.map((p) => {

View File

@ -33,7 +33,6 @@
"Kanban": "Kanban",
"ApplicationLabelTask": "Tasks",
"Projects": "Projects",
"CreateProject": "New Project",
"ProjectNamePlaceholder": "Project name",
"TaskNamePlaceholder": "The boring task",
"TodoDescriptionPlaceholder": "todo...",

View File

@ -33,7 +33,6 @@
"Kanban": "Канбан",
"ApplicationLabelTask": "Задачи",
"Projects": "Проекты",
"CreateProject": "Новый проект",
"ProjectNamePlaceholder": "Название проекта",
"TaskNamePlaceholder": "Задача",
"TodoDescriptionPlaceholder": "todo...",

View File

@ -1,75 +0,0 @@
<!--
// Copyright © 2020 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 core, { Ref } from '@hcengineering/core'
import presentation, { getClient, SpaceCreateCard } from '@hcengineering/presentation'
import { EditBox, Grid, IconFolder, ToggleWithLabel } from '@hcengineering/ui'
import { createEventDispatcher } from 'svelte'
import task, { createKanban, KanbanTemplate } from '@hcengineering/task'
import KanbanTemplateSelector from './kanban/KanbanTemplateSelector.svelte'
import plugin from '../plugin'
const dispatch = createEventDispatcher()
let name: string = ''
const description: string = ''
let templateId: Ref<KanbanTemplate> | undefined
export function canClose (): boolean {
return name === '' && templateId !== undefined
}
const client = getClient()
async function createProject (): Promise<void> {
if (
templateId !== undefined &&
(await client.findOne(task.class.KanbanTemplate, { _id: templateId })) === undefined
) {
throw Error(`Failed to find target kanban template: ${templateId}`)
}
const id = await client.createDoc(task.class.Project, core.space.Space, {
name,
description,
private: false,
archived: false,
members: []
})
await createKanban(client, id, templateId)
}
</script>
<SpaceCreateCard
label={plugin.string.CreateProject}
okAction={createProject}
canSave={name.length > 0}
on:close={() => {
dispatch('close')
}}
>
<Grid column={1} rowGap={1.5}>
<EditBox
label={plugin.string.ProjectName}
icon={IconFolder}
bind:value={name}
placeholder={plugin.string.ProjectNamePlaceholder}
focus
/>
<ToggleWithLabel label={presentation.string.MakePrivate} description={presentation.string.MakePrivateDescription} />
<KanbanTemplateSelector folders={[task.space.ProjectTemplates]} bind:template={templateId} />
</Grid>
</SpaceCreateCard>

View File

@ -106,7 +106,7 @@
p._id,
{
_id: p._id,
label: p.title,
label: p.name,
values: [
{ color: 10, value: 0 },
{ color: 0, value: 0 },

View File

@ -1,78 +0,0 @@
<!--
// 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">
import { getClient } from '@hcengineering/presentation'
import type { Issue } from '@hcengineering/task'
import task from '@hcengineering/task'
import { StyledTextBox } from '@hcengineering/text-editor'
import { EditBox, Grid } from '@hcengineering/ui'
import { createEventDispatcher, onMount } from 'svelte'
import plugin from '../plugin'
export let object: Issue
const dispatch = createEventDispatcher()
const client = getClient()
function change (field: string, value: any) {
client.updateCollection(
object._class,
object.space,
object._id,
object.attachedTo,
object.attachedToClass,
object.collection,
{ [field]: value }
)
}
onMount(() => {
dispatch('open', { ignoreKeys: ['comments', 'name', 'description', 'number'] })
})
const onChangeDescription = (evt: CustomEvent<any>) => change('description', evt.detail)
</script>
{#if object !== undefined}
<Grid column={1} rowGap={1.5}>
<EditBox
label={plugin.string.TaskName}
bind:value={object.name}
icon={task.icon.Task}
placeholder={plugin.string.TaskNamePlaceholder}
focus
on:change={() => change('name', object.name)}
/>
<div class="description">
<StyledTextBox
bind:content={object.description}
placeholder={plugin.string.DescriptionPlaceholder}
on:value={onChangeDescription}
/>
</div>
</Grid>
{/if}
<style lang="scss">
.description {
display: flex;
padding: 1rem;
height: 12rem;
border-radius: 0.25rem;
background-color: var(--accent-bg-color);
border: 1px solid var(--divider-color);
}
</style>

View File

@ -1,78 +0,0 @@
<!--
// 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 { AttachmentsPresenter } from '@hcengineering/attachment-resources'
import { CommentsPresenter } from '@hcengineering/chunter-resources'
import type { WithLookup } from '@hcengineering/core'
import notification from '@hcengineering/notification'
import { Avatar } from '@hcengineering/contact-resources'
import type { Issue, TodoItem } from '@hcengineering/task'
import { ActionIcon, Component, IconMoreH, showPopup, tooltip } from '@hcengineering/ui'
import { ContextMenu } from '@hcengineering/view-resources'
import task from '../plugin'
import TaskPresenter from './TaskPresenter.svelte'
export let object: WithLookup<Issue>
const showMenu = (ev?: Event): void => {
showPopup(ContextMenu, { object }, ev ? (ev.target as HTMLElement) : null)
}
$: todoItems = (object.$lookup?.todoItems as TodoItem[]) ?? []
$: doneTasks = todoItems.filter((it) => it.done)
</script>
<div class="flex-col pt-2 pb-2 pr-4 pl-4">
<div class="flex-between mb-2">
<div class="flex">
<TaskPresenter value={object} />
{#if todoItems.length > 0}
<span
class="ml-2"
use:tooltip={{
label: task.string.TodoItems,
component: task.component.TodoItemsPopup,
props: { value: object }
}}
>
({doneTasks?.length}/{todoItems.length})
</span>
{/if}
</div>
<div class="flex-row-center">
<div class="mr-2">
<Component is={notification.component.NotificationPresenter} props={{ value: object }} />
</div>
<ActionIcon label={task.string.More} action={showMenu} icon={IconMoreH} size={'small'} />
</div>
</div>
<div class="caption-color mb-3 lines-limit-4">{object.name}</div>
<!-- <div class="text-sm lines-limit-2">{object.description}</div> -->
<div class="flex-between">
<div class="flex-row-center">
{#if (object.attachments ?? 0) > 0}
<div class="step-lr75">
<AttachmentsPresenter value={object.attachments} {object} />
</div>
{/if}
{#if (object.comments ?? 0) > 0}
<div class="step-lr75">
<CommentsPresenter value={object.comments} {object} />
</div>
{/if}
</div>
<Avatar avatar={object.$lookup?.assignee?.avatar} size={'x-small'} />
</div>
</div>

View File

@ -64,7 +64,7 @@
itemsDS = doneStates.map((s) => {
return {
id: s._id,
label: s.title,
label: s.name,
icon: s._class === task.class.WonState ? Won : Lost,
color: s._class === task.class.WonState ? 'var(--won-color)' : 'var(--lost-color)'
}

View File

@ -1,48 +0,0 @@
<!--
// 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">
import { getClient } from '@hcengineering/presentation'
import type { Issue, Task } from '@hcengineering/task'
import { Icon } from '@hcengineering/ui'
import task from '../plugin'
export let value: Task
const client = getClient()
const shortLabel = client.getHierarchy().getClass(value._class).shortLabel
let name: string | undefined = undefined
$: if (client.getHierarchy().isDerived(value._class, task.class.Issue)) {
name = (value as Issue).name
}
</script>
<div class="flex item">
<Icon icon={task.icon.Task} size={'large'} />
<div class="ml-2">
{#if shortLabel}{shortLabel}-{/if}{value.number}
</div>
{#if name}
<div class="ml-1">{name}</div>
{/if}
</div>
<style lang="scss">
.item {
align-items: center;
}
</style>

View File

@ -15,12 +15,12 @@
-->
<script lang="ts">
import { getClient } from '@hcengineering/presentation'
import type { Issue } from '@hcengineering/task'
import type { Task } from '@hcengineering/task'
import { Icon } from '@hcengineering/ui'
import { DocNavLink } from '@hcengineering/view-resources'
import task from '../plugin'
export let value: Issue
export let value: Task
export let inline: boolean = false
const client = getClient()

View File

@ -61,7 +61,7 @@
on:drop={onDone(wonState)}
>
<div class="mr-2"><Won size={'small'} /></div>
{wonState.title}
{wonState.name}
</div>
{/each}
{#each lostStates as lostState}
@ -80,7 +80,7 @@
on:drop={onDone(lostState)}
>
<div class="mr-2"><Lost size={'small'} /></div>
{lostState.title}
{lostState.name}
</div>
{/each}
</div>

View File

@ -78,12 +78,14 @@
const lastOne = await client.findOne(_class, {}, { sort: { rank: SortingOrder.Descending } })
if (hierarchy.isDerived(_class, task.class.DoneState)) {
await client.createDoc(_class, kanban.space, {
title: 'New Done State',
ofAttribute: task.attribute.State,
name: 'New Done State',
rank: calcRank(lastOne, undefined)
})
} else {
await client.createDoc(task.class.State, kanban.space, {
title: 'New State',
ofAttribute: task.attribute.State,
name: 'New State',
color: 9,
rank: calcRank(lastOne, undefined)
})

View File

@ -105,12 +105,14 @@
if (hierarchy.isDerived(_class, task.class.DoneState)) {
const targetClass = _class === task.class.WonState ? task.class.WonStateTemplate : task.class.LostStateTemplate
await client.addCollection(targetClass, kanban.space, kanban._id, kanban._class, 'doneStatesC', {
title: 'New Done State',
ofAttribute: task.attribute.DoneState,
name: 'New Done State',
rank: calcRank(lastOne, undefined)
})
} else {
await client.addCollection(task.class.StateTemplate, kanban.space, kanban._id, kanban._class, 'statesC', {
title: 'New State',
name: 'New State',
ofAttribute: task.attribute.DoneState,
color: 9,
rank: calcRank(lastOne, undefined)
})

View File

@ -14,61 +14,94 @@
// limitations under the License.
-->
<script lang="ts">
import { Class, Doc, DocumentQuery, FindOptions, Ref, SortingOrder } from '@hcengineering/core'
import { Kanban as KanbanUI } from '@hcengineering/kanban'
import contact from '@hcengineering/contact'
import {
CategoryType,
Class,
Doc,
DocumentQuery,
DocumentUpdate,
FindOptions,
generateId,
Ref
} from '@hcengineering/core'
import { Item, Kanban as KanbanUI } from '@hcengineering/kanban'
import { getResource } from '@hcengineering/platform'
import { createQuery, getClient } from '@hcengineering/presentation'
import type { Kanban, SpaceWithStates, State, Task } from '@hcengineering/task'
import task from '@hcengineering/task'
import { getEventPositionElement, showPopup } from '@hcengineering/ui'
import { createQuery, getClient, statusStore } from '@hcengineering/presentation'
import { Kanban, SpaceWithStates, Task, TaskGrouping, TaskOrdering } from '@hcengineering/task'
import { getEventPositionElement, Label, showPopup } from '@hcengineering/ui'
import {
AttributeModel,
CategoryOption,
Viewlet,
ViewOptionModel,
ViewOptions,
ViewQueryOption
} from '@hcengineering/view'
import {
ActionContext,
focusStore,
getCategories,
getGroupByValues,
getPresenter,
groupBy,
ListSelectionProvider,
Menu,
noCategory,
SelectDirection,
selectionStore
selectionStore,
setGroupByValues
} from '@hcengineering/view-resources'
import view from '@hcengineering/view-resources/src/plugin'
import { onMount } from 'svelte'
import task from '../../plugin'
import KanbanDragDone from './KanbanDragDone.svelte'
export let _class: Ref<Class<Task>>
export let space: Ref<SpaceWithStates>
export let query: DocumentQuery<Task>
export let options: FindOptions<Task> | undefined
export let space: Ref<SpaceWithStates> | undefined = undefined
export let baseMenuClass: Ref<Class<Doc>> | undefined = undefined
// export let config: string[]
export let query: DocumentQuery<Task> = {}
export let viewOptionsConfig: ViewOptionModel[] | undefined
export let viewOptions: ViewOptions
export let viewlet: Viewlet
let kanban: Kanban
let states: State[] = []
export let options: FindOptions<Task> | undefined
const client = getClient()
$: currentSpace = space
$: groupByKey = (viewOptions.groupBy[0] ?? noCategory) as TaskGrouping
$: orderBy = viewOptions.orderBy
$: sort = { [orderBy[0]]: orderBy[1] }
const kanbanQuery = createQuery()
$: kanbanQuery.query(task.class.Kanban, { attachedTo: space }, (result) => {
kanban = result[0]
$: dontUpdateRank = orderBy[0] !== TaskOrdering.Manual
const spaceQuery = createQuery()
let currentProject: SpaceWithStates | undefined
$: spaceQuery.query(task.class.SpaceWithStates, { _id: currentSpace }, (res) => {
currentProject = res.shift()
})
const statesQuery = createQuery()
$: if (kanban !== undefined) {
statesQuery.query(
task.class.State,
{ space: kanban.space },
(result) => {
states = result
},
{
sort: {
rank: SortingOrder.Ascending
}
}
)
}
let resultQuery: DocumentQuery<any> = { ...query, doneState: null }
$: getResultQuery(query, viewOptionsConfig, viewOptions).then((p) => (resultQuery = p))
$: clazz = client.getHierarchy().getClass(_class)
$: presenterMixin = client.getHierarchy().as(clazz, task.mixin.KanbanCard)
$: cardPresenter = getResource(presenterMixin.card)
/* eslint-disable no-undef */
const client = getClient()
const hierarchy = client.getHierarchy()
async function getResultQuery (
query: DocumentQuery<Task>,
viewOptions: ViewOptionModel[] | undefined,
viewOptionsStore: ViewOptions
): Promise<DocumentQuery<Task>> {
if (viewOptions === undefined) return query
let result = hierarchy.clone(query)
for (const viewOption of viewOptions) {
if (viewOption.actionTarget !== 'query') continue
const queryOption = viewOption as ViewQueryOption
const f = await getResource(queryOption.action)
result = f(viewOptionsStore[queryOption.key] ?? queryOption.defaultValue, query)
}
return result
}
let kanbanUI: KanbanUI
const listProvider = new ListSelectionProvider((offset: 1 | -1 | 0, of?: Doc, dir?: SelectDirection) => {
@ -84,18 +117,100 @@
// selection = undefined
})
}
const onContent = (evt: any) => {
listProvider.update(evt.detail)
}
const onObjFocus = (evt: any) => {
listProvider.updateFocus(evt.detail)
}
const handleCheck = (evt: any) => {
listProvider.updateSelection(evt.detail.docs, evt.detail.value)
}
const onContextMenu = (evt: any) => showMenu(evt.detail.evt, evt.detail.objects)
const issuesQuery = createQuery()
let tasks: Task[] = []
$: resultQuery = { ...query, doneState: null }
$: groupByDocs = groupBy(tasks, groupByKey, categories)
$: issuesQuery.query<Task>(
_class,
resultQuery,
(result) => {
tasks = result
},
{
...options,
lookup: {
...options?.lookup,
assignee: contact.class.Employee,
space: task.class.SpaceWithStates,
state: task.class.State,
doneState: task.class.DoneState
},
sort: {
...options?.sort,
...sort
}
}
)
let categories: CategoryType[] = []
const queryId = generateId()
$: updateCategories(_class, tasks, groupByKey, viewOptions, viewOptionsConfig)
function update () {
updateCategories(_class, tasks, groupByKey, viewOptions, viewOptionsConfig)
}
async function updateCategories (
_class: Ref<Class<Doc>>,
docs: Doc[],
groupByKey: string,
viewOptions: ViewOptions,
viewOptionsModel: ViewOptionModel[] | undefined
) {
categories = await getCategories(client, _class, docs, groupByKey, $statusStore, viewlet.descriptor)
for (const viewOption of viewOptionsModel ?? []) {
if (viewOption.actionTarget !== 'category') continue
const categoryFunc = viewOption as CategoryOption
if (viewOptions[viewOption.key] ?? viewOption.defaultValue) {
const categoryAction = await getResource(categoryFunc.action)
const res = await categoryAction(_class, space, groupByKey, update, queryId, $statusStore, viewlet.descriptor)
if (res !== undefined) {
categories = res
break
}
}
}
}
function getHeader (_class: Ref<Class<Doc>>, groupByKey: string): void {
if (groupByKey === noCategory) {
headerComponent = undefined
} else {
getPresenter(client, _class, { key: groupByKey }, { key: groupByKey }).then((p) => (headerComponent = p))
}
}
let headerComponent: AttributeModel | undefined
$: getHeader(_class, groupByKey)
const getUpdateProps = (doc: Doc, category: CategoryType): DocumentUpdate<Item> | undefined => {
const groupValue =
typeof category === 'object' ? category.values.find((it) => it.space === doc.space)?._id : category
if (groupValue === undefined) {
return undefined
}
return {
[groupByKey]: groupValue,
space: doc.space
}
}
$: clazz = client.getHierarchy().getClass(_class)
$: presenterMixin = client.getHierarchy().as(clazz, task.mixin.KanbanCard)
$: cardPresenter = getResource(presenterMixin.card)
let kanban: Kanban
const kanbanQuery = createQuery()
$: kanbanQuery.query(task.class.Kanban, { attachedTo: space }, (result) => {
kanban = result[0]
})
const getDoneUpdate = (e: any) => ({ doneState: e.detail._id } as DocumentUpdate<Doc>)
</script>
{#await cardPresenter then presenter}
@ -106,18 +221,41 @@
/>
<KanbanUI
bind:this={kanbanUI}
{_class}
{options}
query={resultQuery}
{states}
fieldName={'state'}
on:content={onContent}
on:obj-focus={onObjFocus}
checked={$selectionStore ?? []}
on:check={handleCheck}
on:contextmenu={onContextMenu}
{categories}
{dontUpdateRank}
objects={tasks}
getGroupByValues={(groupByDocs, category) =>
groupByKey === noCategory ? tasks : getGroupByValues(groupByDocs, category)}
{setGroupByValues}
{getUpdateProps}
{groupByDocs}
on:content={(evt) => {
listProvider.update(evt.detail)
}}
on:obj-focus={(evt) => {
listProvider.updateFocus(evt.detail)
}}
selection={listProvider.current($focusStore)}
checked={$selectionStore ?? []}
on:check={(evt) => {
listProvider.updateSelection(evt.detail.docs, evt.detail.value)
}}
on:contextmenu={(evt) => showMenu(evt.detail.evt, evt.detail.objects)}
>
<svelte:fragment slot="header" let:state let:count>
<!-- {@const status = $statusStore.get(state._id)} -->
<div class="header flex-col">
<div class="flex-row-center flex-between">
{#if groupByKey === noCategory}
<span class="text-base fs-bold overflow-label content-accent-color pointer-events-none">
<Label label={view.string.NoGrouping} />
</span>
{:else if headerComponent}
<svelte:component this={headerComponent.presenter} value={state} {space} kind={'list-header'} />
{/if}
</div>
</div>
</svelte:fragment>
<svelte:fragment slot="card" let:object let:dragged>
<svelte:component this={presenter} {object} {dragged} />
</svelte:fragment>
@ -127,9 +265,40 @@
{kanban}
on:done={(e) => {
// eslint-disable-next-line no-undef
onDone({ doneState: e.detail._id })
onDone(getDoneUpdate(e))
}}
/>
</svelte:fragment>
</KanbanUI>
{/await}
<style lang="scss">
.names {
font-size: 0.8125rem;
}
.header {
padding-bottom: 0.75rem;
border-bottom: 1px solid var(--divider-color);
.label {
color: var(--caption-color);
.counter {
color: rgba(var(--caption-color), 0.8);
}
}
}
.tracker-card {
position: relative;
display: flex;
flex-direction: column;
justify-content: center;
// padding: 0.5rem 1rem;
min-height: 6.5rem;
}
.states-bar {
flex-shrink: 10;
width: fit-content;
margin: 0.625rem 1rem 0;
}
</style>

View File

@ -32,7 +32,7 @@
<svelte:component this={value._class === task.class.WonState ? Won : Lost} size={'small'} />
</div>
{#if showTitle}
{value.title}
{value.name}
{/if}
</div>
{/if}

View File

@ -14,20 +14,16 @@
// limitations under the License.
-->
<script lang="ts">
import { Ref } from '@hcengineering/core'
import { createQuery } from '@hcengineering/presentation'
import { Ref, StatusValue } from '@hcengineering/core'
import { statusStore } from '@hcengineering/presentation'
import type { DoneState } from '@hcengineering/task'
import task from '@hcengineering/task'
import DoneStatePresenter from './DoneStatePresenter.svelte'
export let value: Ref<DoneState> | null | undefined
export let value: Ref<DoneState> | StatusValue
export let showTitle: boolean = true
let state: DoneState | undefined
const query = createQuery()
$: value && query.query(task.class.DoneState, { _id: value }, (res) => ([state] = res), { limit: 1 })
</script>
{#if state}
{#if value}
{@const state = $statusStore.get(typeof value === 'string' ? value : value.values[0]._id)}
<DoneStatePresenter value={state} {showTitle} />
{/if}

View File

@ -59,7 +59,7 @@
<div class="mr-2" style="color: {getColor(state._class)}">
<svelte:component this={state._class === task.class.WonState ? Won : Lost} size={'small'} />
</div>
<span class="label">{state.title}</span>
<span class="label">{state.name}</span>
</button>
{/each}
<button

View File

@ -15,15 +15,18 @@
-->
<script lang="ts">
import type { State } from '@hcengineering/task'
import { getPlatformColor } from '@hcengineering/ui'
import { getColorNumberByText, getPlatformColor } from '@hcengineering/ui'
export let value: State
export let value: State | undefined
</script>
{#if value}
<div class="flex-row-center">
<div class="state-container" style="background-color: {getPlatformColor(value.color)}" />
<span class="overflow-label">{value.title}</span>
<div
class="state-container"
style="background-color: {getPlatformColor(value.color ?? getColorNumberByText(value.name))}"
/>
<span class="overflow-label">{value.name}</span>
</div>
{/if}

View File

@ -14,23 +14,20 @@
// limitations under the License.
-->
<script lang="ts">
import { Ref } from '@hcengineering/core'
import { createQuery } from '@hcengineering/presentation'
import task, { State } from '@hcengineering/task'
import { Ref, StatusValue } from '@hcengineering/core'
import { statusStore } from '@hcengineering/presentation'
import { State } from '@hcengineering/task'
import StateEditor from './StateEditor.svelte'
import StatePresenter from './StatePresenter.svelte'
export let value: Ref<State>
export let value: Ref<State> | StatusValue
export let onChange: ((value: Ref<State>) => void) | undefined = undefined
let state: State | undefined
const query = createQuery()
$: query.query(task.class.State, { _id: value }, (res) => ([state] = res), { limit: 1 })
</script>
{#if state}
{#if onChange !== undefined}
<StateEditor {value} space={state.space} {onChange} kind="link" size="medium" />
{#if value}
{@const state = $statusStore.get(typeof value === 'string' ? value : value.values[0]._id)}
{#if onChange !== undefined && state !== undefined}
<StateEditor value={state._id} space={state.space} {onChange} kind="link" size="medium" />
{:else}
<StatePresenter value={state} />
{/if}

View File

@ -14,37 +14,23 @@
// limitations under the License.
-->
<script lang="ts">
import { Ref, SortingOrder } from '@hcengineering/core'
import { createQuery } from '@hcengineering/presentation'
import { Ref } from '@hcengineering/core'
import { statusStore } from '@hcengineering/presentation'
import task, { SpaceWithStates, State } from '@hcengineering/task'
import { getPlatformColor, ScrollerBar } from '@hcengineering/ui'
import { getColorNumberByText, getPlatformColor, ScrollerBar } from '@hcengineering/ui'
import { createEventDispatcher } from 'svelte'
import StatesBarElement from './StatesBarElement.svelte'
import type { StatesBarPosition } from '../..'
import StatesBarElement from './StatesBarElement.svelte'
export let space: Ref<SpaceWithStates>
export let state: Ref<State> | undefined = undefined
export let gap: 'none' | 'small' | 'big' = 'small'
let states: State[] = []
$: states = $statusStore.filter((it) => it.space === space && it.ofAttribute === task.attribute.State)
let divScroll: HTMLElement
const dispatch = createEventDispatcher()
const statesQuery = createQuery()
statesQuery.query(
task.class.State,
space != null ? { space } : {},
(res) => {
states = res
},
{
sort: {
rank: SortingOrder.Ascending
}
}
)
const selectItem = (ev: Event, item: State): void => {
const el: HTMLElement = ev.currentTarget as HTMLElement
const rect = el.getBoundingClientRect()
@ -72,10 +58,10 @@
<ScrollerBar {gap} bind:scroller={divScroll}>
{#each states as item, i (item._id)}
<StatesBarElement
label={item.title}
label={item.name}
position={getPosition(i)}
selected={item._id === state}
color={getPlatformColor(item.color)}
color={getPlatformColor(item.color ?? getColorNumberByText(item.name))}
on:click={(ev) => {
if (item._id !== state) selectItem(ev, item)
}}

View File

@ -26,7 +26,8 @@
getPlatformColor,
eventToHTMLElement,
Component,
IconCircles
IconCircles,
getColorNumberByText
} from '@hcengineering/ui'
import { createEventDispatcher } from 'svelte'
import { ColorsPopup } from '@hcengineering/view-resources'
@ -126,13 +127,18 @@
<!-- svelte-ignore a11y-click-events-have-key-events -->
<div
class="color"
style="background-color: {getPlatformColor(state.color)}"
style="background-color: {getPlatformColor(state.color ?? getColorNumberByText(state.name))}"
on:click={() => {
showPopup(ColorsPopup, { selected: getPlatformColor(state.color) }, elements[i], onColorChange(state))
showPopup(
ColorsPopup,
{ selected: getPlatformColor(state.color ?? getColorNumberByText(state.name)) },
elements[i],
onColorChange(state)
)
}}
/>
<div class="flex-grow caption-color">
<AttributeEditor maxWidth={'20rem'} _class={state._class} object={state} key="title" />
<AttributeEditor maxWidth={'20rem'} _class={state._class} object={state} key="name" />
</div>
{#if states.length > 1}
<!-- svelte-ignore a11y-click-events-have-key-events -->
@ -174,7 +180,7 @@
<Won size={'medium'} />
</div>
<div class="flex-grow caption-color">
<AttributeEditor maxWidth={'13rem'} _class={state._class} object={state} key="title" />
<AttributeEditor maxWidth={'13rem'} _class={state._class} object={state} key="name" />
</div>
{#if wonStates.length > 1}
<!-- svelte-ignore a11y-click-events-have-key-events -->
@ -217,7 +223,7 @@
<Lost size={'medium'} />
</div>
<div class="flex-grow caption-color">
<AttributeEditor maxWidth={'13rem'} _class={state._class} object={state} key="title" />
<AttributeEditor maxWidth={'13rem'} _class={state._class} object={state} key="name" />
</div>
{#if lostStates.length > 1}
<!-- svelte-ignore a11y-click-events-have-key-events -->

View File

@ -17,7 +17,7 @@
import { Ref, SortingOrder } from '@hcengineering/core'
import { createQuery } from '@hcengineering/presentation'
import task, { SpaceWithStates, State } from '@hcengineering/task'
import { getPlatformColor, resizeObserver } from '@hcengineering/ui'
import { getColorNumberByText, getPlatformColor, resizeObserver } from '@hcengineering/ui'
import { createEventDispatcher } from 'svelte'
export let space: Ref<SpaceWithStates>
@ -48,8 +48,11 @@
dispatch('close', state)
}}
>
<div class="color" style="background-color: {getPlatformColor(state.color)}" />
<span class="label">{state.title}</span>
<div
class="color"
style="background-color: {getPlatformColor(state.color ?? getColorNumberByText(state.name))}"
/>
<span class="label">{state.name}</span>
</button>
{/each}
</div>

View File

@ -18,12 +18,9 @@ import { Resources } from '@hcengineering/platform'
import { SpaceWithStates } from '@hcengineering/task'
import { showPopup } from '@hcengineering/ui'
import AssignedTasks from './components/AssignedTasks.svelte'
import CreateProject from './components/CreateProject.svelte'
import EditIssue from './components/EditIssue.svelte'
import KanbanTemplateEditor from './components/kanban/KanbanTemplateEditor.svelte'
import KanbanTemplateSelector from './components/kanban/KanbanTemplateSelector.svelte'
import KanbanView from './components/kanban/KanbanView.svelte'
import KanbanCard from './components/KanbanCard.svelte'
import DoneStateEditor from './components/state/DoneStateEditor.svelte'
import DoneStatePresenter from './components/state/DoneStatePresenter.svelte'
import EditStatuses from './components/state/EditStatuses.svelte'
@ -52,11 +49,8 @@ export type StatesBarPosition = 'start' | 'middle' | 'end' | undefined
export default async (): Promise<Resources> => ({
component: {
CreateProject,
TaskPresenter,
KanbanTemplatePresenter,
EditIssue,
KanbanCard,
Dashboard,
TemplatesIcon,
KanbanView,

View File

@ -20,7 +20,6 @@ import { AnyComponent } from '@hcengineering/ui'
export default mergeIds(taskId, task, {
string: {
CreateProject: '' as IntlString,
Description: '' as IntlString,
DescriptionPlaceholder: '' as IntlString,
ShortDescription: '' as IntlString,

View File

@ -16,28 +16,24 @@
import type { Employee } from '@hcengineering/contact'
import {
AttachedDoc,
Attribute,
Class,
Doc,
Domain,
Interface,
Markup,
Mixin,
Ref,
Space,
Status,
Timestamp,
TxOperations
} from '@hcengineering/core'
import { NotificationType } from '@hcengineering/notification'
import type { Asset, IntlString, Plugin } from '@hcengineering/platform'
import { plugin } from '@hcengineering/platform'
import type { AnyComponent } from '@hcengineering/ui'
import { ViewletDescriptor } from '@hcengineering/view'
import { genRanks } from './utils'
import { NotificationType } from '@hcengineering/notification'
/**
* @public
*/
export const DOMAIN_STATE = 'state' as Domain
/**
* @public
@ -46,22 +42,25 @@ export interface DocWithRank extends Doc {
rank: string
}
/**
* @public
*/
export interface SpaceWithStates extends Space {}
// S T A T E
/**
* @public
*/
export interface State extends DocWithRank {
title: string
color: number
export interface State extends Status {
isArchived?: boolean
}
/**
* @public
*/
export interface DoneState extends DocWithRank {
title: string
export interface DoneState extends Status {
name: string
}
/**
@ -99,27 +98,6 @@ export interface TodoItem extends AttachedDoc, DocWithRank {
items?: number
}
/**
* @public
*/
export interface SpaceWithStates extends Space {}
/**
* @public
*/
export interface Project extends SpaceWithStates {}
/**
* @public
*/
export interface Issue extends Task {
name: string
description: string
comments?: number
attachments?: number
}
/**
* @public
*/
@ -183,6 +161,26 @@ export interface KanbanTemplateSpace extends Space {
editor?: AnyComponent
}
/**
* @public
*/
export enum TaskGrouping {
State = 'state',
DoneStatus = 'doneState',
Assignee = 'assignee',
NoGrouping = '#no_category'
}
/**
* @public
*/
export enum TaskOrdering {
State = 'state',
LastUpdated = 'modifiedOn',
DueDate = 'dueDate',
Manual = 'rank'
}
/**
* @public
*/
@ -201,6 +199,10 @@ const task = plugin(taskId, {
interface: {
DocWithRank: '' as Ref<Interface<DocWithRank>>
},
attribute: {
State: '' as Ref<Attribute<State>>,
DoneState: '' as Ref<Attribute<DoneState>>
},
string: {
StartDate: '' as IntlString,
DueDate: '' as IntlString,
@ -230,8 +232,6 @@ const task = plugin(taskId, {
Dashboard: '' as IntlString
},
class: {
Issue: '' as Ref<Class<Issue>>,
Project: '' as Ref<Class<Project>>,
State: '' as Ref<Class<State>>,
DoneState: '' as Ref<Class<DoneState>>,
WonState: '' as Ref<Class<WonState>>,
@ -264,7 +264,7 @@ const task = plugin(taskId, {
},
global: {
// Global task root, if not attached to some other object.
Task: '' as Ref<Issue>
Task: '' as Ref<Task>
},
space: {
ProjectTemplates: '' as Ref<KanbanTemplateSpace>,
@ -293,7 +293,8 @@ export async function createKanban (
): Promise<Ref<Kanban>> {
if (templateId === undefined) {
await client.createDoc(task.class.State, attachedTo, {
title: 'New State',
ofAttribute: task.attribute.State,
name: 'New State',
color: 9,
rank: [...genRanks(1)][0]
})
@ -301,11 +302,13 @@ export async function createKanban (
const ranks = [...genRanks(2)]
await Promise.all([
client.createDoc(task.class.WonState, attachedTo, {
title: 'Won',
ofAttribute: task.attribute.DoneState,
name: 'Won',
rank: ranks[0]
}),
client.createDoc(task.class.LostState, attachedTo, {
title: 'Lost',
ofAttribute: task.attribute.DoneState,
name: 'Lost',
rank: ranks[1]
})
])
@ -325,8 +328,10 @@ export async function createKanban (
tmplStates.map(
async (state) =>
await client.createDoc(task.class.State, attachedTo, {
ofAttribute: task.attribute.State,
color: state.color,
title: state.title,
description: state.description,
name: state.name,
rank: state.rank
})
)
@ -345,7 +350,12 @@ export async function createKanban (
return
}
return await client.createDoc(cl, attachedTo, { title: state.title, rank: state.rank })
return await client.createDoc(cl, attachedTo, {
ofAttribute: task.attribute.DoneState,
description: state.description,
name: state.name,
rank: state.rank
})
})
)

View File

@ -42,7 +42,9 @@
"CreateProject": "Create project",
"NewProject": "New project",
"ProjectTitlePlaceholder": "Project title",
"ProjectIdentifierPlaceholder": "Project ID",
"Identifier": "Project Identifier",
"IdentifierExists": "Project identifier already exists",
"ProjectIdentifierPlaceholder": "Project Identifier",
"ChooseIcon": "Choose icon",
"AddIssue": "Add Issue",
"NewIssue": "New issue",

View File

@ -42,6 +42,8 @@
"CreateProject": "Создать проект",
"NewProject": "Новый проект",
"ProjectTitlePlaceholder": "Название проекта",
"Identifier": "Идентификатор проекта",
"IdentifierExists": "Идентификатор уже существует проекта",
"ProjectIdentifierPlaceholder": "Идентификатор проекта",
"ChooseIcon": "Выбрать иконку",
"AddIssue": "Добавить задачу",

View File

@ -13,7 +13,7 @@
// limitations under the License.
-->
<script lang="ts">
import { AttachedData, FindOptions, Ref, SortingOrder } from '@hcengineering/core'
import core, { AttachedData, FindOptions, Ref, SortingOrder } from '@hcengineering/core'
import { getClient, ObjectPopup } from '@hcengineering/presentation'
import { calcRank, Issue, IssueDraft } from '@hcengineering/tracker'
import { createEventDispatcher } from 'svelte'
@ -29,7 +29,7 @@
const options: FindOptions<Issue> = {
lookup: {
space: tracker.class.Project,
status: [tracker.class.IssueStatus, { category: tracker.class.IssueStatusCategory }]
status: [tracker.class.IssueStatus, { category: core.class.StatusCategory }]
},
sort: { modifiedOn: SortingOrder.Descending }
}

View File

@ -1,11 +1,11 @@
<script lang="ts">
import { StatusCategory } from '@hcengineering/core'
import { IconSize } from '@hcengineering/ui'
import { IssueStatusCategory } from '@hcengineering/tracker'
import tracker from '../../plugin'
export let size: IconSize
export let fill: string = 'currentColor'
export let category: IssueStatusCategory
export let category: StatusCategory
export let statusIcon: {
index: number | undefined
count: number | undefined

View File

@ -4,7 +4,7 @@
import { Issue, IssueStatus } from '@hcengineering/tracker'
import { Label, ticker } from '@hcengineering/ui'
import tracker from '../../plugin'
import { statusStore } from '../../utils'
import { statusStore } from '@hcengineering/presentation'
import Duration from './Duration.svelte'
import StatusPresenter from './StatusPresenter.svelte'

View File

@ -13,12 +13,12 @@
// limitations under the License.
-->
<script lang="ts">
import { WithLookup } from '@hcengineering/core'
import core, { StatusCategory, WithLookup } from '@hcengineering/core'
import { createQuery, getClient } from '@hcengineering/presentation'
import { IssueStatus, IssueStatusCategory } from '@hcengineering/tracker'
import { IssueStatus } from '@hcengineering/tracker'
import { getPlatformColor, IconSize } from '@hcengineering/ui'
import tracker from '../../plugin'
import { statusStore } from '../../utils'
import { statusStore } from '@hcengineering/presentation'
import StatusIcon from '../icons/StatusIcon.svelte'
export let value: WithLookup<IssueStatus>
@ -29,7 +29,7 @@
const client = getClient()
let category: IssueStatusCategory | undefined
let category: StatusCategory | undefined
let statuses: IssueStatus[] = []
const statusIcon: {
index: number | undefined
@ -40,8 +40,8 @@
$: if (value.category === tracker.issueStatusCategory.Started) {
const _s = [
...$statusStore.statuses.filter(
(it) => it.attachedTo === value.attachedTo && it.category === tracker.issueStatusCategory.Started
...$statusStore.filter(
(it) => it.ofAttribute === value.ofAttribute && it.category === tracker.issueStatusCategory.Started
)
]
_s.sort((a, b) => a.rank.localeCompare(b.rank))
@ -54,9 +54,9 @@
category = status.$lookup.category
}
if (category === undefined) {
category = await client.findOne(tracker.class.IssueStatusCategory, { _id: value.category })
category = await client.findOne(core.class.StatusCategory, { _id: value.category })
}
if (dynamicFillCategories.includes(value.category)) {
if (value.category !== undefined && dynamicFillCategories.includes(value.category)) {
const index = statuses.findIndex((p) => p._id === value._id)
if (index !== -1) {
statusIcon.index = index + 1

View File

@ -13,55 +13,66 @@
// limitations under the License.
-->
<script lang="ts">
import contact, { Employee } from '@hcengineering/contact'
import { employeeByIdStore, employeesStore } from '@hcengineering/contact-resources'
import { Class, Doc, DocumentQuery, generateId, IdMap, Lookup, Ref, toIdMap, WithLookup } from '@hcengineering/core'
import { Kanban, TypeState } from '@hcengineering/kanban'
import contact from '@hcengineering/contact'
import { employeeByIdStore } from '@hcengineering/contact-resources'
import {
CategoryType,
Class,
Doc,
DocumentQuery,
DocumentUpdate,
generateId,
Lookup,
Ref,
WithLookup
} from '@hcengineering/core'
import { Item, Kanban } from '@hcengineering/kanban'
import notification from '@hcengineering/notification'
import { getResource } from '@hcengineering/platform'
import { createQuery, getClient } from '@hcengineering/presentation'
import { createQuery, getClient, statusStore } from '@hcengineering/presentation'
import tags from '@hcengineering/tags'
import {
Component as ComponentType,
Issue,
IssuesGrouping,
IssuesOrdering,
IssueStatus,
Project,
Sprint
} from '@hcengineering/tracker'
import { Issue, IssuesGrouping, IssuesOrdering, Project } from '@hcengineering/tracker'
import {
Button,
Component,
getEventPositionElement,
Icon,
IconAdd,
Label,
Loading,
showPanel,
showPopup,
tooltip
} from '@hcengineering/ui'
import { CategoryOption, Viewlet, ViewOptionModel, ViewOptions, ViewQueryOption } from '@hcengineering/view'
import {
AttributeModel,
CategoryOption,
Viewlet,
ViewOptionModel,
ViewOptions,
ViewQueryOption
} from '@hcengineering/view'
import {
ActionContext,
focusStore,
getCategories,
getGroupByValues,
getPresenter,
groupBy,
ListSelectionProvider,
Menu,
noCategory,
SelectDirection,
selectionStore
selectionStore,
setGroupByValues
} from '@hcengineering/view-resources'
import { sortCategories } from '@hcengineering/view-resources/src/utils'
import view from '@hcengineering/view-resources/src/plugin'
import { onMount } from 'svelte'
import tracker from '../../plugin'
import { issuesGroupBySorting, mapKanbanCategories } from '../../utils'
import ComponentEditor from '../components/ComponentEditor.svelte'
import CreateIssue from '../CreateIssue.svelte'
import AssigneePresenter from './AssigneePresenter.svelte'
import SubIssuesSelector from './edit/SubIssuesSelector.svelte'
import IssuePresenter from './IssuePresenter.svelte'
import IssueStatusIcon from './IssueStatusIcon.svelte'
import ParentNamesPresenter from './ParentNamesPresenter.svelte'
import PriorityEditor from './PriorityEditor.svelte'
import StatusEditor from './StatusEditor.svelte'
@ -75,9 +86,12 @@
export let viewlet: Viewlet
$: currentSpace = space || tracker.project.DefaultProject
$: groupBy = (viewOptions.groupBy[0] ?? noCategory) as IssuesGrouping
$: groupByKey = (viewOptions.groupBy[0] ?? noCategory) as IssuesGrouping
$: orderBy = viewOptions.orderBy
$: sort = { [orderBy[0]]: orderBy[1] }
// issuesGroupBySorting[groupByKey]
$: dontUpdateRank = orderBy[0] !== IssuesOrdering.Manual
const spaceQuery = createQuery()
@ -116,8 +130,12 @@
const lookup: Lookup<Issue> = {
assignee: contact.class.Employee,
space: tracker.class.Project,
status: tracker.class.IssueStatus,
component: tracker.class.Component,
sprint: tracker.class.Sprint,
_id: {
subIssues: tracker.class.Issue
subIssues: tracker.class.Issue,
labels: tags.class.TagReference
}
}
@ -137,11 +155,9 @@
}
const issuesQuery = createQuery()
let issues: Issue[] = []
const lookupIssue: Lookup<Issue> = {
status: tracker.class.IssueStatus,
component: tracker.class.Component,
sprint: tracker.class.Sprint
}
$: groupByDocs = groupBy(issues, groupByKey, categories)
$: issuesQuery.query(
tracker.class.Issue,
resultQuery,
@ -149,80 +165,19 @@
issues = result
},
{
lookup: lookupIssue,
sort: issuesGroupBySorting[groupBy]
lookup,
sort
}
)
const statusesQuery = createQuery()
let statuses: WithLookup<IssueStatus>[] = []
let statusesMap: IdMap<IssueStatus> = new Map()
$: statusesQuery.query(
tracker.class.IssueStatus,
{
space: currentSpace
},
(result) => {
statuses = result
statusesMap = toIdMap(result)
},
{
lookup: { category: tracker.class.IssueStatusCategory }
}
)
const componentsQuery = createQuery()
let components: ComponentType[] = []
$: componentsQuery.query(
tracker.class.Component,
{
space: currentSpace
},
(result) => {
components = result
}
)
const sprintsQuery = createQuery()
let sprints: Sprint[] = []
$: sprintsQuery.query(
tracker.class.Sprint,
{
space: currentSpace
},
(result) => {
sprints = result
}
)
let states: TypeState[]
let categories: CategoryType[] = []
const queryId = generateId()
$: updateCategories(
tracker.class.Issue,
issues,
groupBy,
viewOptions,
viewOptionsConfig,
statuses,
components,
sprints,
$employeesStore
)
$: updateCategories(tracker.class.Issue, issues, groupByKey, viewOptions, viewOptionsConfig)
function update () {
updateCategories(
tracker.class.Issue,
issues,
groupBy,
viewOptions,
viewOptionsConfig,
statuses,
components,
sprints,
$employeesStore
)
updateCategories(tracker.class.Issue, issues, groupByKey, viewOptions, viewOptionsConfig)
}
async function updateCategories (
@ -230,45 +185,50 @@
docs: Doc[],
groupByKey: string,
viewOptions: ViewOptions,
viewOptionsModel: ViewOptionModel[] | undefined,
statuses: WithLookup<IssueStatus>[],
components: ComponentType[],
sprints: Sprint[],
assignee: Employee[]
viewOptionsModel: ViewOptionModel[] | undefined
) {
let categories = await getCategories(client, _class, docs, groupByKey, viewlet.descriptor)
categories = await getCategories(client, _class, docs, groupByKey, $statusStore, viewlet.descriptor)
for (const viewOption of viewOptionsModel ?? []) {
if (viewOption.actionTarget !== 'category') continue
const categoryFunc = viewOption as CategoryOption
if (viewOptions[viewOption.key] ?? viewOption.defaultValue) {
const f = await getResource(categoryFunc.action)
const res = await f(_class, space, groupByKey, update, queryId)
const categoryAction = await getResource(categoryFunc.action)
const res = await categoryAction(_class, space, groupByKey, update, queryId, $statusStore, viewlet.descriptor)
if (res !== undefined) {
for (const category of categories) {
if (!res.includes(category)) {
res.push(category)
}
}
categories = await sortCategories(client, _class, res, groupByKey, viewlet.descriptor)
categories = res
break
}
}
}
const indexes = new Map(categories.map((p, i) => [p, i]))
const res = await mapKanbanCategories(groupByKey, categories, statuses, components, sprints, assignee)
res.sort((a, b) => {
const aIndex = indexes.get(a._id ?? undefined) ?? -1
const bIndex = indexes.get(b._id ?? undefined) ?? -1
return aIndex - bIndex
})
states = res
}
const fullFilled: { [key: string]: boolean } = {}
function getHeader (_class: Ref<Class<Doc>>, groupByKey: string): void {
if (groupByKey === noCategory) {
headerComponent = undefined
} else {
getPresenter(client, _class, { key: groupByKey }, { key: groupByKey }).then((p) => (headerComponent = p))
}
}
let headerComponent: AttributeModel | undefined
$: getHeader(tracker.class.Issue, groupByKey)
const getUpdateProps = (doc: Doc, category: CategoryType): DocumentUpdate<Item> | undefined => {
const groupValue =
typeof category === 'object' ? category.values.find((it) => it.space === doc.space)?._id : category
if (groupValue === undefined) {
return undefined
}
return {
[groupByKey]: groupValue,
space: doc.space
}
}
</script>
{#if !states?.length}
{#if categories.length === 0}}
<Loading />
{:else}
<ActionContext
@ -279,12 +239,14 @@
<!-- svelte-ignore a11y-click-events-have-key-events -->
<Kanban
bind:this={kanbanUI}
_class={tracker.class.Issue}
{states}
{categories}
{dontUpdateRank}
options={{ sort, lookup }}
query={resultQuery}
fieldName={groupBy}
objects={issues}
getGroupByValues={(groupByDocs, category) =>
groupByKey === noCategory ? issues : getGroupByValues(groupByDocs, category)}
{setGroupByValues}
{getUpdateProps}
{groupByDocs}
on:content={(evt) => {
listProvider.update(evt.detail)
}}
@ -299,27 +261,23 @@
on:contextmenu={(evt) => showMenu(evt.detail.evt, evt.detail.objects)}
>
<svelte:fragment slot="header" let:state let:count>
{@const status = statusesMap.get(state._id)}
<!-- {@const status = $statusStore.get(state._id)} -->
<div class="header flex-col">
<div class="flex-between label font-medium w-full h-full">
<div class="flex-row-center gap-2">
{#if state.icon}
{#if groupBy === 'status' && status}
<IssueStatusIcon value={status} size="small" />
{:else}
<Icon icon={state.icon} size="small" />
{/if}
{/if}
<span class="lines-limit-2 ml-2">{state.title}</span>
<span class="counter ml-2 text-md">{count}</span>
</div>
<div class="flex-row-center flex-between">
{#if groupByKey === noCategory}
<span class="text-base fs-bold overflow-label content-accent-color pointer-events-none">
<Label label={view.string.NoGrouping} />
</span>
{:else if headerComponent}
<svelte:component this={headerComponent.presenter} value={state} {space} kind={'list-header'} />
{/if}
<div class="flex gap-1">
<Button
icon={IconAdd}
kind={'transparent'}
showTooltip={{ label: tracker.string.AddIssueTooltip, direction: 'left' }}
on:click={() => {
showPopup(CreateIssue, { space: currentSpace, [groupBy]: state._id }, 'top')
showPopup(CreateIssue, { space: currentSpace, [groupByKey]: state._id }, 'top')
}}
/>
</div>
@ -329,69 +287,71 @@
<svelte:fragment slot="card" let:object>
{@const issue = toIssue(object)}
{@const issueId = object._id}
<div
class="tracker-card"
on:click={() => {
showPanel(tracker.component.EditIssue, object._id, object._class, 'content')
}}
>
<div class="flex-col ml-4 mr-8">
<div class="flex clear-mins names">
<IssuePresenter value={issue} />
<ParentNamesPresenter value={issue} />
{#key issueId}
<div
class="tracker-card"
on:click={() => {
showPanel(tracker.component.EditIssue, object._id, object._class, 'content')
}}
>
<div class="flex-col ml-4 mr-8">
<div class="flex clear-mins names">
<IssuePresenter value={issue} />
<ParentNamesPresenter value={issue} />
</div>
<div class="flex-row-center gap-1 mt-1">
{#if groupByKey !== 'status'}
<StatusEditor value={issue} kind="list" isEditable={false} />
{/if}
<span class="fs-bold caption-color lines-limit-2">
{object.title}
</span>
</div>
</div>
<div class="flex-row-center gap-1 mt-1">
{#if groupBy !== 'status'}
<StatusEditor value={issue} kind="list" isEditable={false} />
{/if}
<span class="fs-bold caption-color lines-limit-2">
{object.title}
</span>
</div>
</div>
<div class="abs-rt-content">
<AssigneePresenter
value={issue.assignee ? $employeeByIdStore.get(issue.assignee) : null}
defaultClass={contact.class.Employee}
object={issue}
isEditable={true}
/>
<div class="flex-center mt-2">
<Component is={notification.component.NotificationPresenter} props={{ value: object }} />
</div>
</div>
<div class="buttons-group xsmall-gap states-bar">
{#if issue && issue.subIssues > 0}
<SubIssuesSelector value={issue} {currentProject} />
{/if}
<PriorityEditor value={issue} isEditable={true} kind={'link-bordered'} size={'inline'} justify={'center'} />
<ComponentEditor
value={issue}
isEditable={true}
kind={'link-bordered'}
size={'inline'}
justify={'center'}
width={''}
bind:onlyIcon={fullFilled[issueId]}
/>
<EstimationEditor kind={'list'} size={'small'} value={issue} />
<div
class="clear-mins"
use:tooltip={{
component: fullFilled[issueId] ? tags.component.LabelsPresenter : undefined,
props: { object: issue, kind: 'full' }
}}
>
<Component
is={tags.component.LabelsPresenter}
props={{ object: issue, ckeckFilled: fullFilled[issueId] }}
on:change={(res) => {
if (res.detail.full) fullFilled[issueId] = true
}}
<div class="abs-rt-content">
<AssigneePresenter
value={issue.assignee ? $employeeByIdStore.get(issue.assignee) : null}
defaultClass={contact.class.Employee}
object={issue}
isEditable={true}
/>
<div class="flex-center mt-2">
<Component is={notification.component.NotificationPresenter} props={{ value: object }} />
</div>
</div>
<div class="buttons-group xsmall-gap states-bar">
{#if issue && issue.subIssues > 0}
<SubIssuesSelector value={issue} {currentProject} />
{/if}
<PriorityEditor value={issue} isEditable={true} kind={'link-bordered'} size={'inline'} justify={'center'} />
<ComponentEditor
value={issue}
isEditable={true}
kind={'link-bordered'}
size={'inline'}
justify={'center'}
width={''}
bind:onlyIcon={fullFilled[issueId]}
/>
<EstimationEditor kind={'list'} size={'small'} value={issue} />
<div
class="clear-mins"
use:tooltip={{
component: fullFilled[issueId] ? tags.component.LabelsPresenter : undefined,
props: { object: issue, kind: 'full' }
}}
>
<Component
is={tags.component.LabelsPresenter}
props={{ object: issue, ckeckFilled: fullFilled[issueId], lookupField: 'labels' }}
on:change={(res) => {
if (res.detail.full) fullFilled[issueId] = true
}}
/>
</div>
</div>
</div>
</div>
{/key}
</svelte:fragment>
</Kanban>
{/if}

View File

@ -20,7 +20,7 @@
import { Button, eventToHTMLElement, SelectPopup, showPopup, TooltipAlignment } from '@hcengineering/ui'
import { createEventDispatcher } from 'svelte'
import tracker from '../../plugin'
import { statusStore } from '../../utils'
import { statusStore } from '@hcengineering/presentation'
import IssueStatusIcon from './IssueStatusIcon.svelte'
import StatusPresenter from './StatusPresenter.svelte'
@ -65,7 +65,7 @@
)
}
$: statuses = $statusStore.statuses.filter((it) => it.attachedTo === value?.space)
$: statuses = $statusStore.statuses.filter((it) => it.space === value?.space)
$: selectedStatus = statuses?.find((status) => status._id === value.status) ?? statuses?.[0]
$: selectedStatusLabel = shouldShowLabel ? selectedStatus?.name : undefined

View File

@ -14,7 +14,6 @@
-->
<script lang="ts">
import { IssueStatus } from '@hcengineering/tracker'
import { statusStore } from '../../utils'
import IssueStatusIcon from './IssueStatusIcon.svelte'
export let value: IssueStatus | undefined
@ -23,12 +22,11 @@
</script>
{#if value}
{@const icon = $statusStore.byId.get(value._id)?.$lookup?.category?.icon}
<div class="flex-presenter">
{#if !inline && icon}
{#if !inline}
<IssueStatusIcon {value} {size} />
{/if}
<span class="overflow-label" class:ml-2={!inline && icon !== undefined}>
<span class="overflow-label" class:ml-2={!inline}>
{value.name}
</span>
</div>

View File

@ -13,15 +13,14 @@
// limitations under the License.
-->
<script lang="ts">
import { Ref } from '@hcengineering/core'
import { IssueStatus } from '@hcengineering/tracker'
import { statusStore } from '../../utils'
import { Ref, Status, StatusValue } from '@hcengineering/core'
import { statusStore } from '@hcengineering/presentation'
import StatusPresenter from './StatusPresenter.svelte'
export let value: Ref<IssueStatus> | undefined
export let value: Ref<Status> | StatusValue | undefined
export let size: 'small' | 'medium' = 'medium'
</script>
{#if value}
<StatusPresenter value={$statusStore.byId.get(value)} {size} />
<StatusPresenter value={$statusStore.get(typeof value === 'string' ? value : value.values[0]?._id)} {size} />
{/if}

View File

@ -13,7 +13,7 @@
// limitations under the License.
-->
<script lang="ts">
import { Ref, SortingOrder, WithLookup } from '@hcengineering/core'
import core, { Ref, SortingOrder, WithLookup } from '@hcengineering/core'
import { createQuery } from '@hcengineering/presentation'
import { Issue, IssueStatus } from '@hcengineering/tracker'
import {
@ -101,7 +101,7 @@
sort: { modifiedOn: SortingOrder.Descending },
lookup: {
space: tracker.class.Project,
status: [tracker.class.IssueStatus, { category: tracker.class.IssueStatusCategory }]
status: [tracker.class.IssueStatus, { category: core.class.StatusCategory }]
}
}
)

View File

@ -29,7 +29,8 @@
} from '@hcengineering/ui'
import { getIssueId } from '../../../issues'
import tracker from '../../../plugin'
import { statusStore, subIssueListProvider } from '../../../utils'
import { subIssueListProvider } from '../../../utils'
import { statusStore } from '@hcengineering/presentation'
export let value: WithLookup<Issue>
export let currentProject: Project | undefined = undefined

View File

@ -29,7 +29,8 @@
} from '@hcengineering/ui'
import { getIssueId } from '../../../issues'
import tracker from '../../../plugin'
import { statusStore, subIssueListProvider } from '../../../utils'
import { subIssueListProvider } from '../../../utils'
import { statusStore } from '@hcengineering/presentation'
export let object: WithLookup<Doc & { related: number }> | undefined
export let value: WithLookup<Doc & { related: number }> | undefined

View File

@ -0,0 +1,41 @@
<script lang="ts">
import presentation, { Card, getClient } from '@hcengineering/presentation'
import { Project } from '@hcengineering/tracker'
import EditBox from '@hcengineering/ui/src/components/EditBox.svelte'
import { createEventDispatcher } from 'svelte'
import tracker from '../../plugin'
export let project: Project
let identifier = project.identifier
const dispatch = createEventDispatcher()
function save () {
dispatch('close', identifier)
}
let projects: Set<string> = new Set()
$: getClient()
.findAll(tracker.class.Project, {})
.then((pr) => {
projects = new Set(pr.map((p) => p.identifier))
})
</script>
<Card
label={projects.has(identifier) ? tracker.string.IdentifierExists : tracker.string.Identifier}
okLabel={presentation.string.Save}
okAction={save}
canSave={identifier !== project.identifier && !projects.has(identifier)}
on:close={() => {
dispatch('close')
}}
>
<div class="float-left-box">
<div class="float-left p-2">
<EditBox bind:value={identifier} />
</div>
</div>
</Card>

View File

@ -14,17 +14,17 @@
-->
<script lang="ts">
import { Employee } from '@hcengineering/contact'
import { AccountArrayEditor } from '@hcengineering/contact-resources'
import { AccountArrayEditor, AssigneeBox } from '@hcengineering/contact-resources'
import core, { Account, DocumentUpdate, generateId, getCurrentAccount, Ref, SortingOrder } from '@hcengineering/core'
import { Asset } from '@hcengineering/platform'
import presentation, { Card, getClient } from '@hcengineering/presentation'
import { AssigneeBox } from '@hcengineering/contact-resources'
import { StyledTextBox } from '@hcengineering/text-editor'
import { genRanks, IssueStatus, Project, TimeReportDayType } from '@hcengineering/tracker'
import { Button, EditBox, eventToHTMLElement, Label, showPopup, ToggleWithLabel } from '@hcengineering/ui'
import { Button, EditBox, eventToHTMLElement, IconEdit, Label, showPopup, ToggleWithLabel } from '@hcengineering/ui'
import { createEventDispatcher } from 'svelte'
import tracker from '../../plugin'
import TimeReportDayDropdown from '../issues/timereport/TimeReportDayDropdown.svelte'
import ChangeIdentity from './ChangeIdentity.svelte'
import ProjectIconChooser from './ProjectIconChooser.svelte'
export let project: Project | undefined = undefined
@ -50,7 +50,7 @@
isNew ? createProject() : updateProject()
}
let identifier: string = 'TSK'
let identifier: string = project?.identifier ?? 'TSK'
const defaultStatusId: Ref<IssueStatus> = generateId()
@ -72,7 +72,7 @@
}
async function updateProject () {
const { sequence, issueStatuses, defaultIssueStatus, identifier, ...projectData } = getProjectData()
const { sequence, issueStatuses, defaultIssueStatus, ...projectData } = getProjectData()
const update: DocumentUpdate<Project> = {}
if (projectData.name !== project?.name) {
update.name = projectData.name
@ -92,6 +92,9 @@
if (projectData.defaultTimeReportDay !== project?.defaultTimeReportDay) {
update.defaultTimeReportDay = projectData.defaultTimeReportDay
}
if (projectData.identifier !== project?.identifier) {
update.identifier = projectData.identifier
}
if (projectData.members.length !== project?.members.length) {
update.members = projectData.members
} else {
@ -118,8 +121,8 @@
defaultCategoryId = tracker.issueStatusCategory.Backlog
): Promise<void> {
const categories = await client.findAll(
tracker.class.IssueStatusCategory,
{},
core.class.StatusCategory,
{ ofAttribute: tracker.attribute.IssueStatus },
{ sort: { order: SortingOrder.Ascending } }
)
const issueStatusRanks = [...genRanks(categories.length)]
@ -128,15 +131,19 @@
const { _id: category, defaultStatusName } = statusCategory
const rank = issueStatusRanks[i]
await client.addCollection(
tracker.class.IssueStatus,
projectId,
projectId,
tracker.class.Project,
'issueStatuses',
{ name: defaultStatusName, category, rank },
category === defaultCategoryId ? defaultStatusId : undefined
)
if (defaultStatusName !== undefined) {
await client.createDoc(
tracker.class.IssueStatus,
projectId,
{
ofAttribute: tracker.attribute.IssueStatus,
name: defaultStatusName,
category,
rank
},
category === defaultCategoryId ? defaultStatusId : undefined
)
}
}
}
@ -147,6 +154,13 @@
}
})
}
function changeIdentity (ev: MouseEvent) {
showPopup(ChangeIdentity, { project }, eventToHTMLElement(ev), (result) => {
if (result != null) {
identifier = result
}
})
}
</script>
<Card
@ -170,12 +184,17 @@
}
}}
/>
<EditBox
bind:value={identifier}
disabled={!isNew}
placeholder={tracker.string.ProjectIdentifierPlaceholder}
kind={'large-style'}
/>
<div class="flex-row-center">
<EditBox
bind:value={identifier}
disabled={!isNew}
placeholder={tracker.string.ProjectIdentifierPlaceholder}
kind={'large-style'}
/>
{#if !isNew}
<Button size={'small'} icon={IconEdit} on:click={changeIdentity} />
{/if}
</div>
</div>
<StyledTextBox
alwaysEdit

View File

@ -17,7 +17,7 @@
import { Issue } from '@hcengineering/tracker'
import { floorFractionDigits, Label } from '@hcengineering/ui'
import tracker from '../../plugin'
import { statusStore } from '../../utils'
import { statusStore } from '@hcengineering/presentation'
import EstimationProgressCircle from '../issues/timereport/EstimationProgressCircle.svelte'
import TimePresenter from '../issues/timereport/TimePresenter.svelte'
export let docs: Issue[] | undefined = undefined

View File

@ -13,9 +13,9 @@
// limitations under the License.
-->
<script lang="ts">
import { AttachedData, Class, Ref, SortingOrder } from '@hcengineering/core'
import core, { Class, Data, Ref, SortingOrder, StatusCategory } from '@hcengineering/core'
import { createQuery, getClient, MessageBox } from '@hcengineering/presentation'
import { calcRank, IssueStatus, IssueStatusCategory, Project } from '@hcengineering/tracker'
import { calcRank, IssueStatus, Project } from '@hcengineering/tracker'
import {
Button,
closeTooltip,
@ -28,10 +28,10 @@
Scroller,
showPopup
} from '@hcengineering/ui'
import { statusStore } from '@hcengineering/presentation'
import { createEventDispatcher } from 'svelte'
import { flip } from 'svelte/animate'
import tracker from '../../plugin'
import { statusStore } from '../../utils'
import StatusEditor from './StatusEditor.svelte'
import StatusPresenter from './StatusPresenter.svelte'
@ -43,9 +43,9 @@
const projectQuery = createQuery()
let project: Project | undefined
let statusCategories: IssueStatusCategory[] | undefined
let statusCategories: StatusCategory[] | undefined
let editingStatus: IssueStatus | Partial<AttachedData<IssueStatus>> | null = null
let editingStatus: IssueStatus | Partial<Data<IssueStatus>> | null = null
let draggingStatus: IssueStatus | null = null
let hoveringStatus: IssueStatus | null = null
@ -53,8 +53,8 @@
async function updateStatusCategories () {
statusCategories = await client.findAll(
tracker.class.IssueStatusCategory,
{},
core.class.StatusCategory,
{ ofAttribute: tracker.attribute.IssueStatus },
{ sort: { order: SortingOrder.Ascending } }
)
}
@ -72,20 +72,14 @@
const nextStatus = $statusStore.statuses[$statusStore.statuses.findIndex(({ _id }) => _id === prevStatus._id) + 1]
isSaving = true
await client.addCollection(
tracker.class.IssueStatus,
projectId,
projectId,
tracker.class.Project,
'issueStatuses',
{
name: editingStatus.name,
description: editingStatus.description,
color: editingStatus.color,
category: editingStatus.category,
rank: calcRank(prevStatus, nextStatus)
}
)
await client.createDoc(tracker.class.IssueStatus, projectId, {
ofAttribute: tracker.attribute.IssueStatus,
name: editingStatus.name,
description: editingStatus.description,
color: editingStatus.color,
category: editingStatus.category,
rank: calcRank(prevStatus, nextStatus)
})
isSaving = false
}
@ -101,7 +95,7 @@
return
}
const updates: Partial<AttachedData<IssueStatus>> = {}
const updates: Partial<Data<IssueStatus>> = {}
if (status.name !== editingStatus.name) {
updates.name = editingStatus.name
}
@ -198,7 +192,7 @@
}
async function handleDrop (toItem: IssueStatus) {
if (draggingStatus?._id !== toItem._id && draggingStatus?.category === toItem.category) {
if (draggingStatus != null && draggingStatus?._id !== toItem._id && draggingStatus?.category === toItem.category) {
const fromIndex = getStatusIndex(draggingStatus)
const toIndex = getStatusIndex(toItem)
const [prev, next] = [
@ -251,7 +245,7 @@
<div class="popupPanel-body__main-content py-10 clear-mins">
{#each statusCategories as category}
{@const statuses =
$statusStore.statuses.filter((s) => s.attachedTo === projectId && s.category === category._id) ?? []}
$statusStore.statuses.filter((s) => s.space === projectId && s.category === category._id) ?? []}
{@const isSingle = statuses.length === 1}
<div class="flex-between category-name">
<Label label={category.label} />

View File

@ -25,8 +25,19 @@ import {
} from '@hcengineering/core'
import { Resources, translate } from '@hcengineering/platform'
import { getClient, MessageBox, ObjectSearchResult } from '@hcengineering/presentation'
import { Issue, Scrum, ScrumRecord, Sprint, Project } from '@hcengineering/tracker'
import { Issue, Project, Scrum, ScrumRecord, Sprint } from '@hcengineering/tracker'
import { showPopup } from '@hcengineering/ui'
import ComponentEditor from './components/components/ComponentEditor.svelte'
import ComponentPresenter from './components/components/ComponentPresenter.svelte'
import Components from './components/components/Components.svelte'
import ComponentStatusEditor from './components/components/ComponentStatusEditor.svelte'
import ComponentStatusPresenter from './components/components/ComponentStatusPresenter.svelte'
import ComponentTitlePresenter from './components/components/ComponentTitlePresenter.svelte'
import EditComponent from './components/components/EditComponent.svelte'
import IconPresenter from './components/components/IconComponent.svelte'
import LeadPresenter from './components/components/LeadPresenter.svelte'
import ProjectComponents from './components/components/ProjectComponents.svelte'
import TargetDatePresenter from './components/components/TargetDatePresenter.svelte'
import CreateIssue from './components/CreateIssue.svelte'
import Inbox from './components/inbox/Inbox.svelte'
import Active from './components/issues/Active.svelte'
@ -52,23 +63,12 @@ import TitlePresenter from './components/issues/TitlePresenter.svelte'
import MyIssues from './components/myissues/MyIssues.svelte'
import NewIssueHeader from './components/NewIssueHeader.svelte'
import NopeComponent from './components/NopeComponent.svelte'
import EditComponent from './components/components/EditComponent.svelte'
import IconPresenter from './components/components/IconComponent.svelte'
import LeadPresenter from './components/components/LeadPresenter.svelte'
import ComponentEditor from './components/components/ComponentEditor.svelte'
import ComponentPresenter from './components/components/ComponentPresenter.svelte'
import Components from './components/components/Components.svelte'
import ComponentStatusEditor from './components/components/ComponentStatusEditor.svelte'
import ComponentStatusPresenter from './components/components/ComponentStatusPresenter.svelte'
import ComponentTitlePresenter from './components/components/ComponentTitlePresenter.svelte'
import TargetDatePresenter from './components/components/TargetDatePresenter.svelte'
import ProjectComponents from './components/components/ProjectComponents.svelte'
import RelationsPopup from './components/RelationsPopup.svelte'
import SetDueDateActionPopup from './components/SetDueDateActionPopup.svelte'
import SetParentIssueActionPopup from './components/SetParentIssueActionPopup.svelte'
import SprintComponentEditor from './components/sprints/SprintComponentEditor.svelte'
import SprintDatePresenter from './components/sprints/SprintDatePresenter.svelte'
import SprintLeadPresenter from './components/sprints/SprintLeadPresenter.svelte'
import SprintComponentEditor from './components/sprints/SprintComponentEditor.svelte'
import CreateIssueTemplate from './components/templates/CreateIssueTemplate.svelte'
import Views from './components/views/Views.svelte'
import Statuses from './components/workflow/Statuses.svelte'
@ -107,15 +107,14 @@ import ComponentSelector from './components/ComponentSelector.svelte'
import IssueTemplatePresenter from './components/templates/IssueTemplatePresenter.svelte'
import IssueTemplates from './components/templates/IssueTemplates.svelte'
import { deleteObject } from '@hcengineering/view-resources/src/utils'
import { deleteObject } from '@hcengineering/view-resources'
import MoveAndDeleteSprintPopup from './components/sprints/MoveAndDeleteSprintPopup.svelte'
import EditIssueTemplate from './components/templates/EditIssueTemplate.svelte'
import TemplateEstimationEditor from './components/templates/EstimationEditor.svelte'
import {
getAllPriority,
getAllComponents,
getAllPriority,
getAllSprints,
getAllStatuses,
issuePrioritySort,
issueStatusSort,
moveIssuesToAnotherSprint,
@ -125,14 +124,14 @@ import {
} from './utils'
import { EmployeeAccount } from '@hcengineering/contact'
import DeleteComponentPresenter from './components/components/DeleteComponentPresenter.svelte'
import StatusRefPresenter from './components/issues/StatusRefPresenter.svelte'
import TimeSpendReportPopup from './components/issues/timereport/TimeSpendReportPopup.svelte'
import DeleteComponentPresenter from './components/components/DeleteComponentPresenter.svelte'
import IssueStatistics from './components/sprints/IssueStatistics.svelte'
import SprintRefPresenter from './components/sprints/SprintRefPresenter.svelte'
import CreateProject from './components/projects/CreateProject.svelte'
import ProjectPresenter from './components/projects/ProjectPresenter.svelte'
import MoveIssues from './components/issues/Move.svelte'
import IssueStatistics from './components/sprints/IssueStatistics.svelte'
import SprintRefPresenter from './components/sprints/SprintRefPresenter.svelte'
export { default as SubIssueList } from './components/issues/edit/SubIssueList.svelte'
@ -435,7 +434,6 @@ export default async (): Promise<Resources> => ({
IssuePrioritySort: issuePrioritySort,
SprintSort: sprintSort,
SubIssueQuery: subIssueQuery,
GetAllStatuses: getAllStatuses,
GetAllPriority: getAllPriority,
GetAllComponents: getAllComponents,
GetAllSprints: getAllSprints

View File

@ -12,12 +12,12 @@
// See the License for the specific language governing permissions and
// limitations under the License.
//
import { Client, Doc, Ref, Space } from '@hcengineering/core'
import { Client, Doc, Ref } from '@hcengineering/core'
import type { IntlString, Metadata, Resource } from '@hcengineering/platform'
import { mergeIds } from '@hcengineering/platform'
import { IssueDraft } from '@hcengineering/tracker'
import { AnyComponent, Location } from '@hcengineering/ui'
import { SortFunc, Viewlet, ViewletDescriptor, ViewQueryAction } from '@hcengineering/view'
import { AllValuesFuncGetter, SortFunc, Viewlet, ViewletDescriptor, ViewQueryAction } from '@hcengineering/view'
import tracker, { trackerId } from '../../tracker/lib'
export default mergeIds(trackerId, tracker, {
@ -90,6 +90,7 @@ export default mergeIds(trackerId, tracker, {
Low: '' as IntlString,
Title: '' as IntlString,
Identifier: '' as IntlString,
IdentifierExists: '' as IntlString,
Description: '' as IntlString,
Status: '' as IntlString,
DefaultIssueStatus: '' as IntlString,
@ -388,17 +389,8 @@ export default mergeIds(trackerId, tracker, {
IssuePrioritySort: '' as SortFunc,
SprintSort: '' as SortFunc,
SubIssueQuery: '' as ViewQueryAction,
GetAllStatuses: '' as Resource<
(space: Ref<Space> | undefined, onUpdate: () => void, queryId: Ref<Doc>) => Promise<any[] | undefined>
>,
GetAllPriority: '' as Resource<
(space: Ref<Space> | undefined, onUpdate: () => void, queryId: Ref<Doc>) => Promise<any[] | undefined>
>,
GetAllComponents: '' as Resource<
(space: Ref<Space> | undefined, onUpdate: () => void, queryId: Ref<Doc>) => Promise<any[] | undefined>
>,
GetAllSprints: '' as Resource<
(space: Ref<Space> | undefined, onUpdate: () => void, queryId: Ref<Doc>) => Promise<any[] | undefined>
>
GetAllPriority: '' as Resource<AllValuesFuncGetter>,
GetAllComponents: '' as Resource<AllValuesFuncGetter>,
GetAllSprints: '' as Resource<AllValuesFuncGetter>
}
})

View File

@ -13,7 +13,7 @@
// limitations under the License.
//
import { Employee, getName } from '@hcengineering/contact'
import { Employee } from '@hcengineering/contact'
import core, {
ApplyOperations,
AttachedDoc,
@ -22,28 +22,26 @@ import core, {
Doc,
DocumentQuery,
DocumentUpdate,
IdMap,
Ref,
SortingOrder,
Space,
StatusCategory,
StatusValue,
toIdMap,
TxCollectionCUD,
TxOperations,
TxUpdateDoc,
WithLookup
TxUpdateDoc
} from '@hcengineering/core'
import { TypeState } from '@hcengineering/kanban'
import { Asset, IntlString, translate } from '@hcengineering/platform'
import { Asset, IntlString } from '@hcengineering/platform'
import { createQuery, getClient } from '@hcengineering/presentation'
import { calcRank } from '@hcengineering/task'
import {
Component,
ComponentStatus,
Issue,
IssuePriority,
IssuesDateModificationPeriod,
IssuesGrouping,
IssuesOrdering,
IssueStatus,
Project,
Sprint,
SprintStatus,
@ -58,11 +56,9 @@ import {
MILLISECONDS_IN_WEEK
} from '@hcengineering/ui'
import { ViewletDescriptor } from '@hcengineering/view'
import { CategoryQuery, ListSelectionProvider, SelectDirection } from '@hcengineering/view-resources'
import { writable } from 'svelte/store'
import { CategoryQuery, groupBy, ListSelectionProvider, SelectDirection } from '@hcengineering/view-resources'
import tracker from './plugin'
import { defaultComponentStatuses, defaultPriorities, defaultSprintStatuses, issuePriorities } from './types'
import { calcRank } from '@hcengineering/task'
import { defaultComponentStatuses, defaultPriorities, defaultSprintStatuses } from './types'
export * from './types'
@ -133,18 +129,6 @@ export const getIssuesModificationDatePeriodTime = (period: IssuesDateModificati
}
}
export const groupBy = (data: any, key: any): { [key: string]: any[] } => {
return data.reduce((storage: { [key: string]: any[] }, item: any) => {
const group = item[key] ?? undefined
storage[group] = storage[group] ?? []
storage[group].push(item)
return storage
}, {})
}
export interface FilterAction {
icon?: Asset | AnySvelteComponent
label?: IntlString
@ -344,28 +328,33 @@ const listIssueKanbanStatusOrder = [
] as const
export async function issueStatusSort (
value: Array<Ref<IssueStatus>>,
value: StatusValue[],
viewletDescriptorId?: Ref<ViewletDescriptor>
): Promise<Array<Ref<IssueStatus>>> {
return await new Promise((resolve) => {
// TODO: How we track category updates.
const query = createQuery(true)
query.query(tracker.class.IssueStatus, { _id: { $in: value } }, (res) => {
if (viewletDescriptorId === tracker.viewlet.Kanban) {
res.sort((a, b) => {
const res = listIssueKanbanStatusOrder.indexOf(a.category) - listIssueKanbanStatusOrder.indexOf(b.category)
if (res === 0) {
return a.rank.localeCompare(b.rank)
}
return res
})
} else {
res.sort((a, b) => listIssueStatusOrder.indexOf(a.category) - listIssueStatusOrder.indexOf(b.category))
): Promise<StatusValue[]> {
// TODO: How we track category updates.
if (viewletDescriptorId === tracker.viewlet.Kanban) {
value.sort((a, b) => {
const res =
listIssueKanbanStatusOrder.indexOf(a.values[0].category as Ref<StatusCategory>) -
listIssueKanbanStatusOrder.indexOf(b.values[0].category as Ref<StatusCategory>)
if (res === 0) {
return a.values[0].rank.localeCompare(b.values[0].rank)
}
resolve(res.map((p) => p._id))
query.unsubscribe()
return res
})
})
} else {
value.sort((a, b) => {
const res =
listIssueStatusOrder.indexOf(a.values[0].category as Ref<StatusCategory>) -
listIssueStatusOrder.indexOf(b.values[0].category as Ref<StatusCategory>)
if (res === 0) {
return a.values[0].rank.localeCompare(b.values[0].rank)
}
return res
})
}
return value
}
export async function issuePrioritySort (value: IssuePriority[]): Promise<IssuePriority[]> {
@ -390,108 +379,6 @@ export async function sprintSort (value: Array<Ref<Sprint>>): Promise<Array<Ref<
})
}
export async function mapKanbanCategories (
groupBy: string,
categories: any[],
statuses: Array<WithLookup<IssueStatus>>,
components: Component[],
sprints: Sprint[],
assignee: Employee[]
): Promise<TypeState[]> {
if (groupBy === IssuesGrouping.NoGrouping) {
return [{ _id: undefined, color: UNSET_COLOR, title: await translate(tracker.string.NoGrouping, {}) }]
}
if (groupBy === IssuesGrouping.Priority) {
const res: TypeState[] = []
for (const priority of categories) {
const title = await translate((issuePriorities as any)[priority].label, {})
res.push({
_id: priority,
title,
color: UNSET_COLOR,
icon: (issuePriorities as any)[priority].icon
})
}
return res
}
if (groupBy === IssuesGrouping.Status) {
return statuses
.filter((p) => categories.includes(p._id))
.map((status) => {
const category = '$lookup' in status ? status.$lookup?.category : undefined
return {
_id: status._id,
title: status.name,
icon: category?.icon,
color: status.color ?? category?.color ?? UNSET_COLOR
}
})
}
if (groupBy === IssuesGrouping.Assignee) {
const noAssignee = await translate(tracker.string.NoAssignee, {})
const res: TypeState[] = assignee
.filter((p) => categories.includes(p._id))
.map((employee) => {
return {
_id: employee._id,
title: getName(employee),
color: UNSET_COLOR,
icon: undefined
}
})
if (categories.includes(undefined)) {
res.push({
_id: null,
title: noAssignee,
color: UNSET_COLOR,
icon: undefined
})
}
return res
}
if (groupBy === IssuesGrouping.Component) {
const noComponent = await translate(tracker.string.NoComponent, {})
const res: TypeState[] = components
.filter((p) => categories.includes(p._id))
.map((component) => ({
_id: component._id,
title: component.label,
color: UNSET_COLOR,
icon: undefined
}))
if (categories.includes(undefined)) {
res.push({
_id: null,
title: noComponent,
color: UNSET_COLOR,
icon: undefined
})
}
return res
}
if (groupBy === IssuesGrouping.Sprint) {
const noSprint = await translate(tracker.string.NoSprint, {})
const res: TypeState[] = sprints
.filter((p) => categories.includes(p._id))
.map((sprint) => ({
_id: sprint._id,
title: sprint.label,
color: UNSET_COLOR,
icon: undefined
}))
if (categories.includes(undefined)) {
res.push({
_id: null,
title: noSprint,
color: UNSET_COLOR,
icon: undefined
})
}
return res
}
return []
}
/**
* @public
*/
@ -604,14 +491,6 @@ async function getAllSomething (
return await promise
}
export async function getAllStatuses (
space: Ref<Space> | undefined,
onUpdate: () => void,
queryId: Ref<Doc>
): Promise<any[] | undefined> {
return await getAllSomething(tracker.class.IssueStatus, space, onUpdate, queryId)
}
export async function getAllPriority (
space: Ref<Space> | undefined,
onUpdate: () => void,
@ -682,15 +561,6 @@ export async function removeProject (project: Project): Promise<void> {
await client.removeDoc(tracker.class.Project, core.space.Space, project._id)
}
/**
* @public
*/
export interface StatusStore {
statuses: Array<WithLookup<IssueStatus>>
byId: IdMap<WithLookup<IssueStatus>>
version: number
}
async function updateIssuesOnMove (
client: TxOperations,
applyOps: ApplyOperations,
@ -761,27 +631,3 @@ export async function moveIssueToSpace (
}
await applyOps.commit()
}
// Issue status live query
export const statusStore = writable<StatusStore>({ statuses: [], byId: new Map(), version: 0 })
const query = createQuery(true)
query.query(
tracker.class.IssueStatus,
{},
(res) => {
statusStore.update((old) => ({
version: old.version + 1,
statuses: res,
byId: toIdMap(res)
}))
},
{
lookup: {
category: tracker.class.IssueStatusCategory
},
sort: {
rank: SortingOrder.Ascending
}
}
)

View File

@ -14,35 +14,31 @@
//
import { Employee, EmployeeAccount } from '@hcengineering/contact'
import type { AttachedDoc, Class, Doc, Markup, Ref, RelatedDocument, Space, Timestamp, Type } from '@hcengineering/core'
import type {
AttachedDoc,
Attribute,
Class,
Doc,
Markup,
Ref,
RelatedDocument,
Space,
Status,
StatusCategory,
Timestamp,
Type
} from '@hcengineering/core'
import type { Asset, IntlString, Plugin, Resource } from '@hcengineering/platform'
import { plugin } from '@hcengineering/platform'
import type { TagCategory, TagElement } from '@hcengineering/tags'
import { TagReference } from '@hcengineering/tags'
import { AnyComponent, Location, ResolvedLocation } from '@hcengineering/ui'
import { Action, ActionCategory } from '@hcengineering/view'
import { TagReference } from '@hcengineering/tags'
/**
* @public
*/
export interface IssueStatus extends AttachedDoc {
name: string
description?: string
color?: number
category: Ref<IssueStatusCategory>
rank: string
}
/**
* @public
*/
export interface IssueStatusCategory extends Doc {
icon: Asset
label: IntlString
color: number
defaultStatusName: string
order: number
}
export interface IssueStatus extends Status {}
/**
* @public
@ -391,7 +387,6 @@ export default plugin(trackerId, {
IssueTemplate: '' as Ref<Class<IssueTemplate>>,
Component: '' as Ref<Class<Component>>,
IssueStatus: '' as Ref<Class<IssueStatus>>,
IssueStatusCategory: '' as Ref<Class<IssueStatusCategory>>,
TypeIssuePriority: '' as Ref<Class<Type<IssuePriority>>>,
TypeComponentStatus: '' as Ref<Class<Type<ComponentStatus>>>,
Sprint: '' as Ref<Class<Sprint>>,
@ -417,12 +412,15 @@ export default plugin(trackerId, {
CreateIssue: '' as AnyComponent,
CreateIssueTemplate: '' as AnyComponent
},
attribute: {
IssueStatus: '' as Ref<Attribute<Status>>
},
issueStatusCategory: {
Backlog: '' as Ref<IssueStatusCategory>,
Unstarted: '' as Ref<IssueStatusCategory>,
Started: '' as Ref<IssueStatusCategory>,
Completed: '' as Ref<IssueStatusCategory>,
Canceled: '' as Ref<IssueStatusCategory>
Backlog: '' as Ref<StatusCategory>,
Unstarted: '' as Ref<StatusCategory>,
Started: '' as Ref<StatusCategory>,
Completed: '' as Ref<StatusCategory>,
Canceled: '' as Ref<StatusCategory>
},
icon: {
TrackerApplication: '' as Asset,

View File

@ -47,7 +47,7 @@
return 0
}
const selectStates = await client.findAll(targetClass, { _id: { $in: Array.from(ids) } }, {})
const unique = new Set(selectStates.map((s) => (s as State).title))
const unique = new Set(selectStates.map((s) => (s as State).name))
return unique.size
}

View File

@ -13,19 +13,17 @@
// limitations under the License.
-->
<script lang="ts">
import { Doc, FindResult, getObjectValue, RefTo, SortingOrder, Ref, Space } from '@hcengineering/core'
import core, { Doc, FindResult, getObjectValue, Ref, RefTo, SortingOrder, Space, Status } from '@hcengineering/core'
import { translate } from '@hcengineering/platform'
import presentation, { getClient } from '@hcengineering/presentation'
import type { State } from '@hcengineering/task'
import task from '@hcengineering/task'
import ui, {
addNotification,
Button,
CheckBox,
deviceOptionsStore,
Label,
Loading,
resizeObserver,
deviceOptionsStore,
addNotification
resizeObserver
} from '@hcengineering/ui'
import { Filter } from '@hcengineering/view'
import { createEventDispatcher, onMount } from 'svelte'
@ -48,27 +46,28 @@
let values: (Doc | undefined | null)[] = []
let objectsPromise: Promise<FindResult<Doc>> | undefined
$: targetClass = (hierarchy.getAttribute(filter.key._class, filter.key.key).type as RefTo<Doc>).to
$: clazz = hierarchy.getClass(targetClass)
const targets = new Map<any, number>()
$: isState = clazz._id === task.class.State ?? false
let statesCount: number[] = []
let states: State[]
$: targetClass = (filter.key.attribute.type as RefTo<Doc>).to
$: clazz = hierarchy.getClass(targetClass)
const groupValues = (val: State[]): (Doc | undefined | null)[] => {
states = val
$: isStatus = client.getHierarchy().isDerived(targetClass, core.class.Status) ?? false
let statusesCount: number[] = []
let statuses: Status[]
const groupValues = (val: Status[]): (Doc | undefined | null)[] => {
statuses = val
const result: Doc[] = []
statesCount = []
const unique = [...new Set(val.map((v) => v.title))]
statusesCount = []
const unique = [...new Set(val.map((v) => v.name))]
unique.forEach((label, i) => {
let count = 0
states.forEach((state) => {
if (state.title === label) {
statuses.forEach((state) => {
if (state.name === label) {
if (!count) result[i] = state
count += targets.get(state._id) ?? 0
}
})
statesCount[i] = count
statusesCount[i] = count
})
return result
}
@ -106,14 +105,16 @@
const oldSize = filter.value.length
filter.value = filter.value.filter((p) => !notExisting.includes(p))
onChange(filter)
addNotification(await translate(view.string.FilterUpdated), filter.key.label, FilterRemovedNotification, {
addNotification(await translate(view.string.FilterUpdated, {}), filter.key.label, FilterRemovedNotification, {
description: await translate(view.string.FilterRemoved, { count: oldSize - (filter.value.length ?? 0) })
})
}
if (targets.has(undefined)) {
values.unshift(undefined)
}
if (isState) values = groupValues(values as State[])
if (isStatus) {
values = groupValues(values as Status[])
}
objectsPromise = undefined
}
@ -123,20 +124,10 @@
function toggle (value: Doc | undefined | null): void {
if (isSelected(value, filter.value)) {
if (isState) {
const ids = states.filter((state) => state.title === (value as State).title).map((s) => s._id)
filter.value = filter.value.filter((p) => !ids.includes(p))
} else filter.value = filter.value.filter((p) => (value ? p !== value._id : p != null))
filter.value = filter.value.filter((p) => (value ? p !== value._id : p != null))
} else {
if (value) {
if (isState) {
filter.value = [
...filter.value,
...states
.filter((state) => state.title === states.filter((s) => s._id === value._id)[0].title)
.map((state) => state._id)
]
} else filter.value = [...filter.value, value._id]
filter.value = [...filter.value, value._id]
} else {
filter.value = [...filter.value, undefined]
}
@ -155,7 +146,7 @@
})
const dispatch = createEventDispatcher()
getValues(search)
$: if (targetClass) getValues(search)
</script>
<div class="selectPopup" use:resizeObserver={() => dispatch('changeContent')}>
@ -197,7 +188,7 @@
{/if}
</div>
<div class="dark-color ml-2">
{#if isState}{statesCount[i]}{:else}{targets.get(value?._id)}{/if}
{#if isStatus}{statusesCount[i]}{:else}{targets.get(value?._id)}{/if}
</div>
</div>
</button>

View File

@ -131,14 +131,17 @@
return props.length
}
let dragItem: Doc | undefined = undefined
let dragItem: {
doc?: Doc
revert?: () => void
} = {}
let listDiv: HTMLDivElement
</script>
<div class="list-container" bind:this={listDiv}>
<ListCategories
newObjectProps={space ? { space } : {}}
newObjectProps={() => (space ? { space } : {})}
{elementByIndex}
{indexById}
{docs}

View File

@ -13,13 +13,13 @@
// limitations under the License.
-->
<script lang="ts">
import { Class, Doc, generateId, Lookup, Ref, Space } from '@hcengineering/core'
import { CategoryType, Class, Doc, generateId, Lookup, Ref, Space } from '@hcengineering/core'
import { getResource, IntlString } from '@hcengineering/platform'
import { getClient } from '@hcengineering/presentation'
import { getClient, statusStore } from '@hcengineering/presentation'
import { AnyComponent } from '@hcengineering/ui'
import { AttributeModel, BuildModelKey, CategoryOption, ViewOptionModel, ViewOptions } from '@hcengineering/view'
import { createEventDispatcher, onDestroy } from 'svelte'
import { buildModel, getAdditionalHeader, getCategories, getPresenter, groupBy } from '../../utils'
import { buildModel, getAdditionalHeader, getCategories, getGroupByValues, getPresenter, groupBy } from '../../utils'
import { CategoryQuery, noCategory } from '../../viewOptions'
import ListCategory from './ListCategory.svelte'
@ -41,17 +41,21 @@
export let props: Record<string, any> = {}
export let level: number
export let initIndex = 0
export let newObjectProps: Record<string, any>
export let newObjectProps: (doc: Doc) => Record<string, any> | undefined
export let docByIndex: Map<number, Doc>
export let viewOptionsConfig: ViewOptionModel[] | undefined
export let dragItem: Doc | undefined
export let dragItem: {
doc?: Doc
revert?: () => void
}
export let listDiv: HTMLDivElement
$: groupByKey = viewOptions.groupBy[level] ?? noCategory
$: groupedDocs = groupBy(docs, groupByKey)
let categories: any[] = []
let categories: CategoryType[] = []
$: updateCategories(_class, docs, groupByKey, viewOptions, viewOptionsConfig)
$: groupByDocs = groupBy(docs, groupByKey, categories)
const client = getClient()
const hierarchy = client.getHierarchy()
@ -71,20 +75,15 @@
viewOptions: ViewOptions,
viewOptionsModel: ViewOptionModel[] | undefined
) {
categories = await getCategories(client, _class, docs, groupByKey)
categories = await getCategories(client, _class, docs, groupByKey, $statusStore)
if (level === 0) {
for (const viewOption of viewOptionsModel ?? []) {
if (viewOption.actionTarget !== 'category') continue
const categoryFunc = viewOption as CategoryOption
if (viewOptions[viewOption.key] ?? viewOption.defaultValue) {
const f = await getResource(categoryFunc.action)
const res = hierarchy.clone(await f(_class, space, groupByKey, update, queryId))
const res = hierarchy.clone(await f(_class, space, groupByKey, update, queryId, $statusStore))
if (res !== undefined) {
for (const category of categories) {
if (!res.includes(category)) {
res.push(category)
}
}
categories = res
return
}
@ -113,7 +112,7 @@
let res = initIndex
for (let index = 0; index < i; index++) {
const cat = categories[index]
res += groupedDocs[cat]?.length ?? 0
res += groupByDocs[cat]?.length ?? 0
}
return res
}
@ -123,8 +122,8 @@
const dispatch = createEventDispatcher()
</script>
{#each categories as category, i (category)}
{@const items = groupedDocs[category] ?? []}
{#each categories as category, i (typeof category === 'object' ? category.name : category)}
{@const items = groupByKey === noCategory || category === undefined ? docs : getGroupByValues(groupByDocs, category)}
<ListCategory
{elementByIndex}
{indexById}

View File

@ -13,7 +13,7 @@
// limitations under the License.
-->
<script lang="ts">
import { Class, Doc, DocumentUpdate, Lookup, Ref, Space } from '@hcengineering/core'
import { Class, Doc, DocumentUpdate, Lookup, PrimitiveType, Ref, Space, StatusValue } from '@hcengineering/core'
import { IntlString } from '@hcengineering/platform'
import { getClient } from '@hcengineering/presentation'
import { calcRank, DocWithRank } from '@hcengineering/task'
@ -33,7 +33,7 @@
import ListHeader from './ListHeader.svelte'
import ListItem from './ListItem.svelte'
export let category: any
export let category: PrimitiveType | StatusValue
export let headerComponent: AttributeModel | undefined
export let singleCat: boolean
export let groupByKey: string
@ -57,10 +57,13 @@
export let _class: Ref<Class<Doc>>
export let config: (string | BuildModelKey)[]
export let viewOptions: ViewOptions
export let newObjectProps: Record<string, any>
export let newObjectProps: (doc: Doc) => Record<string, any> | undefined
export let docByIndex: Map<number, Doc>
export let viewOptionsConfig: ViewOptionModel[] | undefined
export let dragItem: Doc | undefined
export let dragItem: {
doc?: Doc
revert?: () => void
}
export let listDiv: HTMLDivElement
$: lastLevel = level + 1 >= viewOptions.groupBy.length
@ -108,7 +111,18 @@
$: limited = limitGroup(items, limit)
$: selectedObjectIdsSet = new Set<Ref<Doc>>(selectedObjectIds.map((it) => it._id))
$: newObjectProps = { [groupByKey]: category, ...newObjectProps }
$: _newObjectProps = (doc: Doc) => {
const groupValue =
typeof category === 'object' ? category.values.find((it) => it.space === doc.space)?._id : category
if (groupValue === undefined) {
return undefined
}
return {
...newObjectProps(doc),
[groupByKey]: groupValue,
space: doc.space
}
}
function isSelected (doc: Doc, focusStore: FocusSelection): boolean {
return focusStore.focus?._id === doc._id
@ -145,22 +159,25 @@
function dragEnterCat (ev: MouseEvent) {
ev.preventDefault()
if (dragItemIndex === undefined && dragItem !== undefined) {
const index = items.findIndex((p) => p._id === dragItem?._id)
if (dragItemIndex === undefined && dragItem.doc !== undefined) {
const index = items.findIndex((p) => p._id === dragItem.doc?._id)
if (index !== -1) {
dragItemIndex = index
return
}
if (isBorder(ev, 'top')) {
items.unshift(dragItem)
dragItemIndex = 0
items = items
dispatch('row-focus', dragItem)
} else if (isBorder(ev, 'bottom')) {
items.push(dragItem)
dragItemIndex = items.length - 1
items = items
dispatch('row-focus', dragItem)
const props = _newObjectProps(dragItem.doc)
if (props !== undefined) {
if (isBorder(ev, 'top')) {
items.unshift(dragItem.doc)
dragItemIndex = 0
items = items
dispatch('row-focus', dragItem)
} else if (isBorder(ev, 'bottom')) {
items.push(dragItem.doc)
dragItemIndex = items.length - 1
items = items
dispatch('row-focus', dragItem)
}
}
}
}
@ -220,18 +237,24 @@
}
async function drop (update: DocumentUpdate<Doc> = {}) {
if (dragItem !== undefined) {
for (const key in newObjectProps) {
const value = newObjectProps[key]
if ((dragItem as any)[key] !== value) {
;(update as any)[key] = value
if (dragItem.doc !== undefined) {
const props = _newObjectProps(dragItem.doc)
if (props !== undefined) {
for (const key in props) {
const value = props[key]
if ((dragItem as any)[key] !== value) {
;(update as any)[key] = value
}
}
}
if (Object.keys(update).length > 0) {
await client.update(dragItem, update)
if (Object.keys(update).length > 0) {
await client.update(dragItem.doc, update)
}
} else {
dragItem.revert?.()
}
}
dragItem = undefined
dragItem.doc = undefined
dragItem.revert = undefined
dragItemIndex = undefined
}
@ -240,11 +263,12 @@
const rect = listDiv.getBoundingClientRect()
const inRect = ev.clientY > rect.top && ev.clientY < rect.top + rect.height
if (!inRect) {
if (items.findIndex((p) => p._id === dragItem?._id) === -1 && dragItem !== undefined) {
items = [...items.slice(0, initIndex), dragItem, ...items.slice(initIndex)]
if (items.findIndex((p) => p._id === dragItem.doc?._id) === -1 && dragItem.doc !== undefined) {
items = [...items.slice(0, initIndex), dragItem.doc, ...items.slice(initIndex)]
}
if (level === 0) {
dragItem = undefined
dragItem.doc = undefined
dragItem.revert = undefined
}
}
}
@ -261,7 +285,16 @@
ev.dataTransfer.dropEffect = 'move'
}
ev.target?.addEventListener('dragend', (e) => dragEndListener(e, i))
dragItem = docObject
dragItem = {
doc: docObject,
revert: () => {
const d = items.find((it) => it._id === docObject._id)
if (d === undefined) {
items.splice(i, 0, docObject)
items = items
}
}
}
dragItemIndex = i
dispatch('dragstart', {
target: ev.target,
@ -289,7 +322,7 @@
{createItemDialog}
{createItemLabel}
{extraHeaders}
{newObjectProps}
newObjectProps={_newObjectProps}
flat={flatHeaders}
{props}
on:more={() => {
@ -317,7 +350,7 @@
{createItemDialog}
{createItemLabel}
{viewOptions}
{newObjectProps}
newObjectProps={_newObjectProps}
{flatHeaders}
{props}
level={level + 1}

View File

@ -44,13 +44,13 @@
export let flat = false
export let props: Record<string, any> = {}
export let level: number
export let newObjectProps: Record<string, any>
export let newObjectProps: (doc: Doc) => Record<string, any> | undefined
const dispatch = createEventDispatcher()
const handleCreateItem = (event: MouseEvent) => {
if (createItemDialog === undefined) return
showPopup(createItemDialog, newObjectProps, eventToHTMLElement(event))
showPopup(createItemDialog, newObjectProps(items[0]), eventToHTMLElement(event))
}
</script>

View File

@ -0,0 +1,37 @@
<!--
// Copyright © 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 { Status, StatusValue, WithLookup } from '@hcengineering/core'
import { Asset } from '@hcengineering/platform'
import { AnySvelteComponent, Icon } from '@hcengineering/ui'
export let icon: Asset | AnySvelteComponent | undefined = undefined
export let value: Status | WithLookup<Status> | StatusValue | undefined
export let size: 'small' | 'medium' = 'small'
</script>
{#if value}
<div class="flex-presenter">
{#if icon && typeof icon === 'string'}
<Icon {icon} {size} />
{:else if icon !== undefined && typeof icon !== 'string'}
<svelte:component this={icon} {value} {size} />
{/if}
<span class="overflow-label" class:ml-2={icon !== undefined}>
{value.name}
</span>
</div>
{/if}

View File

@ -0,0 +1,30 @@
<!--
// Copyright © 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, Status, StatusValue } from '@hcengineering/core'
import { Asset } from '@hcengineering/platform'
import { AnySvelteComponent } from '@hcengineering/ui'
import { statusStore } from '@hcengineering/presentation'
import StatusPresenter from './StatusPresenter.svelte'
export let value: Ref<Status> | StatusValue | undefined
export let size: 'small' | 'medium' = 'medium'
export let icon: Asset | AnySvelteComponent | undefined = undefined
</script>
{#if value}
<StatusPresenter value={$statusStore.get(typeof value === 'string' ? value : value.values[0]._id)} {size} {icon} />
{/if}

View File

@ -27,9 +27,12 @@ import ColorsPopup from './components/ColorsPopup.svelte'
import DateEditor from './components/DateEditor.svelte'
import DatePresenter from './components/DatePresenter.svelte'
import DocAttributeBar from './components/DocAttributeBar.svelte'
import DocNavLink from './components/DocNavLink.svelte'
import EditBoxPopup from './components/EditBoxPopup.svelte'
import EditDoc from './components/EditDoc.svelte'
import EnumArrayEditor from './components/EnumArrayEditor.svelte'
import EnumEditor from './components/EnumEditor.svelte'
import EnumPresenter from './components/EnumPresenter.svelte'
import FilterBar from './components/filter/FilterBar.svelte'
import FilterTypePopup from './components/filter/FilterTypePopup.svelte'
import ObjectFilter from './components/filter/ObjectFilter.svelte'
@ -49,11 +52,14 @@ import MarkupEditor from './components/MarkupEditor.svelte'
import MarkupEditorPopup from './components/MarkupEditorPopup.svelte'
import MarkupPresenter from './components/MarkupPresenter.svelte'
import Menu from './components/Menu.svelte'
import TreeItem from './components/navigator/TreeItem.svelte'
import TreeNode from './components/navigator/TreeNode.svelte'
import NumberEditor from './components/NumberEditor.svelte'
import NumberPresenter from './components/NumberPresenter.svelte'
import ObjectPresenter from './components/ObjectPresenter.svelte'
import RolePresenter from './components/RolePresenter.svelte'
import SpacePresenter from './components/SpacePresenter.svelte'
import SpaceRefPresenter from './components/SpaceRefPresenter.svelte'
import StringEditor from './components/StringEditor.svelte'
import StringPresenter from './components/StringPresenter.svelte'
import Table from './components/Table.svelte'
@ -62,12 +68,8 @@ import TimestampPresenter from './components/TimestampPresenter.svelte'
import UpDownNavigator from './components/UpDownNavigator.svelte'
import ValueSelector from './components/ValueSelector.svelte'
import ViewletSettingButton from './components/ViewletSettingButton.svelte'
import SpaceRefPresenter from './components/SpaceRefPresenter.svelte'
import EnumArrayEditor from './components/EnumArrayEditor.svelte'
import EnumPresenter from './components/EnumPresenter.svelte'
import TreeNode from './components/navigator/TreeNode.svelte'
import TreeItem from './components/navigator/TreeItem.svelte'
import DocNavLink from './components/DocNavLink.svelte'
import StatusPresenter from './components/status/StatusPresenter.svelte'
import StatusRefPresenter from './components/status/StatusRefPresenter.svelte'
import {
afterResult,
@ -82,11 +84,7 @@ import {
import { IndexedDocumentPreview } from '@hcengineering/presentation'
import { showEmptyGroups } from './viewOptions'
function PositionElementAlignment (e?: Event): PopupAlignment | undefined {
return getEventPopupPositionElement(e)
}
import { statusSort } from './utils'
export { getActions, invokeAction } from './actions'
export { default as ActionContext } from './components/ActionContext.svelte'
export { default as ActionHandler } from './components/ActionHandler.svelte'
@ -95,30 +93,33 @@ export { default as FixedColumn } from './components/FixedColumn.svelte'
export { default as SourcePresenter } from './components/inference/SourcePresenter.svelte'
export { default as LinkPresenter } from './components/LinkPresenter.svelte'
export { default as List } from './components/list/List.svelte'
export { default as MarkupPresenter } from './components/MarkupPresenter.svelte'
export { default as MarkupPreviewPopup } from './components/MarkupPreviewPopup.svelte'
export { default as ContextMenu } from './components/Menu.svelte'
export { default as ObjectBox } from './components/ObjectBox.svelte'
export { default as ObjectSearchBox } from './components/ObjectSearchBox.svelte'
export { default as ObjectPresenter } from './components/ObjectPresenter.svelte'
export { default as ObjectSearchBox } from './components/ObjectSearchBox.svelte'
export { default as StatusPresenter } from './components/status/StatusPresenter.svelte'
export { default as StatusRefPresenter } from './components/status/StatusRefPresenter.svelte'
export { default as TableBrowser } from './components/TableBrowser.svelte'
export { default as ValueSelector } from './components/ValueSelector.svelte'
export { default as MarkupPreviewPopup } from './components/MarkupPreviewPopup.svelte'
export { default as MarkupPresenter } from './components/MarkupPresenter.svelte'
export * from './context'
export * from './filter'
export * from './selection'
export * from './utils'
export {
buildModel,
getActiveViewletId,
getAdditionalHeader,
getCategories,
getCollectionCounter,
getFiltredKeys,
getObjectPresenter,
getObjectPreview,
groupBy,
isCollectionAttr,
LoadingProps,
setActiveViewletId,
getAdditionalHeader,
groupBy,
getCategories
setActiveViewletId
} from './utils'
export * from './viewOptions'
export {
@ -152,6 +153,10 @@ export {
EditBoxPopup
}
function PositionElementAlignment (e?: Event): PopupAlignment | undefined {
return getEventPopupPositionElement(e)
}
export default async (): Promise<Resources> => ({
actionImpl,
component: {
@ -194,7 +199,9 @@ export default async (): Promise<Resources> => ({
IndexedDocumentPreview,
SpaceRefPresenter,
EnumArrayEditor,
EnumPresenter
EnumPresenter,
StatusPresenter,
StatusRefPresenter
},
popup: {
PositionElementAlignment
@ -208,6 +215,7 @@ export default async (): Promise<Resources> => ({
FilterAfterResult: afterResult,
FilterNestedMatchResult: nestedMatchResult,
FilterNestedDontMatchResult: nestedDontMatchResult,
ShowEmptyGroups: showEmptyGroups
ShowEmptyGroups: showEmptyGroups,
StatusSort: statusSort
}
})

View File

@ -16,7 +16,7 @@
import { IntlString, mergeIds } from '@hcengineering/platform'
import { AnyComponent } from '@hcengineering/ui'
import view, { viewId } from '@hcengineering/view'
import view, { SortFunc, viewId } from '@hcengineering/view'
export default mergeIds(viewId, view, {
component: {
@ -64,5 +64,8 @@ export default mergeIds(viewId, view, {
Shown: '' as IntlString,
ShowEmptyGroups: '' as IntlString,
Total: '' as IntlString
},
function: {
StatusSort: '' as SortFunc
}
})

View File

@ -16,11 +16,13 @@
import core, {
AttachedDoc,
CategoryType,
Class,
Client,
Collection,
Doc,
DocumentUpdate,
getObjectValue,
Hierarchy,
Lookup,
Obj,
@ -29,11 +31,14 @@ import core, {
ReverseLookup,
ReverseLookups,
Space,
Status,
StatusManager,
StatusValue,
TxOperations
} from '@hcengineering/core'
import type { IntlString } from '@hcengineering/platform'
import { getResource } from '@hcengineering/platform'
import { AttributeCategory, getAttributePresenterClass, KeyedAttribute } from '@hcengineering/presentation'
import { AttributeCategory, createQuery, getAttributePresenterClass, KeyedAttribute } from '@hcengineering/presentation'
import {
AnyComponent,
ErrorPresenter,
@ -499,46 +504,180 @@ export type FixedWidthStore = Record<string, number>
export const fixedWidthStore = writable<FixedWidthStore>({})
export function groupBy<T extends Doc> (docs: T[], key: string): { [key: string]: T[] } {
export function groupBy<T extends Doc> (
docs: T[],
key: string,
categories?: CategoryType[]
): { [key: string | number]: T[] } {
return docs.reduce((storage: { [key: string]: T[] }, item: T) => {
const group = (item as any)[key] ?? undefined
let group = getObjectValue(key, item) ?? undefined
if (categories !== undefined) {
for (const c of categories) {
if (typeof c === 'object') {
const st = c.values.find((it) => it._id === group)
if (st !== undefined) {
group = st.name
break
}
}
}
}
storage[group] = storage[group] ?? []
storage[group].push(item)
return storage
}, {})
}
/**
* @public
*/
export function getGroupByValues<T extends Doc> (
groupByDocs: Record<string | number, T[]>,
category: CategoryType
): T[] {
if (typeof category === 'object') {
return groupByDocs[category.name] ?? []
} else if (category !== undefined) {
return groupByDocs[category] ?? []
}
return []
}
/**
* @public
*/
export function setGroupByValues (
groupByDocs: Record<string | number, Doc[]>,
category: CategoryType,
docs: Doc[]
): void {
if (typeof category === 'object') {
groupByDocs[category.name] = docs
} else if (category !== undefined) {
groupByDocs[category] = docs
}
}
/**
* Group category references into categories.
* @public
*/
export async function groupByCategory (
client: TxOperations,
_class: Ref<Class<Doc>>,
key: string,
categories: any[],
mgr: StatusManager,
viewletDescriptorId?: Ref<ViewletDescriptor>
): Promise<CategoryType[]> {
const h = client.getHierarchy()
const attr = h.getAttribute(_class, key)
if (attr === undefined) return categories
if (key === noCategory) return [undefined]
const attrClass = getAttributePresenterClass(h, attr).attrClass
const isStatusField = h.isDerived(attrClass, core.class.Status)
let existingCategories: any[] = []
if (isStatusField) {
existingCategories = await groupByStatusCategories(h, attrClass, categories, mgr, viewletDescriptorId)
} else {
const valueSet = new Set<any>()
for (const v of categories) {
if (!valueSet.has(v)) {
valueSet.add(v)
existingCategories.push(v)
}
}
}
return await sortCategories(h, attrClass, existingCategories, viewletDescriptorId)
}
/**
* @public
*/
export async function groupByStatusCategories (
hierarchy: Hierarchy,
attrClass: Ref<Class<Doc>>,
categories: any[],
mgr: StatusManager,
viewletDescriptorId?: Ref<ViewletDescriptor>
): Promise<StatusValue[]> {
const existingCategories: StatusValue[] = []
const statusMap = new Map<string, StatusValue>()
for (const v of categories) {
const status = mgr.byId.get(v)
if (status !== undefined) {
let fst = statusMap.get(status.name.toLowerCase().trim())
if (fst === undefined) {
const statuses = mgr.statuses
.filter(
(it) =>
it.ofAttribute === status.ofAttribute &&
it.name.toLowerCase().trim() === status.name.toLowerCase().trim() &&
(categories.includes(it._id) || it.space === status.space)
)
.sort((a, b) => a.rank.localeCompare(b.rank))
fst = new StatusValue(status.name, status.color, statuses)
statusMap.set(status.name.toLowerCase().trim(), fst)
existingCategories.push(fst)
}
}
}
return await sortCategories(hierarchy, attrClass, existingCategories, viewletDescriptorId)
}
export async function getCategories (
client: TxOperations,
_class: Ref<Class<Doc>>,
docs: Doc[],
key: string,
mgr: StatusManager,
viewletDescriptorId?: Ref<ViewletDescriptor>
): Promise<any[]> {
): Promise<CategoryType[]> {
if (key === noCategory) return [undefined]
const existingCategories = Array.from(new Set(docs.map((x: any) => x[key] ?? undefined)))
return await sortCategories(client, _class, existingCategories, key, viewletDescriptorId)
return await groupByCategory(
client,
_class,
key,
docs.map((it) => getObjectValue(key, it) ?? undefined),
mgr,
viewletDescriptorId
)
}
/**
* @public
*/
export async function sortCategories (
client: TxOperations,
_class: Ref<Class<Doc>>,
hierarchy: Hierarchy,
attrClass: Ref<Class<Doc>>,
existingCategories: any[],
key: string,
viewletDescriptorId?: Ref<ViewletDescriptor>
): Promise<any[]> {
if (key === noCategory) return [undefined]
const hierarchy = client.getHierarchy()
const attr = hierarchy.getAttribute(_class, key)
if (attr === undefined) return existingCategories
const attrClass = getAttributePresenterClass(hierarchy, attr).attrClass
const clazz = hierarchy.getClass(attrClass)
const sortFunc = hierarchy.as(clazz, view.mixin.SortFuncs)
if (sortFunc?.func === undefined) return existingCategories
if (sortFunc?.func === undefined) {
const isStatusField = hierarchy.isDerived(attrClass, core.class.Status)
if (isStatusField) {
existingCategories.sort((a, b) => {
return a.values[0].rank.localeCompare(b.values[0].rank)
})
} else {
existingCategories.sort((a, b) => {
return JSON.stringify(a).localeCompare(JSON.stringify(b))
})
}
return existingCategories
}
const f = await getResource(sortFunc.func)
return await f(existingCategories, viewletDescriptorId)
@ -666,3 +805,34 @@ export async function getObjectLinkFragment (
loc.fragment = getPanelURI(component, object._id, Hierarchy.mixinOrClass(object), 'content')
return loc
}
export async function statusSort (
value: Array<Ref<Status>>,
viewletDescriptorId?: Ref<ViewletDescriptor>
): Promise<Array<Ref<Status>>> {
return await new Promise((resolve) => {
// TODO: How we track category updates.
const query = createQuery(true)
query.query(
core.class.Status,
{ _id: { $in: value } },
(res) => {
res.sort((a, b) => {
const res = (a.$lookup?.category?.order ?? 0) - (b.$lookup?.category?.order ?? 0)
if (res === 0) {
return a.rank.localeCompare(b.rank)
}
return res
})
resolve(res.map((p) => p._id))
query.unsubscribe()
},
{
sort: {
rank: 1
}
}
)
})
}

View File

@ -1,4 +1,4 @@
import { Class, Doc, Ref, SortingOrder, Space } from '@hcengineering/core'
import core, { Class, Doc, Ref, SortingOrder, Space, StatusManager } from '@hcengineering/core'
import { getResource } from '@hcengineering/platform'
import { createQuery, getAttributePresenterClass, getClient, LiveQuery } from '@hcengineering/presentation'
import { getCurrentLocation, locationToUrl } from '@hcengineering/ui'
@ -6,12 +6,14 @@ import {
DropdownViewOption,
ToggleViewOption,
Viewlet,
ViewletDescriptor,
ViewOptionModel,
ViewOptions,
ViewOptionsModel
} from '@hcengineering/view'
import { get, writable } from 'svelte/store'
import view from './plugin'
import { groupByCategory } from './utils'
export const noCategory = '#no_category'
@ -105,7 +107,9 @@ export async function showEmptyGroups (
space: Ref<Space> | undefined,
key: string,
onUpdate: () => void,
queryId: Ref<Doc>
queryId: Ref<Doc>,
mgr: StatusManager,
viewletDescriptorId?: Ref<ViewletDescriptor>
): Promise<any[] | undefined> {
const client = getClient()
const hierarchy = client.getHierarchy()
@ -114,16 +118,19 @@ export async function showEmptyGroups (
if (attr === undefined) return
const { attrClass } = getAttributePresenterClass(hierarchy, attr)
const attributeClass = hierarchy.getClass(attrClass)
if (hierarchy.isDerived(attrClass, core.class.Status)) {
// We do not need extensions for all status categories.
const statuses = mgr.statuses.filter((it) => it.ofAttribute === attr._id).map((it) => it._id)
return await groupByCategory(client, _class, key, statuses, mgr)
}
const mixin = hierarchy.as(attributeClass, view.mixin.AllValuesFunc)
if (mixin.func !== undefined) {
const f = await getResource(mixin.func)
const res = await f(space, onUpdate, queryId)
if (res !== undefined) {
const sortFunc = hierarchy.as(attributeClass, view.mixin.SortFuncs)
if (sortFunc?.func === undefined) return res
const f = await getResource(sortFunc.func)
return await f(res)
return await groupByCategory(client, _class, key, res, mgr, viewletDescriptorId)
}
}
}

View File

@ -16,6 +16,7 @@
import type {
AnyAttribute,
CategoryType,
Class,
Client,
Doc,
@ -25,9 +26,12 @@ import type {
Mixin,
Obj,
ObjQueryType,
PrimitiveType,
Ref,
SortingOrder,
Space,
StatusManager,
StatusValue,
Type,
UXObject
} from '@hcengineering/core'
@ -36,10 +40,10 @@ import type { Preference } from '@hcengineering/preference'
import type {
AnyComponent,
AnySvelteComponent,
PopupAlignment,
PopupPosAlignment,
Location,
Location as PlatformLocation,
Location
PopupAlignment,
PopupPosAlignment
} from '@hcengineering/ui'
/**
@ -236,7 +240,9 @@ export interface ListHeaderExtra extends Class<Doc> {
/**
* @public
*/
export type SortFunc = Resource<(values: any[], viewletDescriptorId?: Ref<ViewletDescriptor>) => Promise<any[]>>
export type SortFunc = Resource<
(values: (PrimitiveType | StatusValue)[], viewletDescriptorId?: Ref<ViewletDescriptor>) => Promise<any[]>
>
/**
* @public
@ -245,11 +251,20 @@ export interface ClassSortFuncs extends Class<Doc> {
func: SortFunc
}
/**
* @public
*/
export type AllValuesFuncGetter = (
space: Ref<Space> | undefined,
onUpdate: () => void,
queryId: Ref<Doc>
) => Promise<any[] | undefined>
/**
* @public
*/
export interface AllValuesFunc extends Class<Doc> {
func: Resource<(space: Ref<Space> | undefined, onUpdate: () => void, queryId: Ref<Doc>) => Promise<any[] | undefined>>
func: Resource<AllValuesFuncGetter>
}
/**
@ -486,19 +501,22 @@ export interface ViewOption {
actionTarget?: 'query' | 'category'
action?: Resource<(value: any, ...params: any) => any>
}
/**
* @public
*/
export type ViewCategoryAction = Resource<
(
export type ViewCategoryActionFunc = (
_class: Ref<Class<Doc>>,
space: Ref<Space> | undefined,
key: string,
onUpdate: () => void,
queryId: Ref<Doc>
) => Promise<any[] | undefined>
>
queryId: Ref<Doc>,
mgr: StatusManager,
viewletDescriptorId?: Ref<ViewletDescriptor>
) => Promise<CategoryType[] | undefined>
/**
* @public
*/
export type ViewCategoryAction = Resource<ViewCategoryActionFunc>
/**
* @public

View File

@ -54,7 +54,7 @@
if (attachTo) {
viewlets = await client.findAll(
view.class.Viewlet,
{ attachTo },
{ attachTo, variant: { $exists: false } },
{
lookup: {
descriptor: view.class.ViewletDescriptor

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