Tracker: parent issues name (#2136)

Signed-off-by: Sergei Ogorelkov <sergei.ogorelkov@xored.com>
This commit is contained in:
Sergei Ogorelkov 2022-06-27 13:04:26 +07:00 committed by GitHub
parent a74ae0ee89
commit 6f0bd0a6fe
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
14 changed files with 225 additions and 49 deletions

View File

@ -10,9 +10,10 @@ Tracker:
- Remember view options - Remember view options
- My issues - My issues
- Names of parent issues
- Roadmap - Roadmap
- Remember view options
- Context menus (Priority/Status/Assignee) - Context menus (Priority/Status/Assignee)
-
Chunter: Chunter:

View File

@ -20,6 +20,6 @@ import serverTracker from '@anticrm/server-tracker'
export function createModel (builder: Builder): void { export function createModel (builder: Builder): void {
builder.createDoc(serverCore.class.Trigger, core.space.Model, { builder.createDoc(serverCore.class.Trigger, core.space.Model, {
trigger: serverTracker.trigger.OnIssueProjectUpdate trigger: serverTracker.trigger.OnIssueUpdate
}) })
} }

View File

@ -43,6 +43,7 @@ import task from '@anticrm/task'
import { import {
Document, Document,
Issue, Issue,
IssueParentInfo,
IssuePriority, IssuePriority,
IssueStatus, IssueStatus,
IssueStatusCategory, IssueStatusCategory,
@ -180,6 +181,8 @@ export class TIssue extends TAttachedDoc implements Issue {
@Prop(ArrOf(TypeRef(tracker.class.Issue)), tracker.string.RelatedTo) @Prop(ArrOf(TypeRef(tracker.class.Issue)), tracker.string.RelatedTo)
relatedIssue!: Ref<Issue>[] relatedIssue!: Ref<Issue>[]
parents!: IssueParentInfo[]
@Prop(Collection(chunter.class.Comment), tracker.string.Comments) @Prop(Collection(chunter.class.Comment), tracker.string.Comments)
comments!: number comments!: number

View File

@ -225,6 +225,36 @@ async function migrateParentIssues (client: MigrationClient): Promise<void> {
} }
} }
async function updateIssueParentInfo (client: MigrationClient, parentIssue: Issue | null): Promise<void> {
const parents =
parentIssue === null ? [] : [{ parentId: parentIssue._id, parentTitle: parentIssue.title }, ...parentIssue.parents]
const migrationResult = await client.update<Issue>(
DOMAIN_TRACKER,
{
_class: tracker.class.Issue,
attachedTo: parentIssue?._id ?? tracker.ids.NoParent,
parents: { $exists: false }
},
{ parents }
)
if (migrationResult.matched > 0) {
const subIssues = await client.find<Issue>(DOMAIN_TRACKER, {
_class: tracker.class.Issue,
attachedTo: parentIssue?._id ?? tracker.ids.NoParent,
subIssues: { $gt: 0 }
})
for (const issue of subIssues) {
await updateIssueParentInfo(client, issue)
}
}
}
async function migrateIssueParentInfo (client: MigrationClient): Promise<void> {
await updateIssueParentInfo(client, null)
}
async function migrateIssueProjects (client: MigrationClient): Promise<void> { async function migrateIssueProjects (client: MigrationClient): Promise<void> {
const issues = await client.find(DOMAIN_TRACKER, { _class: tracker.class.Issue, project: { $exists: false } }) const issues = await client.find(DOMAIN_TRACKER, { _class: tracker.class.Issue, project: { $exists: false } })
@ -288,6 +318,7 @@ async function upgradeProjects (tx: TxOperations): Promise<void> {
export const trackerOperation: MigrateOperation = { export const trackerOperation: MigrateOperation = {
async migrate (client: MigrationClient): Promise<void> { async migrate (client: MigrationClient): Promise<void> {
await Promise.all([migrateIssueProjects(client), migrateParentIssues(client)]) await Promise.all([migrateIssueProjects(client), migrateParentIssues(client)])
await migrateIssueParentInfo(client)
}, },
async upgrade (client: MigrationUpgradeClient): Promise<void> { async upgrade (client: MigrationUpgradeClient): Promise<void> {
const tx = new TxOperations(client, core.account.System) const tx = new TxOperations(client, core.account.System)

View File

@ -64,7 +64,8 @@
priority: priority, priority: priority,
dueDate: null, dueDate: null,
comments: 0, comments: 0,
subIssues: 0 subIssues: 0,
parents: []
} }
const dispatch = createEventDispatcher() const dispatch = createEventDispatcher()
@ -143,7 +144,10 @@
rank: calcRank(lastOne, undefined), rank: calcRank(lastOne, undefined),
comments: 0, comments: 0,
subIssues: 0, subIssues: 0,
dueDate: object.dueDate dueDate: object.dueDate,
parents: parentIssue
? [{ parentId: parentIssue._id, parentTitle: parentIssue.title }, ...parentIssue.parents]
: []
} }
await client.addCollection( await client.addCollection(

View File

@ -13,7 +13,7 @@
// limitations under the License. // limitations under the License.
--> -->
<script lang="ts"> <script lang="ts">
import { AttachedData, FindOptions, SortingOrder } from '@anticrm/core' import { AttachedData, FindOptions, Ref, SortingOrder } from '@anticrm/core'
import { getClient, ObjectPopup } from '@anticrm/presentation' import { getClient, ObjectPopup } from '@anticrm/presentation'
import { calcRank, Issue, IssueStatusCategory } from '@anticrm/tracker' import { calcRank, Issue, IssueStatusCategory } from '@anticrm/tracker'
import { Icon } from '@anticrm/ui' import { Icon } from '@anticrm/ui'
@ -25,7 +25,6 @@
export let width: 'medium' | 'large' | 'full' = 'large' export let width: 'medium' | 'large' | 'full' = 'large'
const client = getClient() const client = getClient()
const dispatch = createEventDispatcher() const dispatch = createEventDispatcher()
const options: FindOptions<Issue> = { const options: FindOptions<Issue> = {
lookup: { status: tracker.class.IssueStatus, space: tracker.class.Team }, lookup: { status: tracker.class.IssueStatus, space: tracker.class.Team },
@ -43,7 +42,12 @@
async function onClose ({ detail: parentIssue }: CustomEvent<Issue | undefined | null>) { async function onClose ({ detail: parentIssue }: CustomEvent<Issue | undefined | null>) {
const vv = Array.isArray(value) ? value : [value] const vv = Array.isArray(value) ? value : [value]
for (const docValue of vv) { for (const docValue of vv) {
if ('_id' in docValue && parentIssue !== undefined && parentIssue?._id !== docValue.attachedTo) { if (
'_id' in docValue &&
parentIssue !== undefined &&
parentIssue?._id !== docValue.attachedTo &&
parentIssue?._id !== docValue._id
) {
let rank: string | null = null let rank: string | null = null
if (parentIssue) { if (parentIssue) {
@ -68,12 +72,24 @@
$: selected = !Array.isArray(value) ? ('attachedTo' in value ? value.attachedTo : undefined) : undefined $: selected = !Array.isArray(value) ? ('attachedTo' in value ? value.attachedTo : undefined) : undefined
$: ignoreObjects = !Array.isArray(value) ? ('_id' in value ? [value._id] : []) : undefined $: ignoreObjects = !Array.isArray(value) ? ('_id' in value ? [value._id] : []) : undefined
$: docQuery = {
'parents.parentId': {
$nin: [
...new Set(
(Array.isArray(value) ? value : [value])
.map((issue) => ('_id' in issue ? issue._id : null))
.filter((x): x is Ref<Issue> => x !== null)
)
]
}
}
$: updateIssueStatusCategories() $: updateIssueStatusCategories()
</script> </script>
<ObjectPopup <ObjectPopup
_class={tracker.class.Issue} _class={tracker.class.Issue}
{options} {options}
{docQuery}
{selected} {selected}
multiSelect={false} multiSelect={false}
allowDeselect={true} allowDeselect={true}

View File

@ -31,6 +31,7 @@
import AssigneePresenter from './AssigneePresenter.svelte' import AssigneePresenter from './AssigneePresenter.svelte'
import SubIssuesSelector from './edit/SubIssuesSelector.svelte' import SubIssuesSelector from './edit/SubIssuesSelector.svelte'
import IssuePresenter from './IssuePresenter.svelte' import IssuePresenter from './IssuePresenter.svelte'
import ParentNamesPresenter from './ParentNamesPresenter.svelte'
import PriorityEditor from './PriorityEditor.svelte' import PriorityEditor from './PriorityEditor.svelte'
export let currentSpace: Ref<Team> = tracker.team.DefaultTeam export let currentSpace: Ref<Team> = tracker.team.DefaultTeam
@ -172,8 +173,11 @@
showPanel(tracker.component.EditIssue, object._id, object._class, 'content') showPanel(tracker.component.EditIssue, object._id, object._class, 'content')
}} }}
> >
<div class="flex-col mr-6"> <div class="flex-col mr-8">
<div class="flex clear-mins names">
<IssuePresenter value={issue} /> <IssuePresenter value={issue} />
<ParentNamesPresenter value={issue} />
</div>
<span class="fs-bold caption-color mt-1 lines-limit-2"> <span class="fs-bold caption-color mt-1 lines-limit-2">
{object.title} {object.title}
</span> </span>
@ -210,6 +214,10 @@
{/await} {/await}
<style lang="scss"> <style lang="scss">
.names {
font-size: 0.8125rem;
}
.header { .header {
padding-bottom: 0.75rem; padding-bottom: 0.75rem;
border-bottom: 1px solid var(--divider-color); border-bottom: 1px solid var(--divider-color);

View File

@ -0,0 +1,48 @@
<!--
// Copyright © 2022 Hardcore Engineering Inc.
//
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License. You may
// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
//
// See the License for the specific language governing permissions and
// limitations under the License.
-->
<script lang="ts">
import type { Issue } from '@anticrm/tracker'
export let value: Issue | undefined
</script>
{#if value}
<div class="root">
<span class="names">
{#each value.parents as parentInfo}
<span class="name">{parentInfo.parentTitle}</span>
{/each}
</span>
</div>
{/if}
<style lang="scss">
.root {
display: flex;
min-width: 0;
.names {
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
color: var(--content-color);
}
.name::before {
content: '';
padding: 0 0.25rem;
}
}
</style>

View File

@ -14,25 +14,29 @@
--> -->
<script lang="ts"> <script lang="ts">
import type { Issue } from '@anticrm/tracker' import type { Issue } from '@anticrm/tracker'
import ParentNamesPresenter from './ParentNamesPresenter.svelte'
export let value: Issue export let value: Issue
export let shouldUseMargin: boolean = false export let shouldUseMargin: boolean = false
</script> </script>
{#if value} {#if value}
<span class="titleLabel flex-grow" class:mTitleLabelWithMargin={shouldUseMargin} title={value.title} <span class="root" class:with-margin={shouldUseMargin} title={value.title}>
>{value.title}</span <span class="name">{value.title}</span>
> <ParentNamesPresenter {value} />
</span>
{/if} {/if}
<style lang="scss"> <style lang="scss">
.titleLabel { .root {
flex-shrink: 10; display: flex;
overflow: hidden; flex-grow: 1;
min-width: 0;
white-space: nowrap; white-space: nowrap;
text-overflow: ellipsis; overflow: hidden;
flex-shrink: 10;
&.mTitleLabelWithMargin { &.with-margin {
margin-left: 0.5rem; margin-left: 0.5rem;
} }
} }

View File

@ -54,7 +54,8 @@
priority: IssuePriority.NoPriority, priority: IssuePriority.NoPriority,
dueDate: null, dueDate: null,
comments: 0, comments: 0,
subIssues: 0 subIssues: 0,
parents: []
} }
} }
@ -94,7 +95,8 @@
...newIssue, ...newIssue,
title: getTitle(newIssue.title), title: getTitle(newIssue.title),
number: (incResult as any).object.sequence, number: (incResult as any).object.sequence,
rank: calcRank(lastOne, undefined) rank: calcRank(lastOne, undefined),
parents: [{ parentId: parentIssue._id, parentTitle: parentIssue.title }, ...parentIssue.parents]
} }
const objectId = await client.addCollection( const objectId = await client.addCollection(

View File

@ -112,6 +112,7 @@ export interface Issue extends AttachedDoc {
subIssues: number subIssues: number
blockedBy?: Ref<Issue>[] blockedBy?: Ref<Issue>[]
relatedIssue?: Ref<Issue>[] relatedIssue?: Ref<Issue>[]
parents: IssueParentInfo[]
comments: number comments: number
attachments?: number attachments?: number
@ -124,6 +125,14 @@ export interface Issue extends AttachedDoc {
rank: string rank: string
} }
/**
* @public
*/
export interface IssueParentInfo {
parentId: Ref<Issue>
parentTitle: string
}
/** /**
* @public * @public
*/ */

View File

@ -13,37 +13,27 @@
// limitations under the License. // limitations under the License.
// //
import core, { AttachedData, Tx, TxUpdateDoc } from '@anticrm/core' import core, { DocumentUpdate, Ref, Tx, TxUpdateDoc } from '@anticrm/core'
import { extractTx, TriggerControl } from '@anticrm/server-core' import { extractTx, TriggerControl } from '@anticrm/server-core'
import tracker, { Issue } from '@anticrm/tracker' import tracker, { Issue } from '@anticrm/tracker'
async function updateSubIssues ( async function updateSubIssues (
updateTx: TxUpdateDoc<Issue>,
control: TriggerControl, control: TriggerControl,
node: Issue, update: DocumentUpdate<Issue> | ((node: Issue) => DocumentUpdate<Issue>)
update: Partial<AttachedData<Issue>>,
shouldSkip = false
): Promise<TxUpdateDoc<Issue>[]> { ): Promise<TxUpdateDoc<Issue>[]> {
let txes: TxUpdateDoc<Issue>[] = [] const subIssues = await control.findAll(tracker.class.Issue, { 'parents.parentId': updateTx.objectId })
if (!shouldSkip && Object.entries(update).some(([key, value]) => value !== node[key as keyof Issue])) { return subIssues.map((issue) => {
txes.push(control.txFactory.createTxUpdateDoc(node._class, node.space, node._id, update)) const docUpdate = typeof update === 'function' ? update(issue) : update
} return control.txFactory.createTxUpdateDoc(issue._class, issue.space, issue._id, docUpdate)
})
if (node.subIssues > 0) {
const subIssues = await control.findAll(tracker.class.Issue, { attachedTo: node._id })
for (const subIssue of subIssues) {
txes = txes.concat(await updateSubIssues(control, subIssue, update))
}
}
return txes
} }
/** /**
* @public * @public
*/ */
export async function OnIssueProjectUpdate (tx: Tx, control: TriggerControl): Promise<Tx[]> { export async function OnIssueUpdate (tx: Tx, control: TriggerControl): Promise<Tx[]> {
const actualTx = extractTx(tx) const actualTx = extractTx(tx)
if (actualTx._class !== core.class.TxUpdateDoc) { if (actualTx._class !== core.class.TxUpdateDoc) {
return [] return []
@ -54,22 +44,56 @@ export async function OnIssueProjectUpdate (tx: Tx, control: TriggerControl): Pr
return [] return []
} }
if (!Object.prototype.hasOwnProperty.call(updateTx.operations, 'project')) { const res: Tx[] = []
return []
}
const update: Partial<AttachedData<Issue>> = { project: updateTx.operations.project ?? null } if (Object.prototype.hasOwnProperty.call(updateTx.operations, 'attachedTo')) {
const [node] = await control.findAll( const [newParent] = await control.findAll(
updateTx.objectClass, tracker.class.Issue,
{ _id: updateTx.objectId, subIssues: { $gt: 0 } }, { _id: updateTx.operations.attachedTo as Ref<Issue> },
{ limit: 1 } { limit: 1 }
) )
return node !== undefined ? await updateSubIssues(control, node, update, true) : [] const updatedParents =
newParent !== undefined ? [{ parentId: newParent._id, parentTitle: newParent.title }, ...newParent.parents] : []
function update (issue: Issue): DocumentUpdate<Issue> {
const parentInfoIndex = issue.parents.findIndex(({ parentId }) => parentId === updateTx.objectId)
return parentInfoIndex === -1
? {}
: { parents: [...issue.parents].slice(0, parentInfoIndex + 1).concat(updatedParents) }
}
res.push(
control.txFactory.createTxUpdateDoc(updateTx.objectClass, updateTx.objectSpace, updateTx.objectId, {
parents: updatedParents
}),
...(await updateSubIssues(updateTx, control, update))
)
}
if (Object.prototype.hasOwnProperty.call(updateTx.operations, 'project')) {
res.push(...(await updateSubIssues(updateTx, control, { project: updateTx.operations.project })))
}
if (Object.prototype.hasOwnProperty.call(updateTx.operations, 'title')) {
function update (issue: Issue): DocumentUpdate<Issue> {
const parentInfoIndex = issue.parents.findIndex(({ parentId }) => parentId === updateTx.objectId)
const updatedParentInfo = { ...issue.parents[parentInfoIndex], parentTitle: updateTx.operations.title as string }
const updatedParents = [...issue.parents]
updatedParents[parentInfoIndex] = updatedParentInfo
return { parents: updatedParents }
}
res.push(...(await updateSubIssues(updateTx, control, update)))
}
return res
} }
// eslint-disable-next-line @typescript-eslint/explicit-function-return-type // eslint-disable-next-line @typescript-eslint/explicit-function-return-type
export default async () => ({ export default async () => ({
trigger: { trigger: {
OnIssueProjectUpdate OnIssueUpdate
} }
}) })

View File

@ -0,0 +1,26 @@
//
// Copyright © 2022 Hardcore Engineering Inc.
//
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License. You may
// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
//
// See the License for the specific language governing permissions and
// limitations under the License.
//
export function arrayEquals<T> (first: Array<T>, second: Array<T>): boolean {
if (first === second) {
return true
}
if (first.length !== second.length) {
return false
}
return first.every((val, index) => val === second[index])
}

View File

@ -27,6 +27,6 @@ export const serverTrackerId = 'server-tracker' as Plugin
*/ */
export default plugin(serverTrackerId, { export default plugin(serverTrackerId, {
trigger: { trigger: {
OnIssueProjectUpdate: '' as Resource<TriggerFunc> OnIssueUpdate: '' as Resource<TriggerFunc>
} }
}) })