Export & Import functionality for issues (#5733)

Signed-off-by: Vyacheslav Tumanov <me@slavatumanov.me>
This commit is contained in:
Vyacheslav Tumanov 2024-06-05 19:16:27 +05:00 committed by GitHub
parent f4d3518c3c
commit 14c1977660
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
16 changed files with 313 additions and 9 deletions

View File

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

View File

@ -29,13 +29,15 @@ export default mergeIds(taskId, task, {
ArchiveSpace: '' as Ref<Action>,
UnarchiveSpace: '' as Ref<Action>,
ArchiveState: '' as Ref<Action>,
PublicLink: '' as Ref<Action<Doc, any>>
PublicLink: '' as Ref<Action<Doc, any>>,
ExportTasks: '' as Ref<Action>
},
actionImpl: {
EditStatuses: '' as ViewAction,
ArchiveSpace: '' as ViewAction,
UnarchiveSpace: '' as ViewAction,
SelectStatus: '' as ViewAction
SelectStatus: '' as ViewAction,
ExportTasks: '' as ViewAction
},
category: {
Task: '' as Ref<ActionCategory>,
@ -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
}
})

View File

@ -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<Action<Doc, any>>,

View File

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

View File

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

View File

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

View File

@ -84,6 +84,7 @@
"TaskCreated": "Создана задача",
"TaskType": "Тип задачи",
"ManageProjects": "Управление проектами",
"Export": "Экспортировать",
"CreateProjectType": "Создать тип проекта",
"ClassicProject": "Классический проект",
"LastSave": "Последнее сохранение",

View File

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

View File

@ -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<void> {
navigate(loc)
}
async function exportTasks (docs: Task | Task[]): Promise<void> {
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<PersonAccount>)?.person
const modeifedByAccount = personAccountById.get(d.modifiedBy as Ref<PersonAccount>)?.person
const createdBy = personById.get(createdByAccount as Ref<Employee>)?.name ?? d.createdBy
const modifiedBy = personById.get(modeifedByAccount as Ref<Employee>)?.name ?? d.modifiedBy
const assignee = employeeById.get(d.assignee as Ref<Employee>)?.name ?? d.assignee
const collaborators = ((d as any)['notification:mixin:Collaborators']?.collaborators ?? []).map(
(id: Ref<PersonAccount>) => {
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<PersonAccount>)?.person
const activityModifiedByAccount = personAccountById.get(act.modifiedBy as Ref<PersonAccount>)?.person
const activitycreatedBy =
employeeById.get((activityCreatedByAccount as any as Ref<Employee>) ?? ('' as Ref<Employee>))?.name ??
act.createdBy
const activitymodifiedBy =
employeeById.get((activityModifiedByAccount as any as Ref<Employee>) ?? ('' as Ref<Employee>))?.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<Resources> => ({
},
actionImpl: {
EditStatuses: editStatuses,
SelectStatus: selectStatus
SelectStatus: selectStatus,
ExportTasks: exportTasks
},
function: {
GetAllStates: getAllStates,

View File

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

View File

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

View File

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

View File

@ -44,6 +44,7 @@
"ProjectTitlePlaceholder": "Новый проект",
"UsedInIssueIDs": "Используется в идентификаторах задач",
"Identifier": "Идентификатор",
"Import": "Импорт",
"ProjectIdentifier": "Идентификатор проекта",
"IdentifierExists": "Идентификатор уже существует проекта",
"ProjectIdentifierPlaceholder": "ПКТ",

View File

@ -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<Space> | 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<void> {
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<void> {
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<Project>)
}
}
inputFile.value = ''
}
client.findOne(view.class.Action, { _id: tracker.action.NewIssue }).then((p) => (keys = p?.keyBinding))
</script>
<div class="antiNav-subheader">
<input
bind:this={inputFile}
multiple
type="file"
name="file"
id="tasksInput"
accept="application/json"
style="display: none"
on:change={fileSelected}
/>
<ButtonWithDropdown
icon={IconAdd}
justify={'left'}

View File

@ -29,8 +29,10 @@ import core, {
type DocumentQuery,
type Ref,
type RelatedDocument,
type TxOperations
type TxOperations,
AccountRole
} from '@hcengineering/core'
import chunter, { type ChatMessage } from '@hcengineering/chunter'
import { type Status, translate, type Resources } from '@hcengineering/platform'
import { getClient, MessageBox, type ObjectSearchResult } from '@hcengineering/presentation'
import { type Issue, type Milestone, type Project } from '@hcengineering/tracker'
@ -163,6 +165,10 @@ import { settingId } from '@hcengineering/setting'
import { getAllStates } from '@hcengineering/task-resources'
import EstimationValueEditor from './components/issues/timereport/EstimationValueEditor.svelte'
import TimePresenter from './components/issues/timereport/TimePresenter.svelte'
import { personAccountByIdStore, personAccountPersonByIdStore, personByIdStore } from '@hcengineering/contact-resources'
import contact, { AvatarType } from '@hcengineering/contact'
import task, { type TaskType } from '@hcengineering/task'
import notification, { type Collaborators } from '@hcengineering/notification'
export { default as AssigneeEditor } from './components/issues/AssigneeEditor.svelte'
export { default as SubIssueList } from './components/issues/edit/SubIssueList.svelte'
@ -424,6 +430,167 @@ async function deleteMilestone (milestones: Milestone | Milestone[]): Promise<vo
}
}
type ImportIssue = Issue & { activity: ChatMessage[] }
export async function importTasks (tasks: File, space: Ref<Project>): Promise<void> {
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<Issue>, 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<Doc, Collaborators>(
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<Resources> => ({
activity: {
TxIssueCreated,
@ -551,7 +718,8 @@ export default async (): Promise<Resources> => ({
EditProject: editProject,
DeleteMilestone: deleteMilestone,
DeleteProject: deleteProject,
DeleteIssue: deleteIssue
DeleteIssue: deleteIssue,
ImportIssues: importTasks
},
resolver: {
Location: resolveLocation

View File

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