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:
@@ -4,10 +4,12 @@ import {
|
||||
ValidationError,
|
||||
} from '@task-stream/api/shared/domain';
|
||||
import { IssueCreatedEvent } from '../events/issue-created.event';
|
||||
import { IssueParentChangedEvent } from '../events/issue-parent-changed.event';
|
||||
import { IssueStatusChangedEvent } from '../events/issue-status-changed.event';
|
||||
import { IssueFactory } from '../factories/issue.factory';
|
||||
import { replay } from '../testing';
|
||||
import { Issue } from './issue';
|
||||
import { ParentCandidate } from './issue-hierarchy';
|
||||
import { mentionedUserIds, withoutMentionMarkup } from './mentions';
|
||||
import { IssueLinkType, IssueStatus, IssueType } from './issue.enums';
|
||||
|
||||
@@ -223,6 +225,93 @@ describe('Issue', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('hierarchy', () => {
|
||||
const parent = (over: Partial<ParentCandidate> = {}): ParentCandidate => ({
|
||||
id: 'epic-1',
|
||||
key: 'TS-1',
|
||||
projectId: 'p-1',
|
||||
ancestorIds: [],
|
||||
...over,
|
||||
});
|
||||
|
||||
it('hangs from a parent, moves and leaves, and survives a replay', () => {
|
||||
const issue = newIssue();
|
||||
issue.setParent('u-pm', parent());
|
||||
issue.setParent('u-pm', parent());
|
||||
issue.setParent('u-pm', parent({ id: 'epic-2', key: 'TS-2' }));
|
||||
issue.setParent('u-pm', null);
|
||||
issue.setParent('u-pm', parent({ id: 'epic-3', key: 'TS-3' }));
|
||||
|
||||
const changes = issue
|
||||
.getUncommittedEvents()
|
||||
.filter((e) => e instanceof IssueParentChangedEvent);
|
||||
expect(changes.map((e) => [e.parentId, e.previousParentId])).toEqual([
|
||||
['epic-1', null],
|
||||
['epic-2', 'epic-1'],
|
||||
[null, 'epic-2'],
|
||||
['epic-3', null],
|
||||
]);
|
||||
expect(replay(issue, Issue).rebuilt.parentId).toBe('epic-3');
|
||||
});
|
||||
|
||||
it('refuses itself, another project, loops and a fourth level', () => {
|
||||
const issue = newIssue();
|
||||
expect(() => issue.setParent('u', parent({ id: issue.id }))).toThrow(
|
||||
BusinessRuleError,
|
||||
);
|
||||
expect(() => issue.setParent('u', parent({ projectId: 'p-2' }))).toThrow(
|
||||
/another project/,
|
||||
);
|
||||
expect(() =>
|
||||
issue.setParent('u', parent({ ancestorIds: [issue.id] })),
|
||||
).toThrow(/loop/);
|
||||
// Under a ticket that hangs from an epic: it becomes a subtask.
|
||||
expect(() =>
|
||||
issue.setParent('u', parent({ ancestorIds: ['t-1'] })),
|
||||
).not.toThrow();
|
||||
expect(() =>
|
||||
issue.setParent('u', parent({ id: 'x', ancestorIds: ['t-1', 'e-1'] })),
|
||||
).toThrow(/at most 2 levels/);
|
||||
// A ticket with subtasks cannot go below a ticket that has a parent.
|
||||
expect(() =>
|
||||
issue.setParent('u', parent({ id: 'y', ancestorIds: ['e-1'] }), 1),
|
||||
).toThrow(/at most 2 levels/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('checklist', () => {
|
||||
it('adds, ticks, rewords and removes steps, and survives a replay', () => {
|
||||
const issue = newIssue();
|
||||
issue.addChecklistItem('u-dev', 'c-1', ' Write the migration ');
|
||||
issue.addChecklistItem('u-dev', 'c-2', 'Update the docs');
|
||||
issue.addChecklistItem('u-dev', 'c-1', 'Duplicate id is ignored');
|
||||
issue.updateChecklistItem('u-dev', 'c-1', { done: true });
|
||||
issue.updateChecklistItem('u-dev', 'c-1', {
|
||||
done: true,
|
||||
text: 'Write the migration',
|
||||
});
|
||||
issue.updateChecklistItem('u-dev', 'c-2', { text: 'Update the README' });
|
||||
issue.removeChecklistItem('u-dev', 'c-2');
|
||||
|
||||
expect(issue.checklist).toEqual([
|
||||
{ id: 'c-1', text: 'Write the migration', done: true },
|
||||
]);
|
||||
expect(replay(issue, Issue).rebuilt.checklist).toEqual(issue.checklist);
|
||||
// created + 2 added + ticked + reworded + removed
|
||||
expect(issue.getUncommittedEvents()).toHaveLength(6);
|
||||
});
|
||||
|
||||
it('refuses empty steps and unknown items', () => {
|
||||
const issue = newIssue();
|
||||
expect(() => issue.addChecklistItem('u', 'c-1', ' ')).toThrow(
|
||||
ValidationError,
|
||||
);
|
||||
expect(() => issue.removeChecklistItem('u', 'nope')).toThrow(
|
||||
BusinessRuleError,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('mentions', () => {
|
||||
it('reads who a text mentions, once each', () => {
|
||||
const text =
|
||||
|
||||
Reference in New Issue
Block a user