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:
2026-10-08 14:01:44 +02:00
parent d4da33d4e8
commit 0606f53026
53 changed files with 1599 additions and 32 deletions

View File

@@ -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,

View File

@@ -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);

View File

@@ -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';

View File

@@ -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);
}
}

View File

@@ -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();
}
}