feat: epics, subtasks and checklists
Tickets can hang from another one of the same project, three levels deep at most (epic → ticket → subtask). The ticket page lists its subtasks with their progress, creates new ones and moves the ticket under an epic; a new Épicas tab shows every epic and how far it has got. Each ticket also gets a checklist for the small steps that don't deserve a ticket. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -7,9 +7,13 @@ import {
|
||||
import { IssueAssignedEvent } from '../events/issue-assigned.event';
|
||||
import { IssueAttachmentAddedEvent } from '../events/issue-attachment-added.event';
|
||||
import { IssueAttachmentRemovedEvent } from '../events/issue-attachment-removed.event';
|
||||
import { IssueChecklistItemAddedEvent } from '../events/issue-checklist-item-added.event';
|
||||
import { IssueChecklistItemRemovedEvent } from '../events/issue-checklist-item-removed.event';
|
||||
import { IssueChecklistItemUpdatedEvent } from '../events/issue-checklist-item-updated.event';
|
||||
import { IssueCreatedEvent } from '../events/issue-created.event';
|
||||
import { IssueDeletedEvent } from '../events/issue-deleted.event';
|
||||
import { IssueLinkedEvent } from '../events/issue-linked.event';
|
||||
import { IssueParentChangedEvent } from '../events/issue-parent-changed.event';
|
||||
import { IssueReleaseChangedEvent } from '../events/issue-release-changed.event';
|
||||
import { IssueStatusChangedEvent } from '../events/issue-status-changed.event';
|
||||
import { IssueUnlinkedEvent } from '../events/issue-unlinked.event';
|
||||
@@ -22,6 +26,12 @@ import {
|
||||
MAX_ATTACHMENTS_PER_ISSUE,
|
||||
normalizeAttachment,
|
||||
} from './issue-attachment';
|
||||
import {
|
||||
ChecklistItem,
|
||||
MAX_CHECKLIST_ITEMS,
|
||||
normalizeChecklistText,
|
||||
} from './issue-checklist';
|
||||
import { ParentCandidate, assertCanNest } from './issue-hierarchy';
|
||||
import { IssueLinkType, IssuePriority } from './issue.enums';
|
||||
|
||||
export interface IssueLink {
|
||||
@@ -95,6 +105,9 @@ export class Issue extends VersionedAggregateRoot implements IssueProps {
|
||||
public attachments: IssueAttachment[] = [];
|
||||
/** People who asked to be told about this ticket. */
|
||||
public watchers: string[] = [];
|
||||
/** The epic or ticket this one breaks down. */
|
||||
public parentId: string | null = null;
|
||||
public checklist: ChecklistItem[] = [];
|
||||
public createdAt!: string;
|
||||
public updatedAt!: string;
|
||||
public deleted = false;
|
||||
@@ -280,6 +293,91 @@ export class Issue extends VersionedAggregateRoot implements IssueProps {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Places the ticket under `parent` (null takes it out). `childHeight` is
|
||||
* how many levels of tickets already hang from this one.
|
||||
*/
|
||||
setParent(
|
||||
actorId: string,
|
||||
parent: ParentCandidate | null,
|
||||
childHeight = 0,
|
||||
): void {
|
||||
if ((parent?.id ?? null) === this.parentId) return;
|
||||
if (parent) assertCanNest(this, parent, childHeight);
|
||||
this.apply(
|
||||
new IssueParentChangedEvent(
|
||||
actorId,
|
||||
this.id,
|
||||
this.projectId,
|
||||
parent?.id ?? null,
|
||||
parent?.key ?? null,
|
||||
this.parentId,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/** Adds a step to the checklist; `id` is generated by the caller. */
|
||||
addChecklistItem(actorId: string, id: string, text: string): void {
|
||||
if (this.checklist.some((i) => i.id === id)) return;
|
||||
if (this.checklist.length >= MAX_CHECKLIST_ITEMS) {
|
||||
throw new BusinessRuleError(
|
||||
`${this.key} already has ${MAX_CHECKLIST_ITEMS} checklist items`,
|
||||
);
|
||||
}
|
||||
this.apply(
|
||||
new IssueChecklistItemAddedEvent(actorId, this.id, this.projectId, {
|
||||
id,
|
||||
text: normalizeChecklistText(text),
|
||||
done: false,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/** Rewords, ticks or unticks a step. Nothing changes, nothing happens. */
|
||||
updateChecklistItem(
|
||||
actorId: string,
|
||||
itemId: string,
|
||||
changes: { text?: string; done?: boolean },
|
||||
): void {
|
||||
const item = this.checklistItem(itemId);
|
||||
const patch: { text?: string; done?: boolean } = {};
|
||||
if (changes.text !== undefined) {
|
||||
const text = normalizeChecklistText(changes.text);
|
||||
if (text !== item.text) patch.text = text;
|
||||
}
|
||||
if (changes.done !== undefined && changes.done !== item.done)
|
||||
patch.done = changes.done;
|
||||
if (Object.keys(patch).length === 0) return;
|
||||
this.apply(
|
||||
new IssueChecklistItemUpdatedEvent(
|
||||
actorId,
|
||||
this.id,
|
||||
this.projectId,
|
||||
itemId,
|
||||
patch,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
removeChecklistItem(actorId: string, itemId: string): void {
|
||||
const item = this.checklistItem(itemId);
|
||||
this.apply(
|
||||
new IssueChecklistItemRemovedEvent(
|
||||
actorId,
|
||||
this.id,
|
||||
this.projectId,
|
||||
item.id,
|
||||
item.text,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
private checklistItem(itemId: string): ChecklistItem {
|
||||
const item = this.checklist.find((i) => i.id === itemId);
|
||||
if (!item) throw new BusinessRuleError('Checklist item not found');
|
||||
return item;
|
||||
}
|
||||
|
||||
/** Deletes the ticket (its project was deleted). Idempotent. */
|
||||
delete(actorId: string): void {
|
||||
if (this.deleted) return;
|
||||
@@ -360,6 +458,32 @@ export class Issue extends VersionedAggregateRoot implements IssueProps {
|
||||
this.watchers = this.watchers.filter((id) => id !== event.userId);
|
||||
}
|
||||
|
||||
onIssueParentChangedEvent(event: IssueParentChangedEvent): void {
|
||||
this.parentId = event.parentId;
|
||||
this.updatedAt = event.occurredAt;
|
||||
}
|
||||
|
||||
onIssueChecklistItemAddedEvent(event: IssueChecklistItemAddedEvent): void {
|
||||
this.checklist = [...this.checklist, { ...event.item }];
|
||||
this.updatedAt = event.occurredAt;
|
||||
}
|
||||
|
||||
onIssueChecklistItemUpdatedEvent(
|
||||
event: IssueChecklistItemUpdatedEvent,
|
||||
): void {
|
||||
this.checklist = this.checklist.map((i) =>
|
||||
i.id === event.itemId ? { ...i, ...event.changes } : i,
|
||||
);
|
||||
this.updatedAt = event.occurredAt;
|
||||
}
|
||||
|
||||
onIssueChecklistItemRemovedEvent(
|
||||
event: IssueChecklistItemRemovedEvent,
|
||||
): void {
|
||||
this.checklist = this.checklist.filter((i) => i.id !== event.itemId);
|
||||
this.updatedAt = event.occurredAt;
|
||||
}
|
||||
|
||||
onIssueDeletedEvent(): void {
|
||||
this.deleted = true;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user