feat: work-in-progress limits and swimlanes on the board
Each status can carry a WIP limit in the project settings; the board shows it next to the count, marks a column that goes over it and says so when a card is dropped there. The board can also be grouped in rows by person, epic, priority or type, remembered per browser. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -99,6 +99,23 @@ describe('Project settings', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps work-in-progress limits, from 1 to 999', () => {
|
||||
const project = newProject();
|
||||
const limited = defaultProjectSettings();
|
||||
limited.statuses[2].wipLimit = 3;
|
||||
project.updateSettings('owner', limited);
|
||||
expect(project.workflow.wipLimit(limited.statuses[2].id)).toBe(3);
|
||||
expect(project.workflow.wipLimit('TODO')).toBeNull();
|
||||
// Saving the same again is no change.
|
||||
const before = project.getUncommittedEvents().length;
|
||||
project.updateSettings('owner', structuredClone(limited));
|
||||
expect(project.getUncommittedEvents()).toHaveLength(before);
|
||||
|
||||
const zero = defaultProjectSettings();
|
||||
zero.statuses[2].wipLimit = 0;
|
||||
expect(() => project.updateSettings('owner', zero)).toThrow(/WIP/);
|
||||
});
|
||||
|
||||
it('rejects settings that would break the board', () => {
|
||||
const project = newProject();
|
||||
const noStart = defaultProjectSettings();
|
||||
|
||||
@@ -61,6 +61,11 @@ export interface WorkflowStatus {
|
||||
requiresReason: boolean;
|
||||
/** Archived: no new tickets or transitions into it; existing ones keep it. */
|
||||
archived: boolean;
|
||||
/**
|
||||
* Work-in-progress limit: how many tickets the column should hold at most
|
||||
* (null: no limit). A warning, not a lock: the board flags a full column.
|
||||
*/
|
||||
wipLimit?: number | null;
|
||||
}
|
||||
|
||||
export interface IssueTypeOption {
|
||||
@@ -193,6 +198,11 @@ export class ProjectWorkflow {
|
||||
return this.settings.statuses.find((s) => s.id === id);
|
||||
}
|
||||
|
||||
/** Most tickets the status should hold (null: no limit). */
|
||||
wipLimit(id: string): number | null {
|
||||
return this.status(id)?.wipLimit ?? null;
|
||||
}
|
||||
|
||||
/** Name to show, also for ids no longer configured. */
|
||||
statusName(id: string): string {
|
||||
return (
|
||||
@@ -287,6 +297,8 @@ export function validateProjectSettings(
|
||||
color: s.color,
|
||||
requiresReason: !!s.requiresReason,
|
||||
archived: !!s.archived,
|
||||
// Only when set: settings saved unchanged must compare equal.
|
||||
...(s.wipLimit != null ? { wipLimit: s.wipLimit } : {}),
|
||||
}));
|
||||
const issueTypes = next.issueTypes.map((t) => ({
|
||||
id: normId(t.id),
|
||||
@@ -326,6 +338,11 @@ export function validateProjectSettings(
|
||||
SETTING_COLORS.includes(s.color),
|
||||
`Color no válido en el estado ${s.name}`,
|
||||
);
|
||||
assertThat(
|
||||
s.wipLimit === undefined ||
|
||||
(Number.isInteger(s.wipLimit) && s.wipLimit >= 1 && s.wipLimit <= 999),
|
||||
`El límite WIP del estado ${s.name} tiene que ser un número entre 1 y 999`,
|
||||
);
|
||||
}
|
||||
for (const t of issueTypes) {
|
||||
assertThat(
|
||||
|
||||
@@ -14,6 +14,7 @@ export interface SettingsPayload {
|
||||
color: string;
|
||||
requiresReason: boolean;
|
||||
archived: boolean;
|
||||
wipLimit?: number | null;
|
||||
}[];
|
||||
transitions: { from: string; to: string[] }[];
|
||||
issueTypes: {
|
||||
|
||||
@@ -425,6 +425,15 @@ export class WorkflowStatusInput {
|
||||
@Field() @IsString() color!: string;
|
||||
@Field({ defaultValue: false }) @IsBoolean() requiresReason!: boolean;
|
||||
@Field({ defaultValue: false }) @IsBoolean() archived!: boolean;
|
||||
@Field(() => Int, {
|
||||
nullable: true,
|
||||
description: 'Work-in-progress limit (null: none)',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Max(999)
|
||||
wipLimit?: number | null;
|
||||
}
|
||||
|
||||
@InputType()
|
||||
|
||||
@@ -33,6 +33,12 @@ export class WorkflowStatusGql {
|
||||
requiresReason!: boolean;
|
||||
@Field({ description: 'No new tickets or transitions into it' })
|
||||
archived!: boolean;
|
||||
@Field(() => Int, {
|
||||
nullable: true,
|
||||
description:
|
||||
'Work-in-progress limit: most tickets the column should hold (a warning, not a lock)',
|
||||
})
|
||||
wipLimit?: number | null;
|
||||
}
|
||||
|
||||
@ObjectType('StatusTransitions')
|
||||
|
||||
@@ -142,6 +142,18 @@ export class WorkflowStatusBodyDto {
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
archived = false;
|
||||
@ApiPropertyOptional({
|
||||
type: Number,
|
||||
nullable: true,
|
||||
minimum: 1,
|
||||
maximum: 999,
|
||||
description: 'Work-in-progress limit (null: none)',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Max(999)
|
||||
wipLimit?: number | null;
|
||||
}
|
||||
|
||||
export class StatusTransitionsBodyDto {
|
||||
|
||||
@@ -39,6 +39,12 @@ export class WorkflowStatusDto {
|
||||
requiresReason!: boolean;
|
||||
@ApiProperty({ description: 'No new tickets or transitions into it' })
|
||||
archived!: boolean;
|
||||
@ApiProperty({
|
||||
type: Number,
|
||||
nullable: true,
|
||||
description: 'Work-in-progress limit (a warning, not a lock)',
|
||||
})
|
||||
wipLimit!: number | null;
|
||||
}
|
||||
|
||||
export class StatusTransitionsDto {
|
||||
|
||||
Reference in New Issue
Block a user