From 14c197766098f2096c3bee73228d8c88f030d406 Mon Sep 17 00:00:00 2001 From: Vyacheslav Tumanov Date: Wed, 5 Jun 2024 19:16:27 +0500 Subject: [PATCH] Export & Import functionality for issues (#5733) Signed-off-by: Vyacheslav Tumanov --- models/task/src/index.ts | 17 ++ models/task/src/plugin.ts | 9 +- models/tracker/src/plugin.ts | 3 +- plugins/task-assets/lang/en.json | 1 + plugins/task-assets/lang/es.json | 1 + plugins/task-assets/lang/pt.json | 1 + plugins/task-assets/lang/ru.json | 1 + plugins/task-resources/package.json | 1 + plugins/task-resources/src/index.ts | 81 ++++++++- plugins/tracker-assets/lang/en.json | 1 + plugins/tracker-assets/lang/es.json | 1 + plugins/tracker-assets/lang/pt.json | 1 + plugins/tracker-assets/lang/ru.json | 1 + .../src/components/NewIssueHeader.svelte | 30 +++ plugins/tracker-resources/src/index.ts | 172 +++++++++++++++++- plugins/tracker-resources/src/plugin.ts | 1 + 16 files changed, 313 insertions(+), 9 deletions(-) diff --git a/models/task/src/index.ts b/models/task/src/index.ts index 33814ad193..33760e4421 100644 --- a/models/task/src/index.ts +++ b/models/task/src/index.ts @@ -427,6 +427,23 @@ export function createModel (builder: Builder): void { task.action.Move ) + createAction( + builder, + { + label: task.string.Export, + action: task.actionImpl.ExportTasks, + icon: view.icon.Move, + input: 'selection', + category: view.category.General, + target: task.class.Task, + context: { + mode: ['context', 'browser'], + group: 'tools' + } + }, + task.action.ExportTasks + ) + builder.createDoc( core.class.StatusCategory, core.space.Model, diff --git a/models/task/src/plugin.ts b/models/task/src/plugin.ts index a97f343a2f..8279487599 100644 --- a/models/task/src/plugin.ts +++ b/models/task/src/plugin.ts @@ -29,13 +29,15 @@ export default mergeIds(taskId, task, { ArchiveSpace: '' as Ref, UnarchiveSpace: '' as Ref, ArchiveState: '' as Ref, - PublicLink: '' as Ref> + PublicLink: '' as Ref>, + ExportTasks: '' as Ref }, actionImpl: { EditStatuses: '' as ViewAction, ArchiveSpace: '' as ViewAction, UnarchiveSpace: '' as ViewAction, - SelectStatus: '' as ViewAction + SelectStatus: '' as ViewAction, + ExportTasks: '' as ViewAction }, category: { Task: '' as Ref, @@ -74,6 +76,7 @@ export default mergeIds(taskId, task, { ManageProjects: '' as IntlString, StateBacklog: '' as IntlString, StateActive: '' as IntlString, - StateUnstarted: '' as IntlString + StateUnstarted: '' as IntlString, + Export: '' as IntlString } }) diff --git a/models/tracker/src/plugin.ts b/models/tracker/src/plugin.ts index f12964cc9c..7f5e01b46c 100644 --- a/models/tracker/src/plugin.ts +++ b/models/tracker/src/plugin.ts @@ -104,7 +104,8 @@ export default mergeIds(trackerId, tracker, { EditProject: '' as ViewAction, DeleteProject: '' as ViewAction, DeleteIssue: '' as ViewAction, - DeleteMilestone: '' as ViewAction + DeleteMilestone: '' as ViewAction, + ImportIssues: '' as ViewAction }, action: { NewRelatedIssue: '' as Ref>, diff --git a/plugins/task-assets/lang/en.json b/plugins/task-assets/lang/en.json index 37ee880f52..066596b8d3 100644 --- a/plugins/task-assets/lang/en.json +++ b/plugins/task-assets/lang/en.json @@ -84,6 +84,7 @@ "TaskCreated": "Task created", "TaskType": "Task type", "ManageProjects": "Project types", + "Export": "Export", "CreateProjectType": "Create project type", "ClassicProject": "Classic project", "LastSave": "Last save", diff --git a/plugins/task-assets/lang/es.json b/plugins/task-assets/lang/es.json index 8a693ba883..913edb49e3 100644 --- a/plugins/task-assets/lang/es.json +++ b/plugins/task-assets/lang/es.json @@ -84,6 +84,7 @@ "TaskCreated": "Tarea creada", "TaskType": "Tipo de tarea", "ManageProjects": "Tipos de proyecto", + "Export": "Exportar", "CreateProjectType": "Crear tipo de proyecto", "ClassicProject": "Proyecto clásico", "LastSave": "Último guardado", diff --git a/plugins/task-assets/lang/pt.json b/plugins/task-assets/lang/pt.json index 1be19701c6..bc4ad2a48a 100644 --- a/plugins/task-assets/lang/pt.json +++ b/plugins/task-assets/lang/pt.json @@ -84,6 +84,7 @@ "TaskCreated": "Tarefa criada", "TaskType": "Tipo de tarefa", "ManageProjects": "Tipos de projetos", + "Export": "Exportar", "CreateProjectType": "Criar tipo de projeto", "ClassicProject": "Projeto clássico", "Última gravação": "Última gravação", diff --git a/plugins/task-assets/lang/ru.json b/plugins/task-assets/lang/ru.json index ec6ec644ea..20cc5f1242 100644 --- a/plugins/task-assets/lang/ru.json +++ b/plugins/task-assets/lang/ru.json @@ -84,6 +84,7 @@ "TaskCreated": "Создана задача", "TaskType": "Тип задачи", "ManageProjects": "Управление проектами", + "Export": "Экспортировать", "CreateProjectType": "Создать тип проекта", "ClassicProject": "Классический проект", "LastSave": "Последнее сохранение", diff --git a/plugins/task-resources/package.json b/plugins/task-resources/package.json index a54907039d..3db5f62ffc 100644 --- a/plugins/task-resources/package.json +++ b/plugins/task-resources/package.json @@ -43,6 +43,7 @@ "@hcengineering/task": "^0.6.20", "@hcengineering/ui": "^0.6.15", "@hcengineering/presentation": "^0.6.3", + "@hcengineering/activity": "^0.6.0", "@hcengineering/text-editor": "^0.6.0", "@hcengineering/contact": "^0.6.24", "@hcengineering/core": "^0.6.32", diff --git a/plugins/task-resources/src/index.ts b/plugins/task-resources/src/index.ts index 883b22ce95..5364188cb4 100644 --- a/plugins/task-resources/src/index.ts +++ b/plugins/task-resources/src/index.ts @@ -14,7 +14,7 @@ // limitations under the License. // -import { +import core, { toIdMap, type Attribute, type Class, @@ -38,8 +38,11 @@ import task, { } from '@hcengineering/task' import { getCurrentLocation, navigate, showPopup } from '@hcengineering/ui' import { type ViewletDescriptor } from '@hcengineering/view' -import { CategoryQuery, statusStore } from '@hcengineering/view-resources' +import { CategoryQuery, groupBy, statusStore } from '@hcengineering/view-resources' import { get, writable } from 'svelte/store' +import { type Employee, type PersonAccount } from '@hcengineering/contact' +import activity from '@hcengineering/activity' +import chunter from '@hcengineering/chunter' import AssignedTasks from './components/AssignedTasks.svelte' import Dashboard from './components/Dashboard.svelte' @@ -72,6 +75,7 @@ import ProjectTypeTasksTypeSectionEditor from './components/projectTypes/Project import ProjectTypeAutomationsSectionEditor from './components/projectTypes/ProjectTypeAutomationsSectionEditor.svelte' import ProjectTypeCollectionsSectionEditor from './components/projectTypes/ProjectTypeCollectionsSectionEditor.svelte' import TaskTypeEditor from './components/taskTypes/TaskTypeEditor.svelte' +import { employeeByIdStore, personAccountByIdStore, personByIdStore } from '@hcengineering/contact-resources' export { default as AssigneePresenter } from './components/AssigneePresenter.svelte' export { default as TypeSelector } from './components/TypeSelector.svelte' @@ -86,6 +90,76 @@ async function editStatuses (object: Project, ev: Event): Promise { navigate(loc) } +async function exportTasks (docs: Task | Task[]): Promise { + const client = getClient() + const ddocs = Array.isArray(docs) ? docs : [docs] + const docsStatuses = ddocs.map((doc) => doc.status) + const statuses = await client.findAll(core.class.Status, { _id: { $in: docsStatuses } }) + const personAccountById = get(personAccountByIdStore) + const personById = get(personByIdStore) + const employeeById = get(employeeByIdStore) + const statusMap = toIdMap(statuses) + const activityMessages = await client.findAll(activity.class.ActivityMessage, { + _class: chunter.class.ChatMessage, + attachedToClass: { $in: ddocs.map((d) => d._class) }, + attachedTo: { $in: ddocs.map((d) => d._id) } + }) + const activityByDoc = groupBy(activityMessages, 'attachedTo') + + const toExport = ddocs.map((d) => { + const statusName = statusMap.get(d.status)?.name ?? d.status + const createdByAccount = personAccountById.get(d.createdBy as Ref)?.person + const modeifedByAccount = personAccountById.get(d.modifiedBy as Ref)?.person + const createdBy = personById.get(createdByAccount as Ref)?.name ?? d.createdBy + const modifiedBy = personById.get(modeifedByAccount as Ref)?.name ?? d.modifiedBy + const assignee = employeeById.get(d.assignee as Ref)?.name ?? d.assignee + const collaborators = ((d as any)['notification:mixin:Collaborators']?.collaborators ?? []).map( + (id: Ref) => { + const personAccount = personAccountById.get(id)?.person + return personAccount !== undefined ? personById.get(personAccount)?.name ?? id : id + } + ) + const activityForDoc = (activityByDoc[d._id] ?? []).map((act) => { + const activityCreatedByAccount = personAccountById.get(act.createdBy as Ref)?.person + const activityModifiedByAccount = personAccountById.get(act.modifiedBy as Ref)?.person + const activitycreatedBy = + employeeById.get((activityCreatedByAccount as any as Ref) ?? ('' as Ref))?.name ?? + act.createdBy + const activitymodifiedBy = + employeeById.get((activityModifiedByAccount as any as Ref) ?? ('' as Ref))?.name ?? + act.modifiedBy + return { + ...act, + createdBy: activitycreatedBy, + modifiedBy: activitymodifiedBy + } + }) + return { + ...d, + status: statusName, + createdBy, + modifiedBy, + assignee, + 'notification:mixin:Collaborators': { + collaborators + }, + activity: activityForDoc + } + }) + const filename = 'tasks' + new Date().toLocaleDateString() + '.json' + const link = document.createElement('a') + link.style.display = 'none' + link.setAttribute('target', '_blank') + link.setAttribute( + 'href', + 'data:application/json;charset=utf-8,%EF%BB%BF' + encodeURIComponent(JSON.stringify(toExport)) + ) + link.setAttribute('download', filename) + document.body.appendChild(link) + link.click() + document.body.removeChild(link) +} + async function selectStatus ( doc: Task | Task[], ev: any, @@ -137,7 +211,8 @@ export default async (): Promise => ({ }, actionImpl: { EditStatuses: editStatuses, - SelectStatus: selectStatus + SelectStatus: selectStatus, + ExportTasks: exportTasks }, function: { GetAllStates: getAllStates, diff --git a/plugins/tracker-assets/lang/en.json b/plugins/tracker-assets/lang/en.json index 9f007c09a8..d3980a6ff2 100644 --- a/plugins/tracker-assets/lang/en.json +++ b/plugins/tracker-assets/lang/en.json @@ -44,6 +44,7 @@ "ProjectTitlePlaceholder": "New project", "UsedInIssueIDs": "Used in issue IDs", "Identifier": "Identifier", + "Import": "Import", "ProjectIdentifier": "Project Identifier", "IdentifierExists": "Project identifier already exists", "ProjectIdentifierPlaceholder": "PRJCT", diff --git a/plugins/tracker-assets/lang/es.json b/plugins/tracker-assets/lang/es.json index 4e8bda0081..a743c97522 100644 --- a/plugins/tracker-assets/lang/es.json +++ b/plugins/tracker-assets/lang/es.json @@ -44,6 +44,7 @@ "ProjectTitlePlaceholder": "Nuevo proyecto", "UsedInIssueIDs": "Utilizado en IDs de tareas", "Identifier": "Identificador", + "Import": "Importar", "ProjectIdentifier": "Identificador del proyecto", "IdentifierExists": "El identificador del proyecto ya existe", "ProjectIdentifierPlaceholder": "PROJ", diff --git a/plugins/tracker-assets/lang/pt.json b/plugins/tracker-assets/lang/pt.json index 419bfd34ac..b5972deebb 100644 --- a/plugins/tracker-assets/lang/pt.json +++ b/plugins/tracker-assets/lang/pt.json @@ -44,6 +44,7 @@ "ProjectTitlePlaceholder": "Novo projeto", "UsedInIssueIDs": "Usado em IDs de problemas", "Identifier": "Identificador", + "Import": "Importar", "ProjectIdentifier": "Identificador do projeto", "IdentifierExists": "O identificador do projeto já existe", "ProjectIdentifierPlaceholder": "PROJ", diff --git a/plugins/tracker-assets/lang/ru.json b/plugins/tracker-assets/lang/ru.json index fa399a021d..055acc401d 100644 --- a/plugins/tracker-assets/lang/ru.json +++ b/plugins/tracker-assets/lang/ru.json @@ -44,6 +44,7 @@ "ProjectTitlePlaceholder": "Новый проект", "UsedInIssueIDs": "Используется в идентификаторах задач", "Identifier": "Идентификатор", + "Import": "Импорт", "ProjectIdentifier": "Идентификатор проекта", "IdentifierExists": "Идентификатор уже существует проекта", "ProjectIdentifierPlaceholder": "ПКТ", diff --git a/plugins/tracker-resources/src/components/NewIssueHeader.svelte b/plugins/tracker-resources/src/components/NewIssueHeader.svelte index 358f4881bf..a6e42d33ce 100644 --- a/plugins/tracker-resources/src/components/NewIssueHeader.svelte +++ b/plugins/tracker-resources/src/components/NewIssueHeader.svelte @@ -20,6 +20,8 @@ import { onDestroy } from 'svelte' import tracker from '../plugin' import CreateIssue from './CreateIssue.svelte' + import { importTasks } from '..' + import { Project } from '@hcengineering/tracker' export let currentSpace: Ref | undefined @@ -50,6 +52,10 @@ { id: tracker.string.NewIssue, label + }, + { + id: tracker.string.Import, + label: tracker.string.Import } ] : [ @@ -61,6 +67,7 @@ const client = getClient() let keys: string[] | undefined = undefined + let inputFile: HTMLInputElement async function dropdownItemSelected (res?: SelectPopupValueType['id']): Promise { if (res == null) return @@ -69,15 +76,38 @@ showPopup(tracker.component.CreateProject, {}, 'top', () => { closed = true }) + } else if (res === tracker.string.Import) { + inputFile.click() } else { await newIssue() } } + async function fileSelected (): Promise { + const list = inputFile.files + if (list === null || list.length === 0) return + for (let index = 0; index < list.length; index++) { + const file = list.item(index) + if (file !== null && currentSpace != null) { + await importTasks(file, currentSpace as Ref) + } + } + inputFile.value = '' + } client.findOne(view.class.Action, { _id: tracker.action.NewIssue }).then((p) => (keys = p?.keyBinding))
+ ): Promise { + const reader = new FileReader() + reader.readAsText(tasks) + + const personAccountById = get(personAccountByIdStore) + const personAccountList = Array.from(personAccountById.values()) + const personAccountPersonById = get(personAccountPersonByIdStore) + const personList = Array.from(get(personByIdStore).values()) + + const client = getClient() + const statuses = await client.findAll(tracker.class.IssueStatus, {}) + reader.onload = async () => { + let tasksArray: ImportIssue[] = Array.from(JSON.parse(reader.result as string)) + const personToImport = Array.from( + new Set(tasksArray.flatMap((t) => [t.createdBy, t.modifiedBy, ...t.activity.flatMap((act) => act.modifiedBy)])) + ).filter((x) => x !== undefined) as string[] + const peopleToAdd = personToImport.filter((p) => personList.find((x) => x.name === p) === undefined) + if (peopleToAdd.length > 0) { + console.log('Next people will be created to import properly', peopleToAdd) + for (const personToCreate of peopleToAdd) { + const personId = await client.createDoc(contact.class.Person, contact.space.Contacts, { + name: personToCreate, + avatarType: AvatarType.COLOR, + city: '', + comments: 0, + channels: 0, + attachments: 0 + }) + await client.createDoc(contact.class.PersonAccount, core.space.Model, { + email: `imported:${personId}`, + person: personId, + role: AccountRole.User + }) + } + } + const idsParent: Array<{ id: Ref, identifier: string }> = [] + + while (tasksArray.length > 0) { + let taskParsing: ImportIssue | undefined = tasksArray.find((t: ImportIssue) => t?.parents?.length === 0) + if (taskParsing === undefined) { + taskParsing = tasksArray.find((t: Issue) => + t?.parents?.every((p) => idsParent.findIndex((par) => par.id === p.parentId) !== -1) + ) + } + if (taskParsing != null) { + tasksArray = tasksArray.filter((t) => t._id !== taskParsing?._id) + const proj = await client.findOne(tracker.class.Project, { _id: space }) + const modifiedByPerson = personList.find((p) => p.name === taskParsing?.modifiedBy)?._id + const assignee = + taskParsing.assignee !== null ? personList.find((p) => p.name === taskParsing?.assignee)?._id ?? null : null + if (modifiedByPerson === undefined) throw new Error('Person not found') + const modifiedBy = personAccountList.find((pA) => pA.person === modifiedByPerson)?._id + if (modifiedBy === undefined) throw new Error('modifiedBy account not found') + + const collaborators = (taskParsing as any)['notification:mixin:Collaborators']?.collaborators + const collaboratorsToImport = + collaborators !== undefined + ? collaborators + .map((name: string) => { + const person = personList.find((p) => p.name === name)?._id + if (person === undefined) return undefined + const account = personAccountPersonById.get(person) + return account?._id + }) + .filter((c: any) => c !== undefined) + : undefined + + const incResult = await client.updateDoc( + tracker.class.Project, + core.space.Space, + space, + { + $inc: { sequence: 1 } + }, + true + ) + const number = (incResult as any).object.sequence + const identifier = `${proj?.identifier}-${number}` + idsParent.push({ id: taskParsing._id, identifier }) + const taskKind = proj?.type !== undefined ? { parent: proj.type } : {} + const kind = (await client.findOne(task.class.TaskType, taskKind)) as TaskType + const status = statuses.find((s) => s.name === taskParsing?.status)?._id + if (status === undefined) throw new Error('status not found') + const taskToCreate = { + title: taskParsing.title, + description: taskParsing.description, + component: taskParsing.component, + milestone: taskParsing.milestone, + number, + status, + priority: taskParsing.priority, + rank: taskParsing.rank, + comments: 0, + subIssues: 0, + dueDate: taskParsing.dueDate, + parents: taskParsing.parents.map((p) => ({ + ...p, + space, + identifier: idsParent.find((par) => par.id === p.parentId)?.identifier ?? p.identifier + })), + reportedTime: 0, + remainingTime: 0, + estimation: taskParsing.estimation, + reports: 0, + childInfo: taskParsing.childInfo, + identifier, + modifiedBy, + assignee, + kind: kind._id + } + await client.addCollection( + tracker.class.Issue, + space, + taskParsing?.attachedTo ?? tracker.ids.NoParent, + taskParsing._class, + 'subIssues', + taskToCreate, + taskParsing._id + ) + + if (collaboratorsToImport !== undefined) { + await client.createMixin( + taskParsing._id, + taskParsing._class, + space, + notification.mixin.Collaborators, + { + collaborators: collaboratorsToImport + } + ) + } + + // Push activity + if (taskParsing.activity !== undefined) { + const act = taskParsing.activity.sort((a, b) => a.modifiedOn - b.modifiedOn) + for (const activityMessage of act) { + const modifiedByPerson = personList.find((p) => p.name === activityMessage.modifiedBy)?._id + const modifiedBy = personAccountList.find((pA) => pA.person === modifiedByPerson)?._id + if (modifiedBy === undefined) throw new Error('modifiedBy account not found') + await client.addCollection( + chunter.class.ChatMessage, + space, + taskParsing._id, + tracker.class.Issue, + 'comments', + { + message: activityMessage.message + }, + activityMessage._id, + activityMessage.modifiedOn, + modifiedBy + ) + } + } + } + } + } +} + export default async (): Promise => ({ activity: { TxIssueCreated, @@ -551,7 +718,8 @@ export default async (): Promise => ({ EditProject: editProject, DeleteMilestone: deleteMilestone, DeleteProject: deleteProject, - DeleteIssue: deleteIssue + DeleteIssue: deleteIssue, + ImportIssues: importTasks }, resolver: { Location: resolveLocation diff --git a/plugins/tracker-resources/src/plugin.ts b/plugins/tracker-resources/src/plugin.ts index 255fd71fe3..051bcaa02d 100644 --- a/plugins/tracker-resources/src/plugin.ts +++ b/plugins/tracker-resources/src/plugin.ts @@ -101,6 +101,7 @@ export default mergeIds(trackerId, tracker, { Title: '' as IntlString, UsedInIssueIDs: '' as IntlString, Identifier: '' as IntlString, + Import: '' as IntlString, ProjectIdentifier: '' as IntlString, IdentifierExists: '' as IntlString, Description: '' as IntlString,