mirror of
https://github.com/hcengineering/platform.git
synced 2024-11-22 11:42:30 +03:00
TSK-462 Allow sub issue drafts (#2823)
Signed-off-by: Denis Bykhov <bykhov.denis@gmail.com>
This commit is contained in:
parent
a259f7c9af
commit
c37502077c
@ -43,7 +43,7 @@ export async function updateAttribute (
|
||||
export function getAttribute (client: Client, object: any, key: KeyedAttribute): any {
|
||||
// Check if attr is mixin and return it's value
|
||||
if (client.getHierarchy().isMixin(key.attr.attributeOf)) {
|
||||
return object[key.attr.attributeOf]?.[key.key]
|
||||
return (client.getHierarchy().as(object, key.attr.attributeOf) as any)[key.key]
|
||||
} else {
|
||||
return object[key.key]
|
||||
}
|
||||
|
@ -1,72 +1,116 @@
|
||||
import { getCurrentAccount } from '@hcengineering/core'
|
||||
import { fetchMetadataLocalStorage, setMetadataLocalStorage } from '@hcengineering/ui'
|
||||
import { deepEqual } from 'fast-equals'
|
||||
import { get, writable } from 'svelte/store'
|
||||
import presentation from './plugin'
|
||||
|
||||
/**
|
||||
* @public
|
||||
*/
|
||||
// eslint-disable-next-line
|
||||
export const draftStore = writable<Record<string, any>>(fetchMetadataLocalStorage(presentation.metadata.Draft) || {})
|
||||
export const draftsStore = writable<Record<string, any>>(fetchMetadataLocalStorage(presentation.metadata.Draft) ?? {})
|
||||
window.addEventListener('storage', storageHandler)
|
||||
const saveInterval = 200
|
||||
|
||||
/**
|
||||
* @public
|
||||
*/
|
||||
export function updateDraftStore (id: string, draft: any): void {
|
||||
draftStore.update((drafts) => {
|
||||
if (draft !== undefined) {
|
||||
drafts[id] = draft
|
||||
} else {
|
||||
// eslint-disable-next-line @typescript-eslint/no-dynamic-delete
|
||||
delete drafts[id]
|
||||
}
|
||||
setMetadataLocalStorage(presentation.metadata.Draft, drafts)
|
||||
return drafts
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* @public
|
||||
*/
|
||||
export function updateUserDraft (id: string, draft: any): void {
|
||||
const me = getCurrentAccount()._id
|
||||
draftStore.update((drafts) => {
|
||||
// eslint-disable-next-line @typescript-eslint/strict-boolean-expressions
|
||||
const userDrafts: Record<string, any> = drafts[me] || {}
|
||||
userDrafts[id] = draft
|
||||
drafts[me] = userDrafts
|
||||
setMetadataLocalStorage(presentation.metadata.Draft, drafts)
|
||||
return drafts
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* @public
|
||||
*/
|
||||
export function getUserDraft (id: string): any {
|
||||
const me = getCurrentAccount()._id
|
||||
const drafts: Record<string, any> = get(draftStore)
|
||||
// eslint-disable-next-line @typescript-eslint/strict-boolean-expressions
|
||||
const userDrafts: Record<string, any> = drafts[me] || {}
|
||||
const draft: Record<string, any> = userDrafts[id]
|
||||
return draft
|
||||
}
|
||||
|
||||
/**
|
||||
* @public
|
||||
*/
|
||||
export function isUserDraftExists (id: string): boolean {
|
||||
const me = getCurrentAccount()._id
|
||||
const drafts: Record<string, any> = get(draftStore)
|
||||
const userDrafts: Record<string, any> = drafts[me]
|
||||
// eslint-disable-next-line @typescript-eslint/strict-boolean-expressions
|
||||
if (!userDrafts) {
|
||||
return false
|
||||
function storageHandler (evt: StorageEvent): void {
|
||||
if (evt.storageArea !== localStorage) return
|
||||
if (evt.key !== presentation.metadata.Draft) return
|
||||
if (evt.newValue !== null) {
|
||||
draftsStore.set(JSON.parse(evt.newValue))
|
||||
}
|
||||
const draftRecord: Record<string, any> = userDrafts[id]
|
||||
// eslint-disable-next-line @typescript-eslint/strict-boolean-expressions
|
||||
if (!draftRecord) {
|
||||
return false
|
||||
}
|
||||
|
||||
function isEmptyDraft<T> (object: T, emptyObj: Partial<T> | undefined): boolean {
|
||||
for (const key in object) {
|
||||
if (key === '_id') continue
|
||||
const value = object[key]
|
||||
let res: boolean = false
|
||||
if (Array.isArray(value)) {
|
||||
res = value.length > 0
|
||||
if (res && emptyObj != null) {
|
||||
res = !deepEqual(value, emptyObj[key])
|
||||
}
|
||||
} else {
|
||||
res = value != null
|
||||
if (res && typeof value === 'string') {
|
||||
res = value.trim() !== ''
|
||||
}
|
||||
if (res && typeof value === 'number') {
|
||||
res = value !== 0
|
||||
}
|
||||
if (res && emptyObj != null) {
|
||||
res = !deepEqual(value, emptyObj[key])
|
||||
}
|
||||
}
|
||||
if (res) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
export class DraftController<T> {
|
||||
private timer: number | undefined = undefined
|
||||
constructor (private readonly id: string) {}
|
||||
|
||||
static remove (id: string): void {
|
||||
const drafts = get(draftsStore)
|
||||
// eslint-disable-next-line @typescript-eslint/no-dynamic-delete
|
||||
delete drafts[id]
|
||||
draftsStore.set(drafts)
|
||||
setMetadataLocalStorage(presentation.metadata.Draft, drafts)
|
||||
}
|
||||
|
||||
static save<T>(id: string, object: T, emptyObj: Partial<T> | undefined = undefined): void {
|
||||
const drafts = get(draftsStore)
|
||||
if (emptyObj !== undefined && isEmptyDraft(object, emptyObj)) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-dynamic-delete
|
||||
delete drafts[id]
|
||||
} else {
|
||||
drafts[id] = object
|
||||
}
|
||||
draftsStore.set(drafts)
|
||||
setMetadataLocalStorage(presentation.metadata.Draft, drafts)
|
||||
}
|
||||
|
||||
get (): T | undefined {
|
||||
const drafts = get(draftsStore)
|
||||
const res = drafts[this.id]
|
||||
return res
|
||||
}
|
||||
|
||||
save (object: T, emptyObj: Partial<T> | undefined = undefined): void {
|
||||
const drafts = get(draftsStore)
|
||||
if (emptyObj !== undefined && isEmptyDraft(object, emptyObj)) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-dynamic-delete
|
||||
delete drafts[this.id]
|
||||
} else {
|
||||
drafts[this.id] = object
|
||||
}
|
||||
draftsStore.set(drafts)
|
||||
setMetadataLocalStorage(presentation.metadata.Draft, drafts)
|
||||
}
|
||||
|
||||
private update (object: T, emptyObj: Partial<T> | undefined): void {
|
||||
this.timer = window.setTimeout(() => {
|
||||
this.save(object, emptyObj)
|
||||
this.update(object, emptyObj)
|
||||
}, saveInterval)
|
||||
}
|
||||
|
||||
unsubscribe (): void {
|
||||
if (this?.timer !== undefined) {
|
||||
clearTimeout(this.timer)
|
||||
}
|
||||
}
|
||||
|
||||
watch (object: T, emptyObj: Partial<T> | undefined = undefined): void {
|
||||
this.unsubscribe()
|
||||
this.save(object, emptyObj)
|
||||
this.update(object, emptyObj)
|
||||
}
|
||||
|
||||
remove (): void {
|
||||
this.unsubscribe()
|
||||
const drafts = get(draftsStore)
|
||||
// eslint-disable-next-line @typescript-eslint/no-dynamic-delete
|
||||
delete drafts[this.id]
|
||||
setMetadataLocalStorage(presentation.metadata.Draft, drafts)
|
||||
draftsStore.set(drafts)
|
||||
}
|
||||
}
|
||||
|
@ -178,7 +178,7 @@
|
||||
</Scroller>
|
||||
{#if showCommenInput}
|
||||
<div class="ref-input">
|
||||
<Component is={chunter.component.CommentInput} props={{ object, shouldSaveDraft: true }} />
|
||||
<Component is={chunter.component.CommentInput} props={{ object }} />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@ -213,7 +213,7 @@
|
||||
</div>
|
||||
{#if showCommenInput}
|
||||
<div class="ref-input">
|
||||
<Component is={chunter.component.CommentInput} props={{ object, shouldSaveDraft: true }} />
|
||||
<Component is={chunter.component.CommentInput} props={{ object }} />
|
||||
</div>
|
||||
{/if}
|
||||
<div class="p-activity select-text" id={activity.string.Activity}>
|
||||
|
@ -13,7 +13,7 @@
|
||||
// limitations under the License.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { createQuery, getClient, draftStore, updateDraftStore } from '@hcengineering/presentation'
|
||||
import { createQuery, DraftController, draftsStore, getClient } from '@hcengineering/presentation'
|
||||
import { ReferenceInput } from '@hcengineering/text-editor'
|
||||
import type { RefAction } from '@hcengineering/text-editor'
|
||||
import { deleteFile, uploadFile } from '../utils'
|
||||
@ -50,6 +50,9 @@
|
||||
const client = getClient()
|
||||
const query = createQuery()
|
||||
|
||||
const draftKey = `${objectId}_attachments`
|
||||
const draftController = new DraftController<Record<Ref<Attachment>, Attachment>>(draftKey)
|
||||
|
||||
let draftAttachments: Record<Ref<Attachment>, Attachment> | undefined = undefined
|
||||
let originalAttachments: Set<Ref<Attachment>> = new Set<Ref<Attachment>>()
|
||||
const newAttachments: Set<Ref<Attachment>> = new Set<Ref<Attachment>>()
|
||||
@ -60,7 +63,7 @@
|
||||
$: objectId && updateAttachments(objectId)
|
||||
|
||||
async function updateAttachments (objectId: Ref<Doc>) {
|
||||
draftAttachments = $draftStore[objectId]
|
||||
draftAttachments = $draftsStore[draftKey]
|
||||
if (draftAttachments && shouldSaveDraft) {
|
||||
attachments.clear()
|
||||
newAttachments.clear()
|
||||
@ -87,9 +90,9 @@
|
||||
}
|
||||
|
||||
async function saveDraft () {
|
||||
if (objectId && shouldSaveDraft) {
|
||||
if (shouldSaveDraft) {
|
||||
draftAttachments = Object.fromEntries(attachments)
|
||||
updateDraftStore(objectId, draftAttachments)
|
||||
draftController.save(draftAttachments)
|
||||
}
|
||||
}
|
||||
|
||||
@ -180,9 +183,7 @@
|
||||
})
|
||||
|
||||
export function removeDraft (removeFiles: boolean) {
|
||||
if (objectId) {
|
||||
updateDraftStore(objectId, undefined)
|
||||
}
|
||||
draftController.remove()
|
||||
if (removeFiles) {
|
||||
newAttachments.forEach(async (p) => {
|
||||
const attachment = attachments.get(p)
|
||||
|
@ -16,7 +16,7 @@
|
||||
import { Attachment } from '@hcengineering/attachment'
|
||||
import { Account, Class, Doc, generateId, Ref, Space, toIdMap } from '@hcengineering/core'
|
||||
import { IntlString, setPlatformStatus, unknownError } from '@hcengineering/platform'
|
||||
import { createQuery, getClient, draftStore, updateDraftStore } from '@hcengineering/presentation'
|
||||
import { createQuery, DraftController, draftsStore, getClient } from '@hcengineering/presentation'
|
||||
import { StyledTextBox } from '@hcengineering/text-editor'
|
||||
import { IconSize } from '@hcengineering/ui'
|
||||
import { createEventDispatcher, onDestroy } from 'svelte'
|
||||
@ -41,6 +41,9 @@
|
||||
export let shouldSaveDraft: boolean = false
|
||||
export let useAttachmentPreview = false
|
||||
|
||||
let draftKey = objectId ? `${objectId}_attachments` : undefined
|
||||
$: draftKey = objectId ? `${objectId}_attachments` : undefined
|
||||
|
||||
const dispatch = createEventDispatcher()
|
||||
|
||||
export function focus (): void {
|
||||
@ -75,10 +78,10 @@
|
||||
const newAttachments: Set<Ref<Attachment>> = new Set<Ref<Attachment>>()
|
||||
const removedAttachments: Set<Attachment> = new Set<Attachment>()
|
||||
|
||||
$: objectId && updateAttachments(objectId)
|
||||
$: objectId && draftKey && updateAttachments(objectId, draftKey)
|
||||
|
||||
async function updateAttachments (objectId: Ref<Doc>) {
|
||||
draftAttachments = $draftStore[objectId]
|
||||
async function updateAttachments (objectId: Ref<Doc>, draftKey: string) {
|
||||
draftAttachments = $draftsStore[draftKey]
|
||||
if (draftAttachments && shouldSaveDraft) {
|
||||
attachments.clear()
|
||||
newAttachments.clear()
|
||||
@ -105,9 +108,9 @@
|
||||
}
|
||||
|
||||
async function saveDraft () {
|
||||
if (objectId && shouldSaveDraft) {
|
||||
if (draftKey && shouldSaveDraft) {
|
||||
draftAttachments = Object.fromEntries(attachments)
|
||||
updateDraftStore(objectId, draftAttachments)
|
||||
DraftController.save(draftKey, draftAttachments)
|
||||
}
|
||||
}
|
||||
|
||||
@ -202,8 +205,8 @@
|
||||
})
|
||||
|
||||
export function removeDraft (removeFiles: boolean) {
|
||||
if (objectId) {
|
||||
updateDraftStore(objectId, undefined)
|
||||
if (draftKey) {
|
||||
DraftController.remove(draftKey)
|
||||
}
|
||||
if (removeFiles) {
|
||||
newAttachments.forEach(async (p) => {
|
||||
|
@ -15,68 +15,46 @@
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { Comment } from '@hcengineering/chunter'
|
||||
import { Doc, generateId, Ref } from '@hcengineering/core'
|
||||
import { getClient, draftStore, updateDraftStore } from '@hcengineering/presentation'
|
||||
import { AttachedData, Doc, generateId, Ref } from '@hcengineering/core'
|
||||
import { DraftController, draftsStore, getClient } from '@hcengineering/presentation'
|
||||
import { AttachmentRefInput } from '@hcengineering/attachment-resources'
|
||||
import { createBacklinks } from '../backlinks'
|
||||
import chunter from '../plugin'
|
||||
import { onDestroy } from 'svelte'
|
||||
|
||||
export let object: Doc
|
||||
export let shouldSaveDraft: boolean = false
|
||||
export let shouldSaveDraft: boolean = true
|
||||
|
||||
const client = getClient()
|
||||
const _class = chunter.class.Comment
|
||||
|
||||
let _id: Ref<Comment> = generateId()
|
||||
let inputContent: string = ''
|
||||
type CommentDraft = AttachedData<Comment> & { _id: Ref<Comment> }
|
||||
|
||||
const draftKey = `${object._id}_comment`
|
||||
const draftController = new DraftController<CommentDraft>(draftKey)
|
||||
|
||||
const empty = {
|
||||
message: '<p></p>',
|
||||
attachments: 0
|
||||
}
|
||||
|
||||
let commentInputBox: AttachmentRefInput
|
||||
let draftComment: Comment | undefined = undefined
|
||||
let saveTimer: number | undefined
|
||||
const draftComment = shouldSaveDraft ? $draftsStore[draftKey] : undefined
|
||||
let comment: CommentDraft = draftComment ?? getDefault()
|
||||
let _id: Ref<Comment> = comment._id
|
||||
let inputContent: string = comment.message
|
||||
|
||||
$: updateDraft(object)
|
||||
$: updateCommentFromDraft(draftComment)
|
||||
|
||||
async function updateDraft (object: Doc) {
|
||||
if (!shouldSaveDraft) {
|
||||
return
|
||||
}
|
||||
draftComment = $draftStore[object._id]
|
||||
if (!draftComment) {
|
||||
_id = generateId()
|
||||
}
|
||||
if (shouldSaveDraft) {
|
||||
draftController.watch(comment, empty)
|
||||
}
|
||||
|
||||
async function updateCommentFromDraft (draftComment: Comment | undefined) {
|
||||
if (!shouldSaveDraft) {
|
||||
return
|
||||
onDestroy(draftController.unsubscribe)
|
||||
|
||||
function getDefault (): CommentDraft {
|
||||
return {
|
||||
_id: generateId(),
|
||||
...empty
|
||||
}
|
||||
inputContent = draftComment ? draftComment.message : ''
|
||||
_id = draftComment ? draftComment._id : _id
|
||||
}
|
||||
|
||||
function commentIsEmpty (message: string, attachments: number): boolean {
|
||||
return (message === '<p></p>' || message === '') && !(attachments > 0)
|
||||
}
|
||||
|
||||
async function saveDraft (object: Doc) {
|
||||
updateDraftStore(object._id, draftComment)
|
||||
}
|
||||
|
||||
async function handleCommentUpdate (message: string, attachments: number) {
|
||||
if (commentIsEmpty(message, attachments)) {
|
||||
draftComment = undefined
|
||||
saveDraft(object)
|
||||
_id = generateId()
|
||||
return
|
||||
}
|
||||
if (!draftComment) {
|
||||
draftComment = createDraftFromObject()
|
||||
}
|
||||
draftComment.message = message
|
||||
draftComment.attachments = attachments
|
||||
|
||||
await commentInputBox.createAttachments()
|
||||
saveDraft(object)
|
||||
}
|
||||
|
||||
async function onUpdate (event: CustomEvent) {
|
||||
@ -84,28 +62,16 @@
|
||||
return
|
||||
}
|
||||
const { message, attachments } = event.detail
|
||||
if (saveTimer) {
|
||||
clearTimeout(saveTimer)
|
||||
if (comment) {
|
||||
comment.message = message
|
||||
comment.attachments = message
|
||||
} else {
|
||||
comment = {
|
||||
_id,
|
||||
message,
|
||||
attachments
|
||||
}
|
||||
}
|
||||
saveTimer = setTimeout(() => {
|
||||
handleCommentUpdate(message, attachments)
|
||||
}, 200)
|
||||
}
|
||||
|
||||
function createDraftFromObject () {
|
||||
const newDraft: Comment = {
|
||||
_id,
|
||||
_class: chunter.class.Comment,
|
||||
space: object.space,
|
||||
modifiedOn: Date.now(),
|
||||
modifiedBy: object.modifiedBy,
|
||||
attachedTo: object._id,
|
||||
attachedToClass: object._class,
|
||||
collection: 'comments',
|
||||
message: '',
|
||||
attachments: 0
|
||||
}
|
||||
return newDraft
|
||||
}
|
||||
|
||||
let loading = false
|
||||
@ -128,8 +94,8 @@
|
||||
|
||||
// Remove draft from Local Storage
|
||||
_id = generateId()
|
||||
draftComment = undefined
|
||||
await saveDraft(object)
|
||||
comment = getDefault()
|
||||
draftController.remove()
|
||||
commentInputBox.removeDraft(false)
|
||||
loading = false
|
||||
}
|
||||
|
@ -89,7 +89,7 @@
|
||||
]
|
||||
|
||||
let matches: Person[] = []
|
||||
$: findPerson(client, { ...object, name: combineName(firstName, lastName) }, channels).then((p) => {
|
||||
$: findPerson(client, combineName(firstName, lastName), channels).then((p) => {
|
||||
matches = p
|
||||
})
|
||||
|
||||
|
@ -69,7 +69,7 @@
|
||||
|
||||
let matches: WithLookup<Organization>[] = []
|
||||
let matchedChannels: AttachedData<Channel>[] = []
|
||||
$: findContacts(client, contact.class.Organization, { ...object, name: object.name }, channels).then((p) => {
|
||||
$: findContacts(client, contact.class.Organization, object.name, channels).then((p) => {
|
||||
matches = p.contacts as Organization[]
|
||||
matchedChannels = p.channels
|
||||
})
|
||||
|
@ -62,7 +62,7 @@
|
||||
let channels: AttachedData<Channel>[] = []
|
||||
|
||||
let matches: Person[] = []
|
||||
$: findPerson(client, { ...object, name: combineName(firstName, lastName) }, channels).then((p) => {
|
||||
$: findPerson(client, combineName(firstName, lastName), channels).then((p) => {
|
||||
matches = p
|
||||
})
|
||||
|
||||
|
@ -13,7 +13,7 @@
|
||||
// limitations under the License.
|
||||
//
|
||||
|
||||
import { AttachedData, Class, Client, Data, Doc, FindResult, Ref } from '@hcengineering/core'
|
||||
import { AttachedData, Class, Client, Doc, FindResult, Ref } from '@hcengineering/core'
|
||||
import { IconSize } from '@hcengineering/ui'
|
||||
import { MD5 } from 'crypto-js'
|
||||
import { Channel, Contact, contactPlugin, Employee, Person } from '.'
|
||||
@ -83,10 +83,10 @@ export async function checkHasGravatar (gravatarId: string, fetch?: typeof windo
|
||||
export async function findContacts (
|
||||
client: Client,
|
||||
_class: Ref<Class<Doc>>,
|
||||
person: Data<Contact>,
|
||||
name: string,
|
||||
channels: AttachedData<Channel>[]
|
||||
): Promise<{ contacts: Contact[], channels: AttachedData<Channel>[] }> {
|
||||
if (channels.length === 0 && person.name.length === 0) {
|
||||
if (channels.length === 0 && name.length === 0) {
|
||||
return { contacts: [], channels: [] }
|
||||
}
|
||||
// Take only first part of first name for match.
|
||||
@ -103,8 +103,8 @@ export async function findContacts (
|
||||
|
||||
if (potentialContactIds.length === 0) {
|
||||
if (client.getHierarchy().isDerived(_class, contactPlugin.class.Person)) {
|
||||
const firstName = getFirstName(person.name).split(' ').shift() ?? ''
|
||||
const lastName = getLastName(person.name)
|
||||
const firstName = getFirstName(name).split(' ').shift() ?? ''
|
||||
const lastName = getLastName(name)
|
||||
// try match using just first/last name
|
||||
potentialContactIds = (
|
||||
await client.findAll(
|
||||
@ -119,7 +119,7 @@ export async function findContacts (
|
||||
} else if (client.getHierarchy().isDerived(_class, contactPlugin.class.Organization)) {
|
||||
// try match using just first/last name
|
||||
potentialContactIds = (
|
||||
await client.findAll(contactPlugin.class.Contact, { name: { $like: `${person.name}` } }, { limit: 100 })
|
||||
await client.findAll(contactPlugin.class.Contact, { name: { $like: `${name}` } }, { limit: 100 })
|
||||
).map((it) => it._id)
|
||||
if (potentialContactIds.length === 0) {
|
||||
return { contacts: [], channels: [] }
|
||||
@ -143,7 +143,7 @@ export async function findContacts (
|
||||
const resChannels: AttachedData<Channel>[] = []
|
||||
for (const c of potentialPersons) {
|
||||
let matches = 0
|
||||
if (c.name === person.name) {
|
||||
if (c.name === name) {
|
||||
matches++
|
||||
}
|
||||
for (const ch of (c.$lookup?.channels as Channel[]) ?? []) {
|
||||
@ -167,12 +167,8 @@ export async function findContacts (
|
||||
/**
|
||||
* @public
|
||||
*/
|
||||
export async function findPerson (
|
||||
client: Client,
|
||||
person: Data<Person>,
|
||||
channels: AttachedData<Channel>[]
|
||||
): Promise<Person[]> {
|
||||
const result = await findContacts(client, contactPlugin.class.Person, person, channels)
|
||||
export async function findPerson (client: Client, name: string, channels: AttachedData<Channel>[]): Promise<Person[]> {
|
||||
const result = await findContacts(client, contactPlugin.class.Person, name, channels)
|
||||
return result.contacts as Person[]
|
||||
}
|
||||
|
||||
|
@ -146,15 +146,12 @@
|
||||
let matches: WithLookup<Contact>[] = []
|
||||
let matchedChannels: AttachedData<Channel>[] = []
|
||||
$: if (targetClass !== undefined) {
|
||||
findContacts(
|
||||
client,
|
||||
targetClass._id,
|
||||
{ ...object, name: formatName(targetClass._id, firstName, lastName, object.name) },
|
||||
channels
|
||||
).then((p) => {
|
||||
matches = p.contacts
|
||||
matchedChannels = p.channels
|
||||
})
|
||||
findContacts(client, targetClass._id, formatName(targetClass._id, firstName, lastName, object.name), channels).then(
|
||||
(p) => {
|
||||
matches = p.contacts
|
||||
matchedChannels = p.channels
|
||||
}
|
||||
)
|
||||
}
|
||||
</script>
|
||||
|
||||
|
@ -28,7 +28,6 @@
|
||||
"eslint": "^8.26.0",
|
||||
"prettier": "^2.7.1",
|
||||
"svelte-check": "^2.8.0",
|
||||
"@types/deep-equal": "^1.0.1",
|
||||
"typescript": "^4.3.5"
|
||||
},
|
||||
"dependencies": {
|
||||
@ -43,7 +42,6 @@
|
||||
"@hcengineering/contact": "^0.6.11",
|
||||
"@hcengineering/login": "^0.6.1",
|
||||
"@hcengineering/workbench": "^0.6.2",
|
||||
"deep-equal": "^2.0.5",
|
||||
"@hcengineering/panel": "^0.6.1",
|
||||
"@hcengineering/activity": "^0.6.0",
|
||||
"@hcengineering/attachment": "^0.6.1",
|
||||
|
@ -34,13 +34,13 @@
|
||||
import presentation, {
|
||||
Card,
|
||||
createQuery,
|
||||
DraftController,
|
||||
draftsStore,
|
||||
getClient,
|
||||
getUserDraft,
|
||||
InlineAttributeBar,
|
||||
KeyedAttribute,
|
||||
MessageBox,
|
||||
PDFViewer,
|
||||
updateUserDraft
|
||||
PDFViewer
|
||||
} from '@hcengineering/presentation'
|
||||
import type { Candidate, CandidateDraft } from '@hcengineering/recruit'
|
||||
import { recognizeDocument } from '@hcengineering/rekoni'
|
||||
@ -55,27 +55,36 @@
|
||||
IconFile as FileIcon,
|
||||
IconInfo,
|
||||
Label,
|
||||
Link,
|
||||
showPopup,
|
||||
Spinner
|
||||
} from '@hcengineering/ui'
|
||||
import deepEqual from 'deep-equal'
|
||||
import { createEventDispatcher } from 'svelte'
|
||||
import { createEventDispatcher, onDestroy } from 'svelte'
|
||||
import recruit from '../plugin'
|
||||
import FileUpload from './icons/FileUpload.svelte'
|
||||
import YesNo from './YesNo.svelte'
|
||||
|
||||
export let shouldSaveDraft: boolean = false
|
||||
export let onDraftChanged: () => void
|
||||
|
||||
const draft: CandidateDraft | undefined = shouldSaveDraft ? getUserDraft(recruit.mixin.Candidate) : undefined
|
||||
const emptyObject = {
|
||||
title: undefined,
|
||||
city: '',
|
||||
avatar: undefined,
|
||||
onsite: undefined,
|
||||
remote: undefined
|
||||
const draftController = new DraftController<CandidateDraft>(recruit.mixin.Candidate)
|
||||
|
||||
function getEmptyCandidate (): CandidateDraft {
|
||||
return {
|
||||
_id: generateId(),
|
||||
firstName: '',
|
||||
lastName: '',
|
||||
title: '',
|
||||
channels: [],
|
||||
skills: [],
|
||||
city: ''
|
||||
}
|
||||
}
|
||||
const empty = {}
|
||||
const client = getClient()
|
||||
const hierarchy = client.getHierarchy()
|
||||
const ignoreKeys = ['onsite', 'remote']
|
||||
|
||||
const draft = shouldSaveDraft ? draftController.get() : undefined
|
||||
let object = draft ?? getEmptyCandidate()
|
||||
type resumeFile = {
|
||||
name: string
|
||||
uuid: string
|
||||
@ -83,12 +92,7 @@
|
||||
type: string
|
||||
lastModified: number
|
||||
}
|
||||
|
||||
let candidateId = draft ? draft.candidateId : generateId()
|
||||
let firstName = draft?.firstName || ''
|
||||
let lastName = draft?.lastName || ''
|
||||
let createMore: boolean = false
|
||||
let saveTimer: number | undefined
|
||||
|
||||
export function canClose (): boolean {
|
||||
return true
|
||||
@ -96,34 +100,24 @@
|
||||
|
||||
let avatarEditor: EditableAvatar
|
||||
|
||||
function toCandidate (draft: CandidateDraft | undefined): Candidate {
|
||||
if (!draft) {
|
||||
return emptyObject as Candidate
|
||||
}
|
||||
return {
|
||||
title: draft?.title || '',
|
||||
city: draft?.city || '',
|
||||
onsite: draft?.onsite,
|
||||
remote: draft?.remote
|
||||
} as Candidate
|
||||
}
|
||||
const client = getClient()
|
||||
const hierarchy = client.getHierarchy()
|
||||
|
||||
const object: Candidate = toCandidate(draft)
|
||||
fillDefaults(hierarchy, empty, recruit.mixin.Candidate)
|
||||
fillDefaults(hierarchy, object, recruit.mixin.Candidate)
|
||||
|
||||
function resumeDraft () {
|
||||
return {
|
||||
uuid: draft?.resumeUuid,
|
||||
name: draft?.resumeName,
|
||||
size: draft?.resumeSize,
|
||||
type: draft?.resumeType,
|
||||
lastModified: draft?.resumeLastModified
|
||||
uuid: object?.resumeUuid,
|
||||
name: object?.resumeName,
|
||||
size: object?.resumeSize,
|
||||
type: object?.resumeType,
|
||||
lastModified: object?.resumeLastModified
|
||||
}
|
||||
}
|
||||
|
||||
let resume = resumeDraft() as resumeFile
|
||||
if (shouldSaveDraft) {
|
||||
draftController.watch(object, empty)
|
||||
}
|
||||
|
||||
onDestroy(() => draftController.unsubscribe())
|
||||
|
||||
const dispatch = createEventDispatcher()
|
||||
|
||||
@ -132,12 +126,10 @@
|
||||
let dragover = false
|
||||
|
||||
let avatar: File | undefined = draft?.avatar
|
||||
let channels: AttachedData<Channel>[] = draft?.channels || []
|
||||
|
||||
let matches: WithLookup<Person>[] = []
|
||||
let matchedChannels: AttachedData<Channel>[] = []
|
||||
|
||||
let skills: TagReference[] = draft?.skills || []
|
||||
const key: KeyedAttribute = {
|
||||
key: 'skills',
|
||||
attr: client.getHierarchy().getAttribute(recruit.mixin.Candidate, 'skills')
|
||||
@ -164,89 +156,11 @@
|
||||
})
|
||||
})
|
||||
|
||||
$: updateDraft(object, firstName, lastName, avatar, channels, skills, resume)
|
||||
|
||||
async function updateDraft (...param: any) {
|
||||
if (saveTimer) {
|
||||
clearTimeout(saveTimer)
|
||||
}
|
||||
saveTimer = setTimeout(() => {
|
||||
saveDraft()
|
||||
}, 200)
|
||||
}
|
||||
|
||||
async function saveDraft () {
|
||||
if (!shouldSaveDraft) {
|
||||
return
|
||||
}
|
||||
|
||||
let newDraft: Data<CandidateDraft> | undefined = createDraftFromObject()
|
||||
const isEmpty = await isDraftEmpty(newDraft)
|
||||
|
||||
if (isEmpty) {
|
||||
newDraft = undefined
|
||||
}
|
||||
|
||||
updateUserDraft(recruit.mixin.Candidate, newDraft)
|
||||
|
||||
if (onDraftChanged) {
|
||||
return onDraftChanged()
|
||||
}
|
||||
}
|
||||
|
||||
function createDraftFromObject () {
|
||||
const newDraft: Data<CandidateDraft> = {
|
||||
candidateId: candidateId as Ref<Candidate>,
|
||||
firstName,
|
||||
lastName,
|
||||
title: object.title,
|
||||
city: object.city,
|
||||
resumeUuid: resume?.uuid,
|
||||
resumeName: resume?.name,
|
||||
resumeType: resume?.type,
|
||||
resumeSize: resume?.size,
|
||||
resumeLastModified: resume?.lastModified,
|
||||
avatar,
|
||||
channels,
|
||||
onsite: object.onsite,
|
||||
remote: object.remote,
|
||||
skills
|
||||
}
|
||||
|
||||
return newDraft
|
||||
}
|
||||
|
||||
async function isDraftEmpty (draft: Data<CandidateDraft>): Promise<boolean> {
|
||||
const emptyDraft: Partial<CandidateDraft> = {
|
||||
firstName: '',
|
||||
lastName: '',
|
||||
title: undefined,
|
||||
city: '',
|
||||
resumeUuid: undefined,
|
||||
resumeName: undefined,
|
||||
resumeType: undefined,
|
||||
resumeSize: undefined,
|
||||
resumeLastModified: undefined,
|
||||
avatar: undefined,
|
||||
channels: [],
|
||||
onsite: undefined,
|
||||
remote: undefined,
|
||||
skills: []
|
||||
}
|
||||
|
||||
for (const key of Object.keys(emptyDraft)) {
|
||||
if (!deepEqual((emptyDraft as any)[key], (draft as any)[key])) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
async function createCandidate () {
|
||||
const candidate: Data<Person> = {
|
||||
name: combineName(firstName, lastName),
|
||||
name: combineName(object.firstName ?? '', object.lastName ?? ''),
|
||||
city: object.city,
|
||||
channels: 0,
|
||||
createOn: Date.now()
|
||||
}
|
||||
if (avatar !== undefined) {
|
||||
@ -255,36 +169,44 @@
|
||||
const candidateData: MixinData<Person, Candidate> = {
|
||||
title: object.title,
|
||||
onsite: object.onsite,
|
||||
remote: object.remote
|
||||
remote: object.remote,
|
||||
skills: 0
|
||||
}
|
||||
|
||||
// Store all extra values.
|
||||
for (const [k, v] of Object.entries(object)) {
|
||||
if (v != null && k !== 'createOn' && k !== 'avatar') {
|
||||
if (client.getHierarchy().getAttribute(recruit.mixin.Candidate, k).attributeOf === recruit.mixin.Candidate) {
|
||||
;(candidateData as any)[k] = v
|
||||
const attr = hierarchy.findAttribute(recruit.mixin.Candidate, k)
|
||||
if (attr === undefined) continue
|
||||
if (attr.attributeOf === recruit.mixin.Candidate) {
|
||||
if ((candidateData as any)[k] === undefined) {
|
||||
;(candidateData as any)[k] = v
|
||||
}
|
||||
} else {
|
||||
;(candidate as any)[k] = v
|
||||
if ((candidate as any)[k] === undefined) {
|
||||
;(candidate as any)[k] = v
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const applyOps = client.apply(candidateId)
|
||||
const applyOps = client.apply(object._id)
|
||||
|
||||
await applyOps.createDoc(contact.class.Person, contact.space.Contacts, candidate, candidateId)
|
||||
await applyOps.createDoc(contact.class.Person, contact.space.Contacts, candidate, object._id)
|
||||
await applyOps.createMixin(
|
||||
candidateId as Ref<Person>,
|
||||
object._id,
|
||||
contact.class.Person,
|
||||
contact.space.Contacts,
|
||||
recruit.mixin.Candidate,
|
||||
candidateData
|
||||
)
|
||||
|
||||
if (resume.uuid !== undefined) {
|
||||
if (object.resumeUuid !== undefined) {
|
||||
const resume = resumeDraft() as resumeFile
|
||||
applyOps.addCollection(
|
||||
attachment.class.Attachment,
|
||||
contact.space.Contacts,
|
||||
candidateId,
|
||||
object._id,
|
||||
contact.class.Person,
|
||||
'attachments',
|
||||
{
|
||||
@ -296,11 +218,11 @@
|
||||
}
|
||||
)
|
||||
}
|
||||
for (const channel of channels) {
|
||||
for (const channel of object.channels) {
|
||||
await applyOps.addCollection(
|
||||
contact.class.Channel,
|
||||
contact.space.Contacts,
|
||||
candidateId,
|
||||
object._id,
|
||||
contact.class.Person,
|
||||
'channels',
|
||||
{
|
||||
@ -313,9 +235,9 @@
|
||||
const categories = await client.findAll(tags.class.TagCategory, { targetClass: recruit.mixin.Candidate })
|
||||
// Tag elements
|
||||
const skillTagElements = toIdMap(
|
||||
await client.findAll(tags.class.TagElement, { _id: { $in: skills.map((it) => it.tag) } })
|
||||
await client.findAll(tags.class.TagElement, { _id: { $in: object.skills.map((it) => it.tag) } })
|
||||
)
|
||||
for (const skill of skills) {
|
||||
for (const skill of object.skills) {
|
||||
// Create update tag if missing
|
||||
if (!skillTagElements.has(skill.tag)) {
|
||||
skill.tag = await client.createDoc(tags.class.TagElement, skill.space, {
|
||||
@ -326,7 +248,7 @@
|
||||
category: findTagCategory(skill.title, categories)
|
||||
})
|
||||
}
|
||||
await applyOps.addCollection(skill._class, skill.space, candidateId, recruit.mixin.Candidate, 'skills', {
|
||||
await applyOps.addCollection(skill._class, skill.space, object._id, recruit.mixin.Candidate, 'skills', {
|
||||
title: skill.title,
|
||||
color: skill.color,
|
||||
tag: skill.tag,
|
||||
@ -335,12 +257,11 @@
|
||||
}
|
||||
|
||||
await applyOps.commit()
|
||||
|
||||
draftController.remove()
|
||||
if (!createMore) {
|
||||
dispatch('close', candidateId)
|
||||
dispatch('close', object._id)
|
||||
}
|
||||
resetObject()
|
||||
saveDraft()
|
||||
}
|
||||
|
||||
function isUndef (value?: string): boolean {
|
||||
@ -373,12 +294,12 @@
|
||||
object.title = doc.title
|
||||
}
|
||||
|
||||
if (isUndef(firstName) && doc.firstName !== undefined) {
|
||||
firstName = doc.firstName
|
||||
if (isUndef(object.firstName) && doc.firstName !== undefined) {
|
||||
object.firstName = doc.firstName
|
||||
}
|
||||
|
||||
if (isUndef(lastName) && doc.lastName !== undefined) {
|
||||
lastName = doc.lastName
|
||||
if (isUndef(object.lastName) && doc.lastName !== undefined) {
|
||||
object.lastName = doc.lastName
|
||||
}
|
||||
|
||||
if (isUndef(object.city) && doc.city !== undefined) {
|
||||
@ -396,7 +317,7 @@
|
||||
avatar = new File([u8arr], doc.avatarName ?? 'avatar.png', { type: doc.avatarFormat ?? 'image/png' })
|
||||
}
|
||||
|
||||
const newChannels = [...channels]
|
||||
const newChannels = [...object.channels]
|
||||
addChannel(newChannels, contact.channelProvider.Email, doc.email)
|
||||
addChannel(newChannels, contact.channelProvider.GitHub, doc.github)
|
||||
addChannel(newChannels, contact.channelProvider.LinkedIn, doc.linkedin)
|
||||
@ -404,7 +325,7 @@
|
||||
addChannel(newChannels, contact.channelProvider.Telegram, doc.telegram)
|
||||
addChannel(newChannels, contact.channelProvider.Twitter, doc.twitter)
|
||||
addChannel(newChannels, contact.channelProvider.Facebook, doc.facebook)
|
||||
channels = newChannels
|
||||
object.channels = newChannels
|
||||
|
||||
// Create skills
|
||||
await elementsPromise
|
||||
@ -448,16 +369,16 @@
|
||||
)
|
||||
)
|
||||
}
|
||||
skills = [...skills, ...newSkills]
|
||||
object.skills = [...object.skills, ...newSkills]
|
||||
} catch (err: any) {
|
||||
console.error(err)
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteResume (): Promise<void> {
|
||||
if (resume.uuid) {
|
||||
if (object.resumeUuid) {
|
||||
try {
|
||||
await deleteFile(resume.uuid)
|
||||
await deleteFile(object.resumeUuid)
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
}
|
||||
@ -469,11 +390,11 @@
|
||||
try {
|
||||
const uploadFile = await getResource(attachment.helper.UploadFile)
|
||||
|
||||
resume.uuid = await uploadFile(file)
|
||||
resume.name = file.name
|
||||
resume.size = file.size
|
||||
resume.type = file.type
|
||||
resume.lastModified = file.lastModified
|
||||
object.resumeUuid = await uploadFile(file)
|
||||
object.resumeName = file.name
|
||||
object.resumeSize = file.size
|
||||
object.resumeType = file.type
|
||||
object.resumeLastModified = file.lastModified
|
||||
|
||||
await recognize(file)
|
||||
} catch (err: any) {
|
||||
@ -501,8 +422,8 @@
|
||||
}
|
||||
|
||||
function addTagRef (tag: TagElement): void {
|
||||
skills = [
|
||||
...skills,
|
||||
object.skills = [
|
||||
...object.skills,
|
||||
{
|
||||
_class: tags.class.TagReference,
|
||||
_id: generateId() as Ref<TagReference>,
|
||||
@ -519,45 +440,34 @@
|
||||
]
|
||||
}
|
||||
|
||||
$: findContacts(
|
||||
client,
|
||||
contact.class.Person,
|
||||
{ ...object, name: combineName(firstName.trim(), lastName.trim()) },
|
||||
channels
|
||||
).then((p) => {
|
||||
matches = p.contacts
|
||||
matchedChannels = p.channels
|
||||
})
|
||||
$: object.firstName &&
|
||||
object.lastName &&
|
||||
findContacts(
|
||||
client,
|
||||
contact.class.Person,
|
||||
combineName(object.firstName.trim(), object.lastName.trim()),
|
||||
object.channels
|
||||
).then((p) => {
|
||||
matches = p.contacts
|
||||
matchedChannels = p.channels
|
||||
})
|
||||
|
||||
const manager = createFocusManager()
|
||||
|
||||
function resetObject (): void {
|
||||
candidateId = generateId()
|
||||
avatar = undefined
|
||||
firstName = ''
|
||||
lastName = ''
|
||||
channels = []
|
||||
skills = []
|
||||
resume = {} as resumeFile
|
||||
object.title = undefined
|
||||
object.city = ''
|
||||
object.avatar = undefined
|
||||
object.onsite = undefined
|
||||
object.remote = undefined
|
||||
object = getEmptyCandidate()
|
||||
fillDefaults(hierarchy, object, recruit.mixin.Candidate)
|
||||
}
|
||||
|
||||
export async function onOutsideClick () {
|
||||
saveDraft()
|
||||
|
||||
if (onDraftChanged) {
|
||||
return onDraftChanged()
|
||||
if (shouldSaveDraft) {
|
||||
draftController.save(object, empty)
|
||||
}
|
||||
}
|
||||
|
||||
async function showConfirmationDialog () {
|
||||
const newDraft = createDraftFromObject()
|
||||
const isFormEmpty = await isDraftEmpty(newDraft)
|
||||
draftController.save(object, empty)
|
||||
const isFormEmpty = $draftsStore[recruit.mixin.Candidate] === undefined
|
||||
|
||||
if (isFormEmpty) {
|
||||
dispatch('close')
|
||||
@ -574,7 +484,7 @@
|
||||
dispatch('close')
|
||||
deleteResume()
|
||||
resetObject()
|
||||
saveDraft()
|
||||
draftController.remove()
|
||||
}
|
||||
}
|
||||
)
|
||||
@ -587,7 +497,8 @@
|
||||
<Card
|
||||
label={recruit.string.CreateTalent}
|
||||
okAction={createCandidate}
|
||||
canSave={!loading && (firstName.length > 0 || lastName.length > 0 || channels.length > 0)}
|
||||
canSave={!loading &&
|
||||
((object.firstName?.length ?? 0) > 0 || (object.lastName?.length ?? 0) > 0 || object.channels.length > 0)}
|
||||
on:close={() => {
|
||||
dispatch('close')
|
||||
}}
|
||||
@ -609,7 +520,7 @@
|
||||
<EditBox
|
||||
disabled={loading}
|
||||
placeholder={recruit.string.PersonFirstNamePlaceholder}
|
||||
bind:value={firstName}
|
||||
bind:value={object.firstName}
|
||||
kind={'large-style'}
|
||||
focus
|
||||
maxWidth={'30rem'}
|
||||
@ -618,7 +529,7 @@
|
||||
<EditBox
|
||||
disabled={loading}
|
||||
placeholder={recruit.string.PersonLastNamePlaceholder}
|
||||
bind:value={lastName}
|
||||
bind:value={object.lastName}
|
||||
maxWidth={'30rem'}
|
||||
kind={'large-style'}
|
||||
focusIndex={2}
|
||||
@ -646,9 +557,9 @@
|
||||
<EditableAvatar
|
||||
disabled={loading}
|
||||
bind:this={avatarEditor}
|
||||
bind:direct={avatar}
|
||||
avatar={object.avatar}
|
||||
id={candidateId}
|
||||
bind:direct={object.avatar}
|
||||
avatar={undefined}
|
||||
id={object._id}
|
||||
size={'large'}
|
||||
/>
|
||||
</div>
|
||||
@ -657,7 +568,7 @@
|
||||
<ChannelsDropdown
|
||||
editable={!loading}
|
||||
focusIndex={10}
|
||||
bind:value={channels}
|
||||
bind:value={object.channels}
|
||||
highlighted={matchedChannels.map((it) => it.provider)}
|
||||
/>
|
||||
<YesNo
|
||||
@ -679,7 +590,7 @@
|
||||
props={{
|
||||
disabled: loading,
|
||||
focusIndex: 102,
|
||||
items: skills,
|
||||
items: object.skills,
|
||||
key,
|
||||
targetClass: recruit.mixin.Candidate,
|
||||
showTitle: false,
|
||||
@ -691,10 +602,10 @@
|
||||
addTagRef(evt.detail)
|
||||
}}
|
||||
on:delete={(evt) => {
|
||||
skills = skills.filter((it) => it._id !== evt.detail)
|
||||
object.skills = object.skills.filter((it) => it._id !== evt.detail)
|
||||
}}
|
||||
/>
|
||||
{#if skills.length > 0}
|
||||
{#if object.skills.length > 0}
|
||||
<div class="flex-break" />
|
||||
<div class="antiComponent antiEmphasized flex-grow mt-2">
|
||||
<Component
|
||||
@ -702,7 +613,7 @@
|
||||
props={{
|
||||
disabled: loading,
|
||||
focusIndex: 102,
|
||||
items: skills,
|
||||
items: object.skills,
|
||||
key,
|
||||
targetClass: recruit.mixin.Candidate,
|
||||
showTitle: false,
|
||||
@ -714,11 +625,11 @@
|
||||
addTagRef(evt.detail)
|
||||
}}
|
||||
on:delete={(evt) => {
|
||||
skills = skills.filter((it) => it._id !== evt.detail)
|
||||
object.skills = object.skills.filter((it) => it._id !== evt.detail)
|
||||
}}
|
||||
on:change={(evt) => {
|
||||
evt.detail.tag.weight = evt.detail.weight
|
||||
skills = skills
|
||||
object.skills = object.skills
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
@ -728,7 +639,7 @@
|
||||
_class={recruit.mixin.Candidate}
|
||||
{object}
|
||||
toClass={contact.class.Contact}
|
||||
ignoreKeys={['onsite', 'remote']}
|
||||
{ignoreKeys}
|
||||
extraProps={{ showNavigate: false }}
|
||||
/>
|
||||
</div>
|
||||
@ -737,7 +648,7 @@
|
||||
<svelte:fragment slot="footer">
|
||||
<div
|
||||
class="flex-center resume"
|
||||
class:solid={dragover || resume.uuid}
|
||||
class:solid={dragover || object.resumeUuid}
|
||||
on:dragover|preventDefault={() => {
|
||||
dragover = true
|
||||
}}
|
||||
@ -746,12 +657,12 @@
|
||||
}}
|
||||
on:drop|preventDefault|stopPropagation={drop}
|
||||
>
|
||||
{#if loading && resume.uuid}
|
||||
<Link label={recruit.string.Parsing} icon={Spinner} disabled />
|
||||
{#if loading && object.resumeUuid}
|
||||
<Button label={recruit.string.Parsing} kind="link" icon={Spinner} disabled />
|
||||
{:else}
|
||||
{#if loading}
|
||||
<Link label={recruit.string.Uploading} icon={Spinner} disabled />
|
||||
{:else if resume.uuid}
|
||||
<Button label={recruit.string.Uploading} kind="link" icon={Spinner} disabled />
|
||||
{:else if object.resumeUuid}
|
||||
<Button
|
||||
disabled={loading}
|
||||
kind={'transparent'}
|
||||
@ -760,13 +671,13 @@
|
||||
on:click={() => {
|
||||
showPopup(
|
||||
PDFViewer,
|
||||
{ file: resume.uuid, name: resume.name },
|
||||
resume.type.startsWith('image/') ? 'centered' : 'float'
|
||||
{ file: object.resumeUuid, name: object.resumeName },
|
||||
object.resumeType?.startsWith('image/') ? 'centered' : 'float'
|
||||
)
|
||||
}}
|
||||
>
|
||||
<svelte:fragment slot="content">
|
||||
<span class="overflow-label disabled">{resume.name}</span>
|
||||
<span class="overflow-label disabled">{object.resumeName}</span>
|
||||
</svelte:fragment>
|
||||
</Button>
|
||||
{:else}
|
||||
|
@ -13,19 +13,15 @@
|
||||
// limitations under the License.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { isUserDraftExists } from '@hcengineering/presentation'
|
||||
import { draftsStore } from '@hcengineering/presentation'
|
||||
import { Button, showPopup } from '@hcengineering/ui'
|
||||
import recruit from '../plugin'
|
||||
import CreateCandidate from './CreateCandidate.svelte'
|
||||
|
||||
let draftExists: boolean = isUserDraftExists(recruit.mixin.Candidate)
|
||||
|
||||
const handleDraftChanged = () => {
|
||||
draftExists = isUserDraftExists(recruit.mixin.Candidate)
|
||||
}
|
||||
$: draftExists = $draftsStore[recruit.mixin.Candidate]
|
||||
|
||||
async function newCandidate (): Promise<void> {
|
||||
showPopup(CreateCandidate, { shouldSaveDraft: true, onDraftChanged: handleDraftChanged }, 'top')
|
||||
showPopup(CreateCandidate, { shouldSaveDraft: true }, 'top')
|
||||
}
|
||||
</script>
|
||||
|
||||
|
@ -18,9 +18,9 @@ import type { Channel, Organization, Person } from '@hcengineering/contact'
|
||||
import type { AttachedData, AttachedDoc, Class, Doc, Mixin, Ref, Space, Timestamp } from '@hcengineering/core'
|
||||
import type { Asset, Plugin, Resource } from '@hcengineering/platform'
|
||||
import { plugin } from '@hcengineering/platform'
|
||||
import { TagReference } from '@hcengineering/tags'
|
||||
import type { KanbanTemplateSpace, SpaceWithStates, Task } from '@hcengineering/task'
|
||||
import { AnyComponent, ResolvedLocation } from '@hcengineering/ui'
|
||||
import { TagReference } from '@hcengineering/tags'
|
||||
|
||||
/**
|
||||
* @public
|
||||
@ -63,8 +63,8 @@ export interface Candidate extends Person {
|
||||
/**
|
||||
* @public
|
||||
*/
|
||||
export interface CandidateDraft extends Doc {
|
||||
candidateId: Ref<Candidate>
|
||||
export interface CandidateDraft {
|
||||
_id: Ref<Candidate>
|
||||
firstName?: string
|
||||
lastName?: string
|
||||
title?: string
|
||||
|
@ -28,7 +28,6 @@
|
||||
"eslint": "^8.26.0",
|
||||
"prettier": "^2.7.1",
|
||||
"svelte-check": "^2.8.0",
|
||||
"@types/deep-equal": "^1.0.1",
|
||||
"typescript": "^4.3.5"
|
||||
},
|
||||
"dependencies": {
|
||||
|
@ -16,32 +16,22 @@
|
||||
import { AttachmentStyledBox } from '@hcengineering/attachment-resources'
|
||||
import chunter from '@hcengineering/chunter'
|
||||
import { Employee } from '@hcengineering/contact'
|
||||
import core, {
|
||||
Account,
|
||||
AttachedData,
|
||||
Data,
|
||||
Doc,
|
||||
fillDefaults,
|
||||
generateId,
|
||||
Ref,
|
||||
SortingOrder
|
||||
} from '@hcengineering/core'
|
||||
import core, { Account, AttachedData, Doc, fillDefaults, generateId, Ref, SortingOrder } from '@hcengineering/core'
|
||||
import { getResource, translate } from '@hcengineering/platform'
|
||||
import {
|
||||
Card,
|
||||
createQuery,
|
||||
DraftController,
|
||||
draftsStore,
|
||||
getClient,
|
||||
getUserDraft,
|
||||
KeyedAttribute,
|
||||
MessageBox,
|
||||
SpaceSelector,
|
||||
updateUserDraft
|
||||
SpaceSelector
|
||||
} from '@hcengineering/presentation'
|
||||
import tags, { TagElement, TagReference } from '@hcengineering/tags'
|
||||
import {
|
||||
calcRank,
|
||||
Component as ComponentType,
|
||||
DraftIssueChild,
|
||||
Issue,
|
||||
IssueDraft,
|
||||
IssuePriority,
|
||||
@ -65,7 +55,7 @@
|
||||
} from '@hcengineering/ui'
|
||||
import view from '@hcengineering/view'
|
||||
import { ObjectBox } from '@hcengineering/view-resources'
|
||||
import { deepEqual } from 'fast-equals'
|
||||
import { onDestroy } from 'svelte'
|
||||
import { createEventDispatcher } from 'svelte'
|
||||
import { activeComponent, activeSprint, generateIssueShortLink, getIssueId, updateIssueRelation } from '../issues'
|
||||
import tracker from '../plugin'
|
||||
@ -91,82 +81,105 @@
|
||||
export let shouldSaveDraft: boolean = false
|
||||
export let parentIssue: Issue | undefined
|
||||
export let originalIssue: Issue | undefined
|
||||
export let onDraftChanged: () => void
|
||||
|
||||
const draft: IssueDraft | undefined = shouldSaveDraft ? getUserDraft(tracker.class.IssueDraft) : undefined
|
||||
const draftController = new DraftController<any>(tracker.ids.IssueDraft)
|
||||
|
||||
const draft: IssueDraft | undefined = shouldSaveDraft ? draftController.get() : undefined
|
||||
const client = getClient()
|
||||
const hierarchy = client.getHierarchy()
|
||||
const parentQuery = createQuery()
|
||||
let _space = space
|
||||
|
||||
let object = draft ?? getDefaultObject()
|
||||
|
||||
$: if (object.parentIssue) {
|
||||
parentQuery.query(
|
||||
tracker.class.Issue,
|
||||
{
|
||||
_id: object.parentIssue
|
||||
},
|
||||
(res) => {
|
||||
;[parentIssue] = res
|
||||
}
|
||||
)
|
||||
} else {
|
||||
parentQuery.unsubscribe()
|
||||
parentIssue = undefined
|
||||
}
|
||||
|
||||
function getDefaultObject (ignoreOriginal = false): IssueDraft {
|
||||
const base: IssueDraft = {
|
||||
_id: generateId(),
|
||||
title: '',
|
||||
description: '',
|
||||
priority,
|
||||
space: _space,
|
||||
component,
|
||||
dueDate: null,
|
||||
attachments: 0,
|
||||
estimation: 0,
|
||||
sprint,
|
||||
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 labels: TagReference[] = draft?.labels || []
|
||||
let objectId: Ref<Issue> = draft?.issueId || generateId()
|
||||
let saveTimer: number | undefined
|
||||
let currentProject: Project | undefined
|
||||
|
||||
function toIssue (initials: AttachedData<Issue>, draft: IssueDraft | undefined): AttachedData<Issue> {
|
||||
if (draft === undefined) {
|
||||
return { ...initials }
|
||||
}
|
||||
const { labels, subIssues, ...issue } = draft
|
||||
return { ...initials, ...issue }
|
||||
}
|
||||
$: updateIssueStatusId(object, currentProject)
|
||||
$: updateAssigneeId(object, currentProject)
|
||||
$: canSave = getTitle(object.title ?? '').length > 0 && object.status !== undefined
|
||||
|
||||
const defaultIssue = {
|
||||
title: '',
|
||||
description: '',
|
||||
assignee,
|
||||
$: empty = {
|
||||
assignee: assignee ?? currentProject?.defaultAssignee,
|
||||
status: status ?? currentProject?.defaultIssueStatus,
|
||||
parentIssue: parentIssue?._id,
|
||||
description: '<p></p>',
|
||||
component,
|
||||
sprint,
|
||||
number: 0,
|
||||
rank: '',
|
||||
status: '' as Ref<IssueStatus>,
|
||||
priority,
|
||||
dueDate: null,
|
||||
comments: 0,
|
||||
attachments: 0,
|
||||
subIssues: 0,
|
||||
parents: [],
|
||||
reportedTime: 0,
|
||||
estimation: 0,
|
||||
reports: 0,
|
||||
childInfo: [],
|
||||
createOn: Date.now()
|
||||
space
|
||||
}
|
||||
|
||||
$: _space = draft?.project || space
|
||||
$: !originalIssue && !draft && updateIssueStatusId(currentProject, status)
|
||||
$: !originalIssue && !draft && updateAssigneeId(currentProject)
|
||||
$: canSave = getTitle(object.title ?? '').length > 0
|
||||
|
||||
$: if (object.space !== _space) {
|
||||
object.space = _space
|
||||
}
|
||||
|
||||
let object = originalIssue
|
||||
? {
|
||||
...originalIssue,
|
||||
title: `${originalIssue.title} (copy)`,
|
||||
subIssues: 0,
|
||||
attachments: 0,
|
||||
reportedTime: 0,
|
||||
reports: 0,
|
||||
childInfo: [],
|
||||
space: _space
|
||||
}
|
||||
: { ...toIssue(defaultIssue, draft), space: _space }
|
||||
fillDefaults(hierarchy, object, tracker.class.Issue)
|
||||
|
||||
function resetObject (): void {
|
||||
templateId = undefined
|
||||
template = undefined
|
||||
object = { ...defaultIssue, space: _space }
|
||||
subIssues = []
|
||||
labels = []
|
||||
if (!originalIssue && !draft) {
|
||||
updateIssueStatusId(currentProject, status)
|
||||
updateAssigneeId(currentProject)
|
||||
}
|
||||
object = getDefaultObject(true)
|
||||
fillDefaults(hierarchy, object, tracker.class.Issue)
|
||||
}
|
||||
|
||||
@ -176,8 +189,6 @@
|
||||
let template: IssueTemplate | undefined = undefined
|
||||
const templateQuery = createQuery()
|
||||
|
||||
let subIssues: DraftIssueChild[] = draft?.subIssues || []
|
||||
|
||||
$: if (templateId !== undefined) {
|
||||
templateQuery.query(tracker.class.IssueTemplate, { _id: templateId }, (res) => {
|
||||
template = res[0]
|
||||
@ -203,14 +214,22 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function updateObject (template: IssueTemplate): Promise<void> {
|
||||
async function updateTemplate (template: IssueTemplate): Promise<void> {
|
||||
if (object.template?.template === template._id) {
|
||||
return
|
||||
}
|
||||
const { _class, _id, space, children, comments, attachments, labels: labels_, description, ...templBase } = template
|
||||
const { _class, _id, space, children, comments, attachments, labels, description, ...templBase } = template
|
||||
|
||||
subIssues = template.children.map((p) => {
|
||||
return { ...p, status: currentProject?.defaultIssueStatus ?? ('' as Ref<IssueStatus>) }
|
||||
object.subIssues = template.children.map((p) => {
|
||||
return {
|
||||
...p,
|
||||
_id: p.id,
|
||||
space: _space,
|
||||
subIssues: [],
|
||||
dueDate: null,
|
||||
labels: [],
|
||||
status: currentProject?.defaultIssueStatus
|
||||
}
|
||||
})
|
||||
|
||||
object = {
|
||||
@ -222,17 +241,11 @@
|
||||
}
|
||||
}
|
||||
appliedTemplateId = templateId
|
||||
const tagElements = await client.findAll(tags.class.TagElement, { _id: { $in: labels_ } })
|
||||
labels = tagElements.map(tagAsRef)
|
||||
const tagElements = await client.findAll(tags.class.TagElement, { _id: { $in: labels } })
|
||||
object.labels = tagElements.map(tagAsRef)
|
||||
}
|
||||
|
||||
function updateTemplate (template?: IssueTemplate): void {
|
||||
if (template !== undefined) {
|
||||
updateObject(template)
|
||||
}
|
||||
}
|
||||
|
||||
$: updateTemplate(template)
|
||||
$: template && updateTemplate(template)
|
||||
|
||||
const dispatch = createEventDispatcher()
|
||||
const spaceQuery = createQuery()
|
||||
@ -248,81 +261,24 @@
|
||||
currentProject = res.shift()
|
||||
})
|
||||
|
||||
async function setPropsFromOriginalIssue () {
|
||||
if (!originalIssue) {
|
||||
return
|
||||
}
|
||||
const { _id, relations, parents } = originalIssue
|
||||
|
||||
if (relations?.[0]) {
|
||||
relatedTo = await client.findOne(tracker.class.Issue, { _id: relations[0]._id as Ref<Issue> })
|
||||
}
|
||||
if (parents?.[0]) {
|
||||
parentIssue = await client.findOne(tracker.class.Issue, { _id: parents[0].parentId })
|
||||
}
|
||||
if (originalIssue.labels) {
|
||||
labels = await client.findAll(tags.class.TagReference, { attachedTo: _id })
|
||||
}
|
||||
}
|
||||
|
||||
async function setPropsFromDraft () {
|
||||
if (!draft?.parentIssue) {
|
||||
return
|
||||
}
|
||||
|
||||
parentIssue = await client.findOne(tracker.class.Issue, { _id: draft.parentIssue as Ref<Issue> })
|
||||
}
|
||||
|
||||
$: originalIssue && setPropsFromOriginalIssue()
|
||||
$: draft && setPropsFromDraft()
|
||||
$: object && updateDraft()
|
||||
|
||||
async function updateDraft () {
|
||||
if (saveTimer) {
|
||||
clearTimeout(saveTimer)
|
||||
}
|
||||
saveTimer = setTimeout(() => {
|
||||
saveDraft()
|
||||
}, 200)
|
||||
}
|
||||
|
||||
async function saveDraft () {
|
||||
if (!shouldSaveDraft) {
|
||||
return
|
||||
}
|
||||
|
||||
let newDraft: Data<IssueDraft> | undefined = createDraftFromObject()
|
||||
const isEmpty = await isDraftEmpty(newDraft)
|
||||
|
||||
if (isEmpty) {
|
||||
newDraft = undefined
|
||||
}
|
||||
updateUserDraft(tracker.class.IssueDraft, newDraft)
|
||||
|
||||
if (onDraftChanged) {
|
||||
return onDraftChanged()
|
||||
}
|
||||
}
|
||||
|
||||
async function updateIssueStatusId (currentProject: Project | undefined, issueStatusId?: Ref<IssueStatus>) {
|
||||
if (issueStatusId !== undefined) {
|
||||
object.status = issueStatusId
|
||||
return
|
||||
}
|
||||
|
||||
if (currentProject?.defaultIssueStatus) {
|
||||
async function updateIssueStatusId (object: IssueDraft, currentProject: Project | undefined) {
|
||||
if (currentProject?.defaultIssueStatus && object.status === undefined) {
|
||||
object.status = currentProject.defaultIssueStatus
|
||||
}
|
||||
}
|
||||
|
||||
function updateAssigneeId (currentProject: Project | undefined) {
|
||||
if (currentProject?.defaultAssignee !== undefined) {
|
||||
object.assignee = currentProject.defaultAssignee
|
||||
} else {
|
||||
object.assignee = null
|
||||
function updateAssigneeId (object: IssueDraft, currentProject: Project | undefined) {
|
||||
if (object.assignee === undefined && currentProject !== undefined) {
|
||||
if (currentProject.defaultAssignee !== undefined) {
|
||||
object.assignee = currentProject.defaultAssignee
|
||||
} else {
|
||||
object.assignee = null
|
||||
}
|
||||
}
|
||||
}
|
||||
function clearParentIssue () {
|
||||
object.parentIssue = undefined
|
||||
parentQuery.unsubscribe()
|
||||
parentIssue = undefined
|
||||
}
|
||||
|
||||
@ -330,94 +286,24 @@
|
||||
return value.trim()
|
||||
}
|
||||
|
||||
async function isDraftEmpty (draft: Data<IssueDraft>): Promise<boolean> {
|
||||
const emptyDraft: Partial<IssueDraft> = {
|
||||
description: '',
|
||||
dueDate: null,
|
||||
estimation: 0,
|
||||
attachments: 0,
|
||||
labels: [],
|
||||
parentIssue: undefined,
|
||||
priority: 0,
|
||||
subIssues: [],
|
||||
template: undefined,
|
||||
title: ''
|
||||
}
|
||||
|
||||
for (const key of Object.keys(emptyDraft)) {
|
||||
if (!deepEqual((emptyDraft as any)[key], (draft as any)[key])) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
if (object.attachments && object.attachments > 0) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (draft.component && draft.component !== defaultIssue.component) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (draft.sprint && draft.sprint !== defaultIssue.sprint) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (draft.status === '') {
|
||||
return true
|
||||
}
|
||||
|
||||
if (currentProject?.defaultIssueStatus) {
|
||||
return draft.status === currentProject.defaultIssueStatus
|
||||
}
|
||||
|
||||
if (draft.assignee === null) {
|
||||
return true
|
||||
}
|
||||
|
||||
if (currentProject?.defaultAssignee) {
|
||||
return draft.assignee === currentProject.defaultAssignee
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
export function canClose (): boolean {
|
||||
return true
|
||||
}
|
||||
|
||||
function createDraftFromObject () {
|
||||
const newDraft: Data<IssueDraft> = {
|
||||
issueId: objectId,
|
||||
title: getTitle(object.title),
|
||||
description: (object.description as string).replaceAll('<p></p>', ''),
|
||||
assignee: object.assignee,
|
||||
component: object.component,
|
||||
sprint: object.sprint,
|
||||
status: object.status,
|
||||
priority: object.priority,
|
||||
dueDate: object.dueDate,
|
||||
estimation: object.estimation,
|
||||
template: object.template,
|
||||
attachments: object.attachments,
|
||||
labels,
|
||||
parentIssue: parentIssue?._id,
|
||||
project: _space,
|
||||
subIssues
|
||||
export async function onOutsideClick () {
|
||||
if (shouldSaveDraft) {
|
||||
draftController.save(object, empty)
|
||||
}
|
||||
|
||||
return newDraft
|
||||
}
|
||||
|
||||
export async function onOutsideClick () {
|
||||
saveDraft()
|
||||
|
||||
if (onDraftChanged) {
|
||||
return onDraftChanged()
|
||||
}
|
||||
$: watch(empty)
|
||||
function watch (empty: Record<string, any>): void {
|
||||
if (!shouldSaveDraft) return
|
||||
draftController.watch(object, empty)
|
||||
}
|
||||
|
||||
async function createIssue () {
|
||||
if (!canSave) {
|
||||
if (!canSave || object.status === undefined) {
|
||||
return
|
||||
}
|
||||
|
||||
@ -463,10 +349,10 @@
|
||||
parentIssue?._class ?? tracker.class.Issue,
|
||||
'subIssues',
|
||||
value,
|
||||
objectId
|
||||
object._id
|
||||
)
|
||||
for (const label of labels) {
|
||||
await client.addCollection(label._class, label.space, objectId, tracker.class.Issue, 'labels', {
|
||||
for (const label of object.labels) {
|
||||
await client.addCollection(label._class, label.space, object._id, tracker.class.Issue, 'labels', {
|
||||
title: label.title,
|
||||
color: label.color,
|
||||
tag: label.tag
|
||||
@ -475,7 +361,7 @@
|
||||
await descriptionBox.createAttachments()
|
||||
|
||||
if (relatedTo !== undefined) {
|
||||
const doc = await client.findOne(tracker.class.Issue, { _id: objectId })
|
||||
const doc = await client.findOne(tracker.class.Issue, { _id: object._id })
|
||||
if (doc !== undefined) {
|
||||
if (client.getHierarchy().isDerived(relatedTo._class, tracker.class.Issue)) {
|
||||
await updateIssueRelation(client, relatedTo as Issue, doc, 'relations', '$push')
|
||||
@ -487,21 +373,20 @@
|
||||
}
|
||||
const parents = parentIssue
|
||||
? [
|
||||
{ parentId: objectId, parentTitle: value.title },
|
||||
{ parentId: object._id, parentTitle: value.title },
|
||||
{ parentId: parentIssue._id, parentTitle: parentIssue.title },
|
||||
...parentIssue.parents
|
||||
]
|
||||
: [{ parentId: objectId, parentTitle: value.title }]
|
||||
: [{ parentId: object._id, parentTitle: value.title }]
|
||||
await subIssuesComponent.save(parents)
|
||||
addNotification(await translate(tracker.string.IssueCreated, {}), getTitle(object.title), IssueNotification, {
|
||||
issueId: objectId,
|
||||
issueId: object._id,
|
||||
subTitlePostfix: (await translate(tracker.string.Created, { value: 1 })).toLowerCase(),
|
||||
issueUrl: currentProject && generateIssueShortLink(getIssueId(currentProject, value as Issue))
|
||||
})
|
||||
|
||||
objectId = generateId()
|
||||
draftController.remove()
|
||||
resetObject()
|
||||
saveDraft()
|
||||
descriptionBox?.removeDraft(false)
|
||||
}
|
||||
|
||||
@ -529,7 +414,12 @@
|
||||
SetParentIssueActionPopup,
|
||||
{ value: { ...object, space: _space, attachedTo: parentIssue?._id } },
|
||||
'top',
|
||||
(selectedIssue) => selectedIssue !== undefined && (parentIssue = selectedIssue)
|
||||
(selectedIssue) => {
|
||||
if (selectedIssue !== undefined) {
|
||||
parentIssue = selectedIssue
|
||||
object.parentIssue = parentIssue?._id
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@ -553,7 +443,7 @@
|
||||
return
|
||||
}
|
||||
|
||||
object = { ...object, component: componentId }
|
||||
object.component = componentId
|
||||
}
|
||||
|
||||
const handleSprintIdChanged = async (sprintId: Ref<Sprint> | null | undefined) => {
|
||||
@ -566,11 +456,12 @@
|
||||
componentSprintId = sprint && sprint.component ? sprint.component : null
|
||||
} else componentSprintId = null
|
||||
|
||||
object = { ...object, sprint: sprintId, component: componentSprintId }
|
||||
object.sprint = sprintId
|
||||
object.component = componentSprintId
|
||||
}
|
||||
|
||||
function addTagRef (tag: TagElement): void {
|
||||
labels = [...labels, tagAsRef(tag)]
|
||||
object.labels = [...object.labels, tagAsRef(tag)]
|
||||
}
|
||||
function handleTemplateChange (evt: CustomEvent<Ref<IssueTemplate>>): void {
|
||||
if (templateId == null) {
|
||||
@ -590,7 +481,7 @@
|
||||
templateId = evt.detail ?? undefined
|
||||
|
||||
if (templateId === undefined) {
|
||||
subIssues = []
|
||||
object.subIssues = []
|
||||
resetObject()
|
||||
}
|
||||
}
|
||||
@ -599,11 +490,10 @@
|
||||
}
|
||||
|
||||
async function showConfirmationDialog () {
|
||||
const newDraft = createDraftFromObject()
|
||||
const isFormEmpty = await isDraftEmpty(newDraft)
|
||||
draftController.save(object, empty)
|
||||
const isFormEmpty = $draftsStore[tracker.ids.IssueDraft] === undefined
|
||||
|
||||
if (isFormEmpty) {
|
||||
console.log('isFormEmpty')
|
||||
dispatch('close')
|
||||
} else {
|
||||
showPopup(
|
||||
@ -617,13 +507,17 @@
|
||||
if (result === true) {
|
||||
dispatch('close')
|
||||
resetObject()
|
||||
saveDraft()
|
||||
draftController.remove()
|
||||
descriptionBox?.removeDraft(true)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
onDestroy(() => draftController.unsubscribe())
|
||||
|
||||
$: objectId = object._id
|
||||
</script>
|
||||
|
||||
<Card
|
||||
@ -675,35 +569,40 @@
|
||||
{#if parentIssue}
|
||||
<ParentIssue issue={parentIssue} on:close={clearParentIssue} />
|
||||
{/if}
|
||||
<EditBox bind:value={object.title} placeholder={tracker.string.IssueTitlePlaceholder} kind={'large-style'} focus />
|
||||
{#key [objectId, appliedTemplateId]}
|
||||
<AttachmentStyledBox
|
||||
bind:this={descriptionBox}
|
||||
{objectId}
|
||||
{shouldSaveDraft}
|
||||
_class={tracker.class.Issue}
|
||||
space={_space}
|
||||
alwaysEdit
|
||||
showButtons={false}
|
||||
emphasized
|
||||
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
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{/key}
|
||||
<div id="issue-name">
|
||||
<EditBox bind:value={object.title} placeholder={tracker.string.IssueTitlePlaceholder} kind={'large-style'} focus />
|
||||
</div>
|
||||
<div id="issue-description">
|
||||
{#key [objectId, appliedTemplateId]}
|
||||
<AttachmentStyledBox
|
||||
bind:this={descriptionBox}
|
||||
objectId={object._id}
|
||||
{shouldSaveDraft}
|
||||
_class={tracker.class.Issue}
|
||||
space={_space}
|
||||
alwaysEdit
|
||||
showButtons={false}
|
||||
emphasized
|
||||
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
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{/key}
|
||||
</div>
|
||||
<SubIssues
|
||||
bind:this={subIssuesComponent}
|
||||
projectId={_space}
|
||||
parent={objectId}
|
||||
parent={object._id}
|
||||
project={currentProject}
|
||||
sprint={object.sprint}
|
||||
component={object.component}
|
||||
bind:subIssues
|
||||
{shouldSaveDraft}
|
||||
bind:subIssues={object.subIssues}
|
||||
/>
|
||||
<svelte:fragment slot="pool">
|
||||
<div id="status-editor">
|
||||
@ -715,26 +614,30 @@
|
||||
on:change={({ detail }) => (object.status = detail)}
|
||||
/>
|
||||
</div>
|
||||
<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)}
|
||||
/>
|
||||
<div id="priority-editor">
|
||||
<PriorityEditor
|
||||
value={object}
|
||||
shouldShowLabel
|
||||
isEditable
|
||||
kind="no-border"
|
||||
size="small"
|
||||
justify="center"
|
||||
on:change={({ detail }) => (object.priority = detail)}
|
||||
/>
|
||||
</div>
|
||||
<div id="assignee-editor">
|
||||
<AssigneeEditor
|
||||
value={object}
|
||||
size="small"
|
||||
kind="no-border"
|
||||
width={'min-content'}
|
||||
on:change={({ detail }) => (object.assignee = detail)}
|
||||
/>
|
||||
</div>
|
||||
<Component
|
||||
is={tags.component.TagsDropdownEditor}
|
||||
props={{
|
||||
items: labels,
|
||||
items: object.labels,
|
||||
key,
|
||||
targetClass: tracker.class.Issue,
|
||||
countLabel: tracker.string.NumberLabels
|
||||
@ -743,10 +646,12 @@
|
||||
addTagRef(evt.detail)
|
||||
}}
|
||||
on:delete={(evt) => {
|
||||
labels = labels.filter((it) => it._id !== evt.detail)
|
||||
object.labels = object.labels.filter((it) => it._id !== evt.detail)
|
||||
}}
|
||||
/>
|
||||
<EstimationEditor kind={'no-border'} size={'small'} value={object} {currentProject} />
|
||||
<div id="estimation-editor">
|
||||
<EstimationEditor kind={'no-border'} size={'small'} value={object} />
|
||||
</div>
|
||||
<ComponentSelector value={object.component} onChange={handleComponentIdChanged} isEditable={true} />
|
||||
<SprintSelector
|
||||
value={object.sprint}
|
||||
@ -756,7 +661,7 @@
|
||||
{#if object.dueDate !== null}
|
||||
<DatePresenter bind:value={object.dueDate} editable />
|
||||
{/if}
|
||||
<ActionIcon icon={IconMoreH} size={'medium'} action={showMoreActions} />
|
||||
<div id="more-actions"><ActionIcon icon={IconMoreH} size={'medium'} action={showMoreActions} /></div>
|
||||
</svelte:fragment>
|
||||
<svelte:fragment slot="footer">
|
||||
<Button
|
||||
|
@ -14,7 +14,7 @@
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { Ref, Space } from '@hcengineering/core'
|
||||
import { getClient, isUserDraftExists } from '@hcengineering/presentation'
|
||||
import { draftsStore, getClient } from '@hcengineering/presentation'
|
||||
import { Button, showPopup } from '@hcengineering/ui'
|
||||
import tracker from '../plugin'
|
||||
import CreateIssue from './CreateIssue.svelte'
|
||||
@ -26,11 +26,8 @@
|
||||
let space: Ref<Space> | undefined
|
||||
$: updateSpace(currentSpace)
|
||||
|
||||
let draftExists: boolean = isUserDraftExists(tracker.class.IssueDraft)
|
||||
|
||||
const handleDraftChanged = () => {
|
||||
draftExists = isUserDraftExists(tracker.class.IssueDraft)
|
||||
}
|
||||
$: draftExists =
|
||||
$draftsStore[tracker.ids.IssueDraft] !== undefined || $draftsStore[tracker.ids.IssueDraftChild] !== undefined
|
||||
|
||||
async function updateSpace (spaceId: Ref<Space> | undefined): Promise<void> {
|
||||
if (spaceId !== undefined) {
|
||||
@ -48,7 +45,7 @@
|
||||
space = project?._id
|
||||
}
|
||||
|
||||
showPopup(CreateIssue, { space, shouldSaveDraft: true, onDraftChanged: handleDraftChanged }, 'top')
|
||||
showPopup(CreateIssue, { space, shouldSaveDraft: true }, 'top')
|
||||
}
|
||||
</script>
|
||||
|
||||
@ -61,6 +58,7 @@
|
||||
kind={'primary'}
|
||||
width={'100%'}
|
||||
on:click={newIssue}
|
||||
id="new-issue"
|
||||
>
|
||||
<div slot="content" class="draft-circle-container">
|
||||
{#if draftExists}
|
||||
|
@ -16,10 +16,10 @@
|
||||
import { AttachedData } from '@hcengineering/core'
|
||||
import { DatePopup } from '@hcengineering/ui'
|
||||
import { getClient } from '@hcengineering/presentation'
|
||||
import { Issue } from '@hcengineering/tracker'
|
||||
import { Issue, IssueDraft } from '@hcengineering/tracker'
|
||||
import { createEventDispatcher } from 'svelte'
|
||||
|
||||
export let value: Issue | AttachedData<Issue> | Issue[]
|
||||
export let value: Issue | AttachedData<Issue> | Issue[] | IssueDraft
|
||||
export let mondayStart = true
|
||||
export let withTime = false
|
||||
|
||||
@ -31,7 +31,7 @@
|
||||
|
||||
const vv = Array.isArray(value) ? value : [value]
|
||||
for (const docValue of vv) {
|
||||
if ('_id' in docValue && newDueDate !== undefined && newDueDate !== docValue.dueDate) {
|
||||
if ('_class' in docValue && newDueDate !== undefined && newDueDate !== docValue.dueDate) {
|
||||
await client.update(docValue, { dueDate: newDueDate })
|
||||
}
|
||||
}
|
||||
|
@ -15,13 +15,13 @@
|
||||
<script lang="ts">
|
||||
import { AttachedData, FindOptions, Ref, SortingOrder } from '@hcengineering/core'
|
||||
import { getClient, ObjectPopup } from '@hcengineering/presentation'
|
||||
import { calcRank, Issue } from '@hcengineering/tracker'
|
||||
import { calcRank, Issue, IssueDraft } from '@hcengineering/tracker'
|
||||
import { createEventDispatcher } from 'svelte'
|
||||
import tracker from '../plugin'
|
||||
import { getIssueId } from '../issues'
|
||||
import IssueStatusIcon from './issues/IssueStatusIcon.svelte'
|
||||
|
||||
export let value: Issue | AttachedData<Issue> | Issue[]
|
||||
export let value: Issue | AttachedData<Issue> | Issue[] | IssueDraft
|
||||
export let width: 'medium' | 'large' | 'full' = 'large'
|
||||
|
||||
const client = getClient()
|
||||
@ -38,7 +38,7 @@
|
||||
const vv = Array.isArray(value) ? value : [value]
|
||||
for (const docValue of vv) {
|
||||
if (
|
||||
'_id' in docValue &&
|
||||
'_class' in docValue &&
|
||||
parentIssue !== undefined &&
|
||||
parentIssue?._id !== docValue.attachedTo &&
|
||||
parentIssue?._id !== docValue._id
|
||||
|
@ -16,9 +16,9 @@
|
||||
import attachment, { Attachment } from '@hcengineering/attachment'
|
||||
import { deleteFile } from '@hcengineering/attachment-resources/src/utils'
|
||||
import core, { AttachedData, Ref, SortingOrder } from '@hcengineering/core'
|
||||
import { draftStore, getClient, updateDraftStore } from '@hcengineering/presentation'
|
||||
import { DraftController, draftsStore, getClient } from '@hcengineering/presentation'
|
||||
import tags from '@hcengineering/tags'
|
||||
import { calcRank, Component, DraftIssueChild, Issue, IssueParentInfo, Project, Sprint } from '@hcengineering/tracker'
|
||||
import { calcRank, Component, Issue, IssueDraft, IssueParentInfo, Project, Sprint } from '@hcengineering/tracker'
|
||||
import { Button, closeTooltip, ExpandCollapse, IconAdd, Scroller } from '@hcengineering/ui'
|
||||
import { onDestroy } from 'svelte'
|
||||
import tracker from '../plugin'
|
||||
@ -32,10 +32,11 @@
|
||||
export let project: Project | undefined
|
||||
export let sprint: Ref<Sprint> | null = null
|
||||
export let component: Ref<Component> | null = null
|
||||
export let subIssues: DraftIssueChild[] = []
|
||||
export let subIssues: IssueDraft[] = []
|
||||
export let shouldSaveDraft: boolean = false
|
||||
|
||||
let isCollapsed = false
|
||||
let isCreating = false
|
||||
let isCreating = $draftsStore[tracker.ids.IssueDraftChild] !== undefined
|
||||
|
||||
async function handleIssueSwap (ev: CustomEvent<{ fromIndex: number; toIndex: number }>) {
|
||||
if (subIssues) {
|
||||
@ -64,7 +65,7 @@
|
||||
},
|
||||
true
|
||||
)
|
||||
const childId: Ref<Issue> = subIssue.id as Ref<Issue>
|
||||
const childId = subIssue._id
|
||||
const cvalue: AttachedData<Issue> = {
|
||||
title: subIssue.title.trim(),
|
||||
description: subIssue.description,
|
||||
@ -72,7 +73,7 @@
|
||||
component: subIssue.component,
|
||||
sprint: subIssue.sprint,
|
||||
number: (incResult as any).object.sequence,
|
||||
status: subIssue.status,
|
||||
status: subIssue.status ?? project.defaultIssueStatus,
|
||||
priority: subIssue.priority,
|
||||
rank: calcRank(lastOne, undefined),
|
||||
comments: 0,
|
||||
@ -98,12 +99,11 @@
|
||||
)
|
||||
|
||||
if ((subIssue.labels?.length ?? 0) > 0) {
|
||||
const tagElements = await client.findAll(tags.class.TagElement, { _id: { $in: subIssue.labels } })
|
||||
for (const label of tagElements) {
|
||||
for (const label of subIssue.labels) {
|
||||
await client.addCollection(tags.class.TagReference, project._id, childId, tracker.class.Issue, 'labels', {
|
||||
title: label.title,
|
||||
color: label.color,
|
||||
tag: label._id
|
||||
tag: label.tag
|
||||
})
|
||||
}
|
||||
}
|
||||
@ -112,7 +112,7 @@
|
||||
}
|
||||
|
||||
async function saveAttachments (issue: Ref<Issue>) {
|
||||
const draftAttachments: Record<Ref<Attachment>, Attachment> | undefined = $draftStore[issue]
|
||||
const draftAttachments = $draftsStore[`${issue}_attachments`]
|
||||
if (draftAttachments) {
|
||||
for (const key in draftAttachments) {
|
||||
await saveAttachment(draftAttachments[key as Ref<Attachment>], issue)
|
||||
@ -133,7 +133,7 @@
|
||||
)
|
||||
}
|
||||
|
||||
export function load (value: DraftIssueChild[]) {
|
||||
export function load (value: IssueDraft[]) {
|
||||
subIssues = value
|
||||
}
|
||||
|
||||
@ -142,14 +142,14 @@
|
||||
onDestroy(() => {
|
||||
if (!saved) {
|
||||
subIssues.forEach((st) => {
|
||||
removeDraft(st.id, true)
|
||||
removeDraft(st._id, true)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
export async function removeDraft (_id: string, removeFiles: boolean = false): Promise<void> {
|
||||
const draftAttachments: Record<Ref<Attachment>, Attachment> | undefined = $draftStore[_id]
|
||||
updateDraftStore(_id, undefined)
|
||||
const draftAttachments = $draftsStore[`${_id}_attachments`]
|
||||
DraftController.remove(`${_id}_attachments`)
|
||||
if (removeFiles && draftAttachments) {
|
||||
for (const key in draftAttachments) {
|
||||
const attachment = draftAttachments[key as Ref<Attachment>]
|
||||
@ -215,6 +215,7 @@
|
||||
{project}
|
||||
{component}
|
||||
{sprint}
|
||||
{shouldSaveDraft}
|
||||
on:close={() => {
|
||||
isCreating = false
|
||||
}}
|
||||
|
@ -17,13 +17,13 @@
|
||||
import { AssigneeBox } from '@hcengineering/contact-resources'
|
||||
import { AttachedData, Ref } from '@hcengineering/core'
|
||||
import { getClient } from '@hcengineering/presentation'
|
||||
import { Issue, IssueTemplateData } from '@hcengineering/tracker'
|
||||
import { Issue, IssueDraft, IssueTemplateData } from '@hcengineering/tracker'
|
||||
import { ButtonKind, ButtonSize, TooltipAlignment } from '@hcengineering/ui'
|
||||
import { createEventDispatcher } from 'svelte'
|
||||
import tracker from '../../plugin'
|
||||
import { getPreviousAssignees } from '../../utils'
|
||||
|
||||
export let value: Issue | AttachedData<Issue> | IssueTemplateData
|
||||
export let value: Issue | AttachedData<Issue> | IssueTemplateData | IssueDraft
|
||||
export let size: ButtonSize = 'large'
|
||||
export let kind: ButtonKind = 'link'
|
||||
export let tooltipAlignment: TooltipAlignment | undefined = undefined
|
||||
@ -37,15 +37,16 @@
|
||||
let projectMembers: Ref<Employee>[] = []
|
||||
let members: Ref<Employee>[] = []
|
||||
|
||||
$: getPreviousAssignees(value).then((res) => {
|
||||
prevAssigned = res
|
||||
})
|
||||
$: '_class' in value &&
|
||||
getPreviousAssignees(value).then((res) => {
|
||||
prevAssigned = res
|
||||
})
|
||||
|
||||
function hasSpace (issue: Issue | AttachedData<Issue> | IssueTemplateData): issue is Issue {
|
||||
function hasSpace (issue: Issue | AttachedData<Issue> | IssueTemplateData | IssueDraft): issue is Issue {
|
||||
return (issue as Issue).space !== undefined
|
||||
}
|
||||
|
||||
async function updateComponentMembers (issue: Issue | AttachedData<Issue> | IssueTemplateData) {
|
||||
async function updateComponentMembers (issue: Issue | AttachedData<Issue> | IssueTemplateData | IssueDraft) {
|
||||
if (issue.component) {
|
||||
const component = await client.findOne(tracker.class.Component, { _id: issue.component })
|
||||
projectLead = component?.lead || undefined
|
||||
@ -76,7 +77,7 @@
|
||||
|
||||
dispatch('change', newAssignee)
|
||||
|
||||
if ('_id' in value) {
|
||||
if ('_class' in value) {
|
||||
await client.update(value, { assignee: newAssignee })
|
||||
}
|
||||
}
|
||||
|
@ -17,9 +17,9 @@
|
||||
import { AttachmentDocList } from '@hcengineering/attachment-resources'
|
||||
import chunter from '@hcengineering/chunter'
|
||||
import { CommentPopup } from '@hcengineering/chunter-resources'
|
||||
import { Ref, SortingOrder } from '@hcengineering/core'
|
||||
import { Ref } from '@hcengineering/core'
|
||||
import { createQuery, getClient, MessageViewer } from '@hcengineering/presentation'
|
||||
import { Issue, IssueStatus, Project } from '@hcengineering/tracker'
|
||||
import { Issue, Project } from '@hcengineering/tracker'
|
||||
import { Label, resizeObserver, Scroller } from '@hcengineering/ui'
|
||||
import tracker from '../../plugin'
|
||||
import AssigneeEditor from './AssigneeEditor.svelte'
|
||||
@ -34,7 +34,6 @@
|
||||
$: space = object.space
|
||||
const issueQuery = createQuery()
|
||||
const spaceQuery = createQuery()
|
||||
const statusesQuery = createQuery()
|
||||
|
||||
$: issueQuery.query(
|
||||
object._class,
|
||||
@ -44,19 +43,7 @@
|
||||
},
|
||||
{ limit: 1 }
|
||||
)
|
||||
let statuses: IssueStatus[] = []
|
||||
|
||||
$: statusesQuery.query(
|
||||
tracker.class.IssueStatus,
|
||||
{ attachedTo: space },
|
||||
(res) => {
|
||||
statuses = res
|
||||
},
|
||||
{
|
||||
lookup: { category: tracker.class.IssueStatusCategory },
|
||||
sort: { rank: SortingOrder.Ascending }
|
||||
}
|
||||
)
|
||||
let currentProject: Project | undefined
|
||||
|
||||
$: spaceQuery.query(tracker.class.Project, { _id: space }, (res) => ([currentProject] = res))
|
||||
|
@ -374,7 +374,7 @@
|
||||
width={''}
|
||||
bind:onlyIcon={fullFilled[issueId]}
|
||||
/>
|
||||
<EstimationEditor kind={'list'} size={'small'} value={issue} {currentProject} />
|
||||
<EstimationEditor kind={'list'} size={'small'} value={issue} />
|
||||
<div
|
||||
class="clear-mins"
|
||||
use:tooltip={{
|
||||
|
@ -15,7 +15,7 @@
|
||||
<script lang="ts">
|
||||
import { AttachedData } from '@hcengineering/core'
|
||||
import { getClient } from '@hcengineering/presentation'
|
||||
import { Issue, IssuePriority, IssueTemplateData } from '@hcengineering/tracker'
|
||||
import { Issue, IssueDraft, IssuePriority, IssueTemplateData } from '@hcengineering/tracker'
|
||||
import {
|
||||
Button,
|
||||
ButtonKind,
|
||||
@ -30,7 +30,7 @@
|
||||
import tracker from '../../plugin'
|
||||
import { defaultPriorities, issuePriorities } from '../../utils'
|
||||
|
||||
export let value: Issue | AttachedData<Issue> | IssueTemplateData
|
||||
export let value: Issue | AttachedData<Issue> | IssueTemplateData | IssueDraft
|
||||
export let isEditable: boolean = true
|
||||
export let shouldShowLabel: boolean = false
|
||||
|
||||
@ -65,7 +65,7 @@
|
||||
|
||||
dispatch('change', newPriority)
|
||||
|
||||
if ('_id' in value) {
|
||||
if ('_class' in value) {
|
||||
await client.update(value, { priority: newPriority })
|
||||
}
|
||||
}
|
||||
|
@ -15,7 +15,7 @@
|
||||
<script lang="ts">
|
||||
import { AttachedData, Ref, WithLookup } from '@hcengineering/core'
|
||||
import { getClient } from '@hcengineering/presentation'
|
||||
import { DraftIssueChild, Issue, IssueStatus, Project } from '@hcengineering/tracker'
|
||||
import { Issue, IssueDraft, IssueStatus, Project } from '@hcengineering/tracker'
|
||||
import type { ButtonKind, ButtonSize } from '@hcengineering/ui'
|
||||
import { Button, eventToHTMLElement, SelectPopup, showPopup, TooltipAlignment } from '@hcengineering/ui'
|
||||
import { createEventDispatcher } from 'svelte'
|
||||
@ -24,10 +24,7 @@
|
||||
import IssueStatusIcon from './IssueStatusIcon.svelte'
|
||||
import StatusPresenter from './StatusPresenter.svelte'
|
||||
|
||||
export let value:
|
||||
| Issue
|
||||
| (AttachedData<Issue> & { space: Ref<Project> })
|
||||
| (DraftIssueChild & { space: Ref<Project> })
|
||||
export let value: Issue | (AttachedData<Issue> & { space: Ref<Project> }) | IssueDraft
|
||||
|
||||
let statuses: WithLookup<IssueStatus>[] | undefined = undefined
|
||||
|
||||
@ -50,7 +47,7 @@
|
||||
|
||||
dispatch('change', newStatus)
|
||||
|
||||
if ('_id' in value) {
|
||||
if ('_class' in value) {
|
||||
await client.update(value, { status: newStatus })
|
||||
}
|
||||
}
|
||||
|
@ -16,11 +16,11 @@
|
||||
import { AttachmentStyledBox } from '@hcengineering/attachment-resources'
|
||||
import core, { Account, AttachedData, Doc, generateId, Ref, SortingOrder } from '@hcengineering/core'
|
||||
import { translate } from '@hcengineering/platform'
|
||||
import presentation, { getClient, KeyedAttribute } from '@hcengineering/presentation'
|
||||
import presentation, { DraftController, getClient, KeyedAttribute } from '@hcengineering/presentation'
|
||||
import tags, { TagElement, TagReference } from '@hcengineering/tags'
|
||||
import { calcRank, Issue, IssuePriority, IssueStatus, Project } from '@hcengineering/tracker'
|
||||
import { calcRank, Issue, IssueDraft, IssuePriority, Project } from '@hcengineering/tracker'
|
||||
import { addNotification, Button, Component, EditBox } from '@hcengineering/ui'
|
||||
import { createEventDispatcher } from 'svelte'
|
||||
import { createEventDispatcher, onDestroy } from 'svelte'
|
||||
import tracker from '../../../plugin'
|
||||
import AssigneeEditor from '../AssigneeEditor.svelte'
|
||||
import IssueNotification from '../IssueNotification.svelte'
|
||||
@ -30,57 +30,69 @@
|
||||
|
||||
export let parentIssue: Issue
|
||||
export let currentProject: Project
|
||||
export let shouldSaveDraft: boolean = false
|
||||
|
||||
const draftController = new DraftController<IssueDraft>(parentIssue._id)
|
||||
const draft = shouldSaveDraft ? draftController.get() : undefined
|
||||
const dispatch = createEventDispatcher()
|
||||
const client = getClient()
|
||||
onDestroy(() => draftController.unsubscribe())
|
||||
|
||||
let object = draft ?? getIssueDefaults()
|
||||
|
||||
let newIssue = { ...getIssueDefaults(), space: currentProject._id }
|
||||
let thisRef: HTMLDivElement
|
||||
let focusIssueTitle: () => void
|
||||
let labels: TagReference[] = []
|
||||
let descriptionBox: AttachmentStyledBox
|
||||
|
||||
let objectId: Ref<Issue> = generateId()
|
||||
const key: KeyedAttribute = {
|
||||
key: 'labels',
|
||||
attr: client.getHierarchy().getAttribute(tracker.class.Issue, 'labels')
|
||||
}
|
||||
|
||||
function getIssueDefaults (): AttachedData<Issue> {
|
||||
function getIssueDefaults (): IssueDraft {
|
||||
return {
|
||||
_id: generateId(),
|
||||
space: currentProject._id,
|
||||
labels: [],
|
||||
subIssues: [],
|
||||
status: currentProject.defaultIssueStatus,
|
||||
assignee: currentProject.defaultAssignee ?? null,
|
||||
title: '',
|
||||
description: '',
|
||||
assignee: null,
|
||||
component: null,
|
||||
number: 0,
|
||||
rank: '',
|
||||
status: '' as Ref<IssueStatus>,
|
||||
component: parentIssue.component,
|
||||
priority: IssuePriority.NoPriority,
|
||||
dueDate: null,
|
||||
comments: 0,
|
||||
subIssues: 0,
|
||||
parents: [],
|
||||
sprint: parentIssue.sprint,
|
||||
estimation: 0,
|
||||
reportedTime: 0,
|
||||
reports: 0,
|
||||
childInfo: [],
|
||||
createOn: Date.now()
|
||||
estimation: 0
|
||||
}
|
||||
}
|
||||
|
||||
function resetToDefaults () {
|
||||
newIssue = { ...getIssueDefaults(), space: currentProject._id }
|
||||
labels = []
|
||||
focusIssueTitle?.()
|
||||
objectId = generateId()
|
||||
const empty = {
|
||||
space: currentProject._id,
|
||||
status: currentProject.defaultIssueStatus,
|
||||
assignee: currentProject.defaultAssignee ?? null,
|
||||
component: parentIssue.component,
|
||||
priority: IssuePriority.NoPriority,
|
||||
sprint: parentIssue.sprint
|
||||
}
|
||||
|
||||
if (shouldSaveDraft) {
|
||||
draftController.watch(object, empty)
|
||||
}
|
||||
|
||||
function resetToDefaults () {
|
||||
object = getIssueDefaults()
|
||||
focusIssueTitle?.()
|
||||
}
|
||||
|
||||
$: objectId = object._id
|
||||
|
||||
function getTitle (value: string) {
|
||||
return value.trim()
|
||||
}
|
||||
|
||||
function close () {
|
||||
draftController.remove()
|
||||
dispatch('close')
|
||||
}
|
||||
|
||||
@ -101,11 +113,18 @@
|
||||
)
|
||||
|
||||
const value: AttachedData<Issue> = {
|
||||
...newIssue,
|
||||
title: getTitle(newIssue.title),
|
||||
...object,
|
||||
comments: 0,
|
||||
subIssues: 0,
|
||||
createOn: Date.now(),
|
||||
reportedTime: 0,
|
||||
reports: 0,
|
||||
childInfo: [],
|
||||
labels: 0,
|
||||
status: object.status ?? currentProject.defaultIssueStatus,
|
||||
title: getTitle(object.title),
|
||||
number: (incResult as any).object.sequence,
|
||||
rank: calcRank(lastOne, undefined),
|
||||
component: parentIssue.component,
|
||||
parents: [{ parentId: parentIssue._id, parentTitle: parentIssue.title }, ...parentIssue.parents]
|
||||
}
|
||||
|
||||
@ -116,23 +135,24 @@
|
||||
parentIssue._class,
|
||||
'subIssues',
|
||||
value,
|
||||
objectId
|
||||
object._id
|
||||
)
|
||||
|
||||
await descriptionBox.createAttachments()
|
||||
|
||||
for (const label of labels) {
|
||||
await client.addCollection(label._class, label.space, objectId, tracker.class.Issue, 'labels', {
|
||||
for (const label of object.labels) {
|
||||
await client.addCollection(label._class, label.space, object._id, tracker.class.Issue, 'labels', {
|
||||
title: label.title,
|
||||
color: label.color,
|
||||
tag: label.tag
|
||||
})
|
||||
}
|
||||
|
||||
addNotification(await translate(tracker.string.IssueCreated, {}), getTitle(newIssue.title), IssueNotification, {
|
||||
issueId: objectId,
|
||||
addNotification(await translate(tracker.string.IssueCreated, {}), getTitle(object.title), IssueNotification, {
|
||||
issueId: object._id,
|
||||
subTitlePostfix: (await translate(tracker.string.Created, { value: 1 })).toLowerCase()
|
||||
})
|
||||
draftController.remove()
|
||||
} finally {
|
||||
resetToDefaults()
|
||||
loading = false
|
||||
@ -140,8 +160,8 @@
|
||||
}
|
||||
|
||||
function addTagRef (tag: TagElement): void {
|
||||
labels = [
|
||||
...labels,
|
||||
object.labels = [
|
||||
...object.labels,
|
||||
{
|
||||
_class: tags.class.TagReference,
|
||||
_id: generateId() as Ref<TagReference>,
|
||||
@ -161,9 +181,9 @@
|
||||
let loading = false
|
||||
|
||||
$: thisRef && thisRef.scrollIntoView({ behavior: 'smooth' })
|
||||
$: canSave = getTitle(newIssue.title ?? '').length > 0
|
||||
$: if (!newIssue.status && currentProject?.defaultIssueStatus) {
|
||||
newIssue.status = currentProject.defaultIssueStatus
|
||||
$: canSave = getTitle(object.title ?? '').length > 0
|
||||
$: if (!object.status && currentProject?.defaultIssueStatus) {
|
||||
object.status = currentProject.defaultIssueStatus
|
||||
}
|
||||
</script>
|
||||
|
||||
@ -171,33 +191,36 @@
|
||||
<div class="flex-row-top">
|
||||
<div id="status-editor" class="mr-1">
|
||||
<StatusEditor
|
||||
value={newIssue}
|
||||
value={object}
|
||||
kind="transparent"
|
||||
size="medium"
|
||||
justify="center"
|
||||
tooltipAlignment="bottom"
|
||||
on:change={({ detail }) => (newIssue.status = detail)}
|
||||
on:change={({ detail }) => (object.status = detail)}
|
||||
/>
|
||||
</div>
|
||||
<div class="w-full flex-col content">
|
||||
<EditBox
|
||||
bind:value={newIssue.title}
|
||||
bind:focusInput={focusIssueTitle}
|
||||
placeholder={tracker.string.IssueTitlePlaceholder}
|
||||
focus
|
||||
/>
|
||||
<div class="mt-4">
|
||||
<div id="sub-issue-name">
|
||||
<EditBox
|
||||
bind:value={object.title}
|
||||
bind:focusInput={focusIssueTitle}
|
||||
placeholder={tracker.string.IssueTitlePlaceholder}
|
||||
focus
|
||||
/>
|
||||
</div>
|
||||
<div class="mt-4" id="sub-issue-description">
|
||||
{#key objectId}
|
||||
<AttachmentStyledBox
|
||||
bind:this={descriptionBox}
|
||||
{objectId}
|
||||
objectId={object._id}
|
||||
refContainer={thisRef}
|
||||
_class={tracker.class.Issue}
|
||||
space={currentProject._id}
|
||||
{shouldSaveDraft}
|
||||
alwaysEdit
|
||||
showButtons
|
||||
maxHeight={'20vh'}
|
||||
bind:content={newIssue.description}
|
||||
bind:content={object.description}
|
||||
placeholder={tracker.string.IssueDescriptionPlaceholder}
|
||||
on:changeSize={() => dispatch('changeContent')}
|
||||
/>
|
||||
@ -207,28 +230,31 @@
|
||||
</div>
|
||||
<div class="mt-4 flex-between">
|
||||
<div class="buttons-group xsmall-gap">
|
||||
<!-- <SpaceSelector _class={tracker.class.Project} label={tracker.string.Project} bind:space /> -->
|
||||
<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"
|
||||
<div id="sub-issue-priority">
|
||||
<PriorityEditor
|
||||
value={object}
|
||||
shouldShowLabel
|
||||
isEditable
|
||||
kind="no-border"
|
||||
on:change={({ detail }) => (newIssue.assignee = detail)}
|
||||
size="small"
|
||||
justify="center"
|
||||
on:change={({ detail }) => (object.priority = detail)}
|
||||
/>
|
||||
{/key}
|
||||
</div>
|
||||
<div id="sub-issue-assignee">
|
||||
{#key object.assignee}
|
||||
<AssigneeEditor
|
||||
value={object}
|
||||
size="small"
|
||||
kind="no-border"
|
||||
on:change={({ detail }) => (object.assignee = detail)}
|
||||
/>
|
||||
{/key}
|
||||
</div>
|
||||
<Component
|
||||
is={tags.component.TagsDropdownEditor}
|
||||
props={{
|
||||
items: labels,
|
||||
items: object.labels,
|
||||
key,
|
||||
targetClass: tracker.class.Issue,
|
||||
countLabel: tracker.string.NumberLabels
|
||||
@ -237,10 +263,10 @@
|
||||
addTagRef(evt.detail)
|
||||
}}
|
||||
on:delete={(evt) => {
|
||||
labels = labels.filter((it) => it._id !== evt.detail)
|
||||
object.labels = object.labels.filter((it) => it._id !== evt.detail)
|
||||
}}
|
||||
/>
|
||||
<EstimationEditor kind={'no-border'} size={'small'} value={newIssue} {currentProject} />
|
||||
<EstimationEditor kind={'no-border'} size={'small'} value={object} />
|
||||
</div>
|
||||
<div class="buttons-group small-gap">
|
||||
<Button label={presentation.string.Cancel} size="small" kind="transparent" on:click={close} />
|
||||
|
@ -56,7 +56,6 @@
|
||||
let title = ''
|
||||
let description = ''
|
||||
let innerWidth: number
|
||||
let isEditing = false
|
||||
let descriptionBox: AttachmentStyledBox
|
||||
let showAllMixins: boolean
|
||||
|
||||
@ -128,7 +127,6 @@
|
||||
}, 5000)
|
||||
}
|
||||
await descriptionBox.createAttachments()
|
||||
isEditing = false
|
||||
}
|
||||
|
||||
let saveTrigger: any
|
||||
@ -208,7 +206,7 @@
|
||||
<div class="mt-6">
|
||||
{#key issue._id && currentProject !== undefined}
|
||||
{#if currentProject !== undefined}
|
||||
<SubIssues {issue} projects={new Map([[currentProject?._id, currentProject]])} />
|
||||
<SubIssues {issue} shouldSaveDraft projects={new Map([[currentProject?._id, currentProject]])} />
|
||||
{/if}
|
||||
{/key}
|
||||
</div>
|
||||
@ -263,24 +261,6 @@
|
||||
{/if}
|
||||
|
||||
<style lang="scss">
|
||||
.title {
|
||||
font-weight: 500;
|
||||
font-size: 1.125rem;
|
||||
color: var(--caption-color);
|
||||
}
|
||||
|
||||
.content {
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.description-preview {
|
||||
color: var(--content-color);
|
||||
line-height: 150%;
|
||||
|
||||
.placeholder {
|
||||
color: var(--dark-color);
|
||||
}
|
||||
}
|
||||
.divider {
|
||||
margin-top: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
|
@ -14,7 +14,7 @@
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { Ref, toIdMap } from '@hcengineering/core'
|
||||
import { createQuery } from '@hcengineering/presentation'
|
||||
import { createQuery, draftsStore } from '@hcengineering/presentation'
|
||||
import { Issue, Project, trackerId } from '@hcengineering/tracker'
|
||||
import {
|
||||
Button,
|
||||
@ -42,10 +42,11 @@
|
||||
|
||||
export let issue: Issue
|
||||
export let projects: Map<Ref<Project>, Project>
|
||||
export let shouldSaveDraft: boolean = false
|
||||
|
||||
let subIssueEditorRef: HTMLDivElement
|
||||
let isCollapsed = false
|
||||
let isCreating = false
|
||||
let isCreating = $draftsStore[issue._id] !== undefined
|
||||
|
||||
$: hasSubIssues = issue.subIssues > 0
|
||||
|
||||
@ -148,7 +149,12 @@
|
||||
{@const project = projects.get(issue.space)}
|
||||
{#if project !== undefined}
|
||||
<div class="pt-4" bind:this={subIssueEditorRef}>
|
||||
<CreateSubIssue parentIssue={issue} currentProject={project} on:close={() => (isCreating = false)} />
|
||||
<CreateSubIssue
|
||||
parentIssue={issue}
|
||||
{shouldSaveDraft}
|
||||
currentProject={project}
|
||||
on:close={() => (isCreating = false)}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
|
@ -16,7 +16,7 @@
|
||||
import { AttachedData } from '@hcengineering/core'
|
||||
|
||||
import { getClient } from '@hcengineering/presentation'
|
||||
import { Issue, Project } from '@hcengineering/tracker'
|
||||
import { Issue, IssueDraft } from '@hcengineering/tracker'
|
||||
import { Button, ButtonKind, ButtonSize, eventToHTMLElement, showPopup } from '@hcengineering/ui'
|
||||
import { EditBoxPopup } from '@hcengineering/view-resources'
|
||||
import { createEventDispatcher } from 'svelte'
|
||||
@ -24,14 +24,13 @@
|
||||
import EstimationPopup from './EstimationPopup.svelte'
|
||||
import EstimationStatsPresenter from './EstimationStatsPresenter.svelte'
|
||||
|
||||
export let value: Issue | AttachedData<Issue>
|
||||
export let value: Issue | AttachedData<Issue> | IssueDraft
|
||||
export let isEditable: boolean = true
|
||||
|
||||
export let kind: ButtonKind = 'link'
|
||||
export let size: ButtonSize = 'large'
|
||||
export let justify: 'left' | 'center' = 'left'
|
||||
export let width: string | undefined = undefined
|
||||
export let currentProject: Project | undefined = undefined
|
||||
|
||||
const client = getClient()
|
||||
const dispatch = createEventDispatcher()
|
||||
@ -64,7 +63,7 @@
|
||||
|
||||
dispatch('change', newEstimation)
|
||||
|
||||
if ('_id' in value) {
|
||||
if ('_class' in value) {
|
||||
await client.update(value, { estimation: newEstimation })
|
||||
} else {
|
||||
value.estimation = newEstimation
|
||||
@ -73,8 +72,8 @@
|
||||
</script>
|
||||
|
||||
{#if value}
|
||||
{#if kind === 'list'}
|
||||
<EstimationStatsPresenter {value} on:click={handleestimationEditorOpened} {currentProject} />
|
||||
{#if kind === 'list' && '_class' in value}
|
||||
<EstimationStatsPresenter {value} on:click={handleestimationEditorOpened} />
|
||||
{:else}
|
||||
<Button
|
||||
showTooltip={isEditable ? { label: tracker.string.Estimation } : undefined}
|
||||
|
@ -100,7 +100,7 @@
|
||||
)
|
||||
}}
|
||||
>
|
||||
<EstimationStatsPresenter value={object} estimation={_value} {currentProject} />
|
||||
<EstimationStatsPresenter value={object} estimation={_value} />
|
||||
</div>
|
||||
</div>
|
||||
</svelte:fragment>
|
||||
|
@ -15,14 +15,13 @@
|
||||
<script lang="ts">
|
||||
import { AttachedData } from '@hcengineering/core'
|
||||
|
||||
import { Issue, Project } from '@hcengineering/tracker'
|
||||
import { Issue } from '@hcengineering/tracker'
|
||||
import { floorFractionDigits } from '@hcengineering/ui'
|
||||
import EstimationProgressCircle from './EstimationProgressCircle.svelte'
|
||||
import TimePresenter from './TimePresenter.svelte'
|
||||
|
||||
export let value: Issue | AttachedData<Issue>
|
||||
export let estimation: number | undefined = undefined
|
||||
export let currentProject: Project | undefined
|
||||
|
||||
$: _estimation = estimation ?? value.estimation
|
||||
|
||||
|
@ -71,7 +71,7 @@
|
||||
/>
|
||||
</FixedColumn>
|
||||
<FixedColumn key={'estimation'} justify={'left'}>
|
||||
<EstimationEditor value={issue} kind={'list'} {currentProject} />
|
||||
<EstimationEditor value={issue} kind={'list'} />
|
||||
</FixedColumn>
|
||||
</div>
|
||||
</svelte:fragment>
|
||||
|
@ -16,7 +16,7 @@
|
||||
import { Ref } from '@hcengineering/core'
|
||||
import { IntlString } from '@hcengineering/platform'
|
||||
import { createQuery, getClient } from '@hcengineering/presentation'
|
||||
import { Issue, IssueTemplate, Sprint, Project } from '@hcengineering/tracker'
|
||||
import { Issue, IssueTemplate, Sprint } from '@hcengineering/tracker'
|
||||
import {
|
||||
ButtonKind,
|
||||
ButtonShape,
|
||||
@ -47,12 +47,6 @@
|
||||
export let compression: boolean = false
|
||||
|
||||
const client = getClient()
|
||||
const spaceQuery = createQuery()
|
||||
|
||||
let currentProject: Project | undefined
|
||||
$: spaceQuery.query(tracker.class.Project, { _id: value.space }, (res) => {
|
||||
currentProject = res.shift()
|
||||
})
|
||||
|
||||
const handleSprintIdChanged = async (newSprintId: Ref<Sprint> | null | undefined) => {
|
||||
if (!isEditable || newSprintId === undefined || value.sprint === newSprintId) {
|
||||
|
@ -15,7 +15,7 @@
|
||||
<script lang="ts">
|
||||
import { Ref } from '@hcengineering/core'
|
||||
import { createQuery } from '@hcengineering/presentation'
|
||||
import { Sprint, Project } from '@hcengineering/tracker'
|
||||
import { Sprint } from '@hcengineering/tracker'
|
||||
import { ButtonKind, DatePresenter, deviceOptionsStore as deviceInfo } from '@hcengineering/ui'
|
||||
import tracker from '../../plugin'
|
||||
import { getDayOfSprint } from '../../utils'
|
||||
@ -25,13 +25,6 @@
|
||||
export let value: Ref<Sprint>
|
||||
export let kind: ButtonKind = 'link'
|
||||
|
||||
const spaceQuery = createQuery()
|
||||
let currentProject: Project | undefined
|
||||
$: sprint &&
|
||||
spaceQuery.query(tracker.class.Project, { _id: sprint.space }, (res) => {
|
||||
;[currentProject] = res
|
||||
})
|
||||
|
||||
const sprintQuery = createQuery()
|
||||
let sprint: Sprint | undefined
|
||||
$: sprintQuery.query(tracker.class.Sprint, { _id: value }, (res) => {
|
||||
|
@ -14,19 +14,12 @@
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { AttachmentStyledBox } from '@hcengineering/attachment-resources'
|
||||
import { generateId, Ref } from '@hcengineering/core'
|
||||
import presentation, { createQuery, getClient, KeyedAttribute } from '@hcengineering/presentation'
|
||||
import { Account, Doc, generateId, Ref } from '@hcengineering/core'
|
||||
import presentation, { DraftController, getClient, KeyedAttribute } from '@hcengineering/presentation'
|
||||
import tags, { TagElement, TagReference } from '@hcengineering/tags'
|
||||
import {
|
||||
Component as ComponentType,
|
||||
DraftIssueChild,
|
||||
IssuePriority,
|
||||
IssueTemplateChild,
|
||||
Project,
|
||||
Sprint
|
||||
} from '@hcengineering/tracker'
|
||||
import { Component as ComponentType, IssueDraft, IssuePriority, Project, Sprint } from '@hcengineering/tracker'
|
||||
import { Button, Component, EditBox } from '@hcengineering/ui'
|
||||
import { createEventDispatcher } from 'svelte'
|
||||
import { createEventDispatcher, onDestroy } from 'svelte'
|
||||
import tracker from '../../plugin'
|
||||
import AssigneeEditor from '../issues/AssigneeEditor.svelte'
|
||||
import PriorityEditor from '../issues/PriorityEditor.svelte'
|
||||
@ -36,36 +29,36 @@
|
||||
export let project: Project
|
||||
export let sprint: Ref<Sprint> | null = null
|
||||
export let component: Ref<ComponentType> | null = null
|
||||
export let childIssue: DraftIssueChild | undefined = undefined
|
||||
export let childIssue: IssueDraft | undefined = undefined
|
||||
export let showBorder = false
|
||||
export let shouldSaveDraft: boolean = false
|
||||
|
||||
const dispatch = createEventDispatcher()
|
||||
const client = getClient()
|
||||
|
||||
let newIssue =
|
||||
childIssue !== undefined ? { ...childIssue, space: project._id } : { ...getIssueDefaults(), space: project._id }
|
||||
const draftController = new DraftController<IssueDraft>(tracker.ids.IssueDraftChild)
|
||||
const draft = shouldSaveDraft ? draftController.get() : undefined
|
||||
let object = childIssue !== undefined ? childIssue : draft ?? getIssueDefaults()
|
||||
let thisRef: HTMLDivElement
|
||||
let focusIssueTitle: () => void
|
||||
let labels: TagElement[] = []
|
||||
|
||||
const labelsQuery = createQuery()
|
||||
|
||||
$: labelsQuery.query(tags.class.TagElement, { _id: { $in: childIssue?.labels ?? [] } }, (res) => {
|
||||
labels = res
|
||||
})
|
||||
onDestroy(() => draftController.unsubscribe())
|
||||
|
||||
const key: KeyedAttribute = {
|
||||
key: 'labels',
|
||||
attr: client.getHierarchy().getAttribute(tracker.class.IssueTemplate, 'labels')
|
||||
}
|
||||
|
||||
function getIssueDefaults (): DraftIssueChild {
|
||||
function getIssueDefaults (): IssueDraft {
|
||||
return {
|
||||
id: generateId(),
|
||||
_id: generateId(),
|
||||
title: '',
|
||||
description: '',
|
||||
assignee: null,
|
||||
assignee: project.defaultAssignee ?? null,
|
||||
status: project.defaultIssueStatus,
|
||||
space: project._id,
|
||||
dueDate: null,
|
||||
subIssues: [],
|
||||
attachments: 0,
|
||||
labels: [],
|
||||
component,
|
||||
priority: IssuePriority.NoPriority,
|
||||
sprint,
|
||||
@ -73,8 +66,21 @@
|
||||
}
|
||||
}
|
||||
|
||||
const empty = {
|
||||
space: project._id,
|
||||
status: project.defaultIssueStatus,
|
||||
assignee: project.defaultAssignee ?? null,
|
||||
component,
|
||||
priority: IssuePriority.NoPriority,
|
||||
sprint
|
||||
}
|
||||
|
||||
if (shouldSaveDraft) {
|
||||
draftController.watch(object, empty)
|
||||
}
|
||||
|
||||
function resetToDefaults () {
|
||||
newIssue = { ...getIssueDefaults(), space: project._id }
|
||||
object = getIssueDefaults()
|
||||
focusIssueTitle?.()
|
||||
}
|
||||
|
||||
@ -83,6 +89,7 @@
|
||||
}
|
||||
|
||||
function close () {
|
||||
draftController.remove()
|
||||
dispatch('close')
|
||||
}
|
||||
|
||||
@ -91,29 +98,36 @@
|
||||
return
|
||||
}
|
||||
|
||||
const value: IssueTemplateChild = {
|
||||
...newIssue,
|
||||
title: getTitle(newIssue.title),
|
||||
component: component ?? null,
|
||||
labels: labels.map((it) => it._id)
|
||||
}
|
||||
if (childIssue === undefined) {
|
||||
dispatch('create', value)
|
||||
} else {
|
||||
dispatch('close', value)
|
||||
}
|
||||
dispatch(childIssue ? 'close' : 'create', object)
|
||||
|
||||
draftController.remove()
|
||||
resetToDefaults()
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
function addTagRef (tag: TagElement): void {
|
||||
labels = [...labels, tag]
|
||||
object.labels = [...object.labels, tagAsRef(tag)]
|
||||
}
|
||||
|
||||
$: thisRef && thisRef.scrollIntoView({ behavior: 'smooth' })
|
||||
$: canSave = getTitle(newIssue.title ?? '').length > 0
|
||||
$: canSave = getTitle(object.title ?? '').length > 0
|
||||
|
||||
$: labelRefs = labels.map((it) => ({ ...(it as unknown as TagReference), _id: generateId(), tag: it._id }))
|
||||
$: objectId = object._id
|
||||
</script>
|
||||
|
||||
<div
|
||||
@ -123,20 +137,22 @@
|
||||
class:antiPopup={showBorder}
|
||||
>
|
||||
<div class="flex-col w-full clear-mins">
|
||||
<EditBox
|
||||
bind:value={newIssue.title}
|
||||
bind:focusInput={focusIssueTitle}
|
||||
kind={'large-style'}
|
||||
placeholder={tracker.string.SubIssueTitlePlaceholder}
|
||||
focus
|
||||
/>
|
||||
<div class="mt-4 clear-mins">
|
||||
{#key newIssue.id}
|
||||
<div id="sub-issue-name">
|
||||
<EditBox
|
||||
bind:value={object.title}
|
||||
bind:focusInput={focusIssueTitle}
|
||||
kind={'large-style'}
|
||||
placeholder={tracker.string.SubIssueTitlePlaceholder}
|
||||
focus
|
||||
/>
|
||||
</div>
|
||||
<div class="mt-4 clear-mins" id="sub-issue-description">
|
||||
{#key objectId}
|
||||
<AttachmentStyledBox
|
||||
objectId={newIssue.id}
|
||||
objectId={object._id}
|
||||
space={project._id}
|
||||
_class={tracker.class.Issue}
|
||||
bind:content={newIssue.description}
|
||||
bind:content={object.description}
|
||||
placeholder={tracker.string.SubIssueDescriptionPlaceholder}
|
||||
showButtons={false}
|
||||
alwaysEdit
|
||||
@ -149,42 +165,50 @@
|
||||
</div>
|
||||
<div class="mt-4 flex-between">
|
||||
<div class="buttons-group xsmall-gap">
|
||||
<StatusEditor
|
||||
value={newIssue}
|
||||
kind="no-border"
|
||||
size="small"
|
||||
shouldShowLabel={true}
|
||||
on:change={({ detail }) => (newIssue.status = detail)}
|
||||
/>
|
||||
<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"
|
||||
<div id="sub-issue-status-editor">
|
||||
<StatusEditor
|
||||
value={object}
|
||||
kind="no-border"
|
||||
on:change={({ detail }) => (newIssue.assignee = detail)}
|
||||
size="small"
|
||||
shouldShowLabel={true}
|
||||
on:change={({ detail }) => (object.status = detail)}
|
||||
/>
|
||||
{/key}
|
||||
<EstimationEditor
|
||||
kind={'no-border'}
|
||||
size={'small'}
|
||||
bind:value={newIssue}
|
||||
on:change={(evt) => {
|
||||
newIssue.estimation = evt.detail
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div id="sub-issue-priority-editor">
|
||||
<PriorityEditor
|
||||
value={object}
|
||||
shouldShowLabel
|
||||
isEditable
|
||||
kind="no-border"
|
||||
size="small"
|
||||
justify="center"
|
||||
on:change={({ detail }) => (object.priority = detail)}
|
||||
/>
|
||||
</div>
|
||||
<div id="sub-issue-assignee-editor">
|
||||
{#key object.assignee}
|
||||
<AssigneeEditor
|
||||
value={object}
|
||||
size="small"
|
||||
kind="no-border"
|
||||
on:change={({ detail }) => (object.assignee = detail)}
|
||||
/>
|
||||
{/key}
|
||||
</div>
|
||||
<div id="sub-issue-estimation-editor">
|
||||
<EstimationEditor
|
||||
kind={'no-border'}
|
||||
size={'small'}
|
||||
bind:value={object}
|
||||
on:change={(evt) => {
|
||||
object.estimation = evt.detail
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<Component
|
||||
is={tags.component.TagsDropdownEditor}
|
||||
props={{
|
||||
items: labelRefs,
|
||||
items: object.labels,
|
||||
key,
|
||||
targetClass: tracker.class.Issue,
|
||||
countLabel: tracker.string.NumberLabels
|
||||
@ -193,7 +217,7 @@
|
||||
addTagRef(evt.detail)
|
||||
}}
|
||||
on:delete={(evt) => {
|
||||
labels = labels.filter((it) => it._id !== evt.detail)
|
||||
object.labels = object.labels.filter((it) => it._id !== evt.detail)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
@ -15,7 +15,7 @@
|
||||
<script lang="ts">
|
||||
import { Ref } from '@hcengineering/core'
|
||||
import { createQuery } from '@hcengineering/presentation'
|
||||
import tracker, { Component, DraftIssueChild, IssueTemplateChild, Project, Sprint } from '@hcengineering/tracker'
|
||||
import tracker, { Component, IssueDraft, Project, Sprint } from '@hcengineering/tracker'
|
||||
import { eventToHTMLElement, IconCircles, showPopup } from '@hcengineering/ui'
|
||||
import { ActionContext, FixedColumn } from '@hcengineering/view-resources'
|
||||
import { createEventDispatcher } from 'svelte'
|
||||
@ -26,7 +26,7 @@
|
||||
import DraftIssueChildEditor from './DraftIssueChildEditor.svelte'
|
||||
import EstimationEditor from './EstimationEditor.svelte'
|
||||
|
||||
export let issues: DraftIssueChild[]
|
||||
export let issues: IssueDraft[]
|
||||
export let project: Ref<Project>
|
||||
export let sprint: Ref<Sprint> | null = null
|
||||
export let component: Ref<Component> | null = null
|
||||
@ -35,7 +35,7 @@
|
||||
let draggingIndex: number | null = null
|
||||
let hoveringIndex: number | null = null
|
||||
|
||||
function openIssue (evt: MouseEvent, target: DraftIssueChild) {
|
||||
function openIssue (evt: MouseEvent, target: IssueDraft) {
|
||||
showPopup(
|
||||
DraftIssueChildEditor,
|
||||
{
|
||||
@ -46,9 +46,9 @@
|
||||
childIssue: target
|
||||
},
|
||||
eventToHTMLElement(evt),
|
||||
(evt: DraftIssueChild | undefined | null) => {
|
||||
(evt: IssueDraft | undefined | null) => {
|
||||
if (evt != null) {
|
||||
const pos = issues.findIndex((it) => it.id === evt.id)
|
||||
const pos = issues.findIndex((it) => it._id === evt._id)
|
||||
if (pos !== -1) {
|
||||
issues[pos] = evt
|
||||
dispatch('update-issue', evt)
|
||||
@ -91,10 +91,10 @@
|
||||
)
|
||||
let currentProject: Project | undefined = undefined
|
||||
|
||||
function getIssueTemplateId (currentProject: Project | undefined, issue: IssueTemplateChild): string {
|
||||
function getIssueTemplateId (currentProject: Project | undefined, issue: IssueDraft): string {
|
||||
return currentProject
|
||||
? `${currentProject.identifier}-${issues.findIndex((it) => it.id === issue.id)}`
|
||||
: `${issues.findIndex((it) => it.id === issue.id)}}`
|
||||
? `${currentProject.identifier}-${issues.findIndex((it) => it._id === issue._id)}`
|
||||
: `${issues.findIndex((it) => it._id === issue._id)}}`
|
||||
}
|
||||
</script>
|
||||
|
||||
@ -104,7 +104,7 @@
|
||||
}}
|
||||
/>
|
||||
|
||||
{#each issues as issue, index (issue.id)}
|
||||
{#each issues as issue, index (issue._id)}
|
||||
<!-- svelte-ignore a11y-click-events-have-key-events -->
|
||||
<div
|
||||
class="flex-between row"
|
||||
@ -138,7 +138,7 @@
|
||||
size={'small'}
|
||||
justify={'center'}
|
||||
on:change={(evt) => {
|
||||
dispatch('update-issue', { id: issue.id, priority: evt.detail })
|
||||
dispatch('update-issue', { id: issue._id, priority: evt.detail })
|
||||
issue.priority = evt.detail
|
||||
}}
|
||||
/>
|
||||
@ -158,14 +158,14 @@
|
||||
size={'large'}
|
||||
bind:value={issue}
|
||||
on:change={(evt) => {
|
||||
dispatch('update-issue', { id: issue.id, estimation: evt.detail })
|
||||
dispatch('update-issue', { id: issue._id, estimation: evt.detail })
|
||||
issue.estimation = evt.detail
|
||||
}}
|
||||
/>
|
||||
<AssigneeEditor
|
||||
value={issue}
|
||||
on:change={(evt) => {
|
||||
dispatch('update-issue', { id: issue.id, assignee: evt.detail })
|
||||
dispatch('update-issue', { id: issue._id, assignee: evt.detail })
|
||||
issue.assignee = evt.detail
|
||||
}}
|
||||
/>
|
||||
|
@ -15,13 +15,13 @@
|
||||
<script lang="ts">
|
||||
import { Data } from '@hcengineering/core'
|
||||
import { getClient } from '@hcengineering/presentation'
|
||||
import { IssueTemplate, IssueTemplateChild } from '@hcengineering/tracker'
|
||||
import { IssueDraft, IssueTemplate, IssueTemplateChild } from '@hcengineering/tracker'
|
||||
import { Button, ButtonKind, ButtonSize, eventToHTMLElement, showPopup } from '@hcengineering/ui'
|
||||
import { EditBoxPopup } from '@hcengineering/view-resources'
|
||||
import { createEventDispatcher } from 'svelte'
|
||||
import tracker from '../../plugin'
|
||||
|
||||
export let value: IssueTemplateChild | IssueTemplate | Data<IssueTemplate>
|
||||
export let value: IssueTemplateChild | IssueTemplate | Data<IssueTemplate> | IssueDraft
|
||||
export let isEditable: boolean = true
|
||||
|
||||
export let kind: ButtonKind = 'link'
|
||||
@ -51,7 +51,7 @@
|
||||
return
|
||||
}
|
||||
|
||||
if ('_id' in value) {
|
||||
if ('_class' in value) {
|
||||
await client.update(value, { estimation: newEstimation })
|
||||
}
|
||||
dispatch('change', newEstimation)
|
||||
|
@ -15,7 +15,6 @@
|
||||
|
||||
import { Employee, getName } from '@hcengineering/contact'
|
||||
import core, {
|
||||
AttachedData,
|
||||
Class,
|
||||
Doc,
|
||||
DocumentQuery,
|
||||
@ -41,7 +40,6 @@ import {
|
||||
IssuesGrouping,
|
||||
IssuesOrdering,
|
||||
IssueStatus,
|
||||
IssueTemplateData,
|
||||
Project,
|
||||
Sprint,
|
||||
SprintStatus,
|
||||
@ -654,15 +652,13 @@ export function subIssueListProvider (subIssues: Issue[], target: Ref<Issue>): v
|
||||
}
|
||||
}
|
||||
|
||||
export async function getPreviousAssignees (
|
||||
issue: Issue | AttachedData<Issue> | IssueTemplateData
|
||||
): Promise<Array<Ref<Employee>>> {
|
||||
export async function getPreviousAssignees (issue: Issue): Promise<Array<Ref<Employee>>> {
|
||||
return await new Promise((resolve) => {
|
||||
const query = createQuery(true)
|
||||
query.query(
|
||||
core.class.Tx,
|
||||
{
|
||||
'tx.objectId': (issue as Issue)._id,
|
||||
'tx.objectId': issue._id,
|
||||
'tx.operations.assignee': { $exists: true }
|
||||
},
|
||||
(res) => {
|
||||
|
@ -198,24 +198,24 @@ export interface Issue extends AttachedDoc {
|
||||
/**
|
||||
* @public
|
||||
*/
|
||||
export interface IssueDraft extends Doc {
|
||||
issueId: Ref<Issue>
|
||||
export interface IssueDraft {
|
||||
_id: Ref<Issue>
|
||||
title: string
|
||||
description: Markup
|
||||
status: Ref<IssueStatus>
|
||||
status?: Ref<IssueStatus>
|
||||
priority: IssuePriority
|
||||
assignee: Ref<Employee> | null
|
||||
component: Ref<Component> | null
|
||||
project: Ref<Project> | null
|
||||
space: Ref<Project>
|
||||
dueDate: Timestamp | null
|
||||
sprint?: Ref<Sprint> | null
|
||||
|
||||
// Estimation in man days
|
||||
estimation: number
|
||||
parentIssue?: string
|
||||
parentIssue?: Ref<Issue>
|
||||
attachments?: number
|
||||
labels?: TagReference[]
|
||||
subIssues?: DraftIssueChild[]
|
||||
labels: TagReference[]
|
||||
subIssues: IssueDraft[]
|
||||
template?: {
|
||||
// A template issue is based on
|
||||
template: Ref<IssueTemplate>
|
||||
@ -265,13 +265,6 @@ export interface IssueTemplate extends Doc, IssueTemplateData {
|
||||
relations?: RelatedDocument[]
|
||||
}
|
||||
|
||||
/**
|
||||
* @public
|
||||
*/
|
||||
export interface DraftIssueChild extends IssueTemplateChild {
|
||||
status: Ref<IssueStatus>
|
||||
}
|
||||
|
||||
/**
|
||||
* @public
|
||||
*
|
||||
@ -395,7 +388,6 @@ export default plugin(trackerId, {
|
||||
class: {
|
||||
Project: '' as Ref<Class<Project>>,
|
||||
Issue: '' as Ref<Class<Issue>>,
|
||||
IssueDraft: '' as Ref<Class<IssueDraft>>,
|
||||
IssueTemplate: '' as Ref<Class<IssueTemplate>>,
|
||||
Component: '' as Ref<Class<Component>>,
|
||||
IssueStatus: '' as Ref<Class<IssueStatus>>,
|
||||
@ -410,7 +402,9 @@ export default plugin(trackerId, {
|
||||
TypeReportedTime: '' as Ref<Class<Type<number>>>
|
||||
},
|
||||
ids: {
|
||||
NoParent: '' as Ref<Issue>
|
||||
NoParent: '' as Ref<Issue>,
|
||||
IssueDraft: '',
|
||||
IssueDraftChild: ''
|
||||
},
|
||||
component: {
|
||||
Tracker: '' as AnyComponent,
|
||||
|
@ -6,6 +6,7 @@ import {
|
||||
createSubissue,
|
||||
DEFAULT_STATUSES,
|
||||
DEFAULT_USER,
|
||||
fillIssueForm,
|
||||
navigate,
|
||||
openIssue,
|
||||
ViewletSelectors
|
||||
@ -195,3 +196,140 @@ test('report-time-from-main-view', async ({ page }) => {
|
||||
await expect(page.locator('.estimation-container >> span').first()).toContainText(`${Number(count.toFixed(2))}d`)
|
||||
}
|
||||
})
|
||||
|
||||
test('create-issue-draft', async ({ page }) => {
|
||||
await navigate(page)
|
||||
|
||||
const issueName = 'Draft issue'
|
||||
const subIssueName = 'Sub issue draft'
|
||||
|
||||
// Click text=Issues >> nth=1
|
||||
await page.locator('text=Issues').nth(1).click()
|
||||
await expect(page).toHaveURL(
|
||||
'http://localhost:8083/workbench/sanity-ws/tracker/tracker%3Aproject%3ADefaultProject/issues'
|
||||
)
|
||||
await expect(page.locator('#new-issue')).toHaveText('New issue')
|
||||
// Click button:has-text("New issue")
|
||||
await page.locator('#new-issue').click()
|
||||
|
||||
// Click [placeholder="Issue title"]
|
||||
await page.locator('#issue-name').click()
|
||||
// Fill [placeholder="Issue title"]
|
||||
await page.locator('#issue-name >> input').fill(issueName)
|
||||
await expect(page.locator('#new-issue')).toHaveText('Resume draft')
|
||||
|
||||
await page.locator('#issue-description').click()
|
||||
await page.locator('#issue-description >> [contenteditable]').fill(issueName)
|
||||
|
||||
// Click button:has-text("Backlog")
|
||||
await page.locator('#status-editor').click()
|
||||
// Click button:has-text("Todo")
|
||||
await page.locator('button:has-text("Todo")').click()
|
||||
|
||||
// Click button:has-text("No priority")
|
||||
await page.locator('#priority-editor').click()
|
||||
// Click button:has-text("Urgent")
|
||||
await page.locator('button:has-text("Urgent")').click()
|
||||
// Click button:has-text("Assignee")
|
||||
await page.locator('#assignee-editor').click()
|
||||
// Click button:has-text("Appleseed John")
|
||||
await page.locator('button:has-text("Appleseed John")').click()
|
||||
// Click button:has-text("0d")
|
||||
await page.locator('#estimation-editor').click()
|
||||
// Click [placeholder="Type text\.\.\."]
|
||||
await page.locator('[placeholder="Type text\\.\\.\\."]').click()
|
||||
// Fill [placeholder="Type text\.\.\."]
|
||||
await page.locator('[placeholder="Type text\\.\\.\\."]').fill('1')
|
||||
await page.locator('.p-1 > .button').click()
|
||||
|
||||
// Click button:nth-child(8)
|
||||
await page.locator('#more-actions').click()
|
||||
// Click button:has-text("Set due date…")
|
||||
await page.locator('button:has-text("Set due date…")').click()
|
||||
// Click text=24 >> nth=0
|
||||
await page.locator('.date-popup-container >> text=24').first().click()
|
||||
// Click button:has-text("+ Add sub-issues")
|
||||
await page.locator('button:has-text("+ Add sub-issues")').click()
|
||||
// Click [placeholder="Sub-issue title"]
|
||||
await page.locator('#sub-issue-name').click()
|
||||
// Fill [placeholder="Sub-issue title"]
|
||||
await page.locator('#sub-issue-name >> input').fill(subIssueName)
|
||||
|
||||
await page.locator('#sub-issue-description').click()
|
||||
await page.locator('#sub-issue-description >> [contenteditable]').fill(subIssueName)
|
||||
|
||||
// Click button:has-text("Backlog")
|
||||
await page.locator('#sub-issue-status-editor').click()
|
||||
// Click button:has-text("In Progress")
|
||||
await page.locator('button:has-text("In Progress")').click()
|
||||
// Click button:has-text("No priority")
|
||||
await page.locator('#sub-issue-priority-editor').click()
|
||||
// Click button:has-text("High")
|
||||
await page.locator('button:has-text("High")').click()
|
||||
// Click button:has-text("Assignee")
|
||||
await page.locator('#sub-issue-assignee-editor').click()
|
||||
// Click button:has-text("Chen Rosamund")
|
||||
await page.locator('button:has-text("Chen Rosamund")').click()
|
||||
// Click button:has-text("0d")
|
||||
await page.locator('#sub-issue-estimation-editor').click()
|
||||
// Double click [placeholder="Type text\.\.\."]
|
||||
await page.locator('[placeholder="Type text\\.\\.\\."]').dblclick()
|
||||
// Fill [placeholder="Type text\.\.\."]
|
||||
await page.locator('[placeholder="Type text\\.\\.\\."]').fill('2')
|
||||
await page.locator('.p-1 > .button').click()
|
||||
|
||||
await page.keyboard.press('Escape')
|
||||
await page.keyboard.press('Escape')
|
||||
|
||||
await page.locator('#new-issue').click()
|
||||
await expect(page.locator('#issue-name')).toHaveText(issueName)
|
||||
await expect(page.locator('#issue-description')).toHaveText(issueName)
|
||||
await expect(page.locator('#status-editor')).toHaveText('Todo')
|
||||
await expect(page.locator('#priority-editor')).toHaveText('Urgent')
|
||||
await expect(page.locator('#assignee-editor')).toHaveText('Appleseed John')
|
||||
await expect(page.locator('#estimation-editor')).toHaveText('1d')
|
||||
await expect(page.locator('.antiCard >> .datetime-button')).toContainText('24')
|
||||
await expect(page.locator('#sub-issue-name')).toHaveText(subIssueName)
|
||||
await expect(page.locator('#sub-issue-description')).toHaveText(subIssueName)
|
||||
await expect(page.locator('#sub-issue-status-editor')).toHaveText('In Progress')
|
||||
await expect(page.locator('#sub-issue-priority-editor')).toHaveText('High')
|
||||
await expect(page.locator('#sub-issue-assignee-editor')).toHaveText('Chen Rosamund')
|
||||
await expect(page.locator('#sub-issue-estimation-editor')).toHaveText('2d')
|
||||
})
|
||||
|
||||
test('sub-issue-draft', async ({ page }) => {
|
||||
await navigate(page)
|
||||
|
||||
const props = {
|
||||
name: getIssueName(),
|
||||
description: 'description',
|
||||
status: DEFAULT_STATUSES[1],
|
||||
priority: 'Urgent',
|
||||
assignee: DEFAULT_USER
|
||||
}
|
||||
const originalName = props.name
|
||||
await navigate(page)
|
||||
await createIssue(page, props)
|
||||
await page.click('text="Issues"')
|
||||
|
||||
// Click [placeholder="Search"]
|
||||
await page.locator('[placeholder="Search"]').click()
|
||||
// Fill [placeholder="Search"]
|
||||
await page.locator('[placeholder="Search"]').fill(props.name)
|
||||
// Press Enter
|
||||
await page.locator('[placeholder="Search"]').press('Enter')
|
||||
|
||||
await openIssue(page, props.name)
|
||||
await checkIssue(page, props)
|
||||
props.name = `sub${props.name}`
|
||||
await page.click('button:has-text("Add sub-issue")')
|
||||
await fillIssueForm(page, props, false)
|
||||
await page.keyboard.press('Escape')
|
||||
await page.keyboard.press('Escape')
|
||||
|
||||
await openIssue(page, originalName)
|
||||
await expect(page.locator('#sub-issue-child-editor >> #sub-issue-name')).toHaveText(props.name)
|
||||
await expect(page.locator('#sub-issue-child-editor >> #sub-issue-description')).toHaveText(props.description)
|
||||
await expect(page.locator('#sub-issue-child-editor >> #sub-issue-priority')).toHaveText(props.priority)
|
||||
await expect(page.locator('#sub-issue-child-editor >> #sub-issue-assignee')).toHaveText(props.assignee)
|
||||
})
|
||||
|
Loading…
Reference in New Issue
Block a user