platform/plugins/tracker-resources/src/components/CreateIssue.svelte
Vyacheslav Tumanov d553a261d8
UBER-158: new popup dialog (#3409)
Signed-off-by: Vyacheslav Tumanov <me@slavatumanov.me>
2023-06-08 22:58:48 +07:00

786 lines
23 KiB
Svelte

<!--
// 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 chunter from '@hcengineering/chunter'
import { Employee } from '@hcengineering/contact'
import core, { Account, AttachedData, Doc, fillDefaults, generateId, Ref, SortingOrder } from '@hcengineering/core'
import { getResource, translate } from '@hcengineering/platform'
import {
Card,
createQuery,
DraftController,
getClient,
KeyedAttribute,
MessageBox,
MultipleDraftController,
SpaceSelector
} from '@hcengineering/presentation'
import tags, { TagElement, TagReference } from '@hcengineering/tags'
import {
calcRank,
Component as ComponentType,
Issue,
IssueDraft,
IssuePriority,
IssueStatus,
IssueTemplate,
Milestone,
Project
} from '@hcengineering/tracker'
import {
addNotification,
Button,
Component,
createFocusManager,
DatePresenter,
EditBox,
FocusHandler,
IconAttachment,
Label,
showPopup
} from '@hcengineering/ui'
import { Attachment } from '@hcengineering/attachment'
import { AttachmentPresenter } from '@hcengineering/attachment-resources'
import view from '@hcengineering/view'
import { ObjectBox } from '@hcengineering/view-resources'
import { createEventDispatcher, onDestroy } from 'svelte'
import { activeComponent, activeMilestone, generateIssueShortLink, getIssueId, updateIssueRelation } from '../issues'
import tracker from '../plugin'
import ComponentSelector from './ComponentSelector.svelte'
import AssigneeEditor from './issues/AssigneeEditor.svelte'
import IssueNotification from './issues/IssueNotification.svelte'
import ParentIssue from './issues/ParentIssue.svelte'
import PriorityEditor from './issues/PriorityEditor.svelte'
import StatusEditor from './issues/StatusEditor.svelte'
import EstimationEditor from './issues/timereport/EstimationEditor.svelte'
import MilestoneSelector from './milestones/MilestoneSelector.svelte'
import SetParentIssueActionPopup from './SetParentIssueActionPopup.svelte'
import SubIssues from './SubIssues.svelte'
import { createBacklinks } from '@hcengineering/chunter-resources'
import ProjectPresenter from './projects/ProjectPresenter.svelte'
export let space: Ref<Project>
export let status: Ref<IssueStatus> | undefined = undefined
export let priority: IssuePriority | undefined = undefined
export let assignee: Ref<Employee> | null = null
export let component: Ref<ComponentType> | null = null
export let milestone: Ref<Milestone> | null = null
export let relatedTo: Doc | undefined
export let shouldSaveDraft: boolean = true
export let parentIssue: Issue | undefined
export let originalIssue: Issue | undefined
const mDraftController = new MultipleDraftController(tracker.ids.IssueDraft)
const id: Ref<Issue> = generateId()
const draftController = new DraftController<IssueDraft>(
shouldSaveDraft ? mDraftController.getNext() ?? id : undefined,
tracker.ids.IssueDraft
)
let draft = shouldSaveDraft ? draftController.get() : undefined
onDestroy(
draftController.subscribe((val) => {
draft = shouldSaveDraft ? val : undefined
})
)
const client = getClient()
const hierarchy = client.getHierarchy()
const parentQuery = createQuery()
let _space = draft?.space ?? space
let object = getDefaultObjectFromDraft() ?? getDefaultObject(id)
let isAssigneeTouched = false
function objectChange (object: IssueDraft, empty: any) {
if (shouldSaveDraft) {
draftController.save(object, empty)
}
}
$: objectChange(object, empty)
$: if (object.parentIssue) {
parentQuery.query(
tracker.class.Issue,
{
_id: object.parentIssue
},
(res) => {
;[parentIssue] = res
}
)
} else {
parentQuery.unsubscribe()
parentIssue = undefined
}
function getDefaultObjectFromDraft (): IssueDraft | undefined {
if (!draft) {
return
}
return {
...draft,
...(status != null ? { status } : {}),
...(priority != null ? { priority } : {}),
...(assignee != null ? { assignee } : {}),
...(component != null ? { component } : {}),
...(milestone != null ? { milestone } : {})
}
}
function getDefaultObject (id: Ref<Issue> | undefined = undefined, ignoreOriginal = false): IssueDraft {
const base: IssueDraft = {
_id: id ?? generateId(),
title: '',
description: '',
priority: priority ?? IssuePriority.NoPriority,
space: _space,
component: component ?? $activeComponent ?? null,
dueDate: null,
attachments: 0,
estimation: 0,
milestone: milestone ?? $activeMilestone ?? null,
status,
assignee,
labels: [],
parentIssue: parentIssue?._id,
subIssues: []
}
if (originalIssue && !ignoreOriginal) {
const res: IssueDraft = {
...base,
description: originalIssue.description,
status: originalIssue.status,
priority: originalIssue.priority,
component: originalIssue.component,
dueDate: originalIssue.dueDate,
assignee: originalIssue.assignee,
estimation: originalIssue.estimation,
parentIssue: originalIssue.parents[0]?.parentId,
title: `${originalIssue.title} (copy)`
}
client.findAll(tags.class.TagReference, { attachedTo: originalIssue._id }).then((p) => {
object.labels = p
})
if (originalIssue.relations?.[0]) {
client.findOne(tracker.class.Issue, { _id: originalIssue.relations[0]._id as Ref<Issue> }).then((p) => {
relatedTo = p
})
}
return res
}
return base
}
fillDefaults(hierarchy, object, tracker.class.Issue)
let subIssuesComponent: SubIssues
let currentProject: Project | undefined
$: updateIssueStatusId(object, currentProject)
$: updateAssigneeId(object, currentProject)
$: canSave = getTitle(object.title ?? '').length > 0 && object.status !== undefined
$: empty = {
assignee: assignee ?? currentProject?.defaultAssignee,
status: status ?? currentProject?.defaultIssueStatus,
parentIssue: parentIssue?._id,
description: '<p></p>',
component: component ?? $activeComponent ?? null,
milestone: milestone ?? $activeMilestone ?? null,
priority: priority ?? IssuePriority.NoPriority,
space: _space
}
$: if (object.space !== _space) {
object.space = _space
}
function resetObject (): void {
templateId = undefined
template = undefined
object = getDefaultObject(undefined, true)
fillDefaults(hierarchy, object, tracker.class.Issue)
}
let templateId: Ref<IssueTemplate> | undefined = draft?.template?.template
let appliedTemplateId: Ref<IssueTemplate> | undefined = draft?.template?.template
let template: IssueTemplate | undefined = undefined
const templateQuery = createQuery()
$: if (templateId !== undefined) {
templateQuery.query(tracker.class.IssueTemplate, { _id: templateId }, (res) => {
template = res[0]
})
} else {
template = undefined
templateQuery.unsubscribe()
}
function tagAsRef (tag: TagElement): TagReference {
return {
_class: tags.class.TagReference,
_id: generateId() as Ref<TagReference>,
attachedTo: '' as Ref<Doc>,
attachedToClass: tracker.class.Issue,
collection: 'labels',
space: tags.space.Tags,
modifiedOn: 0,
modifiedBy: '' as Ref<Account>,
title: tag.title,
tag: tag._id,
color: tag.color
}
}
async function updateTemplate (template: IssueTemplate): Promise<void> {
if (object.template?.template === template._id) {
return
}
const { _class, _id, space, children, comments, attachments, labels, description, ...templBase } = template
object.subIssues = template.children.map((p) => {
return {
...p,
_id: p.id,
space: _space,
subIssues: [],
dueDate: null,
labels: [],
status: currentProject?.defaultIssueStatus
}
})
object = {
...object,
description: description ?? '',
...templBase,
template: {
template: template._id
}
}
appliedTemplateId = templateId
const tagElements = await client.findAll(tags.class.TagElement, { _id: { $in: labels } })
object.labels = tagElements.map(tagAsRef)
fillDefaults(hierarchy, object, tracker.class.Issue)
}
$: template && updateTemplate(template)
const dispatch = createEventDispatcher()
const spaceQuery = createQuery()
let descriptionBox: AttachmentStyledBox
const key: KeyedAttribute = {
key: 'labels',
attr: client.getHierarchy().getAttribute(tracker.class.Issue, 'labels')
}
$: spaceQuery.query(tracker.class.Project, { _id: _space }, (res) => {
resetDefaultAssigneeId()
currentProject = res.shift()
})
async function updateIssueStatusId (object: IssueDraft, currentProject: Project | undefined) {
if (currentProject?.defaultIssueStatus && object.status === undefined) {
object.status = currentProject.defaultIssueStatus
}
}
function resetDefaultAssigneeId () {
if (!isAssigneeTouched && !!object.assignee && object.assignee === currentProject?.defaultAssignee) {
object = { ...object, assignee: assignee ?? null }
}
}
function updateAssigneeId (object: IssueDraft, currentProject: Project | undefined) {
if (!isAssigneeTouched && object.assignee == null && currentProject !== undefined) {
if (currentProject.defaultAssignee !== undefined) {
object.assignee = currentProject.defaultAssignee
} else {
object.assignee = null
}
}
}
function clearParentIssue () {
object.parentIssue = undefined
parentQuery.unsubscribe()
parentIssue = undefined
}
function getTitle (value: string) {
return value.trim()
}
export function canClose (): boolean {
return true
}
export async function onOutsideClick () {
if (shouldSaveDraft) {
draftController.save(object, empty)
}
}
async function createIssue () {
const _id: Ref<Issue> = generateId()
if (!canSave || object.status === undefined) {
return
}
const lastOne = await client.findOne<Issue>(tracker.class.Issue, {}, { sort: { rank: SortingOrder.Descending } })
const incResult = await client.updateDoc(
tracker.class.Project,
core.space.Space,
_space,
{
$inc: { sequence: 1 }
},
true
)
const value: AttachedData<Issue> = {
title: getTitle(object.title),
description: object.description,
assignee: object.assignee,
component: object.component,
milestone: object.milestone,
number: (incResult as any).object.sequence,
status: object.status,
priority: object.priority,
rank: calcRank(lastOne, undefined),
comments: 0,
subIssues: 0,
dueDate: object.dueDate,
parents: parentIssue
? [{ parentId: parentIssue._id, parentTitle: parentIssue.title }, ...parentIssue.parents]
: [],
reportedTime: 0,
estimation: object.estimation,
reports: 0,
relations: relatedTo !== undefined ? [{ _id: relatedTo._id, _class: relatedTo._class }] : [],
childInfo: []
}
await client.addCollection(
tracker.class.Issue,
_space,
parentIssue?._id ?? tracker.ids.NoParent,
parentIssue?._class ?? tracker.class.Issue,
'subIssues',
value,
_id
)
for (const label of object.labels) {
await client.addCollection(label._class, label.space, _id, tracker.class.Issue, 'labels', {
title: label.title,
color: label.color,
tag: label.tag
})
}
await descriptionBox.createAttachments(_id)
if (relatedTo !== undefined) {
const doc = await client.findOne(tracker.class.Issue, { _id })
if (doc !== undefined) {
if (client.getHierarchy().isDerived(relatedTo._class, tracker.class.Issue)) {
await updateIssueRelation(client, relatedTo as Issue, doc, 'relations', '$push')
} else {
const update = await getResource(chunter.backreference.Update)
await update(doc, 'relations', [relatedTo], tracker.string.AddedReference)
}
}
}
const parents = parentIssue
? [
{ parentId: _id, parentTitle: value.title },
{ parentId: parentIssue._id, parentTitle: parentIssue.title },
...parentIssue.parents
]
: [{ parentId: _id, parentTitle: value.title }]
await subIssuesComponent.save(parents, _id)
addNotification(await translate(tracker.string.IssueCreated, {}), getTitle(object.title), IssueNotification, {
issueId: _id,
subTitlePostfix: (await translate(tracker.string.CreatedOne, {})).toLowerCase(),
issueUrl: currentProject && generateIssueShortLink(getIssueId(currentProject, value as Issue))
})
// Create an backlink to document
await createBacklinks(client, _id, tracker.class.Issue, _id, object.description)
draftController.remove()
resetObject()
descriptionBox?.removeDraft(false)
isAssigneeTouched = false
}
async function setParentIssue () {
showPopup(
SetParentIssueActionPopup,
{ value: { ...object, space: _space, attachedTo: parentIssue?._id } },
'top',
(selectedIssue) => {
if (selectedIssue !== undefined) {
parentIssue = selectedIssue
object.parentIssue = parentIssue?._id
}
}
)
}
const handleComponentIdChanged = (componentId: Ref<ComponentType> | null | undefined) => {
if (componentId === undefined) {
return
}
object.component = componentId
}
const handleMilestoneIdChanged = async (milestoneId: Ref<Milestone> | null | undefined) => {
if (milestoneId === undefined) {
return
}
object.milestone = milestoneId
}
function addTagRef (tag: TagElement): void {
object.labels = [...object.labels, tagAsRef(tag)]
}
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,
okLabel: tracker.string.Apply
},
'top',
(result?: boolean) => {
if (result === true) {
templateId = evt.detail ?? undefined
if (templateId === undefined) {
object.subIssues = []
resetObject()
}
}
}
)
}
async function showConfirmationDialog () {
draftController.save(object, empty)
const isFormEmpty = draft === undefined
if (isFormEmpty) {
dispatch('close')
} else {
showPopup(
MessageBox,
{
label: tracker.string.NewIssueDialogClose,
message: tracker.string.NewIssueDialogCloseNote
},
'top',
(result?: boolean) => {
if (result === true) {
dispatch('close')
resetObject()
draftController.remove()
descriptionBox?.removeDraft(true)
}
}
)
}
}
$: objectId = object._id
const manager = createFocusManager()
let attachments: Map<Ref<Attachment>, Attachment> = new Map<Ref<Attachment>, Attachment>()
</script>
<FocusHandler {manager} />
<Card
label={tracker.string.NewIssue}
okAction={createIssue}
{canSave}
okLabel={tracker.string.SaveIssue}
on:close={() => dispatch('close')}
onCancel={showConfirmationDialog}
hideAttachments={attachments.size === 0}
hideSubheader={!parentIssue}
noFade={true}
on:changeContent
>
<svelte:fragment slot="header">
<SpaceSelector
_class={tracker.class.Project}
label={tracker.string.Project}
bind:space={_space}
kind={'secondary'}
size={'small'}
component={ProjectPresenter}
iconWithEmojii={tracker.component.IconWithEmojii}
defaultIcon={tracker.icon.Home}
/>
<ObjectBox
_class={tracker.class.IssueTemplate}
value={templateId}
docQuery={{
space: _space
}}
on:change={handleTemplateChange}
kind={'secondary'}
size={'small'}
label={tracker.string.NoIssueTemplate}
icon={tracker.icon.IssueTemplates}
searchField={'title'}
allowDeselect={true}
showNavigate={false}
docProps={{ disabled: true }}
focusIndex={20000}
/>
</svelte:fragment>
<svelte:fragment slot="title" let:label>
<div class="flex-row-center gap-1">
<div class="mr-2">
<Label {label} />
</div>
{#if relatedTo}
<div class="lower mr-2">
<Label label={tracker.string.RelatedTo} />
</div>
<Component
is={view.component.ObjectPresenter}
props={{
value: relatedTo,
_class: relatedTo._class,
objectId: relatedTo._id,
inline: true,
shouldShowAvatar: false,
noUnderline: true
}}
/>
{/if}
</div>
</svelte:fragment>
<svelte:fragment slot="subheader">
{#if parentIssue}
<ParentIssue issue={parentIssue} on:close={clearParentIssue} />
{/if}
</svelte:fragment>
<div id="issue-name" class="m-3 clear-mins">
<EditBox
focusIndex={1}
bind:value={object.title}
placeholder={tracker.string.IssueTitlePlaceholder}
kind={'large-style'}
autoFocus
fullSize
/>
</div>
<div id="issue-description">
{#key [objectId, appliedTemplateId]}
<AttachmentStyledBox
bind:this={descriptionBox}
focusIndex={2}
objectId={object._id}
{shouldSaveDraft}
_class={tracker.class.Issue}
space={_space}
alwaysEdit
showButtons={false}
kind={'indented'}
isScrollable={false}
enableBackReferences={true}
enableAttachments={false}
bind:content={object.description}
placeholder={tracker.string.IssueDescriptionPlaceholder}
on:changeSize={() => dispatch('changeContent')}
on:attach={(ev) => {
if (ev.detail.action === 'saved') {
object.attachments = ev.detail.value
}
}}
on:attachments={(ev) => {
if (ev.detail.size > 0) attachments = ev.detail.values
else if (ev.detail.size === 0 && ev.detail.values) {
attachments.clear()
attachments = attachments
}
}}
/>
{/key}
</div>
<SubIssues
bind:this={subIssuesComponent}
projectId={_space}
project={currentProject}
milestone={object.milestone}
component={object.component}
bind:subIssues={object.subIssues}
/>
<svelte:fragment slot="pool">
<div id="status-editor">
<StatusEditor
focusIndex={3}
value={object}
kind={'secondary'}
size={'large'}
defaultIssueStatus={currentProject?.defaultIssueStatus}
shouldShowLabel={true}
short
on:refocus={() => {
manager.setFocusPos(3)
}}
on:change={({ detail }) => {
if (object.status !== detail) {
object.status = detail
}
}}
/>
</div>
<div id="priority-editor">
<PriorityEditor
focusIndex={4}
value={object}
shouldShowLabel
isEditable
kind={'secondary'}
size={'large'}
justify="center"
on:change={({ detail }) => {
object.priority = detail
manager.setFocusPos(4)
}}
/>
</div>
<div id="assignee-editor">
<AssigneeEditor
focusIndex={5}
{object}
kind={'secondary'}
size={'large'}
short
on:change={({ detail }) => {
isAssigneeTouched = true
object.assignee = detail
manager.setFocusPos(5)
}}
/>
</div>
<Component
is={tags.component.TagsDropdownEditor}
props={{
focusIndex: 6,
items: object.labels,
key,
targetClass: tracker.class.Issue,
countLabel: tracker.string.NumberLabels,
kind: 'secondary',
size: 'large'
}}
on:open={(evt) => {
addTagRef(evt.detail)
}}
on:delete={(evt) => {
object.labels = object.labels.filter((it) => it._id !== evt.detail)
}}
/>
<ComponentSelector
focusIndex={8}
value={object.component}
onChange={handleComponentIdChanged}
isEditable={true}
kind={'secondary'}
size={'large'}
short
/>
<div id="estimation-editor" class="new-line">
<EstimationEditor focusIndex={7} kind={'secondary'} size={'large'} value={object} />
</div>
<div id="milestone-editor" class="new-line">
<MilestoneSelector
focusIndex={9}
value={object.milestone}
onChange={handleMilestoneIdChanged}
useComponent={(!originalIssue && object.component) || undefined}
kind={'secondary'}
size={'large'}
short
/>
</div>
<div id="duedate-editor" class="new-line">
<DatePresenter
bind:value={object.dueDate}
labelNull={tracker.string.DueDate}
kind={'secondary'}
size={'large'}
editable
/>
</div>
<div id="parentissue-editor" class="new-line">
<Button
icon={tracker.icon.Parent}
label={object.parentIssue ? tracker.string.RemoveParent : tracker.string.SetParent}
kind={'secondary'}
size={'large'}
notSelected={object.parentIssue === undefined}
on:click={object.parentIssue ? clearParentIssue : setParentIssue}
/>
</div>
</svelte:fragment>
<svelte:fragment slot="attachments">
{#if attachments.size > 0}
{#each Array.from(attachments.values()) as attachment}
<AttachmentPresenter
value={attachment}
showPreview
removable
on:remove={(result) => {
if (result.detail !== undefined) descriptionBox.removeAttachmentById(result.detail._id)
}}
/>
{/each}
{/if}
</svelte:fragment>
<svelte:fragment slot="footer">
<Button
focusIndex={10}
icon={IconAttachment}
iconProps={{ fill: 'var(--theme-dark-color)' }}
size={'large'}
kind={'transparent'}
on:click={() => {
descriptionBox.attach()
}}
/>
</svelte:fragment>
</Card>