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:
@@ -7754,6 +7754,11 @@
|
|||||||
"archived": {
|
"archived": {
|
||||||
"type": "boolean",
|
"type": "boolean",
|
||||||
"description": "No new tickets or transitions into it"
|
"description": "No new tickets or transitions into it"
|
||||||
|
},
|
||||||
|
"wipLimit": {
|
||||||
|
"type": "number",
|
||||||
|
"nullable": true,
|
||||||
|
"description": "Work-in-progress limit (a warning, not a lock)"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": [
|
"required": [
|
||||||
@@ -7762,7 +7767,8 @@
|
|||||||
"category",
|
"category",
|
||||||
"color",
|
"color",
|
||||||
"requiresReason",
|
"requiresReason",
|
||||||
"archived"
|
"archived",
|
||||||
|
"wipLimit"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"StatusTransitionsDto": {
|
"StatusTransitionsDto": {
|
||||||
@@ -8067,6 +8073,13 @@
|
|||||||
"archived": {
|
"archived": {
|
||||||
"type": "boolean",
|
"type": "boolean",
|
||||||
"default": false
|
"default": false
|
||||||
|
},
|
||||||
|
"wipLimit": {
|
||||||
|
"type": "number",
|
||||||
|
"nullable": true,
|
||||||
|
"minimum": 1,
|
||||||
|
"maximum": 999,
|
||||||
|
"description": "Work-in-progress limit (null: none)"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": [
|
"required": [
|
||||||
|
|||||||
@@ -1009,6 +1009,11 @@ type WorkflowStatus {
|
|||||||
|
|
||||||
"""Entering it needs a reason"""
|
"""Entering it needs a reason"""
|
||||||
requiresReason: Boolean!
|
requiresReason: Boolean!
|
||||||
|
|
||||||
|
"""
|
||||||
|
Work-in-progress limit: most tickets the column should hold (a warning, not a lock)
|
||||||
|
"""
|
||||||
|
wipLimit: Int
|
||||||
}
|
}
|
||||||
|
|
||||||
input WorkflowStatusInput {
|
input WorkflowStatusInput {
|
||||||
@@ -1018,4 +1023,7 @@ input WorkflowStatusInput {
|
|||||||
id: String!
|
id: String!
|
||||||
name: String!
|
name: String!
|
||||||
requiresReason: Boolean! = false
|
requiresReason: Boolean! = false
|
||||||
|
|
||||||
|
"""Work-in-progress limit (null: none)"""
|
||||||
|
wipLimit: Int
|
||||||
}
|
}
|
||||||
@@ -1160,6 +1160,8 @@ export interface components {
|
|||||||
requiresReason: boolean;
|
requiresReason: boolean;
|
||||||
/** @description No new tickets or transitions into it */
|
/** @description No new tickets or transitions into it */
|
||||||
archived: boolean;
|
archived: boolean;
|
||||||
|
/** @description Work-in-progress limit (a warning, not a lock) */
|
||||||
|
wipLimit: number | null;
|
||||||
};
|
};
|
||||||
StatusTransitionsDto: {
|
StatusTransitionsDto: {
|
||||||
/** @example IN_QA */
|
/** @example IN_QA */
|
||||||
@@ -1270,6 +1272,8 @@ export interface components {
|
|||||||
requiresReason: boolean;
|
requiresReason: boolean;
|
||||||
/** @default false */
|
/** @default false */
|
||||||
archived: boolean;
|
archived: boolean;
|
||||||
|
/** @description Work-in-progress limit (null: none) */
|
||||||
|
wipLimit?: number | null;
|
||||||
};
|
};
|
||||||
StatusTransitionsBodyDto: {
|
StatusTransitionsBodyDto: {
|
||||||
/** @example IN_QA */
|
/** @example IN_QA */
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
## Qué resuelve
|
## Qué resuelve
|
||||||
|
|
||||||
| Necesidad | Cómo se resuelve |
|
| Necesidad | Cómo se resuelve |
|
||||||
| ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
| ----------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| 🐛 Bugs | Tickets de tipo `BUG` con prioridad, área y etiquetas |
|
| 🐛 Bugs | Tickets de tipo `BUG` con prioridad, área y etiquetas |
|
||||||
| 🌍 Públicos | Un proyecto público se puede leer sin iniciar sesión (web, GraphQL y REST): tablero, tickets, comentarios, releases, actividad y estadísticas; «Explorar» los lista |
|
| 🌍 Públicos | Un proyecto público se puede leer sin iniciar sesión (web, GraphQL y REST): tablero, tickets, comentarios, releases, actividad y estadísticas; «Explorar» los lista |
|
||||||
| 🗑️ Borrar | El propietario borra un proyecto confirmando su clave; tickets, comentarios, releases y enlaces desde otros proyectos se borran en cascada (sagas) y la clave queda libre |
|
| 🗑️ Borrar | El propietario borra un proyecto confirmando su clave; tickets, comentarios, releases y enlaces desde otros proyectos se borran en cascada (sagas) y la clave queda libre |
|
||||||
@@ -15,6 +15,7 @@
|
|||||||
| 👥 Asignaciones | Solo a miembros del proyecto; panel de carga por persona |
|
| 👥 Asignaciones | Solo a miembros del proyecto; panel de carga por persona |
|
||||||
| 💬 Comentarios | Decisiones y explicaciones dentro del ticket, con `@menciones`; el autor edita, el admin modera |
|
| 💬 Comentarios | Decisiones y explicaciones dentro del ticket, con `@menciones`; el autor edita, el admin modera |
|
||||||
| 🔎 Vistas | El tablero se filtra por texto, persona (o sin asignar), tipo, prioridad, área y release; los filtros se guardan como vistas propias o compartidas con el proyecto |
|
| 🔎 Vistas | El tablero se filtra por texto, persona (o sin asignar), tipo, prioridad, área y release; los filtros se guardan como vistas propias o compartidas con el proyecto |
|
||||||
|
| 🚦 WIP y carriles | Límite de trabajo en curso por estado (la columna se marca al pasarse) y tablero agrupable por persona, épica, prioridad o tipo |
|
||||||
| 🎲 Estimación | Puntos por ticket (escala 0-1-2-3-5-8-13-21) en la ficha, las tarjetas, las columnas y el backlog; velocidad por sprint y aviso si un sprint planificado supera la media |
|
| 🎲 Estimación | Puntos por ticket (escala 0-1-2-3-5-8-13-21) en la ficha, las tarjetas, las columnas y el backlog; velocidad por sprint y aviso si un sprint planificado supera la media |
|
||||||
| 🏃 Sprints | Iteraciones con fechas y objetivo, una en curso por proyecto, con burndown (tickets o puntos) y traspaso de lo pendiente al cerrar |
|
| 🏃 Sprints | Iteraciones con fechas y objetivo, una en curso por proyecto, con burndown (tickets o puntos) y traspaso de lo pendiente al cerrar |
|
||||||
| 📋 Backlog | Lo pendiente de empezar, en el orden en que se va a coger; se reordena arrastrando y el tablero sigue ese orden |
|
| 📋 Backlog | Lo pendiente de empezar, en el orden en que se va a coger; se reordena arrastrando y el tablero sigue ese orden |
|
||||||
|
|||||||
@@ -157,6 +157,8 @@ stateDiagram-v2
|
|||||||
encima y el de quien queda debajo, leídos del event store; si entretanto el orden cambió, la API lo rechaza y la web
|
encima y el de quien queda debajo, leídos del event store; si entretanto el orden cambió, la API lo rechaza y la web
|
||||||
recarga. Un ticket que nunca se movió ocupa su orden de creación (`IssueRankedEvent` no existe para él). Las
|
recarga. Un ticket que nunca se movió ocupa su orden de creación (`IssueRankedEvent` no existe para él). Las
|
||||||
columnas del tablero y `issues(filter: { sort: RANK })` siguen este orden.
|
columnas del tablero y `issues(filter: { sort: RANK })` siguen este orden.
|
||||||
|
- **Límite WIP**: cada estado puede tener `wipLimit` (1-999) en la configuración del proyecto. Es un aviso, no un
|
||||||
|
bloqueo: la API acepta pasarse y el tablero marca la columna.
|
||||||
- **Sprints**: los planifica y gestiona quien administra el proyecto (owner o admin); mete tickets cualquier miembro.
|
- **Sprints**: los planifica y gestiona quien administra el proyecto (owner o admin); mete tickets cualquier miembro.
|
||||||
Un sprint va de `PLANNED` a `ACTIVE` (uno solo en curso por proyecto) y a `CLOSED`; dura como mucho 90 días y un
|
Un sprint va de `PLANNED` a `ACTIVE` (uno solo en curso por proyecto) y a `CLOSED`; dura como mucho 90 días y un
|
||||||
sprint en curso conserva su fecha de inicio. Un sprint cerrado ya no acepta ni pierde tickets. Al cerrarlo se elige
|
sprint en curso conserva su fecha de inicio. Un sprint cerrado ya no acepta ni pierde tickets. Al cerrarlo se elige
|
||||||
|
|||||||
@@ -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', () => {
|
it('rejects settings that would break the board', () => {
|
||||||
const project = newProject();
|
const project = newProject();
|
||||||
const noStart = defaultProjectSettings();
|
const noStart = defaultProjectSettings();
|
||||||
|
|||||||
@@ -61,6 +61,11 @@ export interface WorkflowStatus {
|
|||||||
requiresReason: boolean;
|
requiresReason: boolean;
|
||||||
/** Archived: no new tickets or transitions into it; existing ones keep it. */
|
/** Archived: no new tickets or transitions into it; existing ones keep it. */
|
||||||
archived: boolean;
|
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 {
|
export interface IssueTypeOption {
|
||||||
@@ -193,6 +198,11 @@ export class ProjectWorkflow {
|
|||||||
return this.settings.statuses.find((s) => s.id === id);
|
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. */
|
/** Name to show, also for ids no longer configured. */
|
||||||
statusName(id: string): string {
|
statusName(id: string): string {
|
||||||
return (
|
return (
|
||||||
@@ -287,6 +297,8 @@ export function validateProjectSettings(
|
|||||||
color: s.color,
|
color: s.color,
|
||||||
requiresReason: !!s.requiresReason,
|
requiresReason: !!s.requiresReason,
|
||||||
archived: !!s.archived,
|
archived: !!s.archived,
|
||||||
|
// Only when set: settings saved unchanged must compare equal.
|
||||||
|
...(s.wipLimit != null ? { wipLimit: s.wipLimit } : {}),
|
||||||
}));
|
}));
|
||||||
const issueTypes = next.issueTypes.map((t) => ({
|
const issueTypes = next.issueTypes.map((t) => ({
|
||||||
id: normId(t.id),
|
id: normId(t.id),
|
||||||
@@ -326,6 +338,11 @@ export function validateProjectSettings(
|
|||||||
SETTING_COLORS.includes(s.color),
|
SETTING_COLORS.includes(s.color),
|
||||||
`Color no válido en el estado ${s.name}`,
|
`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) {
|
for (const t of issueTypes) {
|
||||||
assertThat(
|
assertThat(
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ export interface SettingsPayload {
|
|||||||
color: string;
|
color: string;
|
||||||
requiresReason: boolean;
|
requiresReason: boolean;
|
||||||
archived: boolean;
|
archived: boolean;
|
||||||
|
wipLimit?: number | null;
|
||||||
}[];
|
}[];
|
||||||
transitions: { from: string; to: string[] }[];
|
transitions: { from: string; to: string[] }[];
|
||||||
issueTypes: {
|
issueTypes: {
|
||||||
|
|||||||
@@ -425,6 +425,15 @@ export class WorkflowStatusInput {
|
|||||||
@Field() @IsString() color!: string;
|
@Field() @IsString() color!: string;
|
||||||
@Field({ defaultValue: false }) @IsBoolean() requiresReason!: boolean;
|
@Field({ defaultValue: false }) @IsBoolean() requiresReason!: boolean;
|
||||||
@Field({ defaultValue: false }) @IsBoolean() archived!: 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()
|
@InputType()
|
||||||
|
|||||||
@@ -33,6 +33,12 @@ export class WorkflowStatusGql {
|
|||||||
requiresReason!: boolean;
|
requiresReason!: boolean;
|
||||||
@Field({ description: 'No new tickets or transitions into it' })
|
@Field({ description: 'No new tickets or transitions into it' })
|
||||||
archived!: boolean;
|
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')
|
@ObjectType('StatusTransitions')
|
||||||
|
|||||||
@@ -142,6 +142,18 @@ export class WorkflowStatusBodyDto {
|
|||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsBoolean()
|
@IsBoolean()
|
||||||
archived = false;
|
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 {
|
export class StatusTransitionsBodyDto {
|
||||||
|
|||||||
@@ -39,6 +39,12 @@ export class WorkflowStatusDto {
|
|||||||
requiresReason!: boolean;
|
requiresReason!: boolean;
|
||||||
@ApiProperty({ description: 'No new tickets or transitions into it' })
|
@ApiProperty({ description: 'No new tickets or transitions into it' })
|
||||||
archived!: boolean;
|
archived!: boolean;
|
||||||
|
@ApiProperty({
|
||||||
|
type: Number,
|
||||||
|
nullable: true,
|
||||||
|
description: 'Work-in-progress limit (a warning, not a lock)',
|
||||||
|
})
|
||||||
|
wipLimit!: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class StatusTransitionsDto {
|
export class StatusTransitionsDto {
|
||||||
|
|||||||
@@ -7,3 +7,4 @@ export * from './lib/models/mentions';
|
|||||||
export * from './lib/models/hierarchy';
|
export * from './lib/models/hierarchy';
|
||||||
export * from './lib/models/views';
|
export * from './lib/models/views';
|
||||||
export * from './lib/models/estimation';
|
export * from './lib/models/estimation';
|
||||||
|
export * from './lib/models/swimlanes';
|
||||||
|
|||||||
51
libs/web/tracker/domain/src/lib/models/swimlanes.spec.ts
Normal file
51
libs/web/tracker/domain/src/lib/models/swimlanes.spec.ts
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
import { IssuePriority } from './enums';
|
||||||
|
import { BoardColumn, IssueSummary } from './models';
|
||||||
|
import { overWip, swimlanes } from './swimlanes';
|
||||||
|
|
||||||
|
const card = (id: string, assignee: string | null, parent: string | null) =>
|
||||||
|
({
|
||||||
|
id,
|
||||||
|
assignee: assignee ? { id: assignee, name: assignee } : null,
|
||||||
|
parent: parent ? { id: parent, key: parent, title: parent } : null,
|
||||||
|
priority: IssuePriority.MEDIUM,
|
||||||
|
type: 'TASK',
|
||||||
|
}) as IssueSummary;
|
||||||
|
|
||||||
|
const board: BoardColumn[] = [
|
||||||
|
{
|
||||||
|
status: 'TODO',
|
||||||
|
total: 3,
|
||||||
|
issues: [
|
||||||
|
card('a', 'ana', 'e1'),
|
||||||
|
card('b', null, null),
|
||||||
|
card('c', 'bob', 'e1'),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{ status: 'DONE', total: 1, issues: [card('d', 'ana', null)] },
|
||||||
|
];
|
||||||
|
|
||||||
|
describe('swimlanes', () => {
|
||||||
|
it('splits the board by person, nobody last', () => {
|
||||||
|
const lanes = swimlanes(board, 'assignee');
|
||||||
|
expect(lanes.map((l) => [l.key, l.count])).toEqual([
|
||||||
|
['ana', 2],
|
||||||
|
['bob', 1],
|
||||||
|
['', 1],
|
||||||
|
]);
|
||||||
|
expect(lanes[0].columns.map((c) => c.issues.map((i) => i.id))).toEqual([
|
||||||
|
['a'],
|
||||||
|
['d'],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('groups by epic and keeps one row when not grouping', () => {
|
||||||
|
expect(swimlanes(board, 'epic').map((l) => l.key)).toEqual(['e1', '']);
|
||||||
|
expect(swimlanes(board, 'none')).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('flags a column over its WIP limit', () => {
|
||||||
|
expect(overWip(4, 3)).toBe(true);
|
||||||
|
expect(overWip(3, 3)).toBe(false);
|
||||||
|
expect(overWip(9, null)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
72
libs/web/tracker/domain/src/lib/models/swimlanes.ts
Normal file
72
libs/web/tracker/domain/src/lib/models/swimlanes.ts
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
import { BoardColumn, IssueSummary } from './models';
|
||||||
|
|
||||||
|
/** How the board can be split in rows. */
|
||||||
|
export type SwimlaneMode = 'none' | 'assignee' | 'priority' | 'type' | 'epic';
|
||||||
|
|
||||||
|
export interface Swimlane {
|
||||||
|
/** Stable key of the row (`''` for "nobody", "no epic"...). */
|
||||||
|
key: string;
|
||||||
|
/** Same columns as the board, each with only this row's cards. */
|
||||||
|
columns: BoardColumn[];
|
||||||
|
count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Key of the row a card belongs to. */
|
||||||
|
export function laneKeyOf(issue: IssueSummary, mode: SwimlaneMode): string {
|
||||||
|
switch (mode) {
|
||||||
|
case 'assignee':
|
||||||
|
return issue.assignee?.id ?? '';
|
||||||
|
case 'priority':
|
||||||
|
return issue.priority;
|
||||||
|
case 'type':
|
||||||
|
return issue.type;
|
||||||
|
case 'epic':
|
||||||
|
return issue.parent?.id ?? '';
|
||||||
|
default:
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The board's columns split in rows. `order` ranks keys (lower first);
|
||||||
|
* cards keep their order, and the empty key (nobody, no epic) goes last.
|
||||||
|
*/
|
||||||
|
export function swimlanes(
|
||||||
|
columns: BoardColumn[],
|
||||||
|
mode: SwimlaneMode,
|
||||||
|
order: (key: string) => number = () => 0,
|
||||||
|
): Swimlane[] {
|
||||||
|
if (mode === 'none')
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
key: '',
|
||||||
|
columns,
|
||||||
|
count: columns.reduce((sum, c) => sum + c.issues.length, 0),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
const keys = new Set<string>();
|
||||||
|
for (const column of columns)
|
||||||
|
for (const issue of column.issues) keys.add(laneKeyOf(issue, mode));
|
||||||
|
return [...keys]
|
||||||
|
.sort((a, b) =>
|
||||||
|
a === '' ? 1 : b === '' ? -1 : order(a) - order(b) || a.localeCompare(b),
|
||||||
|
)
|
||||||
|
.map((key) => {
|
||||||
|
const lane = columns.map((column) => {
|
||||||
|
const issues = column.issues.filter(
|
||||||
|
(issue) => laneKeyOf(issue, mode) === key,
|
||||||
|
);
|
||||||
|
return { ...column, issues, total: issues.length };
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
key,
|
||||||
|
columns: lane,
|
||||||
|
count: lane.reduce((sum, c) => sum + c.issues.length, 0),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether a column holds more than its limit (`total` counts all of it). */
|
||||||
|
export function overWip(total: number, limit: number | null): boolean {
|
||||||
|
return limit !== null && total > limit;
|
||||||
|
}
|
||||||
@@ -42,6 +42,8 @@ export interface WorkflowStatus {
|
|||||||
requiresReason: boolean;
|
requiresReason: boolean;
|
||||||
/** No new tickets or transitions into it; existing tickets keep it. */
|
/** No new tickets or transitions into it; existing tickets keep it. */
|
||||||
archived: boolean;
|
archived: boolean;
|
||||||
|
/** Most tickets the column should hold (null/absent: no limit). */
|
||||||
|
wipLimit?: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface StatusTransitions {
|
export interface StatusTransitions {
|
||||||
@@ -156,6 +158,11 @@ export class Workflow {
|
|||||||
return this.settings.areas.filter((a) => !a.archived);
|
return this.settings.areas.filter((a) => !a.archived);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Most tickets the status should hold (null: no limit). */
|
||||||
|
wipLimit(id: string): number | null {
|
||||||
|
return this.status(id).wipLimit ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
requiresReason(id: string): boolean {
|
requiresReason(id: string): boolean {
|
||||||
return this.status(id).requiresReason;
|
return this.status(id).requiresReason;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -77,6 +77,7 @@ export function toSettingsInput(
|
|||||||
color: s.color,
|
color: s.color,
|
||||||
requiresReason: s.requiresReason,
|
requiresReason: s.requiresReason,
|
||||||
archived: s.archived,
|
archived: s.archived,
|
||||||
|
wipLimit: s.wipLimit ?? null,
|
||||||
})),
|
})),
|
||||||
transitions: settings.transitions.map((t) => ({ from: t.from, to: t.to })),
|
transitions: settings.transitions.map((t) => ({ from: t.from, to: t.to })),
|
||||||
issueTypes: settings.issueTypes.map((t) => ({
|
issueTypes: settings.issueTypes.map((t) => ({
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ fragment ProjectFields on Project {
|
|||||||
color
|
color
|
||||||
requiresReason
|
requiresReason
|
||||||
archived
|
archived
|
||||||
|
wipLimit
|
||||||
}
|
}
|
||||||
transitions {
|
transitions {
|
||||||
from
|
from
|
||||||
|
|||||||
@@ -239,6 +239,8 @@ export type WorkflowStatusInput = {
|
|||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
requiresReason?: boolean;
|
requiresReason?: boolean;
|
||||||
|
/** Work-in-progress limit (null: none) */
|
||||||
|
wipLimit?: number | null | undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type UserRefFieldsFragment = { id: string; name: string };
|
export type UserRefFieldsFragment = { id: string; name: string };
|
||||||
@@ -261,6 +263,7 @@ export type ProjectFieldsFragment = {
|
|||||||
color: string;
|
color: string;
|
||||||
requiresReason: boolean;
|
requiresReason: boolean;
|
||||||
archived: boolean;
|
archived: boolean;
|
||||||
|
wipLimit: number | null;
|
||||||
}>;
|
}>;
|
||||||
transitions: Array<{ from: string; to: Array<string> }>;
|
transitions: Array<{ from: string; to: Array<string> }>;
|
||||||
issueTypes: Array<{
|
issueTypes: Array<{
|
||||||
@@ -892,6 +895,7 @@ export type MyProjectsQuery = {
|
|||||||
color: string;
|
color: string;
|
||||||
requiresReason: boolean;
|
requiresReason: boolean;
|
||||||
archived: boolean;
|
archived: boolean;
|
||||||
|
wipLimit: number | null;
|
||||||
}>;
|
}>;
|
||||||
transitions: Array<{ from: string; to: Array<string> }>;
|
transitions: Array<{ from: string; to: Array<string> }>;
|
||||||
issueTypes: Array<{
|
issueTypes: Array<{
|
||||||
@@ -929,6 +933,7 @@ export type ProjectQuery = {
|
|||||||
color: string;
|
color: string;
|
||||||
requiresReason: boolean;
|
requiresReason: boolean;
|
||||||
archived: boolean;
|
archived: boolean;
|
||||||
|
wipLimit: number | null;
|
||||||
}>;
|
}>;
|
||||||
transitions: Array<{ from: string; to: Array<string> }>;
|
transitions: Array<{ from: string; to: Array<string> }>;
|
||||||
issueTypes: Array<{
|
issueTypes: Array<{
|
||||||
@@ -1076,6 +1081,7 @@ export type PublicProjectsQuery = {
|
|||||||
color: string;
|
color: string;
|
||||||
requiresReason: boolean;
|
requiresReason: boolean;
|
||||||
archived: boolean;
|
archived: boolean;
|
||||||
|
wipLimit: number | null;
|
||||||
}>;
|
}>;
|
||||||
transitions: Array<{ from: string; to: Array<string> }>;
|
transitions: Array<{ from: string; to: Array<string> }>;
|
||||||
issueTypes: Array<{
|
issueTypes: Array<{
|
||||||
@@ -1490,6 +1496,7 @@ export type ProjectChangedSubscription = {
|
|||||||
color: string;
|
color: string;
|
||||||
requiresReason: boolean;
|
requiresReason: boolean;
|
||||||
archived: boolean;
|
archived: boolean;
|
||||||
|
wipLimit: number | null;
|
||||||
}>;
|
}>;
|
||||||
transitions: Array<{ from: string; to: Array<string> }>;
|
transitions: Array<{ from: string; to: Array<string> }>;
|
||||||
issueTypes: Array<{
|
issueTypes: Array<{
|
||||||
@@ -1530,6 +1537,7 @@ export type MyProjectsChangedSubscription = {
|
|||||||
color: string;
|
color: string;
|
||||||
requiresReason: boolean;
|
requiresReason: boolean;
|
||||||
archived: boolean;
|
archived: boolean;
|
||||||
|
wipLimit: number | null;
|
||||||
}>;
|
}>;
|
||||||
transitions: Array<{ from: string; to: Array<string> }>;
|
transitions: Array<{ from: string; to: Array<string> }>;
|
||||||
issueTypes: Array<{
|
issueTypes: Array<{
|
||||||
@@ -1648,6 +1656,10 @@ export const ProjectFieldsFragmentDoc = {
|
|||||||
kind: 'Field',
|
kind: 'Field',
|
||||||
name: { kind: 'Name', value: 'archived' },
|
name: { kind: 'Name', value: 'archived' },
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
kind: 'Field',
|
||||||
|
name: { kind: 'Name', value: 'wipLimit' },
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -5664,6 +5676,10 @@ export const MyProjectsDocument = {
|
|||||||
kind: 'Field',
|
kind: 'Field',
|
||||||
name: { kind: 'Name', value: 'archived' },
|
name: { kind: 'Name', value: 'archived' },
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
kind: 'Field',
|
||||||
|
name: { kind: 'Name', value: 'wipLimit' },
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -5865,6 +5881,10 @@ export const ProjectDocument = {
|
|||||||
kind: 'Field',
|
kind: 'Field',
|
||||||
name: { kind: 'Name', value: 'archived' },
|
name: { kind: 'Name', value: 'archived' },
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
kind: 'Field',
|
||||||
|
name: { kind: 'Name', value: 'wipLimit' },
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -6917,6 +6937,10 @@ export const PublicProjectsDocument = {
|
|||||||
kind: 'Field',
|
kind: 'Field',
|
||||||
name: { kind: 'Name', value: 'archived' },
|
name: { kind: 'Name', value: 'archived' },
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
kind: 'Field',
|
||||||
|
name: { kind: 'Name', value: 'wipLimit' },
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -9221,6 +9245,10 @@ export const ProjectChangedDocument = {
|
|||||||
kind: 'Field',
|
kind: 'Field',
|
||||||
name: { kind: 'Name', value: 'archived' },
|
name: { kind: 'Name', value: 'archived' },
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
kind: 'Field',
|
||||||
|
name: { kind: 'Name', value: 'wipLimit' },
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -9409,6 +9437,10 @@ export const MyProjectsChangedDocument = {
|
|||||||
kind: 'Field',
|
kind: 'Field',
|
||||||
name: { kind: 'Name', value: 'archived' },
|
name: { kind: 'Name', value: 'archived' },
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
kind: 'Field',
|
||||||
|
name: { kind: 'Name', value: 'wipLimit' },
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -147,10 +147,9 @@ import { initials, PRIORITY, statusLook, typeLook } from '../ui/copy';
|
|||||||
}
|
}
|
||||||
.ts-backlog__row {
|
.ts-backlog__row {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 2rem auto auto minmax(
|
grid-template-columns:
|
||||||
0,
|
2rem auto auto minmax(0, 1fr)
|
||||||
1fr
|
auto auto 2rem auto auto;
|
||||||
) auto auto 2rem auto auto;
|
|
||||||
gap: 0.625rem;
|
gap: 0.625rem;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding: 0.375rem 0.875rem;
|
padding: 0.375rem 0.875rem;
|
||||||
|
|||||||
@@ -136,3 +136,49 @@
|
|||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
color: var(--ts-muted);
|
color: var(--ts-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.ts-column.is-over {
|
||||||
|
box-shadow: inset 0 3px 0 var(--ts-c-red, var(--ts-ink));
|
||||||
|
|
||||||
|
.ts-column__count {
|
||||||
|
color: var(--ts-c-red, var(--ts-ink));
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Grouped board: heads and rows scroll together. */
|
||||||
|
.ts-lanes {
|
||||||
|
overflow-x: auto;
|
||||||
|
padding-bottom: 0.75rem;
|
||||||
|
|
||||||
|
.ts-board {
|
||||||
|
overflow: visible;
|
||||||
|
padding-bottom: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.ts-board--heads {
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
|
||||||
|
.ts-column--head {
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.ts-lane {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ts-lane__title {
|
||||||
|
position: sticky;
|
||||||
|
left: 0;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin: 0 0 0.5rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ts-column--lane .ts-column__list {
|
||||||
|
min-height: 3.5rem;
|
||||||
|
}
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
untracked,
|
untracked,
|
||||||
viewChild,
|
viewChild,
|
||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
|
import { NgTemplateOutlet } from '@angular/common';
|
||||||
import { form, FormField, maxLength, required } from '@angular/forms/signals';
|
import { form, FormField, maxLength, required } from '@angular/forms/signals';
|
||||||
import {
|
import {
|
||||||
PiActionButtonComponent,
|
PiActionButtonComponent,
|
||||||
@@ -30,7 +31,7 @@ import {
|
|||||||
type SegmentedControlOption,
|
type SegmentedControlOption,
|
||||||
} from '@pi/ui';
|
} from '@pi/ui';
|
||||||
import { AuthFacade } from '@task-stream/web/identity/application';
|
import { AuthFacade } from '@task-stream/web/identity/application';
|
||||||
import { DrawerService } from '@task-stream/web/shared/ui';
|
import { DrawerService, ToastService } from '@task-stream/web/shared/ui';
|
||||||
import {
|
import {
|
||||||
BoardFacade,
|
BoardFacade,
|
||||||
ProjectFacade,
|
ProjectFacade,
|
||||||
@@ -42,15 +43,44 @@ import {
|
|||||||
boardFilterOf,
|
boardFilterOf,
|
||||||
IssuePriority,
|
IssuePriority,
|
||||||
IssueSummary,
|
IssueSummary,
|
||||||
|
overWip,
|
||||||
POINT_SCALE,
|
POINT_SCALE,
|
||||||
ReleaseStatus,
|
ReleaseStatus,
|
||||||
SavedFilter,
|
SavedFilter,
|
||||||
sumPoints,
|
sumPoints,
|
||||||
|
SwimlaneMode,
|
||||||
|
swimlanes,
|
||||||
|
Workflow,
|
||||||
} from '@task-stream/web/tracker/domain';
|
} from '@task-stream/web/tracker/domain';
|
||||||
import { IssueCardComponent } from '../ui/issue-card';
|
import { IssueCardComponent } from '../ui/issue-card';
|
||||||
import { options, pointOptions, PRIORITY, statusLook } from '../ui/copy';
|
import {
|
||||||
|
options,
|
||||||
|
pointOptions,
|
||||||
|
PRIORITY,
|
||||||
|
PRIORITY_LEVEL,
|
||||||
|
statusLook,
|
||||||
|
typeLook,
|
||||||
|
} from '../ui/copy';
|
||||||
|
|
||||||
const ALL = '';
|
const ALL = '';
|
||||||
|
const LANE_KEY = 'ts.board.lanes';
|
||||||
|
|
||||||
|
/** The grouping last chosen on this browser (a convenience, not a setting). */
|
||||||
|
function storedLaneMode(): SwimlaneMode {
|
||||||
|
try {
|
||||||
|
const stored = localStorage.getItem(LANE_KEY);
|
||||||
|
return (['assignee', 'epic', 'priority', 'type'] as const).includes(
|
||||||
|
stored as never,
|
||||||
|
)
|
||||||
|
? (stored as SwimlaneMode)
|
||||||
|
: 'none';
|
||||||
|
} catch {
|
||||||
|
return 'none';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusLookType = (workflow: Workflow, id: string) =>
|
||||||
|
typeLook(workflow, id).label;
|
||||||
/** "Sin asignar" in the person filter. */
|
/** "Sin asignar" in the person filter. */
|
||||||
const NOBODY = '__nobody__';
|
const NOBODY = '__nobody__';
|
||||||
|
|
||||||
@@ -63,6 +93,7 @@ const NOBODY = '__nobody__';
|
|||||||
CdkDrag,
|
CdkDrag,
|
||||||
FormField,
|
FormField,
|
||||||
IssueCardComponent,
|
IssueCardComponent,
|
||||||
|
NgTemplateOutlet,
|
||||||
PiActionButtonComponent,
|
PiActionButtonComponent,
|
||||||
PiSelectFieldComponent,
|
PiSelectFieldComponent,
|
||||||
PiSegmentedControlComponent,
|
PiSegmentedControlComponent,
|
||||||
@@ -139,6 +170,13 @@ const NOBODY = '__nobody__';
|
|||||||
[options]="priorityFilterOptions"
|
[options]="priorityFilterOptions"
|
||||||
[(value)]="priorityFilter"
|
[(value)]="priorityFilter"
|
||||||
/>
|
/>
|
||||||
|
<pi-select-field
|
||||||
|
class="ts-filter"
|
||||||
|
placeholder="Agrupar por"
|
||||||
|
[options]="laneOptions"
|
||||||
|
[value]="laneMode()"
|
||||||
|
(valueChange)="setLaneMode($event)"
|
||||||
|
/>
|
||||||
<pi-select-field
|
<pi-select-field
|
||||||
class="ts-filter"
|
class="ts-filter"
|
||||||
placeholder="Sprint"
|
placeholder="Sprint"
|
||||||
@@ -177,19 +215,79 @@ const NOBODY = '__nobody__';
|
|||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
} @else {
|
} @else {
|
||||||
|
@if (laneMode() === 'none') {
|
||||||
<div class="ts-board" cdkDropListGroup>
|
<div class="ts-board" cdkDropListGroup>
|
||||||
@for (column of board.columns(); track column.status) {
|
@for (column of board.columns(); track column.status) {
|
||||||
<section
|
<section
|
||||||
class="ts-column"
|
class="ts-column"
|
||||||
|
[class.is-over]="isOver(column)"
|
||||||
[attr.data-status]="column.status"
|
[attr.data-status]="column.status"
|
||||||
[style.--tone]="statusTone(column.status)"
|
[style.--tone]="statusTone(column.status)"
|
||||||
>
|
>
|
||||||
|
<ng-container
|
||||||
|
[ngTemplateOutlet]="columnHead"
|
||||||
|
[ngTemplateOutletContext]="{ column }"
|
||||||
|
/>
|
||||||
|
<ng-container
|
||||||
|
[ngTemplateOutlet]="columnList"
|
||||||
|
[ngTemplateOutletContext]="{ column, lane: '' }"
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
} @else {
|
||||||
|
<!-- One scroll for heads and rows, so the columns stay aligned. -->
|
||||||
|
<div class="ts-lanes">
|
||||||
|
<div class="ts-board ts-board--heads">
|
||||||
|
@for (column of board.columns(); track column.status) {
|
||||||
|
<section
|
||||||
|
class="ts-column ts-column--head"
|
||||||
|
[class.is-over]="isOver(column)"
|
||||||
|
[style.--tone]="statusTone(column.status)"
|
||||||
|
>
|
||||||
|
<ng-container
|
||||||
|
[ngTemplateOutlet]="columnHead"
|
||||||
|
[ngTemplateOutletContext]="{ column }"
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
@for (lane of lanes(); track lane.key) {
|
||||||
|
<section class="ts-lane" [attr.aria-label]="laneLabel(lane.key)">
|
||||||
|
<h3 class="ts-lane__title">
|
||||||
|
{{ laneLabel(lane.key) }}
|
||||||
|
<span class="ts-mono ts-muted">{{ lane.count }}</span>
|
||||||
|
</h3>
|
||||||
|
<!-- Cards move between columns of their own row only. -->
|
||||||
|
<div class="ts-board" cdkDropListGroup>
|
||||||
|
@for (column of lane.columns; track column.status) {
|
||||||
|
<section
|
||||||
|
class="ts-column ts-column--lane"
|
||||||
|
[attr.data-status]="column.status"
|
||||||
|
[style.--tone]="statusTone(column.status)"
|
||||||
|
>
|
||||||
|
<ng-container
|
||||||
|
[ngTemplateOutlet]="columnList"
|
||||||
|
[ngTemplateOutletContext]="{ column, lane: lane.key }"
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
<ng-template #columnHead let-column="column">
|
||||||
<header class="ts-column__header">
|
<header class="ts-column__header">
|
||||||
<span class="ts-column__name">{{
|
<span class="ts-column__name">{{ statusLabel(column.status) }}</span>
|
||||||
statusLabel(column.status)
|
<span
|
||||||
}}</span>
|
class="ts-column__count"
|
||||||
<span class="ts-column__count"
|
[attr.title]="
|
||||||
>{{ column.total }}
|
wipOf(column) ? 'Límite WIP: ' + wipOf(column) + ' tickets' : null
|
||||||
|
"
|
||||||
|
>{{ column.total }}{{ wipOf(column) ? ' / ' + wipOf(column) : '' }}
|
||||||
@if (pointsOf(column); as points) {
|
@if (pointsOf(column); as points) {
|
||||||
<span class="ts-column__points" title="Puntos estimados"
|
<span class="ts-column__points" title="Puntos estimados"
|
||||||
>· {{ points }} pts</span
|
>· {{ points }} pts</span
|
||||||
@@ -197,10 +295,13 @@ const NOBODY = '__nobody__';
|
|||||||
}
|
}
|
||||||
</span>
|
</span>
|
||||||
</header>
|
</header>
|
||||||
|
</ng-template>
|
||||||
|
|
||||||
|
<ng-template #columnList let-column="column" let-lane="lane">
|
||||||
<div
|
<div
|
||||||
class="ts-column__list"
|
class="ts-column__list"
|
||||||
cdkDropList
|
cdkDropList
|
||||||
[id]="column.status"
|
[id]="lane + '|' + column.status"
|
||||||
[cdkDropListData]="column"
|
[cdkDropListData]="column"
|
||||||
[cdkDropListDisabled]="!project.canContribute()"
|
[cdkDropListDisabled]="!project.canContribute()"
|
||||||
[cdkDropListEnterPredicate]="canEnter"
|
[cdkDropListEnterPredicate]="canEnter"
|
||||||
@@ -217,10 +318,7 @@ const NOBODY = '__nobody__';
|
|||||||
<p class="ts-column__empty">Nada por aquí</p>
|
<p class="ts-column__empty">Nada por aquí</p>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</ng-template>
|
||||||
}
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
<ng-template #createForm>
|
<ng-template #createForm>
|
||||||
<form
|
<form
|
||||||
@@ -362,6 +460,7 @@ export class BoardPage {
|
|||||||
protected readonly board = inject(BoardFacade);
|
protected readonly board = inject(BoardFacade);
|
||||||
protected readonly project = inject(ProjectFacade);
|
protected readonly project = inject(ProjectFacade);
|
||||||
private readonly drawer = inject(DrawerService);
|
private readonly drawer = inject(DrawerService);
|
||||||
|
private readonly toast = inject(ToastService);
|
||||||
private readonly auth = inject(AuthFacade);
|
private readonly auth = inject(AuthFacade);
|
||||||
private readonly me = computed(() => this.auth.user()?.id ?? null);
|
private readonly me = computed(() => this.auth.user()?.id ?? null);
|
||||||
private readonly createForm =
|
private readonly createForm =
|
||||||
@@ -406,6 +505,59 @@ export class BoardPage {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/* Swimlanes and WIP */
|
||||||
|
protected readonly laneOptions: PiSelectFieldOption<string>[] = [
|
||||||
|
{ value: 'none', label: 'Sin agrupar' },
|
||||||
|
{ value: 'assignee', label: 'Agrupar por persona' },
|
||||||
|
{ value: 'epic', label: 'Agrupar por épica' },
|
||||||
|
{ value: 'priority', label: 'Agrupar por prioridad' },
|
||||||
|
{ value: 'type', label: 'Agrupar por tipo' },
|
||||||
|
];
|
||||||
|
protected readonly laneMode = signal<SwimlaneMode>(storedLaneMode());
|
||||||
|
protected readonly lanes = computed(() =>
|
||||||
|
swimlanes(this.board.columns(), this.laneMode(), (key) =>
|
||||||
|
this.laneMode() === 'priority'
|
||||||
|
? -PRIORITY_LEVEL[key as IssuePriority]
|
||||||
|
: 0,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
protected laneLabel(key: string): string {
|
||||||
|
switch (this.laneMode()) {
|
||||||
|
case 'assignee':
|
||||||
|
return key
|
||||||
|
? (this.project.members().find((m) => m.user.id === key)?.user.name ??
|
||||||
|
'Antiguo miembro')
|
||||||
|
: 'Sin asignar';
|
||||||
|
case 'epic': {
|
||||||
|
if (!key) return 'Sin épica';
|
||||||
|
const card = this.board
|
||||||
|
.columns()
|
||||||
|
.flatMap((c) => c.issues)
|
||||||
|
.find((i) => i.parent?.id === key);
|
||||||
|
return card?.parent ? `${card.parent.key} · ${card.parent.title}` : key;
|
||||||
|
}
|
||||||
|
case 'priority':
|
||||||
|
return PRIORITY[key as IssuePriority]?.label ?? key;
|
||||||
|
case 'type':
|
||||||
|
return statusLookType(this.project.workflow(), key);
|
||||||
|
default:
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
protected setLaneMode(mode: string | null): void {
|
||||||
|
const next = (mode || 'none') as SwimlaneMode;
|
||||||
|
this.laneMode.set(next);
|
||||||
|
try {
|
||||||
|
localStorage.setItem(LANE_KEY, next);
|
||||||
|
} catch {
|
||||||
|
// Private mode: the choice lasts until the page closes.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
protected wipOf = (column: BoardColumn) =>
|
||||||
|
this.project.workflow().wipLimit(column.status);
|
||||||
|
protected isOver = (column: BoardColumn) =>
|
||||||
|
overWip(column.total, this.wipOf(column));
|
||||||
|
|
||||||
/* Saved views */
|
/* Saved views */
|
||||||
protected readonly viewName = signal('');
|
protected readonly viewName = signal('');
|
||||||
protected readonly viewShared = signal(false);
|
protected readonly viewShared = signal(false);
|
||||||
@@ -582,6 +734,12 @@ export class BoardPage {
|
|||||||
if (event.previousContainer === event.container) return;
|
if (event.previousContainer === event.container) return;
|
||||||
const to = event.container.data.status;
|
const to = event.container.data.status;
|
||||||
const issueId = event.item.data.id;
|
const issueId = event.item.data.id;
|
||||||
|
const target = this.board.columns().find((c) => c.status === to);
|
||||||
|
const limit = this.project.workflow().wipLimit(to);
|
||||||
|
if (target && overWip(target.total + 1, limit))
|
||||||
|
this.toast.info(
|
||||||
|
`«${this.statusLabel(to)}» pasa de su límite WIP (${target.total + 1}/${limit}): mejor terminar algo antes de empezar más`,
|
||||||
|
);
|
||||||
if (this.project.workflow().requiresReason(to)) {
|
if (this.project.workflow().requiresReason(to)) {
|
||||||
this.pendingMove = { issueId, to };
|
this.pendingMove = { issueId, to };
|
||||||
this.reasonModel.set({ text: '' });
|
this.reasonModel.set({ text: '' });
|
||||||
|
|||||||
@@ -135,7 +135,8 @@ function idFrom(name: string): string {
|
|||||||
</header>
|
</header>
|
||||||
<div class="ts-rows-head ts-status-grid" aria-hidden="true">
|
<div class="ts-rows-head ts-status-grid" aria-hidden="true">
|
||||||
<span></span><span>Nombre</span><span>Categoría</span
|
<span></span><span>Nombre</span><span>Categoría</span
|
||||||
><span>Color</span><span>Pide motivo</span><span></span>
|
><span>Color</span><span>Límite WIP</span><span>Pide motivo</span
|
||||||
|
><span></span>
|
||||||
</div>
|
</div>
|
||||||
<ol
|
<ol
|
||||||
class="ts-rows"
|
class="ts-rows"
|
||||||
@@ -198,6 +199,21 @@ function idFrom(name: string): string {
|
|||||||
</option>
|
</option>
|
||||||
}
|
}
|
||||||
</select>
|
</select>
|
||||||
|
<input
|
||||||
|
class="ts-input"
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
max="999"
|
||||||
|
placeholder="—"
|
||||||
|
[attr.aria-label]="'Límite WIP de ' + status.name"
|
||||||
|
title="Cuántos tickets debería tener la columna como mucho (vacío: sin límite)"
|
||||||
|
[value]="status.wipLimit ?? ''"
|
||||||
|
(change)="
|
||||||
|
edit('statuses', i, {
|
||||||
|
wipLimit: wipFrom($any($event.target).value),
|
||||||
|
})
|
||||||
|
"
|
||||||
|
/>
|
||||||
<label class="ts-check">
|
<label class="ts-check">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
@@ -693,7 +709,10 @@ function idFrom(name: string): string {
|
|||||||
}
|
}
|
||||||
.ts-status-grid {
|
.ts-status-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1.25rem minmax(10rem, 1fr) 9rem 8rem 5.5rem 6.5rem;
|
grid-template-columns: 1.25rem minmax(
|
||||||
|
10rem,
|
||||||
|
1fr
|
||||||
|
) 9rem 8rem 5.5rem 5.5rem 6.5rem;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
}
|
}
|
||||||
@@ -996,6 +1015,12 @@ export class SettingsPage {
|
|||||||
?.to.includes(to);
|
?.to.includes(to);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** '' (or anything below 1) clears the limit. */
|
||||||
|
protected wipFrom(value: string): number | null {
|
||||||
|
const limit = Math.round(Number(value));
|
||||||
|
return Number.isFinite(limit) && limit >= 1 ? Math.min(limit, 999) : null;
|
||||||
|
}
|
||||||
|
|
||||||
protected edit(section: Section, index: number, changes: object): void {
|
protected edit(section: Section, index: number, changes: object): void {
|
||||||
this.patch((d) => Object.assign(d[section][index], changes));
|
this.patch((d) => Object.assign(d[section][index], changes));
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user