feat: an ordered backlog
A Backlog tab lists the tickets still to start in the order the team will take them; drag them, or use the arrows, to reorder. Moving a ticket only rewrites that ticket's rank, read from the event store, and board columns follow the same order. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -29,6 +29,7 @@ import { AttachFileCommandHandler } from './attach-file.command-handler';
|
||||
import { DetachFileCommandHandler } from './detach-file.command-handler';
|
||||
import { WatchIssueCommandHandler } from './watch-issue.command-handler';
|
||||
import { SetIssueParentCommandHandler } from './set-issue-parent.command-handler';
|
||||
import { RankIssueCommandHandler } from './rank-issue.command-handler';
|
||||
import { DetachFromParentCommandHandler } from './detach-from-parent.command-handler';
|
||||
import {
|
||||
AddChecklistItemCommandHandler,
|
||||
@@ -69,6 +70,7 @@ export const COMMAND_HANDLERS = [
|
||||
DetachFileCommandHandler,
|
||||
WatchIssueCommandHandler,
|
||||
SetIssueParentCommandHandler,
|
||||
RankIssueCommandHandler,
|
||||
AddChecklistItemCommandHandler,
|
||||
UpdateChecklistItemCommandHandler,
|
||||
RemoveChecklistItemCommandHandler,
|
||||
|
||||
@@ -33,6 +33,7 @@ import {
|
||||
import { TrackerStreamsRepository } from '../ports/tracker-streams.repository';
|
||||
import { IssueTree } from './issue-tree';
|
||||
import { SetIssueParentCommand } from './set-issue-parent.command';
|
||||
import { RankIssueCommand } from './rank-issue.command';
|
||||
import {
|
||||
AddChecklistItemCommand,
|
||||
UpdateChecklistItemCommand,
|
||||
@@ -372,6 +373,38 @@ describe('tracker commands (event sourced)', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('orders the backlog from the ranks in the event store', async () => {
|
||||
const { run, rehydrator } = await setup();
|
||||
const projectId = await projectWithTeam(run);
|
||||
const ids = [];
|
||||
for (const title of ['A', 'B', 'C'])
|
||||
ids.push(
|
||||
(
|
||||
await run(
|
||||
new CreateIssueCommand('dev', projectId, IssueType.TASK, title, {}),
|
||||
)
|
||||
).id,
|
||||
);
|
||||
const [a, b, c] = ids;
|
||||
|
||||
// C to the top, then B between C and A: C, B, A.
|
||||
await run(new RankIssueCommand('dev', c, null, a));
|
||||
await run(new RankIssueCommand('dev', b, c, a));
|
||||
const ranks = await Promise.all(
|
||||
[c, b, a].map(
|
||||
async (id) => (await rehydrator.rehydrate(id, Issue)).backlogRank,
|
||||
),
|
||||
);
|
||||
expect([...ranks].sort()).toEqual(ranks);
|
||||
// Neighbours given the wrong way round: the order changed meanwhile.
|
||||
await expect(run(new RankIssueCommand('dev', a, b, c))).rejects.toThrow(
|
||||
BusinessRuleError,
|
||||
);
|
||||
await expect(run(new RankIssueCommand('pm', a, null, c))).rejects.toThrow(
|
||||
ForbiddenError,
|
||||
);
|
||||
});
|
||||
|
||||
it('does not let viewers modify tickets', async () => {
|
||||
const { run } = await setup();
|
||||
const projectId = await projectWithTeam(run);
|
||||
|
||||
@@ -32,3 +32,4 @@ export * from './watch-issue.command';
|
||||
export * from './set-issue-parent.command';
|
||||
export * from './detach-from-parent.command';
|
||||
export * from './change-checklist.command';
|
||||
export * from './rank-issue.command';
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import { CommandHandler, ICommandHandler } from '@nestjs/cqrs';
|
||||
import { CommandResult, ack } from './command-result';
|
||||
import { RankIssueCommand } from './rank-issue.command';
|
||||
import { WriteAccess } from './write-access';
|
||||
|
||||
@CommandHandler(RankIssueCommand)
|
||||
export class RankIssueCommandHandler
|
||||
implements ICommandHandler<RankIssueCommand>
|
||||
{
|
||||
constructor(private readonly access: WriteAccess) {}
|
||||
|
||||
async execute(command: RankIssueCommand): Promise<CommandResult> {
|
||||
const { issue } = await this.access.issueForContributor(
|
||||
command.issueId,
|
||||
command.actorId,
|
||||
);
|
||||
// The neighbours' ranks come from their streams, not from the screen.
|
||||
const [above, below] = await Promise.all(
|
||||
[command.aboveIssueId, command.belowIssueId].map((id) =>
|
||||
id ? this.access.issue(id) : null,
|
||||
),
|
||||
);
|
||||
issue.moveBetween(command.actorId, above, below);
|
||||
await issue.commitAndWait();
|
||||
return ack(issue);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { Command } from '@nestjs/cqrs';
|
||||
import { CommandResult } from './command-result';
|
||||
|
||||
/**
|
||||
* Moves a ticket in the backlog, between the one that ends up above it and the
|
||||
* one that ends up below (null: the top or the bottom).
|
||||
*/
|
||||
export class RankIssueCommand extends Command<CommandResult> {
|
||||
constructor(
|
||||
public readonly actorId: string,
|
||||
public readonly issueId: string,
|
||||
public readonly aboveIssueId: string | null,
|
||||
public readonly belowIssueId: string | null,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
}
|
||||
@@ -30,6 +30,7 @@ import { AttachmentCleanupEventHandler } from './attachment-cleanup.event-handle
|
||||
import { IssueWatchersEventHandler } from './issue-watchers.event-handler';
|
||||
import { IssueParentChangedEventHandler } from './issue-parent-changed.event-handler';
|
||||
import { IssueChecklistEventHandler } from './issue-checklist.event-handler';
|
||||
import { IssueRankedEventHandler } from './issue-ranked.event-handler';
|
||||
|
||||
/** Read-model projections (MongoDB) + realtime, fed by the EventsBridge. */
|
||||
export const EVENT_HANDLERS = [
|
||||
@@ -64,6 +65,7 @@ export const EVENT_HANDLERS = [
|
||||
IssueWatchersEventHandler,
|
||||
IssueParentChangedEventHandler,
|
||||
IssueChecklistEventHandler,
|
||||
IssueRankedEventHandler,
|
||||
];
|
||||
|
||||
export { ReadModelNotifier };
|
||||
|
||||
@@ -2,6 +2,7 @@ import { EventsHandler, IEventHandler } from '@nestjs/cqrs';
|
||||
import {
|
||||
DEFAULT_PROJECT_SETTINGS,
|
||||
IssueCreatedEvent,
|
||||
implicitRank,
|
||||
ProjectWorkflow,
|
||||
} from '@task-stream/api/tracker/domain';
|
||||
import { FindProjectsRepository } from '../ports/find-projects.repository';
|
||||
@@ -49,6 +50,7 @@ export class IssueCreatedEventHandler
|
||||
assignee: users.ofNullable(issue.assigneeId),
|
||||
release: release ? { id: release.id, name: release.name } : null,
|
||||
parent: null,
|
||||
rank: implicitRank(issue.number),
|
||||
labels: issue.labels,
|
||||
figmaUrl: issue.figmaUrl,
|
||||
storyPoints: issue.storyPoints,
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import { EventsHandler, IEventHandler } from '@nestjs/cqrs';
|
||||
import { IssueRankedEvent } from '@task-stream/api/tracker/domain';
|
||||
import { UpsertMaterializedIssueRepository } from '../ports/upsert-materialized-issue.repository';
|
||||
import { ReadModelNotifier, versionOf } from './read-model-notifier';
|
||||
|
||||
@EventsHandler(IssueRankedEvent)
|
||||
export class IssueRankedEventHandler
|
||||
implements IEventHandler<IssueRankedEvent>
|
||||
{
|
||||
constructor(
|
||||
private readonly upsertMaterializedIssueRepository: UpsertMaterializedIssueRepository,
|
||||
private readonly notifier: ReadModelNotifier,
|
||||
) {}
|
||||
|
||||
async handle(event: IssueRankedEvent) {
|
||||
await this.upsertMaterializedIssueRepository.upsert({
|
||||
id: event.aggregateId,
|
||||
rank: event.rank,
|
||||
version: versionOf(event),
|
||||
});
|
||||
await this.notifier.issue(event.aggregateId, event);
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
import { DetachFileCommand } from './commands/detach-file.command';
|
||||
import { WatchIssueCommand } from './commands/watch-issue.command';
|
||||
import { SetIssueParentCommand } from './commands/set-issue-parent.command';
|
||||
import { RankIssueCommand } from './commands/rank-issue.command';
|
||||
import {
|
||||
AddChecklistItemCommand,
|
||||
RemoveChecklistItemCommand,
|
||||
@@ -218,6 +219,18 @@ export class IssuesService {
|
||||
);
|
||||
}
|
||||
|
||||
/** Moves a ticket in the backlog between `above` and `below` (null: an end). */
|
||||
rank(
|
||||
actorId: string,
|
||||
issueId: string,
|
||||
above: string | null,
|
||||
below: string | null,
|
||||
): Promise<CommandResult> {
|
||||
return this.commandBus.execute(
|
||||
new RankIssueCommand(actorId, issueId, above, below),
|
||||
);
|
||||
}
|
||||
|
||||
addChecklistItem(
|
||||
actorId: string,
|
||||
issueId: string,
|
||||
|
||||
@@ -7,6 +7,11 @@ import {
|
||||
StatusCategory,
|
||||
} from '@task-stream/api/tracker/domain';
|
||||
|
||||
export enum IssueSort {
|
||||
RECENT = 'RECENT',
|
||||
RANK = 'RANK',
|
||||
}
|
||||
|
||||
export interface IssueFilter {
|
||||
projectId: string;
|
||||
/** Status, type and area ids of the project's settings. */
|
||||
@@ -21,6 +26,8 @@ export interface IssueFilter {
|
||||
labels?: string[];
|
||||
/** Only the tickets hanging from this one. */
|
||||
parentId?: string;
|
||||
/** RECENT (default): last updated first. RANK: backlog order. */
|
||||
sort?: IssueSort;
|
||||
search?: string;
|
||||
limit: number;
|
||||
offset: number;
|
||||
|
||||
@@ -3,7 +3,10 @@ import {
|
||||
DEFAULT_PROJECT_SETTINGS,
|
||||
StatusCategory,
|
||||
} from '@task-stream/api/tracker/domain';
|
||||
import { FindIssuesRepository } from '../ports/find-issues.repository';
|
||||
import {
|
||||
FindIssuesRepository,
|
||||
IssueSort,
|
||||
} from '../ports/find-issues.repository';
|
||||
import { FindProjectsRepository } from '../ports/find-projects.repository';
|
||||
import { BoardColumn, GetBoardQuery } from './get-board.query';
|
||||
import { ReadAccess } from './read-access';
|
||||
@@ -33,6 +36,8 @@ export class GetBoardQueryHandler
|
||||
const page = await this.findIssues.search({
|
||||
...query.filter,
|
||||
statuses: [status.id],
|
||||
// Each column follows the backlog order.
|
||||
sort: IssueSort.RANK,
|
||||
limit: Math.min(query.perColumn, 200),
|
||||
offset: 0,
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user