Tracker: move "IssueStatus" enum into model (#1449)

Signed-off-by: Sergei Ogorelkov <sergei.ogorelkov@xored.com>
This commit is contained in:
Sergei Ogorelkov 2022-04-23 23:59:57 +07:00 committed by GitHub
parent 51687d1a2b
commit d663e383e6
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
25 changed files with 5840 additions and 3127 deletions

File diff suppressed because it is too large Load Diff

View File

@ -468,10 +468,6 @@ export function createModel (builder: Builder): void {
task.viewlet.Kanban task.viewlet.Kanban
) )
builder.mixin(task.class.DoneState, core.class.Class, view.mixin.AttributePresenter, {
presenter: task.component.DoneStatePresenter
})
builder.mixin(task.class.TodoItem, core.class.Class, view.mixin.AttributeEditor, { builder.mixin(task.class.TodoItem, core.class.Class, view.mixin.AttributeEditor, {
editor: task.component.Todos editor: task.component.Todos
}) })

View File

@ -15,7 +15,7 @@
import type { Employee } from '@anticrm/contact' import type { Employee } from '@anticrm/contact'
import contact from '@anticrm/contact' import contact from '@anticrm/contact'
import { Domain, IndexKind, Markup, Ref, Timestamp } from '@anticrm/core' import { Domain, DOMAIN_MODEL, IndexKind, Markup, Ref, Timestamp } from '@anticrm/core'
import { import {
Builder, Builder,
Collection, Collection,
@ -32,9 +32,9 @@ import {
} from '@anticrm/model' } from '@anticrm/model'
import attachment from '@anticrm/model-attachment' import attachment from '@anticrm/model-attachment'
import chunter from '@anticrm/model-chunter' import chunter from '@anticrm/model-chunter'
import core, { DOMAIN_SPACE, TDoc, TSpace } from '@anticrm/model-core' import core, { DOMAIN_SPACE, TAttachedDoc, TDoc, TSpace } from '@anticrm/model-core'
import { IntlString } from '@anticrm/platform' import { Asset, IntlString } from '@anticrm/platform'
import { Document, Issue, IssuePriority, IssueStatus, Team } from '@anticrm/tracker' import { Document, Issue, IssuePriority, IssueStatus, IssueStatusCategory, Team } from '@anticrm/tracker'
import tracker from './plugin' import tracker from './plugin'
import workbench from '@anticrm/model-workbench' import workbench from '@anticrm/model-workbench'
@ -44,6 +44,35 @@ export { default } from './plugin'
export const DOMAIN_TRACKER = 'tracker' as Domain export const DOMAIN_TRACKER = 'tracker' as Domain
/**
* @public
*/
@Model(tracker.class.IssueStatus, core.class.AttachedDoc, DOMAIN_TRACKER)
export class TIssueStatus extends TAttachedDoc implements IssueStatus {
name!: string
description?: string
color?: number
@Prop(TypeRef(tracker.class.IssueStatusCategory), tracker.string.StatusCategory)
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
}
/** /**
* @public * @public
*/ */
@ -61,6 +90,12 @@ export class TTeam extends TSpace implements Team {
@Prop(TypeNumber(), tracker.string.Number) @Prop(TypeNumber(), tracker.string.Number)
@Hidden() @Hidden()
sequence!: number sequence!: number
@Prop(Collection(tracker.class.IssueStatus), tracker.string.IssueStatuses)
issueStatuses!: number
@Prop(TypeRef(tracker.class.IssueStatus), tracker.string.DefaultIssueStatus)
defaultIssueStatus!: Ref<IssueStatus>
} }
/** /**
@ -77,8 +112,8 @@ export class TIssue extends TDoc implements Issue {
@Index(IndexKind.FullText) @Index(IndexKind.FullText)
description!: Markup description!: Markup
@Prop(TypeNumber(), tracker.string.Status) @Prop(TypeRef(tracker.class.IssueStatus), tracker.string.Status)
status!: IssueStatus status!: Ref<IssueStatus>
@Prop(TypeNumber(), tracker.string.Priority) @Prop(TypeNumber(), tracker.string.Priority)
priority!: IssuePriority priority!: IssuePriority
@ -141,7 +176,72 @@ export class TDocument extends TDoc implements Document {
} }
export function createModel (builder: Builder): void { export function createModel (builder: Builder): void {
builder.createModel(TTeam, TIssue) builder.createModel(TTeam, TIssue, TIssueStatus, TIssueStatusCategory)
builder.createDoc(
tracker.class.IssueStatusCategory,
core.space.Model,
{
label: tracker.string.CategoryBacklog,
icon: tracker.icon.CategoryBacklog,
color: 0,
defaultStatusName: 'Backlog',
order: 0
},
tracker.issueStatusCategory.Backlog
)
builder.createDoc(
tracker.class.IssueStatusCategory,
core.space.Model,
{
label: tracker.string.CategoryUnstarted,
icon: tracker.icon.CategoryUnstarted,
color: 1,
defaultStatusName: 'Todo',
order: 1
},
tracker.issueStatusCategory.Unstarted
)
builder.createDoc(
tracker.class.IssueStatusCategory,
core.space.Model,
{
label: tracker.string.CategoryStarted,
icon: tracker.icon.CategoryStarted,
color: 2,
defaultStatusName: 'In Progress',
order: 2
},
tracker.issueStatusCategory.Started
)
builder.createDoc(
tracker.class.IssueStatusCategory,
core.space.Model,
{
label: tracker.string.CategoryCompleted,
icon: tracker.icon.CategoryCompleted,
color: 3,
defaultStatusName: 'Done',
order: 3
},
tracker.issueStatusCategory.Completed
)
builder.createDoc(
tracker.class.IssueStatusCategory,
core.space.Model,
{
label: tracker.string.CategoryCanceled,
icon: tracker.icon.CategoryCanceled,
color: 4,
defaultStatusName: 'Canceled',
order: 4
},
tracker.issueStatusCategory.Canceled
)
builder.createDoc( builder.createDoc(
workbench.class.Application, workbench.class.Application,

View File

@ -13,11 +13,60 @@
// limitations under the License. // limitations under the License.
// //
import core, { TxOperations } from '@anticrm/core' import core, { generateId, Ref, TxOperations } from '@anticrm/core'
import { MigrateOperation, MigrationClient, MigrationUpgradeClient } from '@anticrm/model' import { MigrateOperation, MigrationClient, MigrationUpgradeClient } from '@anticrm/model'
import { Team } from '@anticrm/tracker' import { IssueStatus, IssueStatusCategory, Team, genRanks } from '@anticrm/tracker'
import tracker from './plugin' import tracker from './plugin'
enum DeprecatedIssueStatus {
Backlog,
Todo,
InProgress,
Done,
Canceled
}
interface CreateTeamIssueStatusesArgs {
tx: TxOperations
teamId: Ref<Team>
categories: IssueStatusCategory[]
defaultStatusId?: Ref<IssueStatus>
defaultCategoryId?: Ref<IssueStatusCategory>
}
const categoryByDeprecatedIssueStatus = {
[DeprecatedIssueStatus.Backlog]: tracker.issueStatusCategory.Backlog,
[DeprecatedIssueStatus.Todo]: tracker.issueStatusCategory.Unstarted,
[DeprecatedIssueStatus.InProgress]: tracker.issueStatusCategory.Started,
[DeprecatedIssueStatus.Done]: tracker.issueStatusCategory.Completed,
[DeprecatedIssueStatus.Canceled]: tracker.issueStatusCategory.Canceled
} as const
async function createTeamIssueStatuses ({
tx,
teamId: attachedTo,
categories,
defaultStatusId,
defaultCategoryId = tracker.issueStatusCategory.Backlog
}: CreateTeamIssueStatusesArgs): Promise<void> {
const issueStatusRanks = [...genRanks(categories.length)]
for (const [i, statusCategory] of categories.entries()) {
const { _id: category, defaultStatusName } = statusCategory
const rank = issueStatusRanks[i]
await tx.addCollection(
tracker.class.IssueStatus,
attachedTo,
attachedTo,
tracker.class.Team,
'issueStatuses',
{ name: defaultStatusName, category, rank },
category === defaultCategoryId ? defaultStatusId : undefined
)
}
}
async function createDefaultTeam (tx: TxOperations): Promise<void> { async function createDefaultTeam (tx: TxOperations): Promise<void> {
const current = await tx.findOne(tracker.class.Team, { const current = await tx.findOne(tracker.class.Team, {
_id: tracker.team.DefaultTeam _id: tracker.team.DefaultTeam
@ -29,6 +78,9 @@ async function createDefaultTeam (tx: TxOperations): Promise<void> {
// Create new if not deleted by customers. // Create new if not deleted by customers.
if (current === undefined && currentDeleted === undefined) { if (current === undefined && currentDeleted === undefined) {
const defaultStatusId: Ref<IssueStatus> = generateId()
const categories = await tx.findAll(tracker.class.IssueStatusCategory, {})
await tx.createDoc<Team>( await tx.createDoc<Team>(
tracker.class.Team, tracker.class.Team,
core.space.Space, core.space.Space,
@ -39,10 +91,60 @@ async function createDefaultTeam (tx: TxOperations): Promise<void> {
members: [], members: [],
archived: false, archived: false,
identifier: 'TSK', identifier: 'TSK',
sequence: 0 sequence: 0,
issueStatuses: 0,
defaultIssueStatus: defaultStatusId
}, },
tracker.team.DefaultTeam tracker.team.DefaultTeam
) )
await createTeamIssueStatuses({ tx, teamId: tracker.team.DefaultTeam, categories, defaultStatusId })
}
}
async function upgradeTeamIssueStatuses (tx: TxOperations): Promise<void> {
const teams = await tx.findAll(tracker.class.Team, { issueStatuses: undefined })
if (teams.length > 0) {
const categories = await tx.findAll(tracker.class.IssueStatusCategory, {})
for (const team of teams) {
const defaultStatusId: Ref<IssueStatus> = generateId()
await tx.update(team, { issueStatuses: 0, defaultIssueStatus: defaultStatusId })
await createTeamIssueStatuses({ tx, teamId: team._id, categories, defaultStatusId })
}
}
}
async function upgradeIssueStatuses (tx: TxOperations): Promise<void> {
const deprecatedStatuses = [
DeprecatedIssueStatus.Backlog,
DeprecatedIssueStatus.Canceled,
DeprecatedIssueStatus.Done,
DeprecatedIssueStatus.InProgress,
DeprecatedIssueStatus.Todo
]
const issues = await tx.findAll(tracker.class.Issue, { status: { $in: deprecatedStatuses as any } })
if (issues.length > 0) {
const statusByDeprecatedStatus = new Map<DeprecatedIssueStatus, Ref<IssueStatus>>()
for (const issue of issues) {
const deprecatedStatus = issue.status as unknown as DeprecatedIssueStatus
if (!statusByDeprecatedStatus.has(deprecatedStatus)) {
const category = categoryByDeprecatedIssueStatus[deprecatedStatus]
const issueStatus = await tx.findOne(tracker.class.IssueStatus, { category })
if (issueStatus === undefined) {
throw new Error(`Could not find a new status for "${DeprecatedIssueStatus[deprecatedStatus]}"`)
}
statusByDeprecatedStatus.set(deprecatedStatus, issueStatus._id)
}
await tx.update(issue, { status: statusByDeprecatedStatus.get(deprecatedStatus) })
}
} }
} }
@ -50,10 +152,20 @@ async function createDefaults (tx: TxOperations): Promise<void> {
await createDefaultTeam(tx) await createDefaultTeam(tx)
} }
async function upgradeTeams (tx: TxOperations): Promise<void> {
await upgradeTeamIssueStatuses(tx)
}
async function upgradeIssues (tx: TxOperations): Promise<void> {
await upgradeIssueStatuses(tx)
}
export const trackerOperation: MigrateOperation = { export const trackerOperation: MigrateOperation = {
async migrate (client: MigrationClient): Promise<void> {}, async migrate (client: MigrationClient): Promise<void> {},
async upgrade (client: MigrationUpgradeClient): Promise<void> { async upgrade (client: MigrationUpgradeClient): Promise<void> {
const tx = new TxOperations(client, core.account.System) const tx = new TxOperations(client, core.account.System)
await createDefaults(tx) await createDefaults(tx)
await upgradeTeams(tx)
await upgradeIssues(tx)
} }
} }

View File

@ -16,14 +16,12 @@
import core, { AttachedDoc, Class, Doc, DocumentQuery, DocumentUpdate, FindOptions, Ref, Space } from '@anticrm/core' import core, { AttachedDoc, Class, Doc, DocumentQuery, DocumentUpdate, FindOptions, Ref, Space } from '@anticrm/core'
import { createQuery, getClient } from '@anticrm/presentation' import { createQuery, getClient } from '@anticrm/presentation'
import { getPlatformColor, ScrollBox } from '@anticrm/ui' import { getPlatformColor, ScrollBox } from '@anticrm/ui'
import { createEventDispatcher, tick } from 'svelte' import { createEventDispatcher } from 'svelte'
import { slide } from 'svelte/transition' import { slide } from 'svelte/transition'
import { DocWithRank } from '../types' import { DocWithRank, StateType, TypeState } from '../types'
import { calcRank } from '../utils' import { calcRank } from '../utils'
type StateType = any
type Item = DocWithRank & { state: StateType; doneState: StateType | null } type Item = DocWithRank & { state: StateType; doneState: StateType | null }
type TypeState = { _id: StateType; title: string; color: number }
type ExtItem = { prev?: Item; it: Item; next?: Item, pos: number } type ExtItem = { prev?: Item; it: Item; next?: Item, pos: number }
type CardDragEvent = DragEvent & { currentTarget: EventTarget & HTMLDivElement } type CardDragEvent = DragEvent & { currentTarget: EventTarget & HTMLDivElement }

View File

@ -6,3 +6,14 @@ import { Doc } from '@anticrm/core'
export interface DocWithRank extends Doc { export interface DocWithRank extends Doc {
rank: string rank: string
} }
export type StateType = any
/**
* @public
*/
export interface TypeState {
_id: StateType
title: string
color: number
}

View File

@ -21,7 +21,7 @@
export let placeholder: IntlString | undefined = undefined export let placeholder: IntlString | undefined = undefined
export let placeholderParam: any | undefined = undefined export let placeholderParam: any | undefined = undefined
export let searchable: boolean = false export let searchable: boolean = false
export let value: Array<{id: number | string, icon: Asset, label: IntlString}> export let value: Array<{id: number | string, icon: Asset, label?: IntlString, text?: string}>
let search: string = '' let search: string = ''
@ -39,10 +39,16 @@
{/if} {/if}
<div class="scroll"> <div class="scroll">
<div class="box"> <div class="box">
{#each value.filter(el => el.label.toLowerCase().includes(search.toLowerCase())) as item} {#each value.filter(el => (el.label ?? el.text ?? '').toLowerCase().includes(search.toLowerCase())) as item}
<button class="menu-item" on:click={() => { dispatch('close', item.id) }}> <button class="menu-item" on:click={() => { dispatch('close', item.id) }}>
<div class="icon"><Icon icon={item.icon} size={'small'} /></div> <div class="icon"><Icon icon={item.icon} size={'small'} /></div>
<span class="label"><Label label={item.label} /></span> <span class="label">
{#if item.label}
<Label label={item.label} />
{:else if item.text}
<span>{item.text}</span>
{/if}
</span>
</button> </button>
{/each} {/each}
</div> </div>

View File

@ -21,10 +21,6 @@
"AddIssue": "Add Issue", "AddIssue": "Add Issue",
"NewIssue": "New issue", "NewIssue": "New issue",
"SaveIssue": "Save issue", "SaveIssue": "Save issue",
"Todo": "Todo",
"InProgress": "In Progress",
"Done": "Done",
"Canceled": "Canceled",
"SetPriority": "Set priority\u2026", "SetPriority": "Set priority\u2026",
"SetStatus": "Set status\u2026", "SetStatus": "Set status\u2026",
"SelectIssue": "Select issue", "SelectIssue": "Select issue",
@ -36,6 +32,12 @@
"Low": "Low", "Low": "Low",
"Unassigned": "Unassigned", "Unassigned": "Unassigned",
"CategoryBacklog": "Backlog",
"CategoryUnstarted": "Unstarted",
"CategoryStarted": "Started",
"CategoryCompleted": "Completed",
"CategoryCanceled": "Canceled",
"Title": "Title", "Title": "Title",
"Description": "", "Description": "",
"Status": "Status", "Status": "Status",

View File

@ -36,11 +36,11 @@ loadMetadata(tracker.icon, {
DueDate: `${icons}#inbox`, // TODO: add icon DueDate: `${icons}#inbox`, // TODO: add icon
Parent: `${icons}#myissues`, // TODO: add icon Parent: `${icons}#myissues`, // TODO: add icon
StatusBacklog: `${icons}#status-backlog`, CategoryBacklog: `${icons}#status-backlog`,
StatusTodo: `${icons}#status-todo`, CategoryUnstarted: `${icons}#status-todo`,
StatusInProgress: `${icons}#status-inprogress`, CategoryStarted: `${icons}#status-inprogress`,
StatusDone: `${icons}#status-done`, CategoryCompleted: `${icons}#status-done`,
StatusCanceled: `${icons}#status-canceled`, CategoryCanceled: `${icons}#status-canceled`,
PriorityNoPriority: `${icons}#priority-nopriority`, PriorityNoPriority: `${icons}#priority-nopriority`,
PriorityUrgent: `${icons}#priority-urgent`, PriorityUrgent: `${icons}#priority-urgent`,
PriorityHigh: `${icons}#priority-high`, PriorityHigh: `${icons}#priority-high`,

View File

@ -47,7 +47,6 @@
"@anticrm/notification-resources": "~0.6.0", "@anticrm/notification-resources": "~0.6.0",
"@anticrm/contact": "~0.6.5", "@anticrm/contact": "~0.6.5",
"@anticrm/view-resources": "~0.6.0", "@anticrm/view-resources": "~0.6.0",
"lexorank": "~1.0.4",
"@anticrm/text-editor": "~0.6.0", "@anticrm/text-editor": "~0.6.0",
"@anticrm/panel": "~0.6.0", "@anticrm/panel": "~0.6.0",
"@anticrm/kanban": "~0.6.0" "@anticrm/kanban": "~0.6.0"

View File

@ -14,28 +14,25 @@
--> -->
<script lang="ts"> <script lang="ts">
import contact, { Employee } from '@anticrm/contact' import contact, { Employee } from '@anticrm/contact'
import core, { Data, generateId, Ref, SortingOrder } from '@anticrm/core' import core, { Data, generateId, Ref, SortingOrder, WithLookup } from '@anticrm/core'
import { Asset, IntlString } from '@anticrm/platform' import { Asset, IntlString } from '@anticrm/platform'
import presentation, { getClient, UserBox, Card, MessageBox } from '@anticrm/presentation' import presentation, { getClient, UserBox, Card, createQuery } from '@anticrm/presentation'
import { Issue, IssuePriority, IssueStatus, Team } from '@anticrm/tracker' import { Issue, IssuePriority, IssueStatus, Team, calcRank } from '@anticrm/tracker'
import { StyledTextBox } from '@anticrm/text-editor' import { StyledTextBox } from '@anticrm/text-editor'
import { EditBox, Button, showPopup, DatePresenter, SelectPopup, IconAttachment, eventToHTMLElement } from '@anticrm/ui' import { EditBox, Button, showPopup, DatePresenter, SelectPopup, IconAttachment, eventToHTMLElement } from '@anticrm/ui'
import { createEventDispatcher } from 'svelte' import { createEventDispatcher } from 'svelte'
import tracker from '../plugin' import tracker from '../plugin'
import { calcRank } from '../utils'
import StatusSelector from './StatusSelector.svelte' import StatusSelector from './StatusSelector.svelte'
import PrioritySelector from './PrioritySelector.svelte' import PrioritySelector from './PrioritySelector.svelte'
export let space: Ref<Team> export let space: Ref<Team>
export let parent: Ref<Issue> | undefined export let parent: Ref<Issue> | undefined
export let status: IssueStatus = IssueStatus.Backlog export let issueStatus: Ref<IssueStatus> | undefined = undefined
export let priority: IssuePriority = IssuePriority.NoPriority export let priority: IssuePriority = IssuePriority.NoPriority
export let assignee: Ref<Employee> | null = null export let assignee: Ref<Employee> | null = null
$: _space = space
$: _parent = parent
let currentAssignee: Ref<Employee> | null = assignee let currentAssignee: Ref<Employee> | null = assignee
let issueStatuses: WithLookup<IssueStatus>[] = []
const object: Data<Issue> = { const object: Data<Issue> = {
title: '', title: '',
@ -43,7 +40,7 @@
assignee: null, assignee: null,
number: 0, number: 0,
rank: '', rank: '',
status: status, status: '' as Ref<IssueStatus>,
priority: priority, priority: priority,
dueDate: null, dueDate: null,
comments: 0 comments: 0
@ -51,8 +48,37 @@
const dispatch = createEventDispatcher() const dispatch = createEventDispatcher()
const client = getClient() const client = getClient()
const statusesQuery = createQuery()
const taskId: Ref<Issue> = generateId() const taskId: Ref<Issue> = generateId()
$: _space = space
$: _parent = parent
$: updateIssueStatusId(space, issueStatus)
$: statusesQuery.query(tracker.class.IssueStatus, { attachedTo: space }, (statuses) => {
issueStatuses = statuses
}, {
lookup: { category: tracker.class.IssueStatusCategory },
sort: { rank: SortingOrder.Ascending }
})
async function updateIssueStatusId (teamId: Ref<Team>, status?: Ref<IssueStatus>) {
if (status !== undefined) {
object.status = status
return
}
const team = await client.findOne(
tracker.class.Team,
{ _id: teamId },
{ lookup: { defaultIssueStatus: tracker.class.IssueStatus } }
)
const teamDefaultIssueStatusId = team?.$lookup?.defaultIssueStatus?._id
if (teamDefaultIssueStatusId) {
object.status = teamDefaultIssueStatusId
}
}
export function canClose (): boolean { export function canClose (): boolean {
// if (object.title !== undefined) { // if (object.title !== undefined) {
// showPopup( // showPopup(
@ -72,6 +98,10 @@
} }
async function createIssue () { async function createIssue () {
if (!object.status) {
return
}
const lastOne = await client.findOne<Issue>( const lastOne = await client.findOne<Issue>(
tracker.class.Issue, tracker.class.Issue,
{ status: object.status }, { status: object.status },
@ -116,12 +146,10 @@
object.priority = newPriority object.priority = newPriority
} }
const handleStatusChanged = (newStatus: IssueStatus | undefined) => { const handleStatusChanged = (statusId: Ref<IssueStatus> | undefined) => {
if (newStatus === undefined) { if (statusId !== undefined) {
return object.status = statusId
} }
object.status = newStatus
} }
</script> </script>
@ -164,8 +192,8 @@
placeholder={tracker.string.IssueDescriptionPlaceholder} placeholder={tracker.string.IssueDescriptionPlaceholder}
/> />
<svelte:fragment slot="pool"> <svelte:fragment slot="pool">
<StatusSelector bind:status={object.status} onStatusChange={handleStatusChanged} /> <StatusSelector selectedStatusId={object.status} statuses={issueStatuses} onStatusChange={handleStatusChanged} />
<PrioritySelector bind:priority={object.priority} onPriorityChange={handlePriorityChanged} /> <PrioritySelector priority={object.priority} onPriorityChange={handlePriorityChanged} />
<UserBox <UserBox
_class={contact.class.Employee} _class={contact.class.Employee}
label={tracker.string.Assignee} label={tracker.string.Assignee}

View File

@ -1,3 +1,17 @@
<!--
// Copyright © 2022 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"> <script lang="ts">
import { Ref, Space } from '@anticrm/core' import { Ref, Space } from '@anticrm/core'
import { Button, showPopup } from '@anticrm/ui' import { Button, showPopup } from '@anticrm/ui'

View File

@ -13,24 +13,23 @@
// limitations under the License. // limitations under the License.
--> -->
<script lang="ts"> <script lang="ts">
import { Ref, WithLookup } from '@anticrm/core'
import { IssueStatus } from '@anticrm/tracker' import { IssueStatus } from '@anticrm/tracker'
import { Button, Icon, Label, showPopup, SelectPopup, eventToHTMLElement } from '@anticrm/ui' import { Button, Icon, showPopup, SelectPopup, eventToHTMLElement } from '@anticrm/ui'
import { issueStatuses } from '../utils'
import tracker from '../plugin' import tracker from '../plugin'
export let status: IssueStatus export let selectedStatusId: Ref<IssueStatus>
export let statuses: WithLookup<IssueStatus>[]
export let kind: 'button' | 'icon' = 'button' export let kind: 'button' | 'icon' = 'button'
export let shouldShowLabel: boolean = true export let shouldShowLabel: boolean = true
export let onStatusChange: ((newStatus: IssueStatus | undefined) => void) | undefined = undefined export let onStatusChange: ((newStatus: Ref<IssueStatus> | undefined) => void) | undefined = undefined
export let isEditable: boolean = true export let isEditable: boolean = true
const statusesInfo = [ $: selectedStatus = statuses.find((status) => status._id === selectedStatusId) ?? statuses[0]
IssueStatus.Backlog, $: selectedStatusIcon = selectedStatus?.$lookup?.category?.icon
IssueStatus.Todo, $: selectedStatusLabel = shouldShowLabel ? selectedStatus?.name : undefined
IssueStatus.InProgress, $: statusesInfo = statuses.map((s) => ({ id: s._id, text: s.name, color: s.color, icon: s.$lookup?.category?.icon }))
IssueStatus.Done,
IssueStatus.Canceled
].map((s) => ({ id: s, ...issueStatuses[s] }))
const handleStatusEditorOpened = (event: MouseEvent) => { const handleStatusEditorOpened = (event: MouseEvent) => {
if (!isEditable) { if (!isEditable) {
@ -47,23 +46,28 @@
{#if kind === 'button'} {#if kind === 'button'}
<Button <Button
label={shouldShowLabel ? issueStatuses[status].label : undefined} icon={selectedStatusIcon}
icon={issueStatuses[status].icon}
width="min-content" width="min-content"
size="small" size="small"
kind="no-border" kind="no-border"
on:click={handleStatusEditorOpened} on:click={handleStatusEditorOpened}
/> >
<svelte:fragment slot="content">
{#if selectedStatusLabel}
<span class="nowrap">{selectedStatusLabel}</span>
{/if}
</svelte:fragment>
</Button>
{:else if kind === 'icon'} {:else if kind === 'icon'}
<div class={isEditable ? 'flex-presenter' : 'presenter'} on:click={handleStatusEditorOpened}> <div class={isEditable ? 'flex-presenter' : 'presenter'} on:click={handleStatusEditorOpened}>
<div class="statusIcon"> {#if selectedStatusIcon}
<Icon icon={issueStatuses[status].icon} size={'small'} /> <div class="statusIcon">
</div> <Icon icon={selectedStatusIcon} size={'small'} />
{#if shouldShowLabel}
<div class="label nowrap ml-2">
<Label label={issueStatuses[status].label} />
</div> </div>
{/if} {/if}
{#if selectedStatusLabel}
<div class="label nowrap ml-2">{selectedStatusLabel}</div>
{/if}
</div> </div>
{/if} {/if}

View File

@ -1,8 +1,22 @@
<!--
// Copyright © 2022 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"> <script lang="ts">
import { Ref } from '@anticrm/core' import { Ref } from '@anticrm/core'
import { IssueStatus, Team, IssuesDateModificationPeriod } from '@anticrm/tracker' import { Team, IssuesDateModificationPeriod } from '@anticrm/tracker'
import Issues from './Issues.svelte'
import tracker from '../../plugin' import tracker from '../../plugin'
import Issues from './Issues.svelte'
export let currentSpace: Ref<Team> export let currentSpace: Ref<Team>
@ -12,6 +26,6 @@
<Issues <Issues
{currentSpace} {currentSpace}
{completedIssuesPeriod} {completedIssuesPeriod}
includedGroups={{ status: [IssueStatus.InProgress, IssueStatus.Todo] }} includedGroups={{ status: [tracker.issueStatusCategory.Unstarted, tracker.issueStatusCategory.Started] }}
title={tracker.string.ActiveIssues} title={tracker.string.ActiveIssues}
/> />

View File

@ -1,8 +1,22 @@
<!--
// Copyright © 2022 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"> <script lang="ts">
import { Ref } from '@anticrm/core' import { Ref } from '@anticrm/core'
import { IssueStatus, Team, IssuesDateModificationPeriod } from '@anticrm/tracker' import { Team, IssuesDateModificationPeriod } from '@anticrm/tracker'
import Issues from './Issues.svelte'
import tracker from '../../plugin' import tracker from '../../plugin'
import Issues from './Issues.svelte'
export let currentSpace: Ref<Team> export let currentSpace: Ref<Team>
@ -13,5 +27,5 @@
title={tracker.string.BacklogIssues} title={tracker.string.BacklogIssues}
{currentSpace} {currentSpace}
{completedIssuesPeriod} {completedIssuesPeriod}
includedGroups={{ status: [IssueStatus.Backlog] }} includedGroups={{ status: [tracker.issueStatusCategory.Backlog] }}
/> />

View File

@ -1,65 +1,59 @@
<!--
// Copyright © 2022 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"> <script lang="ts">
import contact from '@anticrm/contact' import contact from '@anticrm/contact'
import { Class, Doc, FindOptions, Ref, WithLookup } from '@anticrm/core' import { Class, Doc, FindOptions, Ref, SortingOrder, WithLookup } from '@anticrm/core'
import { Kanban } from '@anticrm/kanban' import { Kanban, TypeState } from '@anticrm/kanban'
import { createQuery } from '@anticrm/presentation' import { createQuery } from '@anticrm/presentation'
import { Issue, IssueStatus, Team } from '@anticrm/tracker' import { Issue, Team } from '@anticrm/tracker'
import { Button, Component, eventToHTMLElement, Icon, IconAdd, IconMoreH, showPopup, Tooltip } from '@anticrm/ui' import { Button, eventToHTMLElement, Icon, IconAdd, showPopup, Tooltip } from '@anticrm/ui'
import view from '@anticrm/view'
import { focusStore, ListSelectionProvider, selectionStore } from '@anticrm/view-resources' import { focusStore, ListSelectionProvider, selectionStore } from '@anticrm/view-resources'
import ActionContext from '@anticrm/view-resources/src/components/ActionContext.svelte' import ActionContext from '@anticrm/view-resources/src/components/ActionContext.svelte'
import Menu from '@anticrm/view-resources/src/components/Menu.svelte' import Menu from '@anticrm/view-resources/src/components/Menu.svelte'
import { onMount } from 'svelte' import { onMount } from 'svelte'
import tracker from '../../plugin' import tracker from '../../plugin'
import CreateIssue from '../CreateIssue.svelte' import CreateIssue from '../CreateIssue.svelte'
import AssigneePresenter from './AssigneePresenter.svelte'; import AssigneePresenter from './AssigneePresenter.svelte'
import IssuePresenter from './IssuePresenter.svelte' import IssuePresenter from './IssuePresenter.svelte'
import PriorityPresenter from './PriorityPresenter.svelte'; import PriorityPresenter from './PriorityPresenter.svelte'
export let currentSpace: Ref<Team> export let currentSpace: Ref<Team>
export let baseMenuClass: Ref<Class<Doc>> | undefined = undefined export let baseMenuClass: Ref<Class<Doc>> | undefined = undefined
const states = [
{
_id: IssueStatus.Backlog,
title: 'Backlog',
color: 0,
icon: tracker.icon.StatusBacklog
},
{
_id: IssueStatus.InProgress,
title: 'In progress',
color: 1,
icon: tracker.icon.StatusInProgress
},
{
_id: IssueStatus.Todo,
title: 'To do',
color: 2,
icon: tracker.icon.StatusTodo
},
{
_id: IssueStatus.Done,
title: 'Done',
color: 3,
icon: tracker.icon.StatusDone
},
{
_id: IssueStatus.Canceled,
title: 'Canceled',
color: 4,
icon: tracker.icon.StatusCanceled
}
]
/* eslint-disable no-undef */ /* eslint-disable no-undef */
const spaceQuery = createQuery() const spaceQuery = createQuery()
const statusesQuery = createQuery()
let currentTeam: Team | undefined let currentTeam: Team | undefined
$: spaceQuery.query(tracker.class.Team, { _id: currentSpace }, (res) => { $: spaceQuery.query(tracker.class.Team, { _id: currentSpace }, (res) => {
currentTeam = res.shift() currentTeam = res.shift()
}) })
let states: TypeState[] | undefined
$: statusesQuery.query(tracker.class.IssueStatus, { attachedTo: currentSpace }, (issueStatuses) => {
states = issueStatuses.map((status) => ({
_id: status._id,
title: status.name,
color: status.color ?? status.$lookup?.category?.color ?? 0
}))
}, {
lookup: { category: tracker.class.IssueStatusCategory },
sort: { rank: SortingOrder.Ascending }
})
/* eslint-disable prefer-const */ /* eslint-disable prefer-const */
/* eslint-disable no-unused-vars */ /* eslint-disable no-unused-vars */
let issue: Issue let issue: Issue
@ -107,7 +101,7 @@ import PriorityPresenter from './PriorityPresenter.svelte';
} }
</script> </script>
{#if currentTeam} {#if currentTeam && states}
<ActionContext <ActionContext
context={{ context={{
mode: 'browser' mode: 'browser'
@ -141,7 +135,7 @@ import PriorityPresenter from './PriorityPresenter.svelte';
on:contextmenu={(evt) => showMenu(evt.detail.evt, evt.detail.objects)} on:contextmenu={(evt) => showMenu(evt.detail.evt, evt.detail.objects)}
> >
<svelte:fragment slot="header" let:state let:count> <svelte:fragment slot="header" let:state let:count>
<div class="header flex-col"> <div class="header flex-col">
<div class="flex-between label font-medium w-full h-full mb-4"> <div class="flex-between label font-medium w-full h-full mb-4">
<div class="flex-row-center gap-2"> <div class="flex-row-center gap-2">
<Icon icon={state.icon} size={'small'} /> <Icon icon={state.icon} size={'small'} />
@ -188,10 +182,6 @@ import PriorityPresenter from './PriorityPresenter.svelte';
min-height: 6rem; min-height: 6rem;
user-select: none; user-select: none;
.filter {
border-bottom: 1px solid var(--divider-color);
}
.label { .label {
color: var(--theme-caption-color); color: var(--theme-caption-color);
border-bottom: 1px solid var(--divider-color); border-bottom: 1px solid var(--divider-color);

View File

@ -1,7 +1,21 @@
<!--
// Copyright © 2022 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"> <script lang="ts">
import contact from '@anticrm/contact' import contact from '@anticrm/contact'
import { DocumentQuery, FindOptions, Ref } from '@anticrm/core' import { DocumentQuery, FindOptions, Ref, WithLookup } from '@anticrm/core'
import { Issue, Team } from '@anticrm/tracker' import { Issue, IssueStatus, Team } from '@anticrm/tracker'
import { Component, Button, eventToHTMLElement, IconAdd, Scroller, showPopup, Tooltip } from '@anticrm/ui' import { Component, Button, eventToHTMLElement, IconAdd, Scroller, showPopup, Tooltip } from '@anticrm/ui'
import { createEventDispatcher } from 'svelte' import { createEventDispatcher } from 'svelte'
import tracker from '../../plugin' import tracker from '../../plugin'
@ -12,13 +26,15 @@
export let query: DocumentQuery<Issue> export let query: DocumentQuery<Issue>
export let groupBy: { key: IssuesGroupByKeys | undefined; group: Issue[IssuesGroupByKeys] | undefined } export let groupBy: { key: IssuesGroupByKeys | undefined; group: Issue[IssuesGroupByKeys] | undefined }
export let orderBy: IssuesOrderByKeys export let orderBy: IssuesOrderByKeys
export let statuses: WithLookup<IssueStatus>[]
export let currentSpace: Ref<Team> | undefined = undefined export let currentSpace: Ref<Team> | undefined = undefined
export let currentTeam: Team export let currentTeam: Team
const dispatch = createEventDispatcher() const dispatch = createEventDispatcher()
const options: FindOptions<Issue> = { const options: FindOptions<Issue> = {
lookup: { lookup: {
assignee: contact.class.Employee assignee: contact.class.Employee,
status: tracker.class.IssueStatus
} }
} }
@ -46,7 +62,8 @@
isEditable: false, isEditable: false,
shouldShowLabel: true, shouldShowLabel: true,
value: grouping, value: grouping,
defaultName: groupBy.key === 'assignee' ? tracker.string.NoAssignee : undefined defaultName: groupBy.key === 'assignee' ? tracker.string.NoAssignee : undefined,
statuses: groupBy.key === 'status' ? statuses : undefined
}} }}
/> />
<span class="eLabelCounter ml-2">{issuesAmount}</span> <span class="eLabelCounter ml-2">{issuesAmount}</span>
@ -64,7 +81,7 @@
itemsConfig={[ itemsConfig={[
{ key: '', presenter: tracker.component.PriorityPresenter, props: { currentSpace } }, { key: '', presenter: tracker.component.PriorityPresenter, props: { currentSpace } },
{ key: '', presenter: tracker.component.IssuePresenter, props: { currentTeam } }, { key: '', presenter: tracker.component.IssuePresenter, props: { currentTeam } },
{ key: '', presenter: tracker.component.StatusPresenter, props: { currentSpace } }, { key: '', presenter: tracker.component.StatusPresenter, props: { currentSpace, statuses } },
{ key: '', presenter: tracker.component.TitlePresenter, props: { shouldUseMargin: true } }, { key: '', presenter: tracker.component.TitlePresenter, props: { shouldUseMargin: true } },
{ key: '', presenter: tracker.component.DueDatePresenter, props: { currentSpace } }, { key: '', presenter: tracker.component.DueDatePresenter, props: { currentSpace } },
{ key: 'modifiedOn', presenter: tracker.component.ModificationDatePresenter }, { key: 'modifiedOn', presenter: tracker.component.ModificationDatePresenter },

View File

@ -13,14 +13,14 @@
// limitations under the License. // limitations under the License.
--> -->
<script lang="ts"> <script lang="ts">
import { Ref, Timestamp } from '@anticrm/core' import { Ref, Timestamp, WithLookup } from '@anticrm/core'
import { Issue, IssueStatus, Team } from '@anticrm/tracker' import { Issue, Team } from '@anticrm/tracker'
import { DatePresenter, Tooltip, getDaysDifference } from '@anticrm/ui' import { DatePresenter, Tooltip, getDaysDifference } from '@anticrm/ui'
import { getClient } from '@anticrm/presentation' import { getClient } from '@anticrm/presentation'
import DueDatePopup from './DueDatePopup.svelte' import DueDatePopup from './DueDatePopup.svelte'
import tracker from '../../plugin' import tracker from '../../plugin'
export let value: Issue export let value: WithLookup<Issue>
export let currentSpace: Ref<Team> | undefined = undefined export let currentSpace: Ref<Team> | undefined = undefined
const WARNING_DAYS = 7 const WARNING_DAYS = 7
@ -64,7 +64,10 @@
} }
} }
$: shouldRenderPresenter = dueDateMs && value.status !== IssueStatus.Done && value.status !== IssueStatus.Canceled $: shouldRenderPresenter =
dueDateMs &&
value.$lookup?.status?.category !== tracker.issueStatusCategory.Completed &&
value.$lookup?.status?.category !== tracker.issueStatusCategory.Canceled
</script> </script>
{#if shouldRenderPresenter} {#if shouldRenderPresenter}

View File

@ -14,7 +14,7 @@
--> -->
<script lang="ts"> <script lang="ts">
import contact from '@anticrm/contact' import contact from '@anticrm/contact'
import type { DocumentQuery, Ref } from '@anticrm/core' import { DocumentQuery, Ref, SortingOrder, WithLookup } from '@anticrm/core'
import { createQuery } from '@anticrm/presentation' import { createQuery } from '@anticrm/presentation'
import { import {
Issue, Issue,
@ -22,7 +22,8 @@
IssuesGrouping, IssuesGrouping,
IssuesOrdering, IssuesOrdering,
IssuesDateModificationPeriod, IssuesDateModificationPeriod,
IssueStatus IssueStatus,
IssueStatusCategory
} from '@anticrm/tracker' } from '@anticrm/tracker'
import { Button, Label, ScrollBox, IconOptions, showPopup, eventToHTMLElement } from '@anticrm/ui' import { Button, Label, ScrollBox, IconOptions, showPopup, eventToHTMLElement } from '@anticrm/ui'
import CategoryPresenter from './CategoryPresenter.svelte' import CategoryPresenter from './CategoryPresenter.svelte'
@ -50,9 +51,11 @@
const ENTRIES_LIMIT = 200 const ENTRIES_LIMIT = 200
const spaceQuery = createQuery() const spaceQuery = createQuery()
const issuesQuery = createQuery() const issuesQuery = createQuery()
const statusesQuery = createQuery()
const issuesMap: { [status: string]: number } = {} const issuesMap: { [status: string]: number } = {}
let currentTeam: Team | undefined let currentTeam: Team | undefined
let issues: Issue[] = [] let issues: Issue[] = []
let statusesById: ReadonlyMap<Ref<IssueStatus>, WithLookup<IssueStatus>> = new Map()
$: totalIssues = getTotalIssues(issuesMap) $: totalIssues = getTotalIssues(issuesMap)
@ -71,25 +74,41 @@
$: groupByKey = issuesGroupKeyMap[groupingKey] $: groupByKey = issuesGroupKeyMap[groupingKey]
$: categories = getCategories(groupByKey, issues, !!shouldShowEmptyGroups) $: categories = getCategories(groupByKey, issues, !!shouldShowEmptyGroups)
$: displayedCategories = (categories as any[]).filter((x: ReturnType<typeof getCategories>) => { $: displayedCategories = (categories as any[]).filter((x) => {
return ( if (groupByKey === undefined || includedGroups[groupByKey] === undefined) {
groupByKey === undefined || includedGroups[groupByKey] === undefined || includedGroups[groupByKey]?.includes(x) return true
) }
})
$: includedIssuesQuery = getIncludedIssuesQuery(includedGroups)
$: filteredIssuesQuery = getModifiedOnIssuesFilterQuery(issues, completedIssuesPeriod)
const getIncludedIssuesQuery = (groups: Partial<Record<IssuesGroupByKeys, Array<any>>>) => { if (groupByKey === 'status') {
const category = statusesById.get(x as Ref<IssueStatus>)?.category
return !!(category && includedGroups.status?.includes(category))
}
return includedGroups[groupByKey]?.includes(x)
})
$: includedIssuesQuery = getIncludedIssuesQuery(includedGroups, statuses)
$: filteredIssuesQuery = getModifiedOnIssuesFilterQuery(issues, completedIssuesPeriod)
$: statuses = [...statusesById.values()]
const getIncludedIssuesQuery = (
groups: Partial<Record<IssuesGroupByKeys, Array<any>>>,
issueStatuses: IssueStatus[]
) => {
const resultMap: { [p: string]: { $in: any[] } } = {} const resultMap: { [p: string]: { $in: any[] } } = {}
for (const [key, value] of Object.entries(groups)) { for (const [key, value] of Object.entries(groups)) {
resultMap[key] = { $in: value } const includedCategories = key === 'status' ? filterIssueStatuses(issueStatuses, value) : value
resultMap[key] = { $in: includedCategories }
} }
return resultMap return resultMap
} }
const getModifiedOnIssuesFilterQuery = (currentIssues: Issue[], period: IssuesDateModificationPeriod | null) => { const getModifiedOnIssuesFilterQuery = (
currentIssues: WithLookup<Issue>[],
period: IssuesDateModificationPeriod | null
) => {
const filter: { _id: { $in: Array<Ref<Issue>> } } = { _id: { $in: [] } } const filter: { _id: { $in: Array<Ref<Issue>> } } = { _id: { $in: [] } }
if (!period || period === IssuesDateModificationPeriod.All) { if (!period || period === IssuesDateModificationPeriod.All) {
@ -97,7 +116,10 @@
} }
for (const issue of currentIssues) { for (const issue of currentIssues) {
if (issue.status === IssueStatus.Done && issue.modifiedOn < getIssuesModificationDatePeriodTime(period)) { if (
issue.$lookup?.status?.category === tracker.issueStatusCategory.Completed &&
issue.modifiedOn < getIssuesModificationDatePeriodTime(period)
) {
continue continue
} }
@ -116,6 +138,18 @@
{ limit: ENTRIES_LIMIT, lookup: { assignee: contact.class.Employee } } { limit: ENTRIES_LIMIT, lookup: { assignee: contact.class.Employee } }
) )
$: statusesQuery.query(
tracker.class.IssueStatus,
{ attachedTo: currentSpace },
(issueStatuses) => {
statusesById = new Map(issueStatuses.map((status) => [status._id, status]))
},
{
lookup: { category: tracker.class.IssueStatusCategory },
sort: { rank: SortingOrder.Ascending }
}
)
const getCategories = (key: IssuesGroupByKeys | undefined, elements: Issue[], shouldShowAll: boolean) => { const getCategories = (key: IssuesGroupByKeys | undefined, elements: Issue[], shouldShowAll: boolean) => {
if (!key) { if (!key) {
return [undefined] // No grouping return [undefined] // No grouping
@ -132,6 +166,15 @@
return shouldShowAll ? defaultIssueCategories[key] ?? existingCategories : existingCategories return shouldShowAll ? defaultIssueCategories[key] ?? existingCategories : existingCategories
} }
function filterIssueStatuses (
issueStatuses: IssueStatus[],
issueStatusCategories: Ref<IssueStatusCategory>[]
): Ref<IssueStatus>[] {
const statusCategories = new Set(issueStatusCategories)
return issueStatuses.filter((status) => statusCategories.has(status.category)).map((s) => s._id)
}
const getTotalIssues = (map: { [status: string]: number }) => { const getTotalIssues = (map: { [status: string]: number }) => {
let total = 0 let total = 0
@ -197,6 +240,7 @@
groupBy={{ key: groupByKey, group: category }} groupBy={{ key: groupByKey, group: category }}
orderBy={issuesOrderKeyMap[orderingKey]} orderBy={issuesOrderKeyMap[orderingKey]}
query={resultQuery} query={resultQuery}
{statuses}
{currentSpace} {currentSpace}
{currentTeam} {currentTeam}
on:content={(event) => { on:content={(event) => {

View File

@ -13,7 +13,7 @@
// limitations under the License. // limitations under the License.
--> -->
<script lang="ts"> <script lang="ts">
import { Ref } from '@anticrm/core' import { Ref, WithLookup } from '@anticrm/core'
import { Issue, IssueStatus, Team } from '@anticrm/tracker' import { Issue, IssueStatus, Team } from '@anticrm/tracker'
import { getClient } from '@anticrm/presentation' import { getClient } from '@anticrm/presentation'
import { Tooltip } from '@anticrm/ui' import { Tooltip } from '@anticrm/ui'
@ -21,13 +21,14 @@
import StatusSelector from '../StatusSelector.svelte' import StatusSelector from '../StatusSelector.svelte'
export let value: Issue export let value: Issue
export let statuses: WithLookup<IssueStatus>[]
export let currentSpace: Ref<Team> | undefined = undefined export let currentSpace: Ref<Team> | undefined = undefined
export let isEditable: boolean = true export let isEditable: boolean = true
export let shouldShowLabel: boolean = false export let shouldShowLabel: boolean = false
const client = getClient() const client = getClient()
const handleStatusChanged = async (newStatus: IssueStatus | undefined) => { const handleStatusChanged = async (newStatus: Ref<IssueStatus> | undefined) => {
if (!isEditable || newStatus === undefined) { if (!isEditable || newStatus === undefined) {
return return
} }
@ -49,7 +50,8 @@
kind={'icon'} kind={'icon'}
{isEditable} {isEditable}
{shouldShowLabel} {shouldShowLabel}
status={value.status} {statuses}
selectedStatusId={value.status}
onStatusChange={handleStatusChanged} onStatusChange={handleStatusChanged}
/> />
</Tooltip> </Tooltip>
@ -58,7 +60,8 @@
kind={'icon'} kind={'icon'}
{isEditable} {isEditable}
{shouldShowLabel} {shouldShowLabel}
status={value.status} {statuses}
selectedStatusId={value.status}
onStatusChange={handleStatusChanged} onStatusChange={handleStatusChanged}
/> />
{/if} {/if}

View File

@ -1,5 +1,5 @@
// //
// Copyright © 2020 Anticrm Platform Contributors. // Copyright © 2022 Hardcore Engineering Inc.
// //
// Licensed under the Eclipse Public License, Version 2.0 (the "License"); // 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 // you may not use this file except in compliance with the License. You may
@ -59,6 +59,14 @@ export default mergeIds(trackerId, tracker, {
Identifier: '' as IntlString, Identifier: '' as IntlString,
Description: '' as IntlString, Description: '' as IntlString,
Status: '' as IntlString, Status: '' as IntlString,
DefaultIssueStatus: '' as IntlString,
IssueStatuses: '' as IntlString,
StatusCategory: '' as IntlString,
CategoryBacklog: '' as IntlString,
CategoryUnstarted: '' as IntlString,
CategoryStarted: '' as IntlString,
CategoryCompleted: '' as IntlString,
CategoryCanceled: '' as IntlString,
Number: '' as IntlString, Number: '' as IntlString,
Assignee: '' as IntlString, Assignee: '' as IntlString,
AssignTo: '' as IntlString, AssignTo: '' as IntlString,

View File

@ -1,6 +1,5 @@
// //
// Copyright © 2020, 2021 Anticrm Platform Contributors. // Copyright © 2022 Hardcore Engineering Inc.
// Copyright © 2021 Hardcore Engineering Inc.
// //
// Licensed under the Eclipse Public License, Version 2.0 (the "License"); // 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 // you may not use this file except in compliance with the License. You may
@ -16,10 +15,15 @@
import { Ref, SortingOrder } from '@anticrm/core' import { Ref, SortingOrder } from '@anticrm/core'
import type { Asset, IntlString } from '@anticrm/platform' import type { Asset, IntlString } from '@anticrm/platform'
import { IssuePriority, IssueStatus, Team, IssuesGrouping, IssuesOrdering, Issue, IssuesDateModificationPeriod } from '@anticrm/tracker' import {
IssuePriority,
Team,
IssuesGrouping,
IssuesOrdering,
Issue,
IssuesDateModificationPeriod
} from '@anticrm/tracker'
import { AnyComponent, getMillisecondsInMonth, MILLISECONDS_IN_WEEK } from '@anticrm/ui' import { AnyComponent, getMillisecondsInMonth, MILLISECONDS_IN_WEEK } from '@anticrm/ui'
import { LexoDecimal, LexoNumeralSystem36, LexoRank } from 'lexorank'
import LexoRankBucket from 'lexorank/lib/lexoRank/lexoRankBucket'
import tracker from './plugin' import tracker from './plugin'
export interface NavigationItem { export interface NavigationItem {
@ -36,41 +40,6 @@ export interface Selection {
currentSpecial?: string currentSpecial?: string
} }
/**
* @public
*/
export const genRanks = (count: number): Generator<string, void, unknown> =>
(function * () {
const sys = new LexoNumeralSystem36()
const base = 36
const max = base ** 6
const gap = LexoDecimal.parse(Math.trunc(max / (count + 2)).toString(base), sys)
let cur = LexoDecimal.parse('0', sys)
for (let i = 0; i < count; i++) {
cur = cur.add(gap)
yield new LexoRank(LexoRankBucket.BUCKET_0, cur).toString()
}
})()
/**
* @public
*/
export const calcRank = (prev?: { rank: string }, next?: { rank: string }): string => {
const a = prev?.rank !== undefined ? LexoRank.parse(prev.rank) : LexoRank.min()
const b = next?.rank !== undefined ? LexoRank.parse(next.rank) : LexoRank.max()
return a.between(b).toString()
}
export const issueStatuses: Record<IssueStatus, { icon: Asset, label: IntlString }> = {
[IssueStatus.Backlog]: { icon: tracker.icon.StatusBacklog, label: tracker.string.Backlog },
[IssueStatus.Todo]: { icon: tracker.icon.StatusTodo, label: tracker.string.Todo },
[IssueStatus.InProgress]: { icon: tracker.icon.StatusInProgress, label: tracker.string.InProgress },
[IssueStatus.Done]: { icon: tracker.icon.StatusDone, label: tracker.string.Done },
[IssueStatus.Canceled]: { icon: tracker.icon.StatusCanceled, label: tracker.string.Canceled }
}
export const issuePriorities: Record<IssuePriority, { icon: Asset, label: IntlString }> = { export const issuePriorities: Record<IssuePriority, { icon: Asset, label: IntlString }> = {
[IssuePriority.NoPriority]: { icon: tracker.icon.PriorityNoPriority, label: tracker.string.NoPriority }, [IssuePriority.NoPriority]: { icon: tracker.icon.PriorityNoPriority, label: tracker.string.NoPriority },
[IssuePriority.Urgent]: { icon: tracker.icon.PriorityUrgent, label: tracker.string.Urgent }, [IssuePriority.Urgent]: { icon: tracker.icon.PriorityUrgent, label: tracker.string.Urgent },
@ -99,7 +68,7 @@ export const issuesDateModificationPeriodOptions: Record<IssuesDateModificationP
[IssuesDateModificationPeriod.PastMonth]: tracker.string.PastMonth [IssuesDateModificationPeriod.PastMonth]: tracker.string.PastMonth
} }
export type IssuesGroupByKeys = keyof Pick<Issue, 'status' | 'priority' | 'assignee' > export type IssuesGroupByKeys = keyof Pick<Issue, 'status' | 'priority' | 'assignee'>
export type IssuesOrderByKeys = keyof Pick<Issue, 'status' | 'priority' | 'modifiedOn' | 'dueDate'> export type IssuesOrderByKeys = keyof Pick<Issue, 'status' | 'priority' | 'modifiedOn' | 'dueDate'>
export const issuesGroupKeyMap: Record<IssuesGrouping, IssuesGroupByKeys | undefined> = { export const issuesGroupKeyMap: Record<IssuesGrouping, IssuesGroupByKeys | undefined> = {
@ -130,7 +99,6 @@ export const issuesGroupPresenterMap: Record<IssuesGroupByKeys, AnyComponent | u
} }
export const defaultIssueCategories: Partial<Record<IssuesGroupByKeys, Array<Issue[IssuesGroupByKeys]> | undefined>> = { export const defaultIssueCategories: Partial<Record<IssuesGroupByKeys, Array<Issue[IssuesGroupByKeys]> | undefined>> = {
status: [IssueStatus.InProgress, IssueStatus.Todo, IssueStatus.Backlog, IssueStatus.Done, IssueStatus.Canceled],
priority: [IssuePriority.NoPriority, IssuePriority.Urgent, IssuePriority.High, IssuePriority.Medium, IssuePriority.Low] priority: [IssuePriority.NoPriority, IssuePriority.Urgent, IssuePriority.High, IssuePriority.Medium, IssuePriority.Low]
} }

View File

@ -32,6 +32,7 @@
"@anticrm/contact": "~0.6.5", "@anticrm/contact": "~0.6.5",
"@anticrm/chunter": "~0.6.1", "@anticrm/chunter": "~0.6.1",
"@anticrm/attachment": "~0.6.1", "@anticrm/attachment": "~0.6.1",
"@anticrm/task": "~0.6.0" "@anticrm/task": "~0.6.0",
"lexorank": "~1.0.4"
} }
} }

View File

@ -14,11 +14,33 @@
// //
import { Employee } from '@anticrm/contact' import { Employee } from '@anticrm/contact'
import type { Class, Doc, Markup, Ref, Space, Timestamp } from '@anticrm/core' import type { AttachedDoc, Class, Doc, Markup, Ref, Space, Timestamp } from '@anticrm/core'
import type { Asset, IntlString, Plugin } from '@anticrm/platform' import type { Asset, IntlString, Plugin } from '@anticrm/platform'
import { plugin } from '@anticrm/platform' import { plugin } from '@anticrm/platform'
import { AnyComponent } from '@anticrm/ui' import { AnyComponent } from '@anticrm/ui'
/**
* @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
}
/** /**
* @public * @public
*/ */
@ -26,16 +48,8 @@ export interface Team extends Space {
teamLogo?: string | null teamLogo?: string | null
identifier: string // Team identifier identifier: string // Team identifier
sequence: number sequence: number
} issueStatuses: number
/** defaultIssueStatus: Ref<IssueStatus>
* @public
*/
export enum IssueStatus {
Backlog,
Todo,
InProgress,
Done,
Canceled
} }
/** /**
@ -84,7 +98,7 @@ export enum IssuesDateModificationPeriod {
export interface Issue extends Doc { export interface Issue extends Doc {
title: string title: string
description: Markup description: Markup
status: IssueStatus status: Ref<IssueStatus>
priority: IssuePriority priority: IssuePriority
number: number number: number
@ -153,17 +167,28 @@ export interface Project extends Doc {
*/ */
export const trackerId = 'tracker' as Plugin export const trackerId = 'tracker' as Plugin
export * from './utils'
export default plugin(trackerId, { export default plugin(trackerId, {
class: { class: {
Team: '' as Ref<Class<Team>>, Team: '' as Ref<Class<Team>>,
Issue: '' as Ref<Class<Issue>>, Issue: '' as Ref<Class<Issue>>,
Document: '' as Ref<Class<Document>>, Document: '' as Ref<Class<Document>>,
Project: '' as Ref<Class<Project>> Project: '' as Ref<Class<Project>>,
IssueStatus: '' as Ref<Class<IssueStatus>>,
IssueStatusCategory: '' as Ref<Class<IssueStatusCategory>>
}, },
component: { component: {
Tracker: '' as AnyComponent, Tracker: '' as AnyComponent,
TrackerApp: '' as AnyComponent TrackerApp: '' as AnyComponent
}, },
issueStatusCategory: {
Backlog: '' as Ref<IssueStatusCategory>,
Unstarted: '' as Ref<IssueStatusCategory>,
Started: '' as Ref<IssueStatusCategory>,
Completed: '' as Ref<IssueStatusCategory>,
Canceled: '' as Ref<IssueStatusCategory>
},
icon: { icon: {
TrackerApplication: '' as Asset, TrackerApplication: '' as Asset,
Project: '' as Asset, Project: '' as Asset,
@ -183,11 +208,12 @@ export default plugin(trackerId, {
DueDate: '' as Asset, DueDate: '' as Asset,
Parent: '' as Asset, Parent: '' as Asset,
StatusBacklog: '' as Asset, CategoryBacklog: '' as Asset,
StatusTodo: '' as Asset, CategoryUnstarted: '' as Asset,
StatusInProgress: '' as Asset, CategoryStarted: '' as Asset,
StatusDone: '' as Asset, CategoryCompleted: '' as Asset,
StatusCanceled: '' as Asset, CategoryCanceled: '' as Asset,
PriorityNoPriority: '' as Asset, PriorityNoPriority: '' as Asset,
PriorityUrgent: '' as Asset, PriorityUrgent: '' as Asset,
PriorityHigh: '' as Asset, PriorityHigh: '' as Asset,

View File

@ -0,0 +1,44 @@
//
// Copyright © 2022 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 { LexoRank, LexoDecimal, LexoNumeralSystem36 } from 'lexorank'
import LexoRankBucket from 'lexorank/lib/lexoRank/lexoRankBucket'
/**
* @public
*/
export const genRanks = (count: number): Generator<string, void, unknown> =>
(function * () {
const sys = new LexoNumeralSystem36()
const base = 36
const max = base ** 6
const gap = LexoDecimal.parse(Math.trunc(max / (count + 2)).toString(base), sys)
let cur = LexoDecimal.parse('0', sys)
for (let i = 0; i < count; i++) {
cur = cur.add(gap)
yield new LexoRank(LexoRankBucket.BUCKET_0, cur).toString()
}
})()
/**
* @public
*/
export const calcRank = (prev?: { rank: string }, next?: { rank: string }): string => {
const a = prev?.rank !== undefined ? LexoRank.parse(prev.rank) : LexoRank.min()
const b = next?.rank !== undefined ? LexoRank.parse(next.rank) : LexoRank.max()
return a.between(b).toString()
}