TSK-462 Allow sub issue drafts (#2823)

Signed-off-by: Denis Bykhov <bykhov.denis@gmail.com>
This commit is contained in:
Denis Bykhov 2023-03-24 16:23:44 +06:00 committed by GitHub
parent a259f7c9af
commit c37502077c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
41 changed files with 933 additions and 984 deletions

View File

@ -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]
}

View File

@ -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
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))
}
}
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 {
// eslint-disable-next-line @typescript-eslint/no-dynamic-delete
delete drafts[id]
res = value != null
if (res && typeof value === 'string') {
res = value.trim() !== ''
}
setMetadataLocalStorage(presentation.metadata.Draft, drafts)
return drafts
})
if (res && typeof value === 'number') {
res = value !== 0
}
/**
* @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
})
if (res && emptyObj != null) {
res = !deepEqual(value, emptyObj[key])
}
/**
* @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) {
if (res) {
return false
}
const draftRecord: Record<string, any> = userDrafts[id]
// eslint-disable-next-line @typescript-eslint/strict-boolean-expressions
if (!draftRecord) {
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)
}
}

View File

@ -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}>

View File

@ -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)

View File

@ -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) => {

View File

@ -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
}
inputContent = draftComment ? draftComment.message : ''
_id = draftComment ? draftComment._id : _id
}
onDestroy(draftController.unsubscribe)
function commentIsEmpty (message: string, attachments: number): boolean {
return (message === '<p></p>' || message === '') && !(attachments > 0)
function getDefault (): CommentDraft {
return {
_id: generateId(),
...empty
}
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)
}
saveTimer = setTimeout(() => {
handleCommentUpdate(message, attachments)
}, 200)
}
function createDraftFromObject () {
const newDraft: Comment = {
if (comment) {
comment.message = message
comment.attachments = message
} else {
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
message,
attachments
}
}
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
}

View File

@ -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
})

View File

@ -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
})

View File

@ -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
})

View File

@ -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[]
}

View File

@ -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) => {
findContacts(client, targetClass._id, formatName(targetClass._id, firstName, lastName, object.name), channels).then(
(p) => {
matches = p.contacts
matchedChannels = p.channels
})
}
)
}
</script>

View File

@ -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",

View File

@ -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) {
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 {
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,11 +440,13 @@
]
}
$: findContacts(
$: object.firstName &&
object.lastName &&
findContacts(
client,
contact.class.Person,
{ ...object, name: combineName(firstName.trim(), lastName.trim()) },
channels
combineName(object.firstName.trim(), object.lastName.trim()),
object.channels
).then((p) => {
matches = p.contacts
matchedChannels = p.channels
@ -532,32 +455,19 @@
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}

View File

@ -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>

View File

@ -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

View File

@ -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": {

View File

@ -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) {
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
}
return newDraft
}
export async function onOutsideClick () {
saveDraft()
if (onDraftChanged) {
return onDraftChanged()
if (shouldSaveDraft) {
draftController.save(object, empty)
}
}
$: 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,11 +569,14 @@
{#if parentIssue}
<ParentIssue issue={parentIssue} on:close={clearParentIssue} />
{/if}
<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}
objectId={object._id}
{shouldSaveDraft}
_class={tracker.class.Issue}
space={_space}
@ -696,14 +593,16 @@
}}
/>
{/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,6 +614,7 @@
on:change={({ detail }) => (object.status = detail)}
/>
</div>
<div id="priority-editor">
<PriorityEditor
value={object}
shouldShowLabel
@ -724,6 +624,8 @@
justify="center"
on:change={({ detail }) => (object.priority = detail)}
/>
</div>
<div id="assignee-editor">
<AssigneeEditor
value={object}
size="small"
@ -731,10 +633,11 @@
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

View File

@ -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}

View File

@ -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 })
}
}

View File

@ -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

View File

@ -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
}}

View File

@ -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) => {
$: '_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 })
}
}

View File

@ -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))

View File

@ -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={{

View File

@ -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 })
}
}

View File

@ -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 })
}
}

View File

@ -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">
<div id="sub-issue-name">
<EditBox
bind:value={newIssue.title}
bind:value={object.title}
bind:focusInput={focusIssueTitle}
placeholder={tracker.string.IssueTitlePlaceholder}
focus
/>
<div class="mt-4">
</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 /> -->
<div id="sub-issue-priority">
<PriorityEditor
value={newIssue}
value={object}
shouldShowLabel
isEditable
kind="no-border"
size="small"
justify="center"
on:change={({ detail }) => (newIssue.priority = detail)}
on:change={({ detail }) => (object.priority = detail)}
/>
{#key newIssue.assignee}
</div>
<div id="sub-issue-assignee">
{#key object.assignee}
<AssigneeEditor
value={newIssue}
value={object}
size="small"
kind="no-border"
on:change={({ detail }) => (newIssue.assignee = detail)}
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} />

View File

@ -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;

View File

@ -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}

View File

@ -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}

View File

@ -100,7 +100,7 @@
)
}}
>
<EstimationStatsPresenter value={object} estimation={_value} {currentProject} />
<EstimationStatsPresenter value={object} estimation={_value} />
</div>
</div>
</svelte:fragment>

View File

@ -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

View File

@ -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>

View File

@ -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) {

View File

@ -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) => {

View File

@ -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">
<div id="sub-issue-name">
<EditBox
bind:value={newIssue.title}
bind:value={object.title}
bind:focusInput={focusIssueTitle}
kind={'large-style'}
placeholder={tracker.string.SubIssueTitlePlaceholder}
focus
/>
<div class="mt-4 clear-mins">
{#key newIssue.id}
</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">
<div id="sub-issue-status-editor">
<StatusEditor
value={newIssue}
value={object}
kind="no-border"
size="small"
shouldShowLabel={true}
on:change={({ detail }) => (newIssue.status = detail)}
on:change={({ detail }) => (object.status = detail)}
/>
</div>
<div id="sub-issue-priority-editor">
<PriorityEditor
value={newIssue}
value={object}
shouldShowLabel
isEditable
kind="no-border"
size="small"
justify="center"
on:change={({ detail }) => (newIssue.priority = detail)}
on:change={({ detail }) => (object.priority = detail)}
/>
{#key newIssue.assignee}
</div>
<div id="sub-issue-assignee-editor">
{#key object.assignee}
<AssigneeEditor
value={newIssue}
value={object}
size="small"
kind="no-border"
on:change={({ detail }) => (newIssue.assignee = detail)}
on:change={({ detail }) => (object.assignee = detail)}
/>
{/key}
</div>
<div id="sub-issue-estimation-editor">
<EstimationEditor
kind={'no-border'}
size={'small'}
bind:value={newIssue}
bind:value={object}
on:change={(evt) => {
newIssue.estimation = evt.detail
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>

View File

@ -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
}}
/>

View File

@ -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)

View File

@ -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) => {

View File

@ -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,

View File

@ -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)
})