Issue improvements + Process Support (#2281)

Signed-off-by: Andrey Sobolev <haiodo@gmail.com>
This commit is contained in:
Andrey Sobolev 2022-10-05 13:41:02 +07:00 committed by GitHub
parent ec0795e687
commit d761662c21
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
59 changed files with 3121 additions and 369 deletions

View File

@ -51,7 +51,7 @@ import workbench, { createNavigateAction } from '@hcengineering/model-workbench'
import notification from '@hcengineering/notification'
import { Asset, IntlString } from '@hcengineering/platform'
import setting from '@hcengineering/setting'
import tags from '@hcengineering/tags'
import tags, { TagElement } from '@hcengineering/tags'
import task from '@hcengineering/task'
import {
Document,
@ -61,6 +61,8 @@ import {
IssuePriority,
IssueStatus,
IssueStatusCategory,
IssueTemplate,
IssueTemplateChild,
Project,
ProjectStatus,
Sprint,
@ -254,6 +256,53 @@ export class TIssue extends TAttachedDoc implements Issue {
declare childInfo: IssueChildInfo[]
}
/**
* @public
*/
@Model(tracker.class.IssueTemplate, core.class.Doc, DOMAIN_TRACKER)
@UX(tracker.string.IssueTemplate, tracker.icon.Issue, tracker.string.IssueTemplate)
export class TIssueTemplate extends TDoc implements IssueTemplate {
@Prop(TypeString(), tracker.string.Title)
@Index(IndexKind.FullText)
title!: string
@Prop(TypeMarkup(), tracker.string.Description)
@Index(IndexKind.FullText)
description!: Markup
@Prop(TypeIssuePriority(), tracker.string.Priority)
priority!: IssuePriority
@Prop(TypeRef(contact.class.Employee), tracker.string.Assignee)
assignee!: Ref<Employee> | null
@Prop(TypeRef(tracker.class.Project), tracker.string.Project)
project!: Ref<Project> | null
@Prop(ArrOf(TypeRef(tags.class.TagElement)), tracker.string.Labels)
labels?: Ref<TagElement>[]
declare space: Ref<Team>
@Prop(TypeDate(true), tracker.string.DueDate)
dueDate!: Timestamp | null
@Prop(TypeRef(tracker.class.Sprint), tracker.string.Sprint)
sprint!: Ref<Sprint> | null
@Prop(TypeNumber(), tracker.string.Estimation)
estimation!: number
@Prop(ArrOf(TypeRef(tracker.class.IssueTemplate)), tracker.string.IssueTemplate)
children!: IssueTemplateChild[]
@Prop(Collection(chunter.class.Comment), tracker.string.Comments)
comments!: number
@Prop(Collection(attachment.class.Attachment), tracker.string.Attachments)
attachments!: number
}
/**
* @public
*/
@ -389,6 +438,7 @@ export function createModel (builder: Builder): void {
TTeam,
TProject,
TIssue,
TIssueTemplate,
TIssueStatus,
TIssueStatusCategory,
TTypeIssuePriority,
@ -403,16 +453,20 @@ export function createModel (builder: Builder): void {
attachTo: tracker.class.Issue,
descriptor: tracker.viewlet.List,
config: [
{ key: '', presenter: tracker.component.PriorityEditor, props: { kind: 'list', size: 'small' } },
{ key: '', presenter: tracker.component.IssuePresenter },
{
key: '',
presenter: tracker.component.PriorityEditor,
props: { type: 'priority', kind: 'list', size: 'small' }
},
{ key: '', presenter: tracker.component.IssuePresenter, props: { type: 'issue' } },
{
key: '',
presenter: tracker.component.StatusEditor,
props: { kind: 'list', size: 'small', justify: 'center' }
},
{ key: '', presenter: tracker.component.TitlePresenter, props: { shouldUseMargin: true, fixed: 'left' } },
{ key: '', presenter: tracker.component.TitlePresenter, props: { shouldUseMargin: true } },
{ key: '', presenter: tracker.component.SubIssuesSelector, props: {} },
{ key: '', presenter: tracker.component.GrowPresenter, props: {} },
{ key: '', presenter: tracker.component.GrowPresenter, props: { type: 'grow' } },
{ key: '', presenter: tracker.component.DueDatePresenter, props: { kind: 'list' } },
{
key: '',
@ -429,7 +483,35 @@ export function createModel (builder: Builder): void {
{
key: '$lookup.assignee',
presenter: tracker.component.AssigneePresenter,
props: { defaultClass: contact.class.Employee, shouldShowLabel: false }
props: { issueClass: tracker.class.Issue, defaultClass: contact.class.Employee, shouldShowLabel: false }
}
]
})
builder.createDoc(view.class.Viewlet, core.space.Model, {
attachTo: tracker.class.IssueTemplate,
descriptor: tracker.viewlet.List,
config: [
// { key: '', presenter: tracker.component.PriorityEditor, props: { kind: 'list', size: 'small' } },
{ key: '', presenter: tracker.component.IssueTemplatePresenter, props: { type: 'issue', shouldUseMargin: true } },
{ key: '', presenter: tracker.component.GrowPresenter, props: { type: 'grow' } },
// { key: '', presenter: tracker.component.DueDatePresenter, props: { kind: 'list' } },
{
key: '',
presenter: tracker.component.ProjectEditor,
props: { kind: 'list', size: 'small', shape: 'round', shouldShowPlaceholder: false }
},
{
key: '',
presenter: tracker.component.SprintEditor,
props: { kind: 'list', size: 'small', shape: 'round', shouldShowPlaceholder: false }
},
// { key: '', presenter: tracker.component.EstimationEditor, props: { kind: 'list', size: 'small' } },
{ key: 'modifiedOn', presenter: tracker.component.ModificationDatePresenter, props: { fixed: 'right' } },
{
key: '$lookup.assignee',
presenter: tracker.component.AssigneePresenter,
props: { issueClass: tracker.class.IssueTemplate, defaultClass: contact.class.Employee, shouldShowLabel: false }
}
]
})
@ -533,11 +615,16 @@ export function createModel (builder: Builder): void {
const boardId = 'board'
const projectsId = 'projects'
const sprintsId = 'sprints'
const templatesId = 'templates'
builder.mixin(tracker.class.Issue, core.class.Class, view.mixin.AttributePresenter, {
presenter: tracker.component.IssuePresenter
})
builder.mixin(tracker.class.IssueTemplate, core.class.Class, view.mixin.AttributePresenter, {
presenter: tracker.component.IssueTemplatePresenter
})
builder.mixin(tracker.class.Issue, core.class.Class, view.mixin.PreviewPresenter, {
presenter: tracker.component.IssuePreview
})
@ -671,6 +758,12 @@ export function createModel (builder: Builder): void {
label: tracker.string.Sprints,
icon: tracker.icon.Sprint,
component: tracker.component.Sprints
},
{
id: templatesId,
label: tracker.string.IssueTemplates,
icon: tracker.icon.Issues,
component: tracker.component.IssueTemplates
}
]
}
@ -846,6 +939,10 @@ export function createModel (builder: Builder): void {
filters: ['status', 'priority', 'assignee', 'project', 'sprint', 'dueDate', 'modifiedOn']
})
builder.mixin(tracker.class.IssueTemplate, core.class.Class, view.mixin.ClassFilters, {
filters: ['priority', 'assignee', 'project', 'sprint', 'dueDate', 'modifiedOn']
})
builder.createDoc(
presentation.class.ObjectSearchCategory,
core.space.Model,

View File

@ -15,7 +15,7 @@
//
import type { Doc, PropertyType } from './classes'
import type { Position, PullArray } from './tx'
import type { Position, PullArray, QueryUpdate } from './tx'
/**
* @internal
@ -72,6 +72,45 @@ function $pull (document: Doc, keyval: Record<string, PropertyType>): void {
}
}
function matchArrayElement<T extends Doc> (docs: any[], query: Partial<T>): any[] {
let result = [...docs]
for (const key in query) {
const value = (query as any)[key]
const tresult: any[] = []
for (const object of result) {
const val = object[key]
if (val === value) {
tresult.push(object)
}
}
result = tresult
if (tresult.length === 0) {
break
}
}
return result
}
function $update (document: Doc, keyval: Record<string, PropertyType>): void {
const doc = document as any
for (const key in keyval) {
if (doc[key] === undefined) {
doc[key] = []
}
const val = keyval[key]
if (typeof val === 'object') {
const arr = doc[key] as Array<any>
const desc = val as QueryUpdate<PropertyType>
for (const m of matchArrayElement(arr, desc.$query)) {
for (const [k, v] of Object.entries(desc.$update)) {
m[k] = v
}
}
}
}
}
function $move (document: Doc, keyval: Record<string, PropertyType>): void {
const doc = document as any
for (const key in keyval) {
@ -114,6 +153,7 @@ function $inc (document: Doc, keyval: Record<string, number>): void {
const operators: Record<string, _OperatorFunc> = {
$push,
$pull,
$update,
$move,
$pushMixin,
$inc

View File

@ -114,6 +114,14 @@ export interface Position<X extends PropertyType> {
$position: number
}
/**
* @public
*/
export interface QueryUpdate<X extends PropertyType> {
$query: Partial<X>
$update: Partial<X>
}
/**
* @public
*/
@ -136,6 +144,13 @@ export type ArrayAsElementPosition<T extends object> = {
[P in keyof T]-?: T[P] extends Arr<infer X> ? X | Position<X> : never
}
/**
* @public
*/
export type ArrayAsElementUpdate<T extends object> = {
[P in keyof T]-?: T[P] extends Arr<infer X> ? X | QueryUpdate<X> : never
}
/**
* @public
*/
@ -164,6 +179,13 @@ export interface PushOptions<T extends object> {
$move?: Partial<OmitNever<ArrayMoveDescriptor<Required<T>>>>
}
/**
* @public
*/
export interface UpdateArrayOptions<T extends object> {
$update?: Partial<OmitNever<ArrayAsElementUpdate<Required<T>>>>
}
/**
* @public
*/
@ -193,6 +215,7 @@ export interface SpaceUpdate {
*/
export type DocumentUpdate<T extends Doc> = Partial<Data<T>> &
PushOptions<T> &
UpdateArrayOptions<T> &
PushMixinOptions<T> &
IncOptions<T> &
SpaceUpdate

View File

@ -104,7 +104,11 @@
const person = objects[selection]
if (!multiSelect) {
selected = person._id === selected ? undefined : person._id
if (allowDeselect) {
selected = person._id === selected ? undefined : person._id
} else {
selected = person._id
}
dispatch('close', selected !== undefined ? person : undefined)
} else {
checkSelected(person, objects)

View File

@ -66,6 +66,7 @@
if (selected !== undefined) {
value = selected._id
dispatch('change', value)
dispatch('space', selected)
}
}
}

View File

@ -55,4 +55,5 @@
on:change={(evt) => {
space = evt.detail
}}
on:space
/>

View File

@ -653,7 +653,6 @@ export class LiveQuery extends TxProcessor implements Client {
if (q.result instanceof Promise) {
q.result = await q.result
}
if (q.options?.lookup !== undefined && handleLookup) {
await this.lookup(q._class, doc, q.options.lookup)
}

View File

@ -14,11 +14,11 @@
-->
<script lang="ts">
import type { Asset } from '@hcengineering/platform'
import { AnySvelteComponent, Icon, LabelAndProps, tooltip } from '@hcengineering/ui'
import { AnySvelteComponent, Icon, IconSize, LabelAndProps, tooltip } from '@hcengineering/ui'
import { createEventDispatcher } from 'svelte'
export let icon: Asset | AnySvelteComponent
export let size: 'small' | 'medium' | 'large'
export let size: IconSize
export let selected: boolean = false
export let showTooltip: LabelAndProps | undefined = undefined
export let disabled: boolean = false

View File

@ -1,6 +1,6 @@
<script lang="ts">
import { IntlString } from '@hcengineering/platform'
import { Label } from '@hcengineering/ui'
import { IconSize, Label } from '@hcengineering/ui'
import { createEventDispatcher } from 'svelte'
import textEditorPlugin from '../plugin'
import StyledTextEditor from './StyledTextEditor.svelte'
@ -10,6 +10,7 @@
export let placeholder: IntlString = textEditorPlugin.string.EditorPlaceholder
export let showButtons = true
export let buttonSize: IconSize = 'small'
export let focus = false
let rawValue: string
@ -50,6 +51,7 @@
<StyledTextEditor
{placeholder}
{showButtons}
{buttonSize}
isScrollable={false}
bind:content={rawValue}
bind:this={textEditor}

View File

@ -1,7 +1,7 @@
<script lang="ts">
import { IntlString } from '@hcengineering/platform'
import presentation, { MessageViewer } from '@hcengineering/presentation'
import { ActionIcon, IconCheck, IconClose, IconEdit, Label, ShowMore } from '@hcengineering/ui'
import { ActionIcon, IconCheck, IconClose, IconEdit, IconSize, Label, ShowMore } from '@hcengineering/ui'
import { createEventDispatcher } from 'svelte'
import textEditorPlugin from '../plugin'
import StyledTextEditor from './StyledTextEditor.svelte'
@ -13,6 +13,7 @@
export let emphasized: boolean = false
export let alwaysEdit: boolean = false
export let showButtons: boolean = true
export let buttonSize: IconSize = 'small'
export let hideExtraButtons: boolean = false
export let maxHeight: 'max' | 'card' | string = 'max'
export let previewLimit: number = 240
@ -85,9 +86,9 @@
<StyledTextEditor
{placeholder}
{showButtons}
{buttonSize}
{maxHeight}
{focusable}
useReferences
bind:content={rawValue}
bind:this={textEditor}
on:focus={() => {

View File

@ -15,7 +15,7 @@
<script lang="ts">
import { IntlString } from '@hcengineering/platform'
import { Scroller, showPopup } from '@hcengineering/ui'
import { IconSize, Scroller, showPopup } from '@hcengineering/ui'
import { createEventDispatcher } from 'svelte'
import textEditorPlugin from '../plugin'
import EmojiPopup from './EmojiPopup.svelte'
@ -43,11 +43,11 @@
export let content: string = ''
export let placeholder: IntlString = textEditorPlugin.string.EditorPlaceholder
export let showButtons: boolean = true
export let buttonSize: IconSize = 'large'
export let isScrollable: boolean = true
export let focusable: boolean = false
export let maxHeight: 'max' | 'card' | string = 'max'
export let withoutTopBorder = false
export let useReferences = false
let textEditor: TextEditor
@ -145,28 +145,28 @@
<div class="formatPanel buttons-group xsmall-gap mb-4" class:withoutTopBorder>
<StyleButton
icon={Bold}
size={'small'}
size={buttonSize}
selected={activeModes.has('bold')}
showTooltip={{ label: textEditorPlugin.string.Bold }}
on:click={getToggler(textEditor.toggleBold)}
/>
<StyleButton
icon={Italic}
size={'small'}
size={buttonSize}
selected={activeModes.has('italic')}
showTooltip={{ label: textEditorPlugin.string.Italic }}
on:click={getToggler(textEditor.toggleItalic)}
/>
<StyleButton
icon={Strikethrough}
size={'small'}
size={buttonSize}
selected={activeModes.has('strike')}
showTooltip={{ label: textEditorPlugin.string.Strikethrough }}
on:click={getToggler(textEditor.toggleStrike)}
/>
<StyleButton
icon={Link}
size={'small'}
size={buttonSize}
selected={activeModes.has('link')}
disabled={isSelectionEmpty && !activeModes.has('link')}
showTooltip={{ label: textEditorPlugin.string.Link }}
@ -175,14 +175,14 @@
<div class="buttons-divider" />
<StyleButton
icon={ListNumber}
size={'small'}
size={buttonSize}
selected={activeModes.has('orderedList')}
showTooltip={{ label: textEditorPlugin.string.OrderedList }}
on:click={getToggler(textEditor.toggleOrderedList)}
/>
<StyleButton
icon={ListBullet}
size={'small'}
size={buttonSize}
selected={activeModes.has('bulletList')}
showTooltip={{ label: textEditorPlugin.string.BulletedList }}
on:click={getToggler(textEditor.toggleBulletList)}
@ -190,7 +190,7 @@
<div class="buttons-divider" />
<StyleButton
icon={Quote}
size={'small'}
size={buttonSize}
selected={activeModes.has('blockquote')}
showTooltip={{ label: textEditorPlugin.string.Blockquote }}
on:click={getToggler(textEditor.toggleBlockquote)}
@ -198,14 +198,14 @@
<div class="buttons-divider" />
<StyleButton
icon={Code}
size={'small'}
size={buttonSize}
selected={activeModes.has('code')}
showTooltip={{ label: textEditorPlugin.string.Code }}
on:click={getToggler(textEditor.toggleCode)}
/>
<StyleButton
icon={CodeBlock}
size={'small'}
size={buttonSize}
selected={activeModes.has('codeBlock')}
showTooltip={{ label: textEditorPlugin.string.CodeBlock }}
on:click={getToggler(textEditor.toggleCodeBlock)}
@ -254,9 +254,7 @@
{#if showButtons}
<div class="buttons">
{#each defActions as a}
<div class="p-1">
<StyleButton icon={a.icon} size={'large'} on:click={(evt) => handleAction(a, evt)} />
</div>
<StyleButton icon={a.icon} size={buttonSize} on:click={(evt) => handleAction(a, evt)} />
{/each}
<div class="flex-grow">
<slot />

View File

@ -510,6 +510,7 @@ input.search {
.min-h-60 { min-height: 15rem; }
.max-h-125 { max-height: 31.25rem; }
.max-h-30 { max-height: 7.5rem; }
.max-h-50 { max-height: 12.5rem; }
.max-h-60 { max-height: 15rem; }
.max-w-30 { max-width: 7.5rem; }
.max-w-60 { max-width: 15rem; }

View File

@ -18,6 +18,7 @@
import { IntlString, setPlatformStatus, unknownError } from '@hcengineering/platform'
import { createQuery, getClient } from '@hcengineering/presentation'
import { StyledTextBox } from '@hcengineering/text-editor'
import { IconSize } from '@hcengineering/ui'
import { onDestroy } from 'svelte'
import attachment from '../plugin'
import { deleteFile, uploadFile } from '../utils'
@ -30,6 +31,7 @@
export let placeholder: IntlString | undefined = undefined
export let alwaysEdit = false
export let showButtons = false
export let buttonSize: IconSize = 'small'
export let maxHeight: 'max' | 'card' | string = 'max'
export function attach (): void {
@ -191,7 +193,7 @@
on:dragleave={() => {}}
on:drop|preventDefault|stopPropagation={fileDrop}
>
<StyledTextBox bind:this={refInput} bind:content {placeholder} {alwaysEdit} {showButtons} {maxHeight} />
<StyledTextBox bind:this={refInput} bind:content {placeholder} {alwaysEdit} {showButtons} {buttonSize} {maxHeight} />
{#if attachments.size}
<div class="flex-row-center list scroll-divider-color mt-1">
{#each Array.from(attachments.values()) as attachment}

View File

@ -122,6 +122,7 @@
}}
/>
<Button
id="channel-ok"
focusIndex={3}
kind={'transparent'}
size={'small'}

View File

@ -305,6 +305,9 @@
label: recruit.string.CreateVacancy
}}
bind:value={_space}
on:change={(evt) => {
_space = evt.detail
}}
component={VacancyOrgPresenter}
componentProps={{ inline: true }}
>

View File

@ -436,18 +436,32 @@
bind:value={firstName}
kind={'large-style'}
focus
maxWidth={'30rem'}
focusIndex={1}
/>
<EditBox
placeholder={recruit.string.PersonLastNamePlaceholder}
bind:value={lastName}
maxWidth={'30rem'}
kind={'large-style'}
focusIndex={2}
/>
<div class="mt-1">
<EditBox placeholder={recruit.string.Title} bind:value={object.title} kind={'small-style'} focusIndex={3} />
<EditBox
placeholder={recruit.string.Title}
bind:value={object.title}
kind={'small-style'}
focusIndex={3}
maxWidth={'30rem'}
/>
</div>
<EditBox placeholder={recruit.string.Location} bind:value={object.city} kind={'small-style'} focusIndex={4} />
<EditBox
placeholder={recruit.string.Location}
bind:value={object.city}
kind={'small-style'}
focusIndex={4}
maxWidth={'30rem'}
/>
</div>
<div class="ml-4">
<EditableAvatar

View File

@ -13,13 +13,15 @@
// limitations under the License.
-->
<script lang="ts">
import { AttachmentStyledBox } from '@hcengineering/attachment-resources'
import contact, { Organization } from '@hcengineering/contact'
import core, { Ref } from '@hcengineering/core'
import core, { generateId, getCurrentAccount, Ref } from '@hcengineering/core'
import { Card, getClient, UserBox } from '@hcengineering/presentation'
import task, { createKanban, KanbanTemplate } from '@hcengineering/task'
import { Button, Component, createFocusManager, EditBox, FocusHandler } from '@hcengineering/ui'
import { Button, Component, createFocusManager, EditBox, FocusHandler, IconAttachment } from '@hcengineering/ui'
import { createEventDispatcher } from 'svelte'
import recruit from '../plugin'
import { Vacancy as VacancyClass } from '@hcengineering/recruit'
import Company from './icons/Company.svelte'
import Vacancy from './icons/Vacancy.svelte'
@ -27,7 +29,10 @@
let name: string = ''
const description: string = ''
let fullDescription: string = ''
let templateId: Ref<KanbanTemplate> | undefined
let objectId: Ref<VacancyClass> = generateId()
export let company: Ref<Organization> | undefined
export let preserveCompany: boolean = false
@ -45,19 +50,31 @@
throw Error(`Failed to find target kanban template: ${templateId}`)
}
const id = await client.createDoc(recruit.class.Vacancy, core.space.Space, {
name,
description,
private: false,
archived: false,
company,
members: []
})
const id = await client.createDoc(
recruit.class.Vacancy,
core.space.Space,
{
name,
description,
fullDescription,
private: false,
archived: false,
company,
members: [getCurrentAccount()._id]
},
objectId
)
await createKanban(client, id, templateId)
await descriptionBox.createAttachments()
objectId = generateId()
dispatch('close', id)
}
const manager = createFocusManager()
let descriptionBox: AttachmentStyledBox
</script>
<FocusHandler {manager} />
@ -83,7 +100,7 @@
/>
</div>
</div>
<svelte:fragment slot="pool">
<svelte:fragment slot="header">
<UserBox
focusIndex={3}
_class={contact.class.Organization}
@ -100,6 +117,20 @@
showNavigate={false}
create={{ component: contact.component.CreateOrganization, label: contact.string.CreateOrganization }}
/>
</svelte:fragment>
<AttachmentStyledBox
bind:this={descriptionBox}
{objectId}
_class={recruit.class.Vacancy}
space={objectId}
alwaysEdit
showButtons={false}
maxHeight={'card'}
bind:content={fullDescription}
placeholder={recruit.string.FullDescription}
/>
<svelte:fragment slot="pool">
<Component
is={task.component.KanbanTemplateSelector}
props={{
@ -112,4 +143,13 @@
}}
/>
</svelte:fragment>
<svelte:fragment slot="footer">
<Button
icon={IconAttachment}
kind={'transparent'}
on:click={() => {
descriptionBox.attach()
}}
/>
</svelte:fragment>
</Card>

View File

@ -96,8 +96,7 @@
listProvider.updateSelection(evt.detail.docs, evt.detail.value)
}
const onContextMenu = (evt: any) => showMenu(evt.detail.evt, evt.detail.objects)
$: query = { doneState: null, space }
let resultQuery = query
let resultQuery = { doneState: null, space }
</script>
{#await cardPresenter then presenter}
@ -106,8 +105,13 @@
mode: 'browser'
}}
/>
<FilterBar {_class} {query} on:change={(e) => (resultQuery = e.detail)} />
<FilterBar
{_class}
query={{ doneState: null, space }}
on:change={(e) => {
resultQuery = e.detail
}}
/>
<KanbanUI
bind:this={kanbanUI}
{_class}

View File

@ -217,7 +217,15 @@
"AddedReference": "Added reference",
"AddedAsBlocked": "Marked as blocked",
"AddedAsBlocking": "Marked as blocking"
"AddedAsBlocking": "Marked as blocking",
"IssueTemplate": "Process",
"IssueTemplates": "Process",
"NewProcess": "New Process",
"SaveProcess": "Save Process",
"NoIssueTemplate": "No Process",
"TemplateReplace": "You you replace applied template?",
"TemplateReplaceConfirm": "All changes to template values will be lost."
},
"status": {}
}

View File

@ -217,7 +217,12 @@
"AddedReference": "Добавлена зависимость",
"AddedAsBlocked": "Отмечено как заблокировано",
"AddedAsBlocking": "Отмечено как блокирующее"
"AddedAsBlocking": "Отмечено как блокирующее",
"IssueTemplate": "Процесс",
"IssueTemplates": "Процессы",
"NewProcess": "Новый процесс",
"NoIssueTemplate": "Без процесса"
},
"status": {}
}

View File

@ -18,9 +18,19 @@
import { Employee } from '@hcengineering/contact'
import core, { Account, AttachedData, Doc, generateId, Ref, SortingOrder, WithLookup } from '@hcengineering/core'
import { getResource } from '@hcengineering/platform'
import { Card, createQuery, getClient, KeyedAttribute, SpaceSelector } from '@hcengineering/presentation'
import { Card, createQuery, getClient, KeyedAttribute, MessageBox, SpaceSelector } from '@hcengineering/presentation'
import tags, { TagElement, TagReference } from '@hcengineering/tags'
import { calcRank, Issue, IssuePriority, IssueStatus, Project, Sprint, Team } from '@hcengineering/tracker'
import {
calcRank,
Issue,
IssuePriority,
IssueStatus,
IssueTemplate,
IssueTemplateChild,
Project,
Sprint,
Team
} from '@hcengineering/tracker'
import {
ActionIcon,
Button,
@ -35,6 +45,7 @@
Spinner
} from '@hcengineering/ui'
import view from '@hcengineering/view'
import { ObjectBox } from '@hcengineering/view-resources'
import { createEventDispatcher } from 'svelte'
import { activeProject, activeSprint } from '../issues'
import tracker from '../plugin'
@ -47,6 +58,7 @@
import SetDueDateActionPopup from './SetDueDateActionPopup.svelte'
import SetParentIssueActionPopup from './SetParentIssueActionPopup.svelte'
import SprintSelector from './sprints/SprintSelector.svelte'
import IssueTemplateChilds from './templates/IssueTemplateChilds.svelte'
export let space: Ref<Team>
export let status: Ref<IssueStatus> | undefined = undefined
@ -81,6 +93,69 @@
childInfo: []
}
function resetObject (): void {
object = {
title: '',
description: '',
assignee: assignee,
project: project,
sprint: sprint,
number: 0,
rank: '',
status: '' as Ref<IssueStatus>,
priority: priority,
dueDate: null,
comments: 0,
subIssues: 0,
parents: [],
reportedTime: 0,
estimation: 0,
reports: 0,
childInfo: []
}
subIssues = []
}
let templateId: Ref<IssueTemplate> | undefined = undefined
let template: IssueTemplate | undefined = undefined
const templateQuery = createQuery()
let subIssues: IssueTemplateChild[] = []
$: if (templateId !== undefined) {
templateQuery.query(tracker.class.IssueTemplate, { _id: templateId }, (res) => {
template = res.shift()
})
} else {
template = undefined
templateQuery.unsubscribe()
}
function updateObject (template: IssueTemplate): void {
if (object.template?.template === template._id) {
return
}
const { _class, _id, space, children, comments, attachments, labels, dueDate, ...templBase } = template
subIssues = template.children
object = {
...object,
...templBase,
template: {
template: template._id
}
}
}
function updateTemplate (template?: IssueTemplate): void {
if (template !== undefined) {
updateObject(template)
}
}
$: updateTemplate(template)
const dispatch = createEventDispatcher()
const client = getClient()
const statusesQuery = createQuery()
@ -93,13 +168,20 @@
}
$: _space = space
$: updateIssueStatusId(space, status)
$: updateIssueStatusId(_space, status)
$: canSave = getTitle(object.title ?? '').length > 0
$: statusesQuery.query(tracker.class.IssueStatus, { attachedTo: _space }, (statuses) => (issueStatuses = statuses), {
lookup: { category: tracker.class.IssueStatusCategory },
sort: { rank: SortingOrder.Ascending }
})
$: 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>, issueStatusId?: Ref<IssueStatus>) {
if (issueStatusId !== undefined) {
@ -194,8 +276,73 @@
await update(doc, 'relations', [relatedTo], tracker.string.AddedReference)
}
}
for (const subIssue of subIssues) {
const lastOne = await client.findOne<Issue>(
tracker.class.Issue,
{ status: object.status },
{ sort: { rank: SortingOrder.Descending } }
)
const incResult = await client.updateDoc(
tracker.class.Team,
core.space.Space,
_space,
{
$inc: { sequence: 1 }
},
true
)
const childId: Ref<Issue> = generateId()
const cvalue: AttachedData<Issue> = {
title: getTitle(subIssue.title),
description: subIssue.description,
assignee: subIssue.assignee,
project: subIssue.project,
sprint: subIssue.sprint,
number: (incResult as any).object.sequence,
status: object.status,
priority: subIssue.priority,
rank: calcRank(lastOne, undefined),
comments: 0,
subIssues: 0,
dueDate: subIssue.dueDate,
parents: parentIssue
? [
{ parentId: objectId, parentTitle: value.title },
{ parentId: parentIssue._id, parentTitle: parentIssue.title },
...parentIssue.parents
]
: [{ parentId: objectId, parentTitle: value.title }],
reportedTime: 0,
estimation: object.estimation,
reports: 0,
relations: [],
childInfo: []
}
await client.addCollection(
tracker.class.Issue,
_space,
objectId,
tracker.class.Issue,
'subIssues',
cvalue,
childId
)
if ((subIssue.labels?.length ?? 0) > 0) {
const tagElements = await client.findAll(tags.class.TagElement, { _id: { $in: subIssue.labels } })
for (const label of tagElements) {
await client.addCollection(tags.class.TagReference, _space, childId, tracker.class.Issue, 'labels', {
title: label.title,
color: label.color,
tag: label._id
})
}
}
}
objectId = generateId()
resetObject()
}
async function showMoreActions (ev: Event) {
@ -275,6 +422,31 @@
}
]
}
function handleTemplateChange (evt: CustomEvent<Ref<IssueTemplate>>): void {
if (templateId == null) {
templateId = evt.detail
return
}
// Template is already specified, ask to replace.
showPopup(
MessageBox,
{
label: tracker.string.TemplateReplace,
message: tracker.string.TemplateReplaceConfirm
},
'top',
(result?: boolean) => {
if (result === true) {
templateId = evt.detail ?? undefined
if (templateId === undefined) {
subIssues = []
resetObject()
}
}
}
)
}
</script>
<Card
@ -299,6 +471,18 @@
disabled
on:click={() => {}}
/> -->
<ObjectBox
_class={tracker.class.IssueTemplate}
value={templateId}
on:change={handleTemplateChange}
size={'small'}
label={tracker.string.NoIssueTemplate}
icon={tracker.icon.Issues}
searchField={'title'}
allowDeselect={true}
showNavigate={false}
docProps={{ disableClick: true }}
/>
</svelte:fragment>
<svelte:fragment slot="title" let:label>
<div class="flex-row-center gap-1">
@ -320,17 +504,20 @@
<ParentIssue issue={parentIssue} on:close={clearParentIssue} />
{/if}
<EditBox bind:value={object.title} placeholder={tracker.string.IssueTitlePlaceholder} kind={'large-style'} focus />
<AttachmentStyledBox
bind:this={descriptionBox}
{objectId}
_class={tracker.class.Issue}
space={_space}
alwaysEdit
showButtons={false}
maxHeight={'card'}
bind:content={object.description}
placeholder={tracker.string.IssueDescriptionPlaceholder}
/>
{#key object.description}
<AttachmentStyledBox
bind:this={descriptionBox}
{objectId}
_class={tracker.class.Issue}
space={_space}
alwaysEdit
showButtons={false}
maxHeight={'card'}
bind:content={object.description}
placeholder={tracker.string.IssueDescriptionPlaceholder}
/>
{/key}
<IssueTemplateChilds bind:children={subIssues} sprint={object.sprint} project={object.project} />
<svelte:fragment slot="pool">
<div class="flex flex-wrap" style:gap={'0.2vw'}>
{#if issueStatuses}

View File

@ -13,15 +13,15 @@
// limitations under the License.
-->
<script lang="ts">
import { createEventDispatcher } from 'svelte'
import { Employee } from '@hcengineering/contact'
import { AttachedData, Ref } from '@hcengineering/core'
import { getClient, EmployeeBox } from '@hcengineering/presentation'
import { Issue } from '@hcengineering/tracker'
import { EmployeeBox, getClient } from '@hcengineering/presentation'
import { Issue, IssueTemplateData } from '@hcengineering/tracker'
import { ButtonKind, ButtonSize, TooltipAlignment } from '@hcengineering/ui'
import { createEventDispatcher } from 'svelte'
import tracker from '../../plugin'
export let value: Issue | AttachedData<Issue>
export let value: Issue | AttachedData<Issue> | IssueTemplateData
export let size: ButtonSize = 'large'
export let kind: ButtonKind = 'link'
export let tooltipAlignment: TooltipAlignment | undefined = undefined

View File

@ -15,7 +15,7 @@
<script lang="ts">
import contact, { Employee } from '@hcengineering/contact'
import { Class, Doc, Ref } from '@hcengineering/core'
import { Issue } from '@hcengineering/tracker'
import { Issue, IssueTemplate } from '@hcengineering/tracker'
import { UsersPopup, getClient } from '@hcengineering/presentation'
import { AttributeModel } from '@hcengineering/view'
import { eventToHTMLElement, showPopup } from '@hcengineering/ui'
@ -25,6 +25,7 @@
export let value: Employee | null | undefined
export let issueId: Ref<Issue>
export let issueClass: Ref<Class<Issue | IssueTemplate>> = tracker.class.Issue
export let defaultClass: Ref<Class<Doc>> | undefined = undefined
export let isEditable: boolean = true
export let shouldShowLabel: boolean = false
@ -51,7 +52,7 @@
return
}
const currentIssue = await client.findOne(tracker.class.Issue, { _id: issueId })
const currentIssue = await client.findOne(issueClass, { _id: issueId })
if (currentIssue === undefined) {
return
@ -59,15 +60,7 @@
const newAssignee = result === null ? null : result._id
await client.updateCollection(
currentIssue._class,
currentIssue.space,
currentIssue._id,
currentIssue.attachedTo,
currentIssue.attachedToClass,
currentIssue.collection,
{ assignee: newAssignee }
)
await client.update(currentIssue, { assignee: newAssignee })
}
const handleAssigneeEditorOpened = async (event: MouseEvent) => {

View File

@ -18,7 +18,8 @@
import notification from '@hcengineering/notification'
import { createQuery, getClient } from '@hcengineering/presentation'
import { Issue, IssueStatus, Team } from '@hcengineering/tracker'
import {
import ui, {
ActionIcon,
Button,
CheckBox,
Component,
@ -26,6 +27,7 @@
ExpandCollapse,
getEventPositionElement,
IconAdd,
IconMoreH,
showPopup,
Spinner,
tooltip
@ -36,7 +38,6 @@
import tracker from '../../plugin'
import { IssuesGroupByKeys, issuesGroupEditorMap, IssuesOrderByKeys, issuesSortOrderMap } from '../../utils'
import CreateIssue from '../CreateIssue.svelte'
import GrowPresenter from './GrowPresenter.svelte'
export let _class: Ref<Class<Doc>>
export let currentSpace: Ref<Team> | undefined = undefined
@ -75,13 +76,19 @@
let personPresenter: AttributeModel
const defaultLimit = 20
const autoFoldLimit = 20
const singleCategoryLimit = 200
const isCollapsedMap: Record<any, boolean> = {}
const noCategory = '#no_category'
$: {
const exkeys = new Set(Object.keys(isCollapsedMap))
for (const c of categories) {
if (!exkeys.delete(c)) {
isCollapsedMap[c] = false
if (!exkeys.delete(toCat(c))) {
isCollapsedMap[toCat(c)] = categories.length === 1 ? false : (groupedIssues[c]?.length ?? 0) > autoFoldLimit
}
}
for (const k of exkeys) {
@ -160,7 +167,13 @@
)
}
const handleCollapseCategory = (category: any) => (isCollapsedMap[category] = !isCollapsedMap[category])
function toCat (category: any): any {
return category ?? noCategory
}
const handleCollapseCategory = (category: any) => {
isCollapsedMap[category] = !isCollapsedMap[category]
}
const getLoadingElementsLength = (props: LoadingProps, options?: FindOptions<Doc>) => {
if (options?.limit && options?.limit > 0) {
@ -181,12 +194,27 @@
const checkWidth = (key: string, result: CustomEvent): void => {
if (result !== undefined) propsWidth[key] = result.detail
}
function limitGroup (
category: any,
groupes: { [key: string | number | symbol]: Issue[] },
categoryLimit: Record<any, number>
): Issue[] {
const issues = groupes[category] ?? []
if (Object.keys(groupes).length === 1) {
return issues.slice(0, singleCategoryLimit)
}
const limit = categoryLimit[toCat(category)] ?? defaultLimit
return issues.slice(0, limit)
}
const categoryLimit: Record<any, number> = {}
</script>
<div class="issueslist-container" style={varsStyle}>
{#each categories as category}
{@const items = groupedIssues[category] ?? []}
{@const limited = limitGroup(category, groupedIssues, categoryLimit) ?? []}
{#if headerComponent || groupByKey === 'assignee'}
<div class="flex-between categoryHeader row" on:click={() => handleCollapseCategory(category)}>
<div class="flex-between categoryHeader row" on:click={() => handleCollapseCategory(toCat(category))}>
<div class="flex-row-center gap-2 clear-mins">
{#if groupByKey === 'assignee' && personPresenter}
<svelte:component
@ -216,17 +244,29 @@
}}
/>
{/if}
<span class="text-base content-dark-color ml-4">{(groupedIssues[category] ?? []).length}</span>
{#if limited.length < items.length}
<span class="text-base content-dark-color ml-4"> {limited.length} / {items.length}</span>
<ActionIcon
size={'small'}
icon={IconMoreH}
label={ui.string.ShowMore}
action={() => {
categoryLimit[toCat(category)] = limited.length + 20
}}
/>
{:else}
<span class="text-base content-dark-color ml-4">{items.length}</span>
{/if}
</div>
<div class="clear-mins" use:tooltip={{ label: tracker.string.AddIssueTooltip }}>
<Button icon={IconAdd} kind={'transparent'} on:click={(event) => handleNewIssueAdded(event, category)} />
</div>
</div>
{/if}
<ExpandCollapse isExpanded={!isCollapsedMap[category]} duration={400}>
<ExpandCollapse isExpanded={!isCollapsedMap[toCat(category)]} duration={400}>
{#if itemModels}
{#if groupedIssues[category]}
{#each groupedIssues[category] as docObject (docObject._id)}
{#each limited as docObject (docObject._id)}
<div
bind:this={objectRefs[combinedGroupedIssues.findIndex((x) => x === docObject)]}
class="listGrid antiList__row row gap-2 flex-grow"
@ -262,8 +302,8 @@
/>
</div>
</div>
{#each itemModels as attributeModel, attributeModelIndex}
{#if attributeModelIndex === 0}
{#each itemModels as attributeModel}
{#if attributeModel.props?.type === 'priority'}
<div class="priorityPresenter">
<svelte:component
this={attributeModel.presenter}
@ -274,7 +314,7 @@
{currentTeam}
/>
</div>
{:else if attributeModelIndex === 1}
{:else if attributeModel.props?.type === 'issue'}
<div class="issuePresenter">
<FixedColumn
width={propsWidth.issue}
@ -292,7 +332,7 @@
/>
</FixedColumn>
</div>
{:else if attributeModelIndex === 3 || attributeModel.presenter === GrowPresenter}
{:else if attributeModel.props?.type === 'grow'}
<svelte:component
this={attributeModel.presenter}
value={getObjectValue(attributeModel.key, docObject) ?? ''}

View File

@ -13,16 +13,24 @@
// limitations under the License.
-->
<script lang="ts">
import { createEventDispatcher } from 'svelte'
import { AttachedData } from '@hcengineering/core'
import { Issue, IssuePriority } from '@hcengineering/tracker'
import { getClient } from '@hcengineering/presentation'
import { Button, eventToHTMLElement } from '@hcengineering/ui'
import { ButtonKind, ButtonSize, showPopup, SelectPopup, Icon, Label } from '@hcengineering/ui'
import { Issue, IssuePriority, IssueTemplateData } from '@hcengineering/tracker'
import {
Button,
ButtonKind,
ButtonSize,
eventToHTMLElement,
Icon,
Label,
SelectPopup,
showPopup
} from '@hcengineering/ui'
import { createEventDispatcher } from 'svelte'
import tracker from '../../plugin'
import { defaultPriorities, issuePriorities } from '../../utils'
export let value: Issue | AttachedData<Issue>
export let value: Issue | AttachedData<Issue> | IssueTemplateData
export let isEditable: boolean = true
export let shouldShowLabel: boolean = false

View File

@ -13,15 +13,15 @@
// limitations under the License.
-->
<script lang="ts">
import { createEventDispatcher } from 'svelte'
import { AttachedData, Ref, SortingOrder, WithLookup } from '@hcengineering/core'
import { Issue, IssueStatus } from '@hcengineering/tracker'
import { createQuery, getClient } from '@hcengineering/presentation'
import { Button, showPopup, SelectPopup, TooltipAlignment, eventToHTMLElement } from '@hcengineering/ui'
import { Issue, IssueStatus } from '@hcengineering/tracker'
import type { ButtonKind, ButtonSize } from '@hcengineering/ui'
import { Button, eventToHTMLElement, SelectPopup, showPopup, TooltipAlignment } from '@hcengineering/ui'
import { createEventDispatcher } from 'svelte'
import tracker from '../../plugin'
import StatusPresenter from './StatusPresenter.svelte'
import IssueStatusIcon from './IssueStatusIcon.svelte'
import StatusPresenter from './StatusPresenter.svelte'
export let value: Issue | AttachedData<Issue>
export let statuses: WithLookup<IssueStatus>[] | undefined = undefined

View File

@ -181,16 +181,6 @@
on:close={() => dispatch('close')}
>
{@const { attachedTo: parentIssue } = issue?.$lookup ?? {}}
<svelte:fragment slot="subtitle">
<div class="flex-between flex-grow">
<div class="buttons-group xsmall-gap">
<Button icon={IconEdit} kind={'transparent'} size="medium" on:click={edit} />
{#if innerWidth < 900}
<Button icon={IconMoreH} kind={'transparent'} size="medium" />
{/if}
</div>
</div>
</svelte:fragment>
<svelte:fragment slot="navigator">
<UpDownNavigator element={issue} />
</svelte:fragment>

View File

@ -17,12 +17,12 @@
import { getClient } from '@hcengineering/presentation'
import { Issue } from '@hcengineering/tracker'
import { Button, ButtonKind, ButtonSize, eventToHTMLElement, Label, showPopup } from '@hcengineering/ui'
import { Button, ButtonKind, ButtonSize, eventToHTMLElement, showPopup } from '@hcengineering/ui'
import EditBoxPopup from '@hcengineering/view-resources/src/components/EditBoxPopup.svelte'
import { createEventDispatcher } from 'svelte'
import tracker from '../../../plugin'
import EstimationPopup from './EstimationPopup.svelte'
import EstimationProgressCircle from './EstimationProgressCircle.svelte'
import EstimationStatsPresenter from './EstimationStatsPresenter.svelte'
export let value: Issue | AttachedData<Issue>
export let isEditable: boolean = true
@ -43,16 +43,11 @@
}
if (kind === 'list') {
showPopup(
EstimationPopup,
{ value: value.estimation, format: 'number', object: value },
eventToHTMLElement(event),
(res) => {
if (res != null) {
changeEstimation(res)
}
showPopup(EstimationPopup, { value: value.estimation, format: 'number', object: value }, 'top', (res) => {
if (res != null) {
changeEstimation(res)
}
)
})
} else {
showPopup(EditBoxPopup, { value, format: 'number' }, eventToHTMLElement(event), (res) => {
if (res !== undefined) {
@ -75,65 +70,11 @@
value.estimation = newEstimation
}
}
$: childReportTime = (value.childInfo ?? []).map((it) => it.reportedTime).reduce((a, b) => a + b, 0)
$: childEstimationTime = (value.childInfo ?? []).map((it) => it.estimation).reduce((a, b) => a + b, 0)
function hourFloor (value: number): number {
const days = Math.ceil(value)
const hours = value - days
return days + Math.floor(hours * 100) / 100
}
</script>
{#if value}
{#if kind === 'list'}
<div class="estimation-container" on:click={handleestimationEditorOpened}>
<div class="icon">
<EstimationProgressCircle value={Math.max(value.reportedTime, childReportTime)} max={value.estimation} />
</div>
<span class="overflow-label label flex-row-center flex-nowrap text-md">
{#if value.reportedTime > 0 || childReportTime > 0}
{#if childReportTime}
{@const rchildReportTime = hourFloor(childReportTime)}
{@const reportDiff = rchildReportTime - hourFloor(value.reportedTime)}
{#if reportDiff !== 0 && value.reportedTime !== 0}
<div class="flex flex-nowrap mr-1" class:showError={reportDiff > 0}>
<Label label={tracker.string.TimeSpendValue} params={{ value: rchildReportTime }} />
</div>
<div class="romColor">
(<Label label={tracker.string.TimeSpendValue} params={{ value: hourFloor(value.reportedTime) }} />)
</div>
{:else if value.reportedTime === 0}
<Label label={tracker.string.TimeSpendValue} params={{ value: hourFloor(childReportTime) }} />
{:else}
<Label label={tracker.string.TimeSpendValue} params={{ value: hourFloor(value.reportedTime) }} />
{/if}
{:else}
<Label label={tracker.string.TimeSpendValue} params={{ value: hourFloor(value.reportedTime) }} />
{/if}
<div class="p-1">/</div>
{/if}
{#if childEstimationTime}
{@const childEstTime = Math.round(childEstimationTime)}
{@const estimationDiff = childEstTime - Math.round(value.estimation)}
{#if estimationDiff !== 0}
<div class="flex flex-nowrap mr-1" class:showWarning={estimationDiff !== 0}>
<Label label={tracker.string.TimeSpendValue} params={{ value: childEstTime }} />
</div>
{#if value.estimation !== 0}
<div class="romColor">
(<Label label={tracker.string.TimeSpendValue} params={{ value: hourFloor(value.estimation) }} />)
</div>
{/if}
{:else}
<Label label={tracker.string.TimeSpendValue} params={{ value: hourFloor(value.estimation) }} />
{/if}
{:else}
<Label label={tracker.string.TimeSpendValue} params={{ value: hourFloor(value.estimation) }} />
{/if}
</span>
</div>
<EstimationStatsPresenter {value} on:click={handleestimationEditorOpened} />
{:else}
<Button
showTooltip={isEditable ? { label: tracker.string.Estimation } : undefined}
@ -149,44 +90,3 @@
/>
{/if}
{/if}
<style lang="scss">
.estimation-container {
display: flex;
align-items: center;
flex-shrink: 0;
min-width: 0;
cursor: pointer;
.icon {
display: flex;
justify-content: center;
align-items: center;
flex-shrink: 0;
width: 1rem;
height: 1rem;
color: var(--content-color);
}
.label {
margin-left: 0.5rem;
font-weight: 500;
font-size: 0.8125rem;
color: var(--accent-color);
}
&:hover {
.icon {
color: var(--caption-color) !important;
}
}
.showError {
color: var(--error-color) !important;
}
.showWarning {
color: var(--warning-color) !important;
}
.romColor {
color: var(--content-color) !important;
}
}
</style>

View File

@ -14,28 +14,17 @@
// limitations under the License.
-->
<script lang="ts">
import contact from '@hcengineering/contact'
import { FindOptions } from '@hcengineering/core'
import presentation, { Card } from '@hcengineering/presentation'
import { Issue, TimeSpendReport } from '@hcengineering/tracker'
import {
Button,
EditBox,
EditStyle,
eventToHTMLElement,
IconAdd,
Label,
Scroller,
tableSP,
showPopup
} from '@hcengineering/ui'
import { TableBrowser } from '@hcengineering/view-resources'
import { SortingOrder, WithLookup } from '@hcengineering/core'
import presentation, { Card, createQuery } from '@hcengineering/presentation'
import { Issue, IssueStatus, Team } from '@hcengineering/tracker'
import { Button, EditBox, EditStyle, eventToHTMLElement, IconAdd, Label, showPopup } from '@hcengineering/ui'
import { createEventDispatcher } from 'svelte'
import tracker from '../../../plugin'
import IssuePresenter from '../IssuePresenter.svelte'
import ParentNamesPresenter from '../ParentNamesPresenter.svelte'
import EstimationPresenter from './EstimationPresenter.svelte'
import EstimationStatsPresenter from './EstimationStatsPresenter.svelte'
import SubIssuesEstimations from './SubIssuesEstimations.svelte'
import TimeSpendReportPopup from './TimeSpendReportPopup.svelte'
import TimeSpendReports from './TimeSpendReports.svelte'
export let value: string | number | undefined
export let format: 'text' | 'password' | 'number'
@ -49,10 +38,43 @@
function _onkeypress (ev: KeyboardEvent) {
if (ev.key === 'Enter') dispatch('close', _value)
}
const options: FindOptions<TimeSpendReport> = {
lookup: { employee: contact.class.Employee, attachedTo: tracker.class.Issue }
}
$: childIds = Array.from((object.childInfo ?? []).map((it) => it.childId))
const query = createQuery()
let currentTeam: Team | undefined
let issueStatuses: WithLookup<IssueStatus>[] | undefined
$: query.query(
object._class,
{ _id: object._id },
(res) => {
const r = res.shift()
if (r !== undefined) {
object = r
currentTeam = r.$lookup?.space
}
},
{
lookup: {
space: tracker.class.Team
}
}
)
const statusesQuery = createQuery()
$: currentTeam &&
statusesQuery.query(
tracker.class.IssueStatus,
{ attachedTo: currentTeam._id },
(statuses) => (issueStatuses = statuses),
{
lookup: { category: tracker.class.IssueStatusCategory },
sort: { rank: SortingOrder.Ascending }
}
)
</script>
<Card
@ -66,6 +88,15 @@
dispatch('close', null)
}}
>
<svelte:fragment slot="title">
<div class="flex-row-center">
<Label label={tracker.string.Estimation} />
<div class="ml-2">
<EstimationStatsPresenter value={object} />
</div>
</div>
</svelte:fragment>
<svelte:fragment slot="header">
<IssuePresenter value={object} disableClick />
</svelte:fragment>
@ -78,49 +109,29 @@
placeholder={tracker.string.Estimation}
focus
on:keypress={_onkeypress}
on:change={() => {
if (typeof _value === 'number') {
object.estimation = _value
}
}}
/>
</div>
</div>
<Label label={tracker.string.ChildEstimation} />:
<div class="h-50">
<Scroller fade={tableSP}>
<TableBrowser
showFilterBar={false}
_class={tracker.class.Issue}
query={{ _id: { $in: childIds } }}
config={[
'',
{ key: 'estimation', presenter: EstimationPresenter, label: tracker.string.Estimation },
{ key: '', presenter: ParentNamesPresenter, props: { maxWidth: '20rem' }, label: tracker.string.Title }
]}
{options}
/>
</Scroller>
</div>
<Label label={tracker.string.ReportedTime} />:
<div class="h-50">
<Scroller fade={tableSP}>
<TableBrowser
_class={tracker.class.TimeSpendReport}
query={{ attachedTo: { $in: [object._id, ...childIds] } }}
showFilterBar={false}
config={[
'$lookup.attachedTo',
'',
'$lookup.employee',
{
key: '$lookup.attachedTo',
presenter: ParentNamesPresenter,
props: { maxWidth: '20rem' },
label: tracker.string.Title
},
'date',
'description'
]}
{options}
/>
</Scroller>
</div>
{#if currentTeam && issueStatuses}
<SubIssuesEstimations
issue={object}
issueStatuses={new Map([[currentTeam._id, issueStatuses]])}
teams={new Map([[currentTeam?._id, currentTeam]])}
/>
{/if}
{#if currentTeam}
<TimeSpendReports
issue={object}
teams={new Map([[currentTeam?._id, currentTeam]])}
query={{ attachedTo: { $in: [object._id, ...childIds] } }}
/>
{/if}
<svelte:fragment slot="buttons">
<Button
icon={IconAdd}

View File

@ -0,0 +1,122 @@
<!--
// 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">
import { AttachedData } from '@hcengineering/core'
import { Issue } from '@hcengineering/tracker'
import { Label } from '@hcengineering/ui'
import tracker from '../../../plugin'
import EstimationProgressCircle from './EstimationProgressCircle.svelte'
export let value: Issue | AttachedData<Issue>
$: childReportTime =
value.reportedTime + (value.childInfo ?? []).map((it) => it.reportedTime).reduce((a, b) => a + b, 0)
$: childEstimationTime = (value.childInfo ?? []).map((it) => it.estimation).reduce((a, b) => a + b, 0)
function hourFloor (value: number): number {
const days = Math.ceil(value)
const hours = value - days
return days + Math.floor(hours * 100) / 100
}
</script>
<div class="estimation-container" on:click>
<div class="icon">
<EstimationProgressCircle value={Math.max(value.reportedTime, childReportTime)} max={value.estimation} />
</div>
<span class="overflow-label label flex-row-center flex-nowrap text-md">
{#if value.reportedTime > 0 || childReportTime > 0}
{#if childReportTime}
{@const rchildReportTime = hourFloor(childReportTime)}
{@const reportDiff = rchildReportTime - hourFloor(value.reportedTime)}
{#if reportDiff !== 0 && value.reportedTime !== 0}
<div class="flex flex-nowrap mr-1" class:showError={reportDiff > 0}>
<Label label={tracker.string.TimeSpendValue} params={{ value: rchildReportTime }} />
</div>
<div class="romColor">
(<Label label={tracker.string.TimeSpendValue} params={{ value: hourFloor(value.reportedTime) }} />)
</div>
{:else if value.reportedTime === 0}
<Label label={tracker.string.TimeSpendValue} params={{ value: hourFloor(childReportTime) }} />
{:else}
<Label label={tracker.string.TimeSpendValue} params={{ value: hourFloor(value.reportedTime) }} />
{/if}
{:else}
<Label label={tracker.string.TimeSpendValue} params={{ value: hourFloor(value.reportedTime) }} />
{/if}
<div class="p-1">/</div>
{/if}
{#if childEstimationTime}
{@const childEstTime = Math.round(childEstimationTime)}
{@const estimationDiff = childEstTime - Math.round(value.estimation)}
{#if estimationDiff !== 0}
<div class="flex flex-nowrap mr-1" class:showWarning={estimationDiff !== 0}>
<Label label={tracker.string.TimeSpendValue} params={{ value: childEstTime }} />
</div>
{#if value.estimation !== 0}
<div class="romColor">
(<Label label={tracker.string.TimeSpendValue} params={{ value: hourFloor(value.estimation) }} />)
</div>
{/if}
{:else}
<Label label={tracker.string.TimeSpendValue} params={{ value: hourFloor(value.estimation) }} />
{/if}
{:else}
<Label label={tracker.string.TimeSpendValue} params={{ value: hourFloor(value.estimation) }} />
{/if}
</span>
</div>
<style lang="scss">
.estimation-container {
display: flex;
align-items: center;
flex-shrink: 0;
min-width: 0;
cursor: pointer;
.icon {
display: flex;
justify-content: center;
align-items: center;
flex-shrink: 0;
width: 1rem;
height: 1rem;
color: var(--content-color);
}
.label {
margin-left: 0.5rem;
font-weight: 500;
font-size: 0.8125rem;
color: var(--accent-color);
}
&:hover {
.icon {
color: var(--caption-color) !important;
}
}
.showError {
color: var(--error-color) !important;
}
.showWarning {
color: var(--warning-color) !important;
}
.romColor {
color: var(--content-color) !important;
}
}
</style>

View File

@ -0,0 +1,114 @@
<!--
// 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">
import contact from '@hcengineering/contact'
import { Doc, Ref } from '@hcengineering/core'
import { UserBox } from '@hcengineering/presentation'
import { Issue, Team } from '@hcengineering/tracker'
import { getEventPositionElement, ListView, showPopup } from '@hcengineering/ui'
import { ContextMenu, FixedColumn, ListSelectionProvider, SelectDirection } from '@hcengineering/view-resources'
import { getIssueId } from '../../../issues'
import tracker from '../../../plugin'
import EstimationPresenter from './EstimationPresenter.svelte'
export let issues: Issue[]
export let teams: Map<Ref<Team>, Team>
function showContextMenu (ev: MouseEvent, object: Issue) {
showPopup(ContextMenu, { object }, getEventPositionElement(ev))
}
const listProvider = new ListSelectionProvider((offset: 1 | -1 | 0, of?: Doc, dir?: SelectDirection) => {})
let varsStyle: string = ''
const propsWidth: Record<string, number> = { issue: 0 }
$: if (propsWidth) {
varsStyle = ''
for (const key in propsWidth) varsStyle += `--fixed-${key}: ${propsWidth[key]}px;`
}
const checkWidth = (key: string, result: CustomEvent): void => {
if (result !== undefined) propsWidth[key] = result.detail
}
</script>
<ListView count={issues.length}>
<svelte:fragment slot="item" let:item>
{@const issue = issues[item]}
{@const currentTeam = teams.get(issue.space)}
<div
class="flex-between row"
style={varsStyle}
on:contextmenu|preventDefault={(ev) => showContextMenu(ev, issue)}
on:mouseover={() => {
listProvider.updateFocus(issue)
}}
on:focus={() => {
listProvider.updateFocus(issue)
}}
>
<div class="flex-row-center clear-mins gap-2 p-2">
<span class="issuePresenter">
<FixedColumn
width={propsWidth.issue}
key={'issue'}
justify={'left'}
on:update={(result) => checkWidth('issue', result)}
>
{#if currentTeam}
{getIssueId(currentTeam, issue)}
{/if}
</FixedColumn>
</span>
<span class="text name" title={issue.title}>
{issue.title}
</span>
</div>
<div class="flex-center flex-no-shrink">
<UserBox
label={tracker.string.Assignee}
_class={contact.class.Employee}
value={issue.assignee}
readonly
showNavigate={false}
/>
<EstimationPresenter value={issue.estimation} />
</div>
</div>
</svelte:fragment>
</ListView>
<style lang="scss">
.row {
.text {
font-weight: 500;
color: var(--caption-color);
}
.issuePresenter {
flex-shrink: 0;
min-width: 0;
min-height: 0;
font-weight: 500;
color: var(--content-color);
}
.name {
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
}
}
</style>

View File

@ -0,0 +1,62 @@
<!--
// 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">
import { Ref, SortingOrder, WithLookup } from '@hcengineering/core'
import { createQuery } from '@hcengineering/presentation'
import { Issue, IssueStatus, Team } from '@hcengineering/tracker'
import { Label, Scroller, Spinner } from '@hcengineering/ui'
import tracker from '../../../plugin'
import EstimationSubIssueList from './EstimationSubIssueList.svelte'
export let issue: Issue
export let teams: Map<Ref<Team>, Team>
export let issueStatuses: Map<Ref<Team>, WithLookup<IssueStatus>[]>
const subIssuesQuery = createQuery()
let subIssues: Issue[] | undefined
$: hasSubIssues = issue.subIssues > 0
$: subIssuesQuery.query(tracker.class.Issue, { attachedTo: issue._id }, async (result) => (subIssues = result), {
sort: { estimation: SortingOrder.Descending }
})
$: total = (subIssues ?? []).reduce((a, b) => a + b.estimation, 0)
</script>
{#if subIssues && issueStatuses}
{#if hasSubIssues}
<Label label={tracker.string.ChildEstimation} />: {total}
<div class="h-50">
<Scroller>
<EstimationSubIssueList issues={subIssues} {teams} />
</Scroller>
</div>
{/if}
{:else}
<div class="flex-center pt-3">
<Spinner />
</div>
{/if}
<style lang="scss">
.list {
border-top: 1px solid var(--divider-color);
&.collapsed {
padding-top: 1px;
border-top: none;
}
}
</style>

View File

@ -84,6 +84,7 @@
label={contact.string.Employee}
kind={'link-bordered'}
bind:value={data.employee}
showNavigate={false}
/>
<DatePresenter kind={'link'} bind:value={data.date} editable />
</div>

View File

@ -0,0 +1,64 @@
<!--
// 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">
import { DocumentQuery, Ref, SortingOrder } from '@hcengineering/core'
import { createQuery } from '@hcengineering/presentation'
import { Issue, Team, TimeSpendReport } from '@hcengineering/tracker'
import { Label, Scroller, Spinner } from '@hcengineering/ui'
import tracker from '../../../plugin'
import TimeSpendReportsList from './TimeSpendReportsList.svelte'
export let issue: Issue
export let teams: Map<Ref<Team>, Team>
export let query: DocumentQuery<TimeSpendReport>
const subIssuesQuery = createQuery()
let reports: TimeSpendReport[] | undefined
$: subIssuesQuery.query(tracker.class.TimeSpendReport, query, async (result) => (reports = result), {
sort: { modifiedOn: SortingOrder.Descending },
lookup: {
attachedTo: tracker.class.Issue
}
})
$: total = (reports ?? []).reduce((a, b) => a + b.value, 0)
</script>
{#if reports}
<Label label={tracker.string.ReportedTime} />: {issue.reportedTime}
<Label label={tracker.string.TimeSpendReports} />: {total}
<div class="h-50">
<Scroller>
<TimeSpendReportsList {reports} {teams} />
</Scroller>
</div>
{:else}
<div class="flex-center pt-3">
<Spinner />
</div>
{/if}
<style lang="scss">
.list {
border-top: 1px solid var(--divider-color);
&.collapsed {
padding-top: 1px;
border-top: none;
}
}
</style>

View File

@ -0,0 +1,129 @@
<!--
// 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">
import contact from '@hcengineering/contact'
import { Doc, Ref, Space, WithLookup } from '@hcengineering/core'
import UserBox from '@hcengineering/presentation/src/components/UserBox.svelte'
import { Team, TimeSpendReport } from '@hcengineering/tracker'
import { eventToHTMLElement, getEventPositionElement, ListView, showPopup } from '@hcengineering/ui'
import DatePresenter from '@hcengineering/ui/src/components/calendar/DatePresenter.svelte'
import { ContextMenu, FixedColumn, ListSelectionProvider, SelectDirection } from '@hcengineering/view-resources'
import { getIssueId } from '../../../issues'
import tracker from '../../../plugin'
import EstimationPresenter from './EstimationPresenter.svelte'
import TimeSpendReportPopup from './TimeSpendReportPopup.svelte'
export let reports: WithLookup<TimeSpendReport>[]
export let teams: Map<Ref<Team>, Team>
function showContextMenu (ev: MouseEvent, object: TimeSpendReport) {
showPopup(ContextMenu, { object }, getEventPositionElement(ev))
}
const listProvider = new ListSelectionProvider((offset: 1 | -1 | 0, of?: Doc, dir?: SelectDirection) => {})
let varsStyle: string = ''
const propsWidth: Record<string, number> = { issue: 0 }
$: if (propsWidth) {
varsStyle = ''
for (const key in propsWidth) varsStyle += `--fixed-${key}: ${propsWidth[key]}px;`
}
const checkWidth = (key: string, result: CustomEvent): void => {
if (result !== undefined) propsWidth[key] = result.detail
}
const toTeamId = (ref: Ref<Space>) => ref as Ref<Team>
function editSpendReport (event: MouseEvent, value: TimeSpendReport): void {
showPopup(
TimeSpendReportPopup,
{ issue: value.attachedTo, issueClass: value.attachedToClass, value: value, assignee: value.employee },
eventToHTMLElement(event)
)
}
</script>
<ListView count={reports.length}>
<svelte:fragment slot="item" let:item>
{@const report = reports[item]}
{@const currentTeam = teams.get(toTeamId(report.space))}
<div
class="flex-between row"
style={varsStyle}
on:contextmenu|preventDefault={(ev) => showContextMenu(ev, report)}
on:mouseover={() => {
listProvider.updateFocus(report)
}}
on:focus={() => {
listProvider.updateFocus(report)
}}
on:click={(evt) => editSpendReport(evt, report)}
>
<div class="flex-row-center clear-mins gap-2 p-2">
<span class="issuePresenter">
<FixedColumn
width={propsWidth.issue}
key={'issue'}
justify={'left'}
on:update={(result) => checkWidth('issue', result)}
>
{#if currentTeam && report.$lookup?.attachedTo}
{getIssueId(currentTeam, report.$lookup?.attachedTo)}
{/if}
</FixedColumn>
</span>
{#if report.$lookup?.attachedTo?.title}
<span class="text name" title={report.$lookup?.attachedTo?.title}>
{report.$lookup?.attachedTo?.title}
</span>
{/if}
</div>
<div class="flex-center flex-no-shrink">
<UserBox
label={tracker.string.Assignee}
_class={contact.class.Employee}
value={report.employee}
readonly
showNavigate={false}
/>
<EstimationPresenter value={report.value} />
<DatePresenter value={report.modifiedOn} />
</div>
</div>
</svelte:fragment>
</ListView>
<style lang="scss">
.row {
.text {
font-weight: 500;
color: var(--caption-color);
}
.issuePresenter {
flex-shrink: 0;
min-width: 0;
min-height: 0;
font-weight: 500;
color: var(--content-color);
}
.name {
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
}
}
</style>

View File

@ -13,16 +13,18 @@
// limitations under the License.
-->
<script lang="ts">
import core, { DocumentQuery, getCurrentAccount, Ref, TxCollectionCUD } from '@hcengineering/core'
import type { Issue, IssueStatus } from '@hcengineering/tracker'
import type { EmployeeAccount } from '@hcengineering/contact'
import core, { DocumentQuery, getCurrentAccount, Ref, TxCollectionCUD } from '@hcengineering/core'
import notification from '@hcengineering/notification'
import type { IntlString } from '@hcengineering/platform'
import { createQuery } from '@hcengineering/presentation'
import notification from '@hcengineering/notification'
import type { Issue } from '@hcengineering/tracker'
import { ViewOptionModel } from '@hcengineering/view-resources'
import tracker from '../../plugin'
import { getDefaultViewOptionsConfig } from '../../utils'
import IssuesView from '../issues/IssuesView.svelte'
import ModeSelector from '../ModeSelector.svelte'
import tracker from '../../plugin'
const config: [string, IntlString][] = [
['assigned', tracker.string.Assigned],
@ -30,18 +32,12 @@
['subscribed', tracker.string.Subscribed]
]
const currentUser = getCurrentAccount() as EmployeeAccount
let assigned = { assignee: currentUser.employee, status: { $in: [] as Ref<IssueStatus>[] } }
const assigned = { assignee: currentUser.employee }
let created = { _id: { $in: [] as Ref<Issue>[] } }
let subscribed = { _id: { $in: [] as Ref<Issue>[] } }
const statusQuery = createQuery()
$: statusQuery.query(
tracker.class.IssueStatus,
{ category: { $in: [tracker.issueStatusCategory.Started, tracker.issueStatusCategory.Unstarted] } },
(result) => {
assigned = { ...assigned, status: { $in: result.map(({ _id }) => _id) } }
}
)
const viewOptionsConfig: ViewOptionModel[] = getDefaultViewOptionsConfig(false)
const createdQuery = createQuery()
$: createdQuery.query<TxCollectionCUD<Issue, Issue>>(
core.class.TxCollectionCUD,
@ -83,7 +79,7 @@
$: query = getQuery(mode, { assigned, created, subscribed })
</script>
<IssuesView {query} title={tracker.string.MyIssues}>
<IssuesView {query} title={tracker.string.MyIssues} {viewOptionsConfig}>
<svelte:fragment slot="afterHeader">
<ModeSelector {config} {mode} onChange={handleChangeMode} />
</svelte:fragment>

View File

@ -14,7 +14,7 @@
-->
<script lang="ts">
import { Ref } from '@hcengineering/core'
import { Issue, Project } from '@hcengineering/tracker'
import { Issue, IssueTemplate, Project } from '@hcengineering/tracker'
import { getClient } from '@hcengineering/presentation'
import { ButtonKind, ButtonShape, ButtonSize, tooltip } from '@hcengineering/ui'
import { IntlString } from '@hcengineering/platform'
@ -22,7 +22,7 @@
import ProjectSelector from '../ProjectSelector.svelte'
import { activeProject } from '../../issues'
export let value: Issue
export let value: Issue | IssueTemplate
export let isEditable: boolean = true
export let shouldShowLabel: boolean = true
export let popupPlaceholder: IntlString = tracker.string.MoveToProject
@ -43,15 +43,7 @@
return
}
await client.updateCollection(
value._class,
value.space,
value._id,
value.attachedTo,
value.attachedToClass,
value.collection,
{ project: newProjectId }
)
await client.update(value, { project: newProjectId })
}
</script>

View File

@ -16,7 +16,7 @@
import { Ref, WithLookup } from '@hcengineering/core'
import { IntlString } from '@hcengineering/platform'
import { createQuery, getClient } from '@hcengineering/presentation'
import { Issue, IssueStatus, Sprint } from '@hcengineering/tracker'
import { Issue, IssueStatus, IssueTemplate, Sprint } from '@hcengineering/tracker'
import { ButtonKind, ButtonShape, ButtonSize, Label, tooltip } from '@hcengineering/ui'
import DatePresenter from '@hcengineering/ui/src/components/calendar/DatePresenter.svelte'
import { activeSprint } from '../../issues'
@ -25,7 +25,7 @@
import EstimationProgressCircle from '../issues/timereport/EstimationProgressCircle.svelte'
import SprintSelector from './SprintSelector.svelte'
export let value: Issue
export let value: Issue | IssueTemplate
export let isEditable: boolean = true
export let shouldShowLabel: boolean = true
export let popupPlaceholder: IntlString = tracker.string.MoveToSprint
@ -47,15 +47,7 @@
return
}
await client.updateCollection(
value._class,
value.space,
value._id,
value.attachedTo,
value.attachedToClass,
value.collection,
{ sprint: newSprintId }
)
await client.update(value, { sprint: newSprintId })
}
$: ids = new Set(issues?.map((it) => it._id) ?? [])

View File

@ -0,0 +1,230 @@
<!--
// 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">
import { AttachmentStyledBox } from '@hcengineering/attachment-resources'
import { Employee } from '@hcengineering/contact'
import { Data, generateId, Ref } from '@hcengineering/core'
import { Card, getClient, KeyedAttribute, SpaceSelector } from '@hcengineering/presentation'
import tags, { TagElement } from '@hcengineering/tags'
import { IssuePriority, IssueTemplate, Project, Sprint, Team } from '@hcengineering/tracker'
import { Button, Component, EditBox, IconAttachment, Label } from '@hcengineering/ui'
import { createEventDispatcher } from 'svelte'
import { activeProject, activeSprint } from '../../issues'
import tracker from '../../plugin'
import AssigneeEditor from '../issues/AssigneeEditor.svelte'
import PriorityEditor from '../issues/PriorityEditor.svelte'
import ProjectSelector from '../ProjectSelector.svelte'
import SprintSelector from '../sprints/SprintSelector.svelte'
import SubIssueTemplates from './IssueTemplateChilds.svelte'
export let space: Ref<Team>
export let priority: IssuePriority = IssuePriority.NoPriority
export let assignee: Ref<Employee> | null = null
export let project: Ref<Project> | null = $activeProject ?? null
export let sprint: Ref<Sprint> | null = $activeSprint ?? null
const dueDate: number | undefined = undefined
let labels: TagElement[] = []
let objectId: Ref<IssueTemplate> = generateId()
let object: Data<IssueTemplate> = {
title: '',
description: '',
assignee: assignee,
project: project,
sprint: sprint,
priority: priority,
dueDate: dueDate ?? null,
estimation: 0,
children: [],
labels: [],
comments: 0,
attachments: 0
}
const dispatch = createEventDispatcher()
const client = getClient()
let descriptionBox: AttachmentStyledBox
const key: KeyedAttribute = {
key: 'labels',
attr: client.getHierarchy().getAttribute(tracker.class.Issue, 'labels')
}
$: _space = space
let spaceRef: Team | undefined
$: canSave = getTitle(object.title ?? '').length > 0
function getTitle (value: string) {
return value.trim()
}
export function canClose (): boolean {
return !canSave
}
async function createIssueTemplate () {
if (!canSave) {
return
}
const value: Data<IssueTemplate> = {
title: getTitle(object.title),
description: object.description,
assignee: object.assignee,
project: object.project,
sprint: object.sprint,
priority: object.priority,
dueDate: dueDate ?? null,
estimation: object.estimation,
children: object.children,
comments: 0,
attachments: 0,
labels: labels.map((it) => it._id)
}
await client.createDoc(tracker.class.IssueTemplate, _space, value, objectId)
await descriptionBox.createAttachments()
objectId = generateId()
}
const handleProjectIdChanged = (projectId: Ref<Project> | null | undefined) => {
if (projectId === undefined) {
return
}
object = { ...object, project: projectId }
}
const handleSprintIdChanged = (sprintId: Ref<Sprint> | null | undefined) => {
if (sprintId === undefined) {
return
}
object = { ...object, sprint: sprintId }
}
function addTagRef (tag: TagElement): void {
labels = [...labels, tag]
}
</script>
<Card
label={tracker.string.NewProcess}
okAction={createIssueTemplate}
{canSave}
okLabel={tracker.string.SaveProcess}
on:close={() => {
dispatch('close')
}}
createMore={false}
>
<svelte:fragment slot="header">
<div class="flex-row-center">
<SpaceSelector
_class={tracker.class.Team}
label={tracker.string.Team}
bind:space={_space}
on:space={(evt) => {
spaceRef = evt.detail
}}
/>
</div>
</svelte:fragment>
<svelte:fragment slot="title" let:label>
<div class="flex-row-center gap-1">
<div class="mr-2">
<Label {label} />
</div>
</div>
</svelte:fragment>
<EditBox
bind:value={object.title}
placeholder={tracker.string.IssueTitlePlaceholder}
maxWidth={'37.5rem'}
kind={'large-style'}
focus
/>
<AttachmentStyledBox
bind:this={descriptionBox}
{objectId}
_class={tracker.class.Issue}
space={_space}
alwaysEdit
showButtons={false}
maxHeight={'card'}
bind:content={object.description}
placeholder={tracker.string.IssueDescriptionPlaceholder}
/>
<SubIssueTemplates
bind:children={object.children}
project={object.project}
sprint={object.sprint}
teamId={spaceRef?.identifier ?? 'TSK'}
/>
<svelte:fragment slot="pool">
<div class="flex flex-wrap" style:gap={'0.2vw'}>
<PriorityEditor
value={object}
shouldShowLabel
isEditable
kind="no-border"
size="small"
justify="center"
on:change={({ detail }) => (object.priority = detail)}
/>
<AssigneeEditor
value={object}
size="small"
kind="no-border"
width={'min-content'}
on:change={({ detail }) => (object.assignee = detail)}
/>
<Component
is={tags.component.TagsDropdownEditor}
props={{
items: labels,
key,
targetClass: tracker.class.Issue,
countLabel: tracker.string.NumberLabels
}}
on:open={(evt) => {
addTagRef(evt.detail)
}}
on:delete={(evt) => {
labels = labels.filter((it) => it._id !== evt.detail)
}}
/>
<!-- <EstimationEditor kind={'no-border'} size={'small'} value={object} /> -->
<!-- <NumberEditor kind={'no-border'} bind:value={dueDate} placeholder={tracker.string.DueDate}
focus={false} onChange={({ detail }) => (dueDate = detail) }/> -->
<ProjectSelector value={object.project} onChange={handleProjectIdChanged} />
<SprintSelector value={object.sprint} onChange={handleSprintIdChanged} useProject={object.project ?? undefined} />
</div>
</svelte:fragment>
<svelte:fragment slot="footer">
<Button
icon={IconAttachment}
kind={'transparent'}
on:click={() => {
descriptionBox.attach()
}}
/>
</svelte:fragment>
</Card>

View File

@ -0,0 +1,340 @@
<!--
// 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">
import { AttachmentDocList, AttachmentStyledBox } from '@hcengineering/attachment-resources'
import { Class, Data, Doc, Ref, WithLookup } from '@hcengineering/core'
import notification from '@hcengineering/notification'
import { Panel } from '@hcengineering/panel'
import { getResource } from '@hcengineering/platform'
import presentation, { createQuery, getClient, MessageViewer } from '@hcengineering/presentation'
import setting, { settingId } from '@hcengineering/setting'
import type { IssueTemplate, IssueTemplateChild, Team } from '@hcengineering/tracker'
import {
Button,
EditBox,
getCurrentLocation,
IconAttachment,
IconEdit,
IconMoreH,
Label,
navigate,
Scroller,
showPopup
} from '@hcengineering/ui'
import { ContextMenu, UpDownNavigator } from '@hcengineering/view-resources'
import { createEventDispatcher, onDestroy, onMount } from 'svelte'
import tracker from '../../plugin'
import SubIssueTemplates from './IssueTemplateChilds.svelte'
import TemplateControlPanel from './TemplateControlPanel.svelte'
export let _id: Ref<IssueTemplate>
export let _class: Ref<Class<IssueTemplate>>
let lastId: Ref<Doc> = _id
let lastClass: Ref<Class<Doc>> = _class
const query = createQuery()
const dispatch = createEventDispatcher()
const client = getClient()
let template: WithLookup<IssueTemplate> | undefined
let currentTeam: Team | undefined
let title = ''
let description = ''
let innerWidth: number
let isEditing = false
let descriptionBox: AttachmentStyledBox
const notificationClient = getResource(notification.function.GetNotificationClient).then((res) => res())
$: read(_id)
function read (_id: Ref<Doc>) {
if (lastId !== _id) {
const prev = lastId
const prevClass = lastClass
lastId = _id
lastClass = _class
notificationClient.then((client) => client.updateLastView(prev, prevClass))
}
}
onDestroy(async () => {
notificationClient.then((client) => client.updateLastView(_id, _class))
})
$: _id &&
_class &&
query.query(
_class,
{ _id },
async (result) => {
;[template] = result
title = template.title
description = template.description
currentTeam = template.$lookup?.space
},
{ lookup: { space: tracker.class.Team } }
)
$: canSave = title.trim().length > 0
$: isDescriptionEmpty = !new DOMParser().parseFromString(description, 'text/html').documentElement.innerText?.trim()
function edit (ev: MouseEvent) {
ev.preventDefault()
isEditing = true
}
function cancelEditing (ev: MouseEvent) {
ev.preventDefault()
isEditing = false
if (template) {
title = template.title
description = template.description
}
}
async function save (ev: MouseEvent) {
ev.preventDefault()
if (!template || !canSave) {
return
}
const updates: Partial<Data<IssueTemplate>> = {}
const trimmedTitle = title.trim()
if (trimmedTitle.length > 0 && trimmedTitle !== template.title) {
updates.title = trimmedTitle
}
if (description !== template.description) {
updates.description = description
}
if (Object.keys(updates).length > 0) {
await client.update(template, updates)
}
await descriptionBox.createAttachments()
isEditing = false
}
function showMenu (ev?: Event): void {
if (template) {
showPopup(ContextMenu, { object: template }, (ev as MouseEvent).target as HTMLElement)
}
}
onMount(() => {
dispatch('open', { ignoreKeys: ['comments', 'name', 'description', 'number'] })
})
const createIssue = async (evt: CustomEvent<IssueTemplateChild>): Promise<void> => {
if (template === undefined) {
return
}
await client.update(template, {
$push: { children: evt.detail }
})
}
const updateIssue = async (evt: CustomEvent<Partial<IssueTemplateChild>>): Promise<void> => {
if (template === undefined) {
return
}
await client.update(template, {
$update: {
children: {
$query: { id: evt.detail.id },
$update: evt.detail
}
}
})
}
const updateIssues = async (evt: CustomEvent<IssueTemplateChild[]>): Promise<void> => {
if (template === undefined) {
return
}
await client.update(template, {
children: evt.detail
})
}
</script>
{#if template !== undefined}
<Panel
object={template}
isHeader
isAside={true}
isSub={false}
withoutActivity={isEditing}
bind:innerWidth
on:close={() => dispatch('close')}
>
<svelte:fragment slot="navigator">
<UpDownNavigator element={template} />
</svelte:fragment>
<svelte:fragment slot="header">
<span class="fs-title">
{template.title}
</span>
</svelte:fragment>
<svelte:fragment slot="tools">
{#if isEditing}
<Button kind={'transparent'} label={presentation.string.Cancel} on:click={cancelEditing} />
<Button disabled={!canSave} label={presentation.string.Save} on:click={save} />
{:else}
<Button icon={IconEdit} kind={'transparent'} size={'medium'} on:click={edit} />
<Button icon={IconMoreH} kind={'transparent'} size={'medium'} on:click={showMenu} />
{/if}
</svelte:fragment>
{#if isEditing}
<Scroller>
<div class="popupPanel-body__main-content py-10 clear-mins content">
<EditBox
bind:value={title}
maxWidth="53.75rem"
placeholder={tracker.string.IssueTitlePlaceholder}
kind="large-style"
/>
<div class="flex-between mt-6">
{#key description}
<div class="flex-grow">
<AttachmentStyledBox
bind:this={descriptionBox}
objectId={_id}
_class={tracker.class.Issue}
space={template.space}
alwaysEdit
showButtons
maxHeight={'card'}
bind:content={description}
placeholder={tracker.string.IssueDescriptionPlaceholder}
/>
</div>
{/key}
<div
class="tool"
on:click={() => {
descriptionBox.attach()
}}
>
<IconAttachment size={'large'} />
</div>
</div>
</div>
</Scroller>
{:else}
<span class="title select-text">{title}</span>
<div class="mt-6 description-preview select-text">
{#if isDescriptionEmpty}
<div class="placeholder" on:click={edit}>
<Label label={tracker.string.IssueDescriptionPlaceholder} />
</div>
{:else}
<MessageViewer message={description} />
{/if}
</div>
<div class="mt-6">
{#key template._id && currentTeam !== undefined}
{#if currentTeam !== undefined}
<SubIssueTemplates
bind:children={template.children}
on:create-issue={createIssue}
on:update-issue={updateIssue}
on:update-issues={updateIssues}
/>
{/if}
{/key}
</div>
<div class="mt-6">
<AttachmentDocList value={template} />
</div>
{/if}
<span slot="actions-label"> ID </span>
<svelte:fragment slot="actions">
<div class="flex-grow" />
<!-- {#if issueId}
<CopyToClipboard issueUrl={generateIssueShortLink(issueId)} {issueId} />
{/if} -->
<Button
icon={setting.icon.Setting}
kind={'transparent'}
showTooltip={{ label: setting.string.ClassSetting }}
on:click={(ev) => {
ev.stopPropagation()
const loc = getCurrentLocation()
loc.path[2] = settingId
loc.path[3] = 'setting'
loc.path[4] = 'classes'
loc.path.length = 5
loc.query = { _class }
loc.fragment = undefined
navigate(loc)
}}
/>
</svelte:fragment>
<svelte:fragment slot="custom-attributes">
{#if template && currentTeam}
<TemplateControlPanel issue={template} />
{/if}
<div class="divider" />
<!-- <IssueStatusActivity issue={template} /> -->
</svelte:fragment>
</Panel>
{/if}
<style lang="scss">
.title {
font-weight: 500;
font-size: 1.125rem;
color: var(--theme-caption-color);
}
.content {
height: auto;
}
.description-preview {
color: var(--theme-content-color);
line-height: 150%;
.placeholder {
color: var(--theme-content-trans-color);
}
}
.divider {
margin-top: 1rem;
margin-bottom: 1rem;
grid-column: 1 / 3;
height: 1px;
background-color: var(--divider-color);
}
.tool {
align-self: start;
width: 20px;
height: 20px;
opacity: 0.3;
cursor: pointer;
&:hover {
opacity: 1;
}
}
</style>

View File

@ -0,0 +1,183 @@
<!--
// 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">
import { generateId, Ref } from '@hcengineering/core'
import presentation, { getClient, KeyedAttribute } from '@hcengineering/presentation'
import tags, { TagElement, TagReference } from '@hcengineering/tags'
import { StyledTextArea } from '@hcengineering/text-editor'
import { IssuePriority, IssueTemplateChild, Project, Sprint } from '@hcengineering/tracker'
import { Button, Component, EditBox } from '@hcengineering/ui'
import { createEventDispatcher } from 'svelte'
import tracker from '../../plugin'
import AssigneeEditor from '../issues/AssigneeEditor.svelte'
import PriorityEditor from '../issues/PriorityEditor.svelte'
export let sprint: Ref<Sprint> | null = null
export let project: Ref<Project> | null = null
export let childIssue: IssueTemplateChild | undefined = undefined
export let showBorder = false
const dispatch = createEventDispatcher()
const client = getClient()
let newIssue: IssueTemplateChild = childIssue !== undefined ? { ...childIssue } : getIssueDefaults()
let thisRef: HTMLDivElement
let focusIssueTitle: () => void
let labels: TagElement[] = []
const key: KeyedAttribute = {
key: 'labels',
attr: client.getHierarchy().getAttribute(tracker.class.IssueTemplate, 'labels')
}
function getIssueDefaults (): IssueTemplateChild {
return {
id: generateId(),
title: '',
description: '',
assignee: null,
project: null,
priority: IssuePriority.NoPriority,
dueDate: null,
sprint: sprint,
estimation: 0
}
}
function resetToDefaults () {
newIssue = getIssueDefaults()
focusIssueTitle?.()
}
function getTitle (value: string) {
return value.trim()
}
function close () {
dispatch('close')
}
async function createIssue () {
if (!canSave) {
return
}
const value: IssueTemplateChild = {
...newIssue,
title: getTitle(newIssue.title),
project: project ?? null,
labels: labels.map((it) => it._id)
}
if (childIssue === undefined) {
dispatch('create', value)
} else {
dispatch('close', value)
}
resetToDefaults()
}
function addTagRef (tag: TagElement): void {
labels = [...labels, tag]
}
$: thisRef && thisRef.scrollIntoView({ behavior: 'smooth' })
$: canSave = getTitle(newIssue.title ?? '').length > 0
$: labelRefs = labels.map((it) => ({ ...(it as unknown as TagReference), _id: generateId(), tag: it._id }))
</script>
<div bind:this={thisRef} class="flex-col root" class:antiPopup={showBorder}>
<div class="flex-row-top">
<div class="w-full flex-col content">
<EditBox
bind:value={newIssue.title}
bind:focusInput={focusIssueTitle}
maxWidth="33rem"
placeholder={tracker.string.IssueTitlePlaceholder}
focus
/>
<div class="mt-4">
{#key newIssue.description}
<StyledTextArea
bind:content={newIssue.description}
placeholder={tracker.string.IssueDescriptionPlaceholder}
showButtons={false}
/>
{/key}
</div>
</div>
</div>
<div class="mt-4 flex-between">
<div class="buttons-group xsmall-gap">
<PriorityEditor
value={newIssue}
shouldShowLabel
isEditable
kind="no-border"
size="small"
justify="center"
on:change={({ detail }) => (newIssue.priority = detail)}
/>
{#key newIssue.assignee}
<AssigneeEditor
value={newIssue}
size="small"
kind="no-border"
on:change={({ detail }) => (newIssue.assignee = detail)}
/>
{/key}
<Component
is={tags.component.TagsDropdownEditor}
props={{
items: labelRefs,
key,
targetClass: tracker.class.Issue,
countLabel: tracker.string.NumberLabels
}}
on:open={(evt) => {
addTagRef(evt.detail)
}}
on:delete={(evt) => {
labels = labels.filter((it) => it._id !== evt.detail)
}}
/>
</div>
<div class="buttons-group small-gap">
<Button label={presentation.string.Cancel} size="small" kind="transparent" on:click={close} />
<Button
disabled={!canSave}
label={presentation.string.Save}
size="small"
kind="no-border"
on:click={createIssue}
/>
</div>
</div>
</div>
<style lang="scss">
.root {
padding: 0.5rem 1.5rem;
background-color: var(--theme-bg-accent-color);
border: 1px solid var(--theme-button-border-enabled);
border-radius: 0.5rem;
overflow: hidden;
.content {
padding-top: 0.3rem;
}
}
</style>

View File

@ -0,0 +1,244 @@
<!--
// 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">
import { Ref } from '@hcengineering/core'
import { IssueTemplateChild, Project, Sprint } from '@hcengineering/tracker'
import { eventToHTMLElement, showPopup } from '@hcengineering/ui'
import { ActionContext, FixedColumn } from '@hcengineering/view-resources'
import { createEventDispatcher } from 'svelte'
import { flip } from 'svelte/animate'
import Circles from '../icons/Circles.svelte'
import AssigneeEditor from '../issues/AssigneeEditor.svelte'
import PriorityEditor from '../issues/PriorityEditor.svelte'
import IssueTemplateChildEditor from './IssueTemplateChildEditor.svelte'
export let issues: IssueTemplateChild[]
export let sprint: Ref<Sprint> | null = null
export let project: Ref<Project> | null = null
// export let template: IssueTemplate | Data<IssueTemplate>
export let teamId: string
const dispatch = createEventDispatcher()
let draggingIndex: number | null = null
let hoveringIndex: number | null = null
function openIssue (evt: MouseEvent, target: IssueTemplateChild) {
showPopup(
IssueTemplateChildEditor,
{
showBorder: true,
sprint,
project,
childIssue: target
},
eventToHTMLElement(evt),
(evt: IssueTemplateChild | undefined | null) => {
if (evt != null) {
const pos = issues.findIndex((it) => it.id === evt.id)
if (pos !== -1) {
issues[pos] = evt
dispatch('update-issue', evt)
}
}
}
)
}
function resetDrag () {
draggingIndex = null
hoveringIndex = null
}
function handleDragStart (ev: DragEvent, index: number) {
if (ev.dataTransfer) {
ev.dataTransfer.effectAllowed = 'move'
ev.dataTransfer.dropEffect = 'move'
draggingIndex = index
}
}
function handleDrop (ev: DragEvent, toIndex: number) {
if (ev.dataTransfer && draggingIndex !== null && toIndex !== draggingIndex) {
ev.dataTransfer.dropEffect = 'move'
dispatch('move', { fromIndex: draggingIndex, toIndex })
}
resetDrag()
}
function showContextMenu (ev: MouseEvent, object: IssueTemplateChild) {
// showPopup(ContextMenu, { object }, getEventPositionElement(ev))
}
let varsStyle: string = ''
const propsWidth: Record<string, number> = { issue: 0 }
$: if (propsWidth) {
varsStyle = ''
for (const key in propsWidth) varsStyle += `--fixed-${key}: ${propsWidth[key]}px;`
}
const checkWidth = (key: string, result: CustomEvent): void => {
if (result !== undefined) propsWidth[key] = result.detail
}
export function getIssueTemplateId (team: string, issue: IssueTemplateChild): string {
return `${team}-${issues.findIndex((it) => it.id === issue.id)}`
}
</script>
<ActionContext
context={{
mode: 'browser'
}}
/>
{#each issues as issue, index (issue.id)}
<div
class="flex-between row"
class:is-dragging={index === draggingIndex}
class:is-dragged-over-up={draggingIndex !== null && index < draggingIndex && index === hoveringIndex}
class:is-dragged-over-down={draggingIndex !== null && index > draggingIndex && index === hoveringIndex}
style={varsStyle}
animate:flip={{ duration: 400 }}
draggable={true}
on:click|self={(evt) => openIssue(evt, issue)}
on:contextmenu|preventDefault={(ev) => showContextMenu(ev, issue)}
on:dragstart={(ev) => handleDragStart(ev, index)}
on:dragover|preventDefault={() => false}
on:dragenter={() => (hoveringIndex = index)}
on:drop|preventDefault={(ev) => handleDrop(ev, index)}
on:dragend={resetDrag}
>
<div class="draggable-container">
<div class="draggable-mark"><Circles /></div>
</div>
<div class="flex-row-center ml-6 clear-mins gap-2">
<PriorityEditor
value={issue}
isEditable
kind={'list'}
size={'small'}
justify={'center'}
on:change={(evt) => {
dispatch('update-issue', { id: issue.id, priority: evt.detail })
issue.priority = evt.detail
}}
/>
<span class="issuePresenter" on:click={(evt) => openIssue(evt, issue)}>
<FixedColumn
width={propsWidth.issue}
key={'issue'}
justify={'left'}
on:update={(result) => checkWidth('issue', result)}
>
{getIssueTemplateId(teamId, issue)}
</FixedColumn>
</span>
<span class="text name" title={issue.title} on:click={(evt) => openIssue(evt, issue)}>
{issue.title}
</span>
</div>
<div class="flex-center flex-no-shrink">
<AssigneeEditor
value={issue}
on:change={(evt) => {
dispatch('update-issue', { id: issue.id, assignee: evt.detail })
issue.assignee = evt.detail
}}
/>
</div>
</div>
{/each}
<style lang="scss">
.row {
position: relative;
border-bottom: 1px solid var(--divider-color);
.text {
font-weight: 500;
color: var(--caption-color);
}
.issuePresenter {
flex-shrink: 0;
min-width: 0;
min-height: 0;
font-weight: 500;
color: var(--content-color);
cursor: pointer;
&:hover {
color: var(--caption-color);
text-decoration: underline;
}
&:active {
color: var(--accent-color);
}
}
.name {
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
}
.draggable-container {
position: absolute;
display: flex;
align-items: center;
height: 100%;
width: 1.5rem;
cursor: grabbing;
.draggable-mark {
opacity: 0;
width: 0.375rem;
height: 1rem;
margin-left: 0.75rem;
transition: opacity 0.1s;
}
}
&:hover {
.draggable-mark {
opacity: 0.4;
}
}
&.is-dragging::before {
position: absolute;
content: '';
background-color: var(--theme-bg-color);
opacity: 0.4;
inset: 0;
}
&.is-dragged-over-up::before {
position: absolute;
content: '';
inset: 0;
border-top: 1px solid var(--theme-bg-check);
}
&.is-dragged-over-down::before {
position: absolute;
content: '';
inset: 0;
border-bottom: 1px solid var(--theme-bg-check);
}
}
</style>

View File

@ -0,0 +1,128 @@
<!--
// 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">
import { Ref } from '@hcengineering/core'
import { IssueTemplateChild, Project, Sprint } from '@hcengineering/tracker'
import { Button, closeTooltip, ExpandCollapse, IconAdd } from '@hcengineering/ui'
import { createEventDispatcher } from 'svelte'
import tracker from '../../plugin'
import Collapsed from '../icons/Collapsed.svelte'
import Expanded from '../icons/Expanded.svelte'
import IssueTemplateChildEditor from './IssueTemplateChildEditor.svelte'
import IssueTemplateChildList from './IssueTemplateChildList.svelte'
export let children: IssueTemplateChild[] = []
export let teamId: string = 'TSK'
export let sprint: Ref<Sprint> | null = null
export let project: Ref<Project> | null = null
const dispatch = createEventDispatcher()
let isCollapsed = false
let isCreating = false
async function handleIssueSwap (ev: CustomEvent<{ fromIndex: number; toIndex: number }>) {
if (children) {
const { fromIndex, toIndex } = ev.detail
const [fromIssue] = children.splice(fromIndex, 1)
const leftPart = children.slice(0, toIndex)
const rightPart = children.slice(toIndex)
children = [...leftPart, fromIssue, ...rightPart]
dispatch('update-issues', children)
}
}
$: hasSubIssues = children.length > 0
</script>
<div class="flex-between">
{#if hasSubIssues}
<Button
width="min-content"
icon={isCollapsed ? Collapsed : Expanded}
size="small"
kind="transparent"
label={tracker.string.SubIssuesList}
labelParams={{ subIssues: children.length }}
on:click={() => {
isCollapsed = !isCollapsed
isCreating = false
}}
/>
{/if}
<Button
id="add-sub-issue"
width="min-content"
icon={hasSubIssues ? IconAdd : undefined}
label={hasSubIssues ? undefined : tracker.string.AddSubIssues}
labelParams={{ subIssues: 0 }}
kind={'transparent'}
size={'small'}
showTooltip={{ label: tracker.string.AddSubIssues, props: { subIssues: 1 }, direction: 'bottom' }}
on:click={() => {
closeTooltip()
isCreating = true
isCollapsed = false
}}
/>
</div>
<div class="mt-1">
<ExpandCollapse isExpanded={!isCollapsed} duration={400}>
{#if hasSubIssues}
<div class="list" class:collapsed={isCollapsed}>
<IssueTemplateChildList
{project}
{sprint}
bind:issues={children}
{teamId}
on:move={handleIssueSwap}
on:update-issue
/>
</div>
{/if}
</ExpandCollapse>
<ExpandCollapse isExpanded={!isCollapsed} duration={400}>
{#if isCreating}
<div class="pt-4">
<IssueTemplateChildEditor
{project}
{sprint}
on:close={() => {
isCreating = false
}}
on:create={(evt) => {
if (children === undefined) {
children = []
}
children = [...children, evt.detail]
dispatch('create-issue', evt.detail)
}}
/>
</div>
{/if}
</ExpandCollapse>
</div>
<style lang="scss">
.list {
border-top: 1px solid var(--divider-color);
&.collapsed {
padding-top: 1px;
border-top: none;
}
}
</style>

View File

@ -0,0 +1,67 @@
<!--
// 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">
import { WithLookup } from '@hcengineering/core'
import type { IssueTemplate } from '@hcengineering/tracker'
import { Icon, showPanel } from '@hcengineering/ui'
import tracker from '../../plugin'
export let value: WithLookup<IssueTemplate>
// export let inline: boolean = false
export let disableClick = false
function handleIssueEditorOpened () {
if (disableClick) {
return
}
showPanel(tracker.component.EditIssueTemplate, value._id, value._class, 'content')
}
$: title = `${value?.title}`
</script>
{#if value}
<span class="issuePresenterRoot flex" class:noPointer={disableClick} title="title" on:click={handleIssueEditorOpened}>
<Icon icon={tracker.icon.Issues} size={'small'} />
<span class="ml-2">
{title}
</span>
</span>
{/if}
<style lang="scss">
.issuePresenterRoot {
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
flex-shrink: 0;
max-width: 15rem;
font-size: 0.8125rem;
color: var(--content-color);
cursor: pointer;
&.noPointer {
cursor: default;
}
&:hover {
color: var(--caption-color);
}
&:active {
color: var(--accent-color);
}
}
</style>

View File

@ -0,0 +1,26 @@
<!--
// 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">
import { DocumentQuery, Ref } from '@hcengineering/core'
import { IssueTemplate, Team } from '@hcengineering/tracker'
import tracker from '../../plugin'
import IssueTemplatesView from './IssueTemplatesView.svelte'
export let currentSpace: Ref<Team>
const query: DocumentQuery<IssueTemplate> = { space: currentSpace }
</script>
<IssueTemplatesView {query} title={tracker.string.IssueTemplates} />

View File

@ -0,0 +1,17 @@
<script lang="ts">
import { DocumentQuery, WithLookup } from '@hcengineering/core'
import { IssueTemplate, ViewOptions } from '@hcengineering/tracker'
import { Viewlet } from '@hcengineering/view'
import { viewOptionsStore } from '@hcengineering/view-resources'
import TemplatesList from './TemplatesList.svelte'
import tracker from '../../plugin'
export let viewlet: WithLookup<Viewlet>
export let query: DocumentQuery<IssueTemplate> = {}
$: vo = $viewOptionsStore as ViewOptions
</script>
{#if viewlet?.$lookup?.descriptor?.component}
<TemplatesList _class={tracker.class.IssueTemplate} config={viewlet.config} {query} viewOptions={vo} />
{/if}

View File

@ -0,0 +1,116 @@
<script lang="ts">
import { DocumentQuery, WithLookup } from '@hcengineering/core'
import { IntlString, translate } from '@hcengineering/platform'
import { getClient } from '@hcengineering/presentation'
import { IssueTemplate } from '@hcengineering/tracker'
import { Button, IconAdd, IconDetails, IconDetailsFilled, showPopup } from '@hcengineering/ui'
import view, { Viewlet } from '@hcengineering/view'
import { FilterBar, getActiveViewletId, ViewOptionModel, ViewOptionsButton } from '@hcengineering/view-resources'
import tracker from '../../plugin'
import { getDefaultViewOptionsTemplatesConfig } from '../../utils'
import IssuesHeader from '../issues/IssuesHeader.svelte'
import CreateIssueTemplate from './CreateIssueTemplate.svelte'
import IssueTemplatesContent from './IssueTemplatesContent.svelte'
export let query: DocumentQuery<IssueTemplate> = {}
export let title: IntlString | undefined = undefined
export let label: string = ''
export let viewOptionsConfig: ViewOptionModel[] = getDefaultViewOptionsTemplatesConfig()
export let panelWidth: number = 0
let viewlet: WithLookup<Viewlet> | undefined = undefined
let search = ''
let searchQuery: DocumentQuery<IssueTemplate> = { ...query }
function updateSearchQuery (search: string): void {
searchQuery = search === '' ? { ...query } : { ...query, $search: search }
}
$: updateSearchQuery(search)
$: if (query) updateSearchQuery(search)
let resultQuery: DocumentQuery<IssueTemplate> = { ...searchQuery }
const client = getClient()
let viewlets: WithLookup<Viewlet>[] = []
$: update()
async function update (): Promise<void> {
viewlets = await client.findAll(
view.class.Viewlet,
{ attachTo: tracker.class.IssueTemplate },
{
lookup: {
descriptor: view.class.ViewletDescriptor
}
}
)
const _id = getActiveViewletId()
viewlet = viewlets.find((viewlet) => viewlet._id === _id) || viewlets[0]
}
$: if (!label && title) {
translate(title, {}).then((res) => {
label = res
})
}
let asideFloat: boolean = false
let asideShown: boolean = true
$: if (panelWidth < 900 && !asideFloat) asideFloat = true
$: if (panelWidth >= 900 && asideFloat) {
asideFloat = false
asideShown = false
}
let docWidth: number
let docSize: boolean = false
$: if (docWidth <= 900 && !docSize) docSize = true
$: if (docWidth > 900 && docSize) docSize = false
const showCreateDialog = async () => {
showPopup(CreateIssueTemplate, { targetElement: null }, null)
}
</script>
<IssuesHeader {viewlets} {label} bind:viewlet bind:search showLabelSelector={$$slots.label_selector}>
<svelte:fragment slot="label_selector">
<slot name="label_selector" />
</svelte:fragment>
<svelte:fragment slot="extra">
<Button
size="small"
icon={IconAdd}
label={tracker.string.IssueTemplate}
kind="secondary"
on:click={showCreateDialog}
/>
{#if viewlet}
<ViewOptionsButton viewOptionsKey={viewlet._id} config={viewOptionsConfig} />
{/if}
{#if asideFloat && $$slots.aside}
<div class="buttons-divider" />
<Button
icon={asideShown ? IconDetailsFilled : IconDetails}
kind={'transparent'}
size={'medium'}
selected={asideShown}
on:click={() => {
asideShown = !asideShown
}}
/>
{/if}
</svelte:fragment>
</IssuesHeader>
<slot name="afterHeader" />
<FilterBar _class={tracker.class.IssueTemplate} query={searchQuery} on:change={(e) => (resultQuery = e.detail)} />
<div class="flex w-full h-full clear-mins">
{#if viewlet}
<IssueTemplatesContent {viewlet} query={resultQuery} />
{/if}
{#if $$slots.aside !== undefined && asideShown}
<div class="popupPanel-body__aside flex" class:float={asideFloat} class:shown={asideShown}>
<slot name="aside" />
</div>
{/if}
</div>

View File

@ -0,0 +1,110 @@
<!--
// 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">
import { Doc } from '@hcengineering/core'
import { AttributeBarEditor, createQuery, getClient, KeyedAttribute } from '@hcengineering/presentation'
import type { IssueTemplate } from '@hcengineering/tracker'
import { Label } from '@hcengineering/ui'
import { getFiltredKeys, isCollectionAttr } from '@hcengineering/view-resources/src/utils'
import tracker from '../../plugin'
import AssigneeEditor from '../issues/AssigneeEditor.svelte'
import PriorityEditor from '../issues/PriorityEditor.svelte'
import ProjectEditor from '../projects/ProjectEditor.svelte'
import SprintEditor from '../sprints/SprintEditor.svelte'
export let issue: IssueTemplate
const query = createQuery()
let showIsBlocking = false
let blockedBy: Doc[]
$: query.query(tracker.class.Issue, { blockedBy: { _id: issue._id, _class: issue._class } }, (result) => {
blockedBy = result
showIsBlocking = result.length > 0
})
const client = getClient()
const hierarchy = client.getHierarchy()
let keys: KeyedAttribute[] = []
function updateKeys (ignoreKeys: string[]): void {
const filtredKeys = getFiltredKeys(hierarchy, issue._class, ignoreKeys)
keys = filtredKeys.filter((key) => !isCollectionAttr(hierarchy, key))
}
$: updateKeys(['title', 'description', 'priority', 'number', 'assignee', 'project', 'sprint'])
</script>
<div class="content">
<span class="label">
<Label label={tracker.string.Priority} />
</span>
<PriorityEditor value={issue} shouldShowLabel />
<span class="label">
<Label label={tracker.string.Assignee} />
</span>
<AssigneeEditor value={issue} />
<!-- <span class="labelTop">
<Label label={tracker.string.Labels} />
</span> -->
<!-- <Component is={tags.component.TagsAttributeEditor} props={{ object: issue, label: tracker.string.AddLabel }} /> -->
<div class="divider" />
<span class="label">
<Label label={tracker.string.Project} />
</span>
<ProjectEditor value={issue} />
{#if issue.sprint}
<span class="label">
<Label label={tracker.string.Sprint} />
</span>
<SprintEditor value={issue} />
{/if}
{#if keys.length > 0}
<div class="divider" />
{#each keys as key (typeof key === 'string' ? key : key.key)}
<AttributeBarEditor {key} _class={issue._class} object={issue} showHeader={true} />
{/each}
{/if}
</div>
<style lang="scss">
.content {
display: grid;
grid-template-columns: 1fr 1.5fr;
grid-auto-flow: row;
justify-content: start;
align-items: center;
gap: 1rem;
margin-top: 1rem;
width: 100%;
height: min-content;
}
.divider {
grid-column: 1 / 3;
height: 1px;
background-color: var(--divider-color);
}
.labelTop {
align-self: start;
margin-top: 0.385rem;
}
</style>

View File

@ -0,0 +1,65 @@
<script lang="ts">
import contact, { Employee } from '@hcengineering/contact'
import { Class, Doc, DocumentQuery, Ref, WithLookup } from '@hcengineering/core'
import { createQuery } from '@hcengineering/presentation'
import { IssueTemplate, ViewOptions } from '@hcengineering/tracker'
import { defaultSP, Scroller } from '@hcengineering/ui'
import { BuildModelKey } from '@hcengineering/view'
import tracker from '../../plugin'
import {
getCategories,
groupBy as groupByFunc,
issuesGroupKeyMap,
issuesOrderKeyMap,
issuesSortOrderMap
} from '../../utils'
import IssuesListBrowser from '../issues/IssuesListBrowser.svelte'
export let _class: Ref<Class<Doc>>
export let config: (string | BuildModelKey)[]
export let query: DocumentQuery<IssueTemplate> = {}
export let viewOptions: ViewOptions
$: currentSpace = typeof query.space === 'string' ? query.space : tracker.team.DefaultTeam
$: ({ groupBy, orderBy, shouldShowEmptyGroups, shouldShowSubIssues } = viewOptions)
$: groupByKey = issuesGroupKeyMap[groupBy]
$: orderByKey = issuesOrderKeyMap[orderBy]
$: groupedIssues = groupByFunc(issues, groupBy)
$: categories = getCategories(groupByKey, issues, !!shouldShowEmptyGroups, [], employees)
$: employees = issues.map((x) => x.$lookup?.assignee).filter(Boolean) as Employee[]
const issuesQuery = createQuery()
let issues: WithLookup<IssueTemplate>[] = []
$: issuesQuery.query(
tracker.class.IssueTemplate,
{ ...query },
(result) => {
issues = result
},
{
sort: { [orderByKey]: issuesSortOrderMap[orderByKey] },
lookup: {
assignee: contact.class.Employee,
space: tracker.class.Team,
sprint: tracker.class.Sprint
}
}
)
</script>
<div class="w-full h-full clear-mins">
<Scroller fade={defaultSP}>
<IssuesListBrowser
{_class}
{currentSpace}
{groupByKey}
orderBy={orderByKey}
statuses={[]}
{employees}
{categories}
itemsConfig={config}
{groupedIssues}
/>
</Scroller>
</div>

View File

@ -79,6 +79,11 @@ import RelatedIssues from './components/issues/related/RelatedIssues.svelte'
import ProjectSelector from './components/ProjectSelector.svelte'
import IssueTemplatePresenter from './components/templates/IssueTemplatePresenter.svelte'
import IssueTemplates from './components/templates/IssueTemplates.svelte'
import EditIssueTemplate from './components/templates/EditIssueTemplate.svelte'
export async function queryIssue<D extends Issue> (
_class: Ref<Class<D>>,
client: Client,
@ -195,7 +200,10 @@ export default async (): Promise<Resources> => ({
SubIssuesSelector,
GrowPresenter,
RelatedIssues,
ProjectSelector
ProjectSelector,
IssueTemplates,
IssueTemplatePresenter,
EditIssueTemplate
},
completion: {
IssueQuery: async (client: Client, query: string, filter?: { in?: RelatedDocument[], nin?: RelatedDocument[] }) =>

View File

@ -117,6 +117,7 @@ export default mergeIds(trackerId, tracker, {
ChangeDueDate: '' as IntlString,
ModificationDate: '' as IntlString,
Issue: '' as IntlString,
IssueTemplate: '' as IntlString,
Document: '' as IntlString,
DocumentIcon: '' as IntlString,
DocumentColor: '' as IntlString,
@ -232,7 +233,14 @@ export default mergeIds(trackerId, tracker, {
CapacityValue: '' as IntlString,
AddedReference: '' as IntlString,
AddedAsBlocked: '' as IntlString,
AddedAsBlocking: '' as IntlString
AddedAsBlocking: '' as IntlString,
IssueTemplates: '' as IntlString,
NewProcess: '' as IntlString,
SaveProcess: '' as IntlString,
NoIssueTemplate: '' as IntlString,
TemplateReplace: '' as IntlString,
TemplateReplaceConfirm: '' as IntlString
},
component: {
NopeComponent: '' as AnyComponent,
@ -257,6 +265,7 @@ export default mergeIds(trackerId, tracker, {
AssigneePresenter: '' as AnyComponent,
DueDatePresenter: '' as AnyComponent,
EditIssue: '' as AnyComponent,
EditIssueTemplate: '' as AnyComponent,
CreateTeam: '' as AnyComponent,
NewIssueHeader: '' as AnyComponent,
IconPresenter: '' as AnyComponent,
@ -286,7 +295,10 @@ export default mergeIds(trackerId, tracker, {
EstimationEditor: '' as AnyComponent,
GrowPresenter: '' as AnyComponent,
ProjectSelector: '' as AnyComponent
ProjectSelector: '' as AnyComponent,
IssueTemplates: '' as AnyComponent,
IssueTemplatePresenter: '' as AnyComponent
},
function: {
IssueTitleProvider: '' as Resource<(client: Client, ref: Ref<Doc>) => Promise<string>>

View File

@ -23,6 +23,7 @@ import {
IssuesGrouping,
IssuesOrdering,
IssueStatus,
IssueTemplate,
ProjectStatus,
Sprint,
SprintStatus,
@ -305,7 +306,7 @@ const listIssueStatusOrder = [
export function getCategories (
key: IssuesGroupByKeys | undefined,
elements: Array<WithLookup<Issue>>,
elements: Array<WithLookup<Issue | IssueTemplate>>,
shouldShowAll: boolean,
statuses: IssueStatus[],
employees: Employee[]
@ -320,7 +321,7 @@ export function getCategories (
const existingCategories = Array.from(
new Set(
elements.map((x) => {
return x[key]
return (x as any)[key]
})
)
)
@ -509,49 +510,90 @@ export async function getPriorityStates (): Promise<TypeState[]> {
)
}
export function getDefaultViewOptionsConfig (): ViewOptionModel[] {
return [
{
key: 'groupBy',
label: tracker.string.Grouping,
defaultValue: 'status',
values: [
{ id: 'status', label: tracker.string.Status },
{ id: 'assignee', label: tracker.string.Assignee },
{ id: 'priority', label: tracker.string.Priority },
{ id: 'project', label: tracker.string.Project },
{ id: 'sprint', label: tracker.string.Sprint },
{ id: 'noGrouping', label: tracker.string.NoGrouping }
],
type: 'dropdown'
},
{
key: 'orderBy',
label: tracker.string.Ordering,
defaultValue: 'status',
values: [
{ id: 'status', label: tracker.string.Status },
{ id: 'modifiedOn', label: tracker.string.LastUpdated },
{ id: 'priority', label: tracker.string.Priority },
{ id: 'dueDate', label: tracker.string.DueDate },
{ id: 'rank', label: tracker.string.Manual }
],
type: 'dropdown'
},
{
key: 'shouldShowSubIssues',
label: tracker.string.SubIssues,
defaultValue: false,
type: 'toggle'
},
{
key: 'shouldShowEmptyGroups',
label: tracker.string.ShowEmptyGroups,
defaultValue: false,
type: 'toggle',
hidden: ({ groupBy }) => !['status', 'priority'].includes(groupBy)
}
]
export function getDefaultViewOptionsConfig (enableSubIssues = true): ViewOptionModel[] {
const groupByCategory: ViewOptionModel = {
key: 'groupBy',
label: tracker.string.Grouping,
defaultValue: 'status',
values: [
{ id: 'status', label: tracker.string.Status },
{ id: 'assignee', label: tracker.string.Assignee },
{ id: 'priority', label: tracker.string.Priority },
{ id: 'project', label: tracker.string.Project },
{ id: 'sprint', label: tracker.string.Sprint },
{ id: 'noGrouping', label: tracker.string.NoGrouping }
],
type: 'dropdown'
}
const orderByCategory: ViewOptionModel = {
key: 'orderBy',
label: tracker.string.Ordering,
defaultValue: 'status',
values: [
{ id: 'status', label: tracker.string.Status },
{ id: 'modifiedOn', label: tracker.string.LastUpdated },
{ id: 'priority', label: tracker.string.Priority },
{ id: 'dueDate', label: tracker.string.DueDate },
{ id: 'rank', label: tracker.string.Manual }
],
type: 'dropdown'
}
const showSubIssuesCategory: ViewOptionModel = {
key: 'shouldShowSubIssues',
label: tracker.string.SubIssues,
defaultValue: false,
type: 'toggle'
}
const showEmptyGroups: ViewOptionModel = {
key: 'shouldShowEmptyGroups',
label: tracker.string.ShowEmptyGroups,
defaultValue: false,
type: 'toggle',
hidden: ({ groupBy }) => !['status', 'priority'].includes(groupBy)
}
const result: ViewOptionModel[] = [groupByCategory, orderByCategory]
if (enableSubIssues) {
result.push(showSubIssuesCategory)
}
result.push(showEmptyGroups)
return result
}
export function getDefaultViewOptionsTemplatesConfig (): ViewOptionModel[] {
const groupByCategory: ViewOptionModel = {
key: 'groupBy',
label: tracker.string.Grouping,
defaultValue: 'project',
values: [
{ id: 'assignee', label: tracker.string.Assignee },
{ id: 'priority', label: tracker.string.Priority },
{ id: 'project', label: tracker.string.Project },
{ id: 'sprint', label: tracker.string.Sprint },
{ id: 'noGrouping', label: tracker.string.NoGrouping }
],
type: 'dropdown'
}
const orderByCategory: ViewOptionModel = {
key: 'orderBy',
label: tracker.string.Ordering,
defaultValue: 'priority',
values: [
{ id: 'modifiedOn', label: tracker.string.LastUpdated },
{ id: 'priority', label: tracker.string.Priority },
{ id: 'dueDate', label: tracker.string.DueDate }
],
type: 'dropdown'
}
const showEmptyGroups: ViewOptionModel = {
key: 'shouldShowEmptyGroups',
label: tracker.string.ShowEmptyGroups,
defaultValue: false,
type: 'toggle',
hidden: ({ groupBy }) => !['status', 'priority'].includes(groupBy)
}
const result: ViewOptionModel[] = [groupByCategory, orderByCategory]
result.push(showEmptyGroups)
return result
}
/**

View File

@ -17,7 +17,7 @@ import { Employee } from '@hcengineering/contact'
import type { AttachedDoc, Class, Doc, Markup, Ref, RelatedDocument, Space, Timestamp, Type } from '@hcengineering/core'
import type { Asset, IntlString, Plugin, Resource } from '@hcengineering/platform'
import { plugin } from '@hcengineering/platform'
import type { TagCategory } from '@hcengineering/tags'
import type { TagCategory, TagElement } from '@hcengineering/tags'
import { AnyComponent, Location } from '@hcengineering/ui'
import { Action, ActionCategory } from '@hcengineering/view'
@ -172,6 +172,54 @@ export interface Issue extends AttachedDoc {
reports: number
childInfo: IssueChildInfo[]
template?: {
// A template issue is based on
template: Ref<IssueTemplate>
// Child id in template
childId?: string
}
}
/**
* @public
*/
export interface IssueTemplateData {
title: string
description: Markup
priority: IssuePriority
assignee: Ref<Employee> | null
project: Ref<Project> | null
sprint?: Ref<Sprint> | null
// Estimation in man days
estimation: number
dueDate: number | null
labels?: Ref<TagElement>[]
}
/**
* @public
*/
export interface IssueTemplateChild extends IssueTemplateData {
id: string
}
/**
* @public
*/
export interface IssueTemplate extends Doc, IssueTemplateData {
space: Ref<Team>
children: IssueTemplateChild[]
// Discussion stuff
comments: number
attachments?: number
}
/**
@ -279,6 +327,7 @@ export default plugin(trackerId, {
class: {
Team: '' as Ref<Class<Team>>,
Issue: '' as Ref<Class<Issue>>,
IssueTemplate: '' as Ref<Class<IssueTemplate>>,
Document: '' as Ref<Class<Document>>,
Project: '' as Ref<Class<Project>>,
IssueStatus: '' as Ref<Class<IssueStatus>>,

View File

@ -20,9 +20,10 @@
// export let label: IntlString
export let placeholder: IntlString
export let value: number
export let value: number | undefined
export let focus: boolean
export let onChange: (value: number) => void
export let maxWidth: string = '10rem'
export let onChange: (value: number | undefined) => void
export let kind: 'no-border' | 'link' = 'no-border'
export let readonly = false

View File

@ -0,0 +1,162 @@
<!--
// 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 type { Class, Doc, DocumentQuery, FindOptions, Ref } from '@hcengineering/core'
import { Asset, IntlString } from '@hcengineering/platform'
import presentation, { getClient, ObjectCreate } from '@hcengineering/presentation'
import {
ActionIcon,
AnySvelteComponent,
Button,
ButtonKind,
ButtonSize,
getEventPositionElement,
getFocusManager,
Icon,
IconOpen,
Label,
LabelAndProps,
showPanel,
showPopup
} from '@hcengineering/ui'
import view from '@hcengineering/view'
import { createEventDispatcher } from 'svelte'
import ObjectBoxPopup from './ObjectBoxPopup.svelte'
import ObjectPresenter from './ObjectPresenter.svelte'
export let _class: Ref<Class<Doc>>
export let excluded: Ref<Doc>[] | undefined = undefined
export let options: FindOptions<Doc> | undefined = undefined
export let docQuery: DocumentQuery<Doc> | undefined = undefined
export let label: IntlString
export let icon: Asset | AnySvelteComponent | undefined = undefined
export let placeholder: IntlString = presentation.string.Search
export let value: Ref<Doc> | null | undefined
export let allowDeselect = false
export let titleDeselect: IntlString | undefined = undefined
export let readonly = false
export let kind: ButtonKind = 'no-border'
export let size: ButtonSize = 'small'
export let justify: 'left' | 'center' = 'center'
export let width: string | undefined = undefined
export let focusIndex = -1
export let showTooltip: LabelAndProps | undefined = undefined
export let showNavigate = true
export let id: string | undefined = undefined
export let searchField: string = 'name'
export let docProps: Record<string, any> = {}
export let create: ObjectCreate | undefined = undefined
const dispatch = createEventDispatcher()
let selected: Doc | undefined
let container: HTMLElement
const client = getClient()
async function updateSelected (value: Ref<Doc> | null | undefined) {
selected = value ? await client.findOne(_class, { _id: value }) : undefined
}
$: updateSelected(value)
const mgr = getFocusManager()
const _click = (ev: MouseEvent): void => {
if (!readonly) {
showPopup(
ObjectBoxPopup,
{
_class,
options,
docQuery,
ignoreObjects: excluded ?? [],
icon,
allowDeselect,
selected: value,
titleDeselect,
placeholder,
create,
searchField,
docProps
},
!$$slots.content ? container : getEventPositionElement(ev),
(result) => {
if (result === null) {
value = null
selected = undefined
dispatch('change', null)
} else if (result !== undefined && result._id !== value) {
value = result._id
dispatch('change', value)
}
mgr?.setFocusPos(focusIndex)
}
)
}
}
$: hideIcon = size === 'x-large' || (size === 'large' && kind !== 'link')
</script>
<div {id} bind:this={container} class="min-w-0" class:w-full={width === '100%'} class:h-full={$$slots.content}>
{#if $$slots.content}
<div class="w-full h-full flex-streatch" on:click={_click}>
<slot name="content" />
</div>
{:else}
<Button {focusIndex} width={width ?? 'min-content'} {size} {kind} {justify} {showTooltip} on:click={_click}>
<span slot="content" class="overflow-label flex-grow" class:flex-between={showNavigate && selected}>
<div
class="disabled"
style:width={showNavigate && selected
? `calc(${width ?? 'min-content'} - 1.5rem)`
: `${width ?? 'min-content'}`}
>
{#if selected}
<ObjectPresenter
objectId={selected._id}
_class={selected._class}
value={selected}
props={{ ...docProps, isInteractive: false, inline: true, size: 'x-small' }}
/>
{:else}
<div class="flex-row-center">
{#if icon}
<Icon {icon} size={kind === 'link' ? 'small' : size} />
{/if}
<div class="ml-2">
<Label {label} />
</div>
</div>
{/if}
</div>
{#if selected && showNavigate}
<ActionIcon
icon={IconOpen}
size={'small'}
action={() => {
if (selected) {
showPanel(view.component.EditDoc, selected._id, selected._class, 'content')
}
}}
/>
{/if}
</span>
</Button>
{/if}
</div>

View File

@ -0,0 +1,73 @@
<!--
// 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 { Person } from '@hcengineering/contact'
import type { Class, Doc, DocumentQuery, FindOptions, Ref } from '@hcengineering/core'
import type { IntlString } from '@hcengineering/platform'
import presentation, { ObjectCreate, ObjectPopup } from '@hcengineering/presentation'
import { afterUpdate, createEventDispatcher } from 'svelte'
import ObjectPresenter from './ObjectPresenter.svelte'
export let _class: Ref<Class<Doc>>
export let options: FindOptions<Doc> = {}
export let selected: Ref<Doc> | undefined
export let docQuery: DocumentQuery<Doc> = {}
export let multiSelect: boolean = false
export let allowDeselect: boolean = false
export let titleDeselect: IntlString | undefined = undefined
export let placeholder: IntlString = presentation.string.Search
export let selectedObjects: Ref<Person>[] = []
export let ignoreObjects: Ref<Person>[] = []
export let shadows: boolean = true
export let create: ObjectCreate | undefined = undefined
export let searchField: string = 'name'
export let docProps: Record<string, any> = {}
const dispatch = createEventDispatcher()
afterUpdate(() => dispatch('changeContent'))
</script>
<ObjectPopup
{_class}
{options}
{selected}
{searchField}
{multiSelect}
{allowDeselect}
{titleDeselect}
{placeholder}
{docQuery}
groupBy={'_class'}
bind:selectedObjects
bind:ignoreObjects
{shadows}
{create}
on:update
on:close
on:changeContent={() => dispatch('changeContent')}
>
<svelte:fragment slot="item" let:item={doc}>
<div class="flex flex-grow overflow-label">
<ObjectPresenter
objectId={doc._id}
_class={doc._class}
value={doc}
props={{ ...docProps, isInteractive: false, inline: true, size: 'x-small' }}
/>
</div>
</svelte:fragment>
</ObjectPopup>

View File

@ -77,6 +77,8 @@ export { default as ContextMenu } from './components/Menu.svelte'
export { default as TableBrowser } from './components/TableBrowser.svelte'
export { default as FixedColumn } from './components/FixedColumn.svelte'
export { default as ViewOptionsButton } from './components/ViewOptionsButton.svelte'
export { default as ValueSelector } from './components/ValueSelector.svelte'
export { default as ObjectBox } from './components/ObjectBox.svelte'
export * from './context'
export * from './filter'
export * from './selection'

View File

@ -28,6 +28,7 @@ import core, {
Lookup,
Mixin,
ModelDb,
QueryUpdate,
Ref,
ReverseLookups,
SortingOrder,
@ -649,6 +650,37 @@ class MongoAdapter extends MongoAdapterBase {
}
]
return await this.db.collection(domain).bulkWrite(ops as any)
} else if (operator === '$update') {
const keyval = (tx.operations as any).$update
const arr = Object.keys(keyval)[0]
const desc = keyval[arr] as QueryUpdate<any>
const ops = [
{
updateOne: {
filter: {
_id: tx.objectId,
...Object.fromEntries(Object.entries(desc.$query).map((it) => [arr + '.' + it[0], it[1]]))
},
update: {
$set: {
...Object.fromEntries(Object.entries(desc.$update).map((it) => [arr + '.$.' + it[0], it[1]]))
}
}
}
},
{
updateOne: {
filter: { _id: tx.objectId },
update: {
$set: {
modifiedBy: tx.modifiedBy,
modifiedOn: tx.modifiedOn
}
}
}
}
]
return await this.db.collection(domain).bulkWrite(ops as any)
} else {
if (tx.retrieve === true) {
const result = await this.db.collection(domain).findOneAndUpdate(

View File

@ -42,7 +42,7 @@ test.describe('recruit tests', () => {
await page.locator('.antiPopup').locator('text=Email').click()
const emailInput = page.locator('[placeholder="john\\.appleseed@apple\\.com"]')
await emailInput.fill(email)
await emailInput.press('Enter')
await page.locator('#channel-ok.button').click()
await page.locator('.antiCard button:has-text("Create")').click()
await page.waitForSelector('form.antiCard', { state: 'detached' })