Tracker: change "Issue" type to "AttachedDoc" (#1875)

Signed-off-by: Sergei Ogorelkov <sergei.ogorelkov@xored.com>
This commit is contained in:
Sergei Ogorelkov 2022-06-02 09:42:44 +07:00 committed by GitHub
parent 6fefb8c739
commit 883393788b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
17 changed files with 334 additions and 97 deletions

View File

@ -125,9 +125,9 @@ export class TTeam extends TSpace implements Team {
/**
* @public
*/
@Model(tracker.class.Issue, core.class.Doc, DOMAIN_TRACKER)
@Model(tracker.class.Issue, core.class.AttachedDoc, DOMAIN_TRACKER)
@UX(tracker.string.Issue, tracker.icon.Issue, tracker.string.Issue)
export class TIssue extends TDoc implements Issue {
export class TIssue extends TAttachedDoc implements Issue {
@Prop(TypeString(), tracker.string.Title)
@Index(IndexKind.FullText)
title!: string
@ -151,8 +151,8 @@ export class TIssue extends TDoc implements Issue {
@Prop(TypeRef(tracker.class.Project), tracker.string.Project)
project!: Ref<Project> | null
@Prop(TypeRef(tracker.class.Issue), tracker.string.Parent)
parentIssue!: Ref<Issue>
@Prop(Collection(tracker.class.Issue), tracker.string.SubIssues)
subIssues!: number
@Prop(ArrOf(TypeRef(tracker.class.Issue)), tracker.string.BlockedBy)
blockedBy!: Ref<Issue>[]

View File

@ -13,9 +13,9 @@
// limitations under the License.
//
import core, { generateId, Ref, TxOperations } from '@anticrm/core'
import core, { Doc, generateId, Ref, TxOperations } from '@anticrm/core'
import { MigrateOperation, MigrationClient, MigrationUpgradeClient } from '@anticrm/model'
import { IssueStatus, IssueStatusCategory, Team, genRanks } from '@anticrm/tracker'
import { IssueStatus, IssueStatusCategory, Team, genRanks, Issue } from '@anticrm/tracker'
import { DOMAIN_TRACKER } from '.'
import tracker from './plugin'
@ -149,6 +149,53 @@ async function upgradeIssueStatuses (tx: TxOperations): Promise<void> {
}
}
async function migrateParentIssues (client: MigrationClient): Promise<void> {
let { updated } = await client.update(
DOMAIN_TRACKER,
{ _class: tracker.class.Issue, attachedToClass: { $exists: false } },
{
subIssues: 0,
collection: 'subIssues',
attachedToClass: tracker.class.Issue
}
)
updated += (
await client.update(
DOMAIN_TRACKER,
{ _class: tracker.class.Issue, parentIssue: { $exists: true } },
{ $rename: { parentIssue: 'attachedTo' } }
)
).updated
updated += (
await client.update(
DOMAIN_TRACKER,
{ _class: tracker.class.Issue, attachedTo: { $in: [null, undefined] } },
{ attachedTo: tracker.ids.NoParent }
)
).updated
if (updated === 0) {
return
}
const childrenCountById = new Map<Ref<Doc>, number>()
const parentIssueIds = (
await client.find<Issue>(DOMAIN_TRACKER, {
_class: tracker.class.Issue,
attachedTo: { $nin: [tracker.ids.NoParent] }
})
).map((issue) => issue.attachedTo)
for (const issueId of parentIssueIds) {
const count = childrenCountById.get(issueId) ?? 0
childrenCountById.set(issueId, count + 1)
}
for (const [_id, childrenCount] of childrenCountById) {
await client.update(DOMAIN_TRACKER, { _id }, { subIssues: childrenCount })
}
}
async function migrateIssueProjects (client: MigrationClient): Promise<void> {
const issues = await client.find(DOMAIN_TRACKER, { _class: tracker.class.Issue, project: { $exists: false } })
@ -197,7 +244,7 @@ async function upgradeProjects (tx: TxOperations): Promise<void> {
export const trackerOperation: MigrateOperation = {
async migrate (client: MigrationClient): Promise<void> {
await Promise.all([migrateIssueProjects(client)])
await Promise.all([migrateIssueProjects(client), migrateParentIssues(client)])
},
async upgrade (client: MigrationUpgradeClient): Promise<void> {
const tx = new TxOperations(client, core.account.System)

View File

@ -14,7 +14,7 @@
-->
<script lang="ts">
import contact, { Employee } from '@anticrm/contact'
import core, { Data, generateId, Ref, SortingOrder, WithLookup } from '@anticrm/core'
import core, { AttachedData, Ref, SortingOrder, WithLookup } from '@anticrm/core'
import presentation, { Card, createQuery, getClient, SpaceSelector, UserBox } from '@anticrm/presentation'
import { StyledTextBox } from '@anticrm/text-editor'
import { calcRank, Issue, IssuePriority, IssueStatus, Project, Team } from '@anticrm/tracker'
@ -48,7 +48,7 @@
let issueStatuses: WithLookup<IssueStatus>[] | undefined
let parentIssue: Issue | undefined
let object: Data<Issue> = {
let object: AttachedData<Issue> = {
title: '',
description: '',
assignee: null,
@ -58,13 +58,13 @@
status: '' as Ref<IssueStatus>,
priority: priority,
dueDate: null,
comments: 0
comments: 0,
subIssues: 0
}
const dispatch = createEventDispatcher()
const client = getClient()
const statusesQuery = createQuery()
const taskId: Ref<Issue> = generateId()
$: _space = space
$: updateIssueStatusId(space, status)
@ -95,7 +95,6 @@
function clearParentIssue () {
parentIssue = undefined
object.parentIssue = undefined
}
function getTitle (value: string) {
@ -126,7 +125,7 @@
true
)
const value: Data<Issue> = {
const value: AttachedData<Issue> = {
title: getTitle(object.title),
description: object.description,
assignee: currentAssignee,
@ -135,12 +134,19 @@
status: object.status,
priority: object.priority,
rank: calcRank(lastOne, undefined),
parentIssue: object.parentIssue,
comments: 0,
subIssues: 0,
dueDate: object.dueDate
}
await client.createDoc(tracker.class.Issue, _space, value, taskId)
await client.addCollection(
tracker.class.Issue,
_space,
parentIssue?._id ?? tracker.ids.NoParent,
parentIssue?._class ?? tracker.class.Issue,
'subIssues',
value
)
}
async function showMoreActions (ev: Event) {
@ -160,23 +166,18 @@
}
const setParentIssue = {
label: object.parentIssue ? tracker.string.ChangeParent : tracker.string.SetParent,
label: parentIssue ? tracker.string.ChangeParent : tracker.string.SetParent,
icon: tracker.icon.Parent,
action: async () =>
showPopup(
SetParentIssueActionPopup,
{ value: { ...object, space }, shouldSaveOnChange: false },
{ value: { ...object, space, attachedTo: parentIssue?._id }, shouldSaveOnChange: false },
'top',
(selectedIssue) => {
if (selectedIssue !== undefined) {
parentIssue = selectedIssue
object.parentIssue = parentIssue?._id
}
}
(selectedIssue) => selectedIssue !== undefined && (parentIssue = selectedIssue)
)
}
const removeParentIssue = object.parentIssue && {
const removeParentIssue = parentIssue && {
label: tracker.string.RemoveParent,
icon: tracker.icon.Parent,
action: clearParentIssue

View File

@ -30,7 +30,15 @@
const newDueDate = detail && detail?.getTime()
if (shouldSaveOnChange && newDueDate !== undefined && newDueDate !== value.dueDate) {
await client.update(value, { dueDate: newDueDate })
await client.updateCollection(
value._class,
value.space,
value._id,
value.attachedTo,
value.attachedToClass,
value.collection,
{ dueDate: newDueDate }
)
}
dispatch('update', newDueDate)

View File

@ -42,8 +42,16 @@
}
async function onClose ({ detail: parentIssue }: CustomEvent<Issue | undefined | null>) {
if (shouldSaveOnChange && parentIssue !== undefined && parentIssue?._id !== value.parentIssue) {
await client.update(value, { parentIssue: parentIssue?._id })
if (shouldSaveOnChange && parentIssue !== undefined && parentIssue?._id !== value.attachedTo) {
await client.updateCollection(
value._class,
value.space,
value._id,
value.attachedTo,
value.attachedToClass,
'subIssues',
{ attachedTo: parentIssue === null ? tracker.ids.NoParent : parentIssue._id }
)
}
dispatch('close', parentIssue)
@ -59,7 +67,7 @@
<ObjectPopup
_class={tracker.class.Issue}
{options}
selected={value.parentIssue}
selected={value.attachedTo}
multiSelect={false}
allowDeselect={true}
placeholder={tracker.string.SetParent}

View File

@ -30,7 +30,15 @@
return
}
await client.update(value, { assignee: newAssignee })
await client.updateCollection(
value._class,
value.space,
value._id,
value.attachedTo,
value.attachedToClass,
value.collection,
{ assignee: newAssignee }
)
}
</script>

View File

@ -60,7 +60,15 @@
const newAssignee = result === null ? null : result._id
await client.update(currentIssue, { assignee: newAssignee })
await client.updateCollection(
currentIssue._class,
currentIssue.space,
currentIssue._id,
currentIssue.attachedTo,
currentIssue.attachedToClass,
currentIssue.collection,
{ assignee: newAssignee }
)
}
const handleAssigneeEditorOpened = async (event: MouseEvent) => {

View File

@ -27,7 +27,15 @@
return
}
await client.update(value, { dueDate: newDueDate })
await client.updateCollection(
value._class,
value.space,
value._id,
value.attachedTo,
value.attachedToClass,
value.collection,
{ dueDate: newDueDate }
)
}
$: today = new Date(new Date(Date.now()).setHours(0, 0, 0, 0))

View File

@ -26,7 +26,15 @@
$: dueDateMs = value.dueDate
const handleDueDateChanged = async (newDate: number | null) => {
await client.update(value, { dueDate: newDate })
await client.updateCollection(
value._class,
value.space,
value._id,
value.attachedTo,
value.attachedToClass,
value.collection,
{ dueDate: newDate }
)
}
$: shouldRenderPresenter =

View File

@ -36,7 +36,15 @@
return
}
await client.update(value, { priority: newPriority })
await client.updateCollection(
value._class,
value.space,
value._id,
value.attachedTo,
value.attachedToClass,
value.collection,
{ priority: newPriority }
)
}
</script>

View File

@ -38,7 +38,15 @@
return
}
await client.update(value, { status: newStatus })
await client.updateCollection(
value._class,
value.space,
value._id,
value.attachedTo,
value.attachedToClass,
value.collection,
{ status: newStatus }
)
}
</script>

View File

@ -47,7 +47,7 @@
const dispatch = createEventDispatcher()
const client = getClient()
let issue: Issue | undefined
let issue: WithLookup<Issue> | undefined
let currentTeam: Team | undefined
let issueStatuses: WithLookup<IssueStatus>[] | undefined
let title = ''
@ -65,7 +65,7 @@
title = issue.title
description = issue.description
},
{ lookup: { parentIssue: _class } }
{ lookup: { attachedTo: tracker.class.Issue } }
)
$: if (issue) {
@ -123,7 +123,15 @@
}
if (Object.keys(updates).length > 0) {
await client.update(issue, updates)
await client.updateCollection(
issue._class,
issue.space,
issue._id,
issue.attachedTo,
issue.attachedToClass,
issue.collection,
updates
)
}
isEditing = false
@ -152,6 +160,7 @@
on:fullsize
on:close={() => dispatch('close')}
>
{@const { attachedTo: parentIssue } = issue?.$lookup ?? {}}
<svelte:fragment slot="subtitle">
<div class="flex-between flex-grow">
<div class="buttons-group xsmall-gap">
@ -184,7 +193,7 @@
{#if isEditing}
<Scroller>
<div class="popupPanel-body__main-content py-10 clear-mins content">
{#if issue?.parentIssue}
{#if parentIssue}
<div class="mb-6">
{#if currentTeam && issueStatuses}
<SubIssueSelector {issue} {issueStatuses} team={currentTeam} />
@ -211,7 +220,7 @@
</div>
</Scroller>
{:else}
{#if issue?.parentIssue}
{#if parentIssue}
<div class="mb-6">
{#if currentTeam && issueStatuses}
<SubIssueSelector {issue} {issueStatuses} team={currentTeam} />
@ -261,6 +270,7 @@
.description-preview {
color: var(--theme-content-color);
line-height: 150%;
.placeholder {
color: var(--theme-content-trans-color);

View File

@ -16,7 +16,17 @@
import { Ref, SortingOrder, WithLookup } from '@anticrm/core'
import { createQuery } from '@anticrm/presentation'
import { Team, Issue, IssueStatus } from '@anticrm/tracker'
import { Icon, Tooltip, showPanel, IconForward, IconDetails, showPopup, SelectPopup, closeTooltip } from '@anticrm/ui'
import {
Icon,
Tooltip,
showPanel,
IconForward,
IconDetails,
showPopup,
SelectPopup,
closeTooltip,
Spinner
} from '@anticrm/ui'
import tracker from '../../../plugin'
export let issue: WithLookup<Issue>
@ -43,9 +53,9 @@
}
function openParentIssue () {
if (issue.parentIssue) {
if (parentIssue) {
closeTooltip()
openIssue(issue.parentIssue)
openIssue(parentIssue._id)
}
}
@ -77,21 +87,23 @@
}
}
$: subIssuesQeury.query(
$: areSubIssuesLoading = !subIssues
$: parentIssue = issue.$lookup?.attachedTo ? (issue.$lookup?.attachedTo as Issue) : null
$: parentIssue &&
subIssuesQeury.query(
tracker.class.Issue,
{ space: issue.space, parentIssue: issue.parentIssue },
{ space: issue.space, attachedTo: parentIssue._id },
(res) => (subIssues = res),
{ sort: { modifiedOn: SortingOrder.Descending } }
)
$: parentIssue = issue.$lookup?.parentIssue ?? null
</script>
<div class="flex root">
<div class="clear-mins parent-issue item">
{#if parentIssue}
{#if parentIssue}
<div class="flex root">
<div class="item clear-mins">
<Tooltip label={tracker.string.OpenParent} direction="bottom" fill>
{@const icon = getIssueStatusIcon(issue)}
<div class="flex-center" on:click={openParentIssue}>
<div class="flex-center parent-issue" on:click={openParentIssue}>
{#if icon}
<div class="pr-2">
<Icon {icon} size="small" />
@ -101,14 +113,21 @@
<span class="overflow-label issue-title">{parentIssue.title}</span>
</div>
</Tooltip>
{/if}
</div>
<div class="item">
{#if areSubIssuesLoading}
<div class="flex-center spinner">
<Spinner size="small" />
</div>
{:else}
<Tooltip label={tracker.string.OpenSub} direction="bottom">
<div bind:this={subIssuesElement} class="flex-center sub-issues item" on:click|preventDefault={showSubIssues}>
{#if subIssues?.length !== undefined}
<span class="overflow-label">{subIssues.length}</span>
{/if}
<div
bind:this={subIssuesElement}
class="flex-center sub-issues"
on:click|preventDefault={areSubIssuesLoading ? undefined : showSubIssues}
>
<span class="overflow-label">{subIssues?.length}</span>
<div class="ml-2">
<!-- TODO: fix icon -->
<Icon icon={IconDetails} size="small" />
@ -118,23 +137,41 @@
</div>
</div>
</Tooltip>
</div>
{/if}
</div>
</div>
{/if}
<style lang="scss">
$padding: 0.375rem 0.75rem;
$border: 1px solid var(--button-border-color);
.root {
max-width: fit-content;
line-height: 150%;
border: 1px solid var(--button-border-color);
border: $border;
border-radius: 0.25rem;
box-shadow: var(--primary-shadow);
.item {
padding: 0.375rem 0.75rem;
position: relative;
&:not(:first-child)::before {
position: absolute;
content: '';
border-left: $border;
inset: 0.375rem auto;
}
}
}
.spinner {
padding: $padding;
height: 100%;
}
.parent-issue {
border-right: 1px solid var(--button-border-color);
padding: $padding;
.issue-title {
color: var(--theme-content-accent-color);
@ -154,6 +191,7 @@
}
.sub-issues {
padding: $padding;
color: var(--theme-content-color);
transition: color 0.15s;

View File

@ -39,7 +39,15 @@
return
}
await client.update(value, { project: newProjectId })
await client.updateCollection(
value._class,
value.space,
value._id,
value.attachedTo,
value.attachedToClass,
value.collection,
{ project: newProjectId }
)
}
</script>

View File

@ -86,7 +86,7 @@ export default mergeIds(trackerId, tracker, {
Assignee: '' as IntlString,
AssignTo: '' as IntlString,
AssignedTo: '' as IntlString,
Parent: '' as IntlString,
SubIssues: '' as IntlString,
SetParent: '' as IntlString,
ChangeParent: '' as IntlString,
RemoveParent: '' as IntlString,

View File

@ -97,7 +97,7 @@ export enum IssuesDateModificationPeriod {
/**
* @public
*/
export interface Issue extends Doc {
export interface Issue extends AttachedDoc {
title: string
description: Markup
status: Ref<IssueStatus>
@ -108,7 +108,7 @@ export interface Issue extends Doc {
project: Ref<Project> | null
// For subtasks
parentIssue?: Ref<Issue>
subIssues: number
blockedBy?: Ref<Issue>[]
relatedIssue?: Ref<Issue>[]
@ -189,6 +189,9 @@ export default plugin(trackerId, {
IssueStatusCategory: '' as Ref<Class<IssueStatusCategory>>,
TypeIssuePriority: '' as Ref<Class<Type<IssuePriority>>>
},
ids: {
NoParent: '' as Ref<Issue>
},
component: {
Tracker: '' as AnyComponent,
TrackerApp: '' as AnyComponent

View File

@ -15,12 +15,14 @@
//
import core, {
Account,
AttachedDoc,
Class,
ClassifierKind,
Collection,
Doc,
DocumentQuery,
DocumentUpdate,
Domain,
DOMAIN_MODEL,
DOMAIN_TX,
@ -125,12 +127,82 @@ class TServerStorage implements ServerStorage {
}
}
private async getCollectionUpdateTx<D extends Doc>(
_id: Ref<D>,
_class: Ref<Class<D>>,
modifiedBy: Ref<Account>,
modifiedOn: number,
attachedTo: D,
update: DocumentUpdate<D>
): Promise<Tx> {
const txFactory = new TxFactory(modifiedBy)
const baseClass = this.hierarchy.getBaseClass(_class)
if (baseClass !== _class) {
// Mixin operation is required.
const tx = txFactory.createTxMixin(_id, attachedTo._class, attachedTo.space, _class, update)
tx.modifiedOn = modifiedOn
return tx
} else {
const tx = txFactory.createTxUpdateDoc(_class, attachedTo.space, _id, update)
tx.modifiedOn = modifiedOn
return tx
}
}
private async updateCollection (ctx: MeasureContext, tx: Tx): Promise<Tx[]> {
if (tx._class !== core.class.TxCollectionCUD) {
return []
}
const colTx = tx as TxCollectionCUD<Doc, AttachedDoc>
const _id = colTx.objectId
const _class = colTx.objectClass
const { operations } = colTx.tx as TxUpdateDoc<AttachedDoc>
if (
colTx.tx._class !== core.class.TxUpdateDoc ||
this.hierarchy.getDomain(_class) === DOMAIN_MODEL // We could not update increments for model classes
) {
return []
}
if (operations?.attachedTo === undefined || operations.attachedTo === _id) {
return []
}
const oldAttachedTo = (await this.findAll(ctx, _class, { _id }, { limit: 1 }))[0]
let oldTx: Tx | null = null
if (oldAttachedTo !== undefined) {
oldTx = await this.getCollectionUpdateTx(_id, _class, tx.modifiedBy, colTx.modifiedOn, oldAttachedTo, {
$inc: { [colTx.collection]: -1 }
})
}
const newAttachedToClass = operations.attachedToClass ?? _class
const newAttachedToCollection = operations.collection ?? colTx.collection
const newAttachedTo = (await this.findAll(ctx, newAttachedToClass, { _id: operations.attachedTo }, { limit: 1 }))[0]
let newTx: Tx | null = null
if (newAttachedTo !== undefined) {
newTx = await this.getCollectionUpdateTx(
newAttachedTo._id,
newAttachedTo._class,
tx.modifiedBy,
colTx.modifiedOn,
newAttachedTo,
{ $inc: { [newAttachedToCollection]: 1 } }
)
}
return [...(oldTx !== null ? [oldTx] : []), ...(newTx !== null ? [newTx] : [])]
}
async processCollection (ctx: MeasureContext, tx: Tx): Promise<Tx[]> {
if (tx._class === core.class.TxCollectionCUD) {
const colTx = tx as TxCollectionCUD<Doc, AttachedDoc>
const _id = colTx.objectId
const _class = colTx.objectClass
let attachedTo: Doc | undefined
// Skip model operations
if (this.hierarchy.getDomain(_class) === DOMAIN_MODEL) {
@ -139,26 +211,20 @@ class TServerStorage implements ServerStorage {
}
const isCreateTx = colTx.tx._class === core.class.TxCreateDoc
if (isCreateTx || colTx.tx._class === core.class.TxRemoveDoc) {
attachedTo = (await this.findAll(ctx, _class, { _id }, { limit: 1 }))[0]
if (attachedTo !== undefined) {
const txFactory = new TxFactory(tx.modifiedBy)
const baseClass = this.hierarchy.getBaseClass(_class)
if (baseClass !== _class) {
// Mixin opeeration is required.
const tx = txFactory.createTxMixin(_id, attachedTo._class, attachedTo.space, _class, {
$inc: { [colTx.collection]: isCreateTx ? 1 : -1 }
})
tx.modifiedOn = colTx.modifiedOn
const isDeleteTx = colTx.tx._class === core.class.TxRemoveDoc
const isUpdateTx = colTx.tx._class === core.class.TxUpdateDoc
return [tx]
if (isCreateTx || isDeleteTx || isUpdateTx) {
if (isUpdateTx) {
return await this.updateCollection(ctx, tx)
} else {
const tx = txFactory.createTxUpdateDoc(_class, attachedTo.space, _id, {
const attachedTo = (await this.findAll(ctx, _class, { _id }, { limit: 1 }))[0]
if (attachedTo !== undefined) {
return [
await this.getCollectionUpdateTx(_id, _class, tx.modifiedBy, colTx.modifiedOn, attachedTo, {
$inc: { [colTx.collection]: isCreateTx ? 1 : -1 }
})
tx.modifiedOn = colTx.modifiedOn
return [tx]
]
}
}
}