feat: definition of done per project
A project can ask that tickets reach a DONE status only with their checklist ticked (settings.checklistBeforeDone, off by default; absent when saving means unchanged). Members get a 422 telling how many steps are left; owners and admins may still close it. Settings has the switch and the ticket's checklist says when the project asks for it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1511,4 +1511,60 @@ describe('task-stream (e2e)', () => {
|
|||||||
);
|
);
|
||||||
expect((await rest('token-ana', 'GET', '/me/views')).body).toEqual([]);
|
expect((await rest('token-ana', 'GET', '/me/views')).body).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('keeps unfinished checklists out of DONE when the project asks', async () => {
|
||||||
|
const key = 'H' + Date.now().toString(36).slice(-5).toUpperCase();
|
||||||
|
await rest('token-ana', 'POST', '/projects', { key, name: key });
|
||||||
|
const project = await eventually(
|
||||||
|
() => rest('token-ana', 'GET', `/projects/${key}`),
|
||||||
|
(r) => r.status === 200,
|
||||||
|
);
|
||||||
|
expect(project.body['settings'].checklistBeforeDone).toBe(false);
|
||||||
|
await rest('token-ana', 'POST', `/projects/${key}/members`, {
|
||||||
|
userId: '102',
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
(
|
||||||
|
await rest('token-ana', 'PUT', `/projects/${key}/settings`, {
|
||||||
|
...project.body['settings'],
|
||||||
|
checklistBeforeDone: true,
|
||||||
|
})
|
||||||
|
).status,
|
||||||
|
).toBe(200);
|
||||||
|
const issue = (
|
||||||
|
await rest('token-bob', 'POST', `/projects/${key}/issues`, {
|
||||||
|
type: 'TASK',
|
||||||
|
title: 'Con pasos',
|
||||||
|
})
|
||||||
|
).body['key'];
|
||||||
|
await eventually(
|
||||||
|
() => rest('token-bob', 'GET', `/issues/${issue}`),
|
||||||
|
(r) => r.status === 200,
|
||||||
|
);
|
||||||
|
await rest('token-bob', 'POST', `/issues/${issue}/checklist`, {
|
||||||
|
text: 'Probar en Safari',
|
||||||
|
});
|
||||||
|
for (const status of [
|
||||||
|
'TODO',
|
||||||
|
'IN_PROGRESS',
|
||||||
|
'IN_REVIEW',
|
||||||
|
'READY_FOR_QA',
|
||||||
|
'IN_QA',
|
||||||
|
])
|
||||||
|
await rest('token-bob', 'PUT', `/issues/${issue}/status`, { status });
|
||||||
|
|
||||||
|
const refused = await rest('token-bob', 'PUT', `/issues/${issue}/status`, {
|
||||||
|
status: 'DONE',
|
||||||
|
});
|
||||||
|
expect(refused.status).toBe(422);
|
||||||
|
expect(refused.body['message']).toMatch(/queda 1 paso/);
|
||||||
|
// The owner may close it anyway.
|
||||||
|
expect(
|
||||||
|
(
|
||||||
|
await rest('token-ana', 'PUT', `/issues/${issue}/status`, {
|
||||||
|
status: 'DONE',
|
||||||
|
})
|
||||||
|
).status,
|
||||||
|
).toBe(200);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -10982,6 +10982,10 @@
|
|||||||
"items": {
|
"items": {
|
||||||
"$ref": "#/components/schemas/CustomFieldDefinitionDto"
|
"$ref": "#/components/schemas/CustomFieldDefinitionDto"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"checklistBeforeDone": {
|
||||||
|
"type": "boolean",
|
||||||
|
"description": "Definition of done: tickets reach DONE only with their checklist ticked"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": [
|
"required": [
|
||||||
@@ -10989,7 +10993,8 @@
|
|||||||
"transitions",
|
"transitions",
|
||||||
"issueTypes",
|
"issueTypes",
|
||||||
"areas",
|
"areas",
|
||||||
"customFields"
|
"customFields",
|
||||||
|
"checklistBeforeDone"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"ProjectDto": {
|
"ProjectDto": {
|
||||||
@@ -11379,6 +11384,10 @@
|
|||||||
"items": {
|
"items": {
|
||||||
"$ref": "#/components/schemas/CustomFieldDefinitionBodyDto"
|
"$ref": "#/components/schemas/CustomFieldDefinitionBodyDto"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"checklistBeforeDone": {
|
||||||
|
"type": "boolean",
|
||||||
|
"description": "Definition of done: tickets reach a DONE status only with their checklist ticked (owners and admins may override). Absent: unchanged"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": [
|
"required": [
|
||||||
|
|||||||
@@ -1185,6 +1185,11 @@ Statuses (board order), transitions, ticket types, areas and custom fields
|
|||||||
"""
|
"""
|
||||||
type ProjectSettings {
|
type ProjectSettings {
|
||||||
areas: [AreaOption!]!
|
areas: [AreaOption!]!
|
||||||
|
|
||||||
|
"""
|
||||||
|
Definition of done: tickets reach DONE only with their checklist ticked (owners and admins may override)
|
||||||
|
"""
|
||||||
|
checklistBeforeDone: Boolean!
|
||||||
customFields: [CustomFieldDefinition!]!
|
customFields: [CustomFieldDefinition!]!
|
||||||
issueTypes: [IssueTypeOption!]!
|
issueTypes: [IssueTypeOption!]!
|
||||||
statuses: [WorkflowStatus!]!
|
statuses: [WorkflowStatus!]!
|
||||||
@@ -1197,6 +1202,11 @@ The whole configuration (replaces the previous one). Statuses go in board order.
|
|||||||
input ProjectSettingsInput {
|
input ProjectSettingsInput {
|
||||||
areas: [AreaOptionInput!]!
|
areas: [AreaOptionInput!]!
|
||||||
|
|
||||||
|
"""
|
||||||
|
Definition of done: DONE only with the checklist ticked. Absent: unchanged
|
||||||
|
"""
|
||||||
|
checklistBeforeDone: Boolean
|
||||||
|
|
||||||
"""Absent: unchanged. Fields cannot be removed: archive them."""
|
"""Absent: unchanged. Fields cannot be removed: archive them."""
|
||||||
customFields: [CustomFieldDefinitionInput!]
|
customFields: [CustomFieldDefinitionInput!]
|
||||||
issueTypes: [IssueTypeOptionInput!]!
|
issueTypes: [IssueTypeOptionInput!]!
|
||||||
|
|||||||
@@ -1747,6 +1747,8 @@ export interface components {
|
|||||||
issueTypes: components["schemas"]["IssueTypeOptionDto"][];
|
issueTypes: components["schemas"]["IssueTypeOptionDto"][];
|
||||||
areas: components["schemas"]["AreaOptionDto"][];
|
areas: components["schemas"]["AreaOptionDto"][];
|
||||||
customFields: components["schemas"]["CustomFieldDefinitionDto"][];
|
customFields: components["schemas"]["CustomFieldDefinitionDto"][];
|
||||||
|
/** @description Definition of done: tickets reach DONE only with their checklist ticked */
|
||||||
|
checklistBeforeDone: boolean;
|
||||||
};
|
};
|
||||||
ProjectDto: {
|
ProjectDto: {
|
||||||
/** Format: uuid */
|
/** Format: uuid */
|
||||||
@@ -1895,6 +1897,8 @@ export interface components {
|
|||||||
areas: components["schemas"]["AreaOptionBodyDto"][];
|
areas: components["schemas"]["AreaOptionBodyDto"][];
|
||||||
/** @description Absent: unchanged. Fields cannot be removed: archive them. */
|
/** @description Absent: unchanged. Fields cannot be removed: archive them. */
|
||||||
customFields?: components["schemas"]["CustomFieldDefinitionBodyDto"][];
|
customFields?: components["schemas"]["CustomFieldDefinitionBodyDto"][];
|
||||||
|
/** @description Definition of done: tickets reach a DONE status only with their checklist ticked (owners and admins may override). Absent: unchanged */
|
||||||
|
checklistBeforeDone?: boolean;
|
||||||
};
|
};
|
||||||
/** @enum {string} */
|
/** @enum {string} */
|
||||||
IssuePriority: "LOWEST" | "LOW" | "MEDIUM" | "HIGH" | "CRITICAL";
|
IssuePriority: "LOWEST" | "LOW" | "MEDIUM" | "HIGH" | "CRITICAL";
|
||||||
|
|||||||
@@ -118,6 +118,12 @@ nuevo proyecto se quedan fuera). Las etiquetas no tienen catálogo, así que no
|
|||||||
Los proyectos creados antes de que existiera la configuración usan los valores por defecto (sus eventos no la
|
Los proyectos creados antes de que existiera la configuración usan los valores por defecto (sus eventos no la
|
||||||
llevan). Al arrancar, la API rellena la categoría de los tickets del read model que no la tienen.
|
llevan). Al arrancar, la API rellena la categoría de los tickets del read model que no la tienen.
|
||||||
|
|
||||||
|
### Definición de «Hecho»
|
||||||
|
|
||||||
|
`settings.checklistBeforeDone` (desactivada por defecto): con ella, un ticket no pasa a un estado de categoría `DONE`
|
||||||
|
mientras le quede algún paso de la checklist sin marcar (`422`, «quedan N pasos…»). El propietario y los admins sí
|
||||||
|
pueden cerrarlo; las automatizaciones, no. Al guardar la configuración, si no viene, se queda como estaba.
|
||||||
|
|
||||||
### Campos personalizados
|
### Campos personalizados
|
||||||
|
|
||||||
Datos propios del proyecto en cada ticket («Navegador», «Cliente», «Coste»…), en `settings.customFields`: `id`, `name`,
|
Datos propios del proyecto en cada ticket («Navegador», «Cliente», «Coste»…), en `settings.customFields`: `id`, `name`,
|
||||||
|
|||||||
@@ -294,7 +294,7 @@ visualización de la plataforma:
|
|||||||
| `/p/:key/roadmap` | Hoja de ruta: épicas en el tiempo, con sprints y releases; flechas «bloquea» / «depende de» entre épicas y aviso si una empieza antes de que acabe la que la bloquea |
|
| `/p/:key/roadmap` | Hoja de ruta: épicas en el tiempo, con sprints y releases; flechas «bloquea» / «depende de» entre épicas y aviso si una empieza antes de que acabe la que la bloquea |
|
||||||
| `/p/:key/dashboard` | Seguimiento: cifras, flujo por estado, lo que ha cambiado desde tu última visita (guardada en este navegador), sprint en curso, tickets en riesgo, previsión de las próximas releases, antigüedad de lo que está en curso, cuellos de botella, salud del backlog (cada número abre la lista filtrada: `?missing=estimate`, `area`, `assignee` o `?staleDays=90`), calidad (bugs por prioridad y por semana, QA por área, bugs tras cada release), abiertos por un campo de lista a elegir, equipo con sus puntos abiertos y tipos. Se recarga solo con cada cambio |
|
| `/p/:key/dashboard` | Seguimiento: cifras, flujo por estado, lo que ha cambiado desde tu última visita (guardada en este navegador), sprint en curso, tickets en riesgo, previsión de las próximas releases, antigüedad de lo que está en curso, cuellos de botella, salud del backlog (cada número abre la lista filtrada: `?missing=estimate`, `area`, `assignee` o `?staleDays=90`), calidad (bugs por prioridad y por semana, QA por área, bugs tras cada release), abiertos por un campo de lista a elegir, equipo con sus puntos abiertos y tipos. Se recarga solo con cada cambio |
|
||||||
| `/p/:key/analytics` | Estadísticas: creados vs. resueltos, flujo acumulado, burnup de una release, tiempos de ciclo y entrega, QA y resueltos por persona (30 días a 1 año) |
|
| `/p/:key/analytics` | Estadísticas: creados vs. resueltos, flujo acumulado, burnup de una release, tiempos de ciclo y entrega, QA y resueltos por persona (30 días a 1 año) |
|
||||||
| `/p/:key/settings` | Configuración (propietario y admins; pestaña oculta para el resto): estados (orden, categoría, color, motivo), transiciones, tipos, áreas y campos personalizados; archivar en lugar de borrar |
|
| `/p/:key/settings` | Configuración (propietario y admins; pestaña oculta para el resto): estados (orden, categoría, color, motivo), transiciones, tipos, áreas, definición de «Hecho» y campos personalizados; archivar en lugar de borrar |
|
||||||
| `/p/:key/activity` | Actividad en vivo |
|
| `/p/:key/activity` | Actividad en vivo |
|
||||||
| `/p/:key/members` | Miembros |
|
| `/p/:key/members` | Miembros |
|
||||||
|
|
||||||
|
|||||||
@@ -37,7 +37,6 @@ Nada: la tanda está hecha (ver 10.5).
|
|||||||
- **Sincronizar con issues de Gitea o GitHub**: que un issue del repositorio cree o actualice un ticket, y al revés.
|
- **Sincronizar con issues de Gitea o GitHub**: que un issue del repositorio cree o actualice un ticket, y al revés.
|
||||||
- **Búsqueda con lenguaje de consulta**: `estado:abierto asignado:yo navegador:Safari prioridad>=alta`, guardable como
|
- **Búsqueda con lenguaje de consulta**: `estado:abierto asignado:yo navegador:Safari prioridad>=alta`, guardable como
|
||||||
vista.
|
vista.
|
||||||
- **Definición de «Hecho»**: no pasar a un estado `DONE` con la lista de comprobación a medias (salvo un admin).
|
|
||||||
- **Entornos de despliegue**: en qué entorno está cada ticket (pruebas, producción), desde los avisos de la
|
- **Entornos de despliegue**: en qué entorno está cada ticket (pruebas, producción), desde los avisos de la
|
||||||
integración continua.
|
integración continua.
|
||||||
- **Equipos dentro del proyecto**: asignar a un equipo y mencionarlo (`@frontend`).
|
- **Equipos dentro del proyecto**: asignar a un equipo y mencionarlo (`@frontend`).
|
||||||
@@ -119,3 +118,5 @@ Mucho trabajo para lo que aportan hoy; se pueden recuperar si alguien los necesi
|
|||||||
lugar de devolver un 409.
|
lugar de devolver un 409.
|
||||||
- Vistas guardadas en «Mi trabajo»: filtros propios entre proyectos (proyectos, asignados a mí, que sigo, estados,
|
- Vistas guardadas en «Mi trabajo»: filtros propios entre proyectos (proyectos, asignados a mí, que sigo, estados,
|
||||||
prioridades, solo bugs, vencidos, texto), en una barra encima de «Mi trabajo».
|
prioridades, solo bugs, vencidos, texto), en una barra encima de «Mi trabajo».
|
||||||
|
- Definición de «Hecho» por proyecto: con ella activada, un ticket no se cierra con la checklist a medias (el
|
||||||
|
propietario y los admins sí pueden).
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
@@ -14,11 +14,14 @@ export class ChangeIssueStatusCommandHandler
|
|||||||
command.issueId,
|
command.issueId,
|
||||||
command.actorId,
|
command.actorId,
|
||||||
);
|
);
|
||||||
|
const role = project.roleOf(command.actorId);
|
||||||
issue.changeStatus(
|
issue.changeStatus(
|
||||||
command.actorId,
|
command.actorId,
|
||||||
command.status,
|
command.status,
|
||||||
command.reason,
|
command.reason,
|
||||||
project.workflow,
|
project.workflow,
|
||||||
|
// The definition of done holds back members, not who runs the project.
|
||||||
|
role === 'OWNER' || role === 'ADMIN',
|
||||||
);
|
);
|
||||||
await issue.commitAndWait();
|
await issue.commitAndWait();
|
||||||
return ack(issue);
|
return ack(issue);
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ import { IssueUpdatedEvent } from '../events/issue-updated.event';
|
|||||||
import { IssueUnwatchedEvent } from '../events/issue-unwatched.event';
|
import { IssueUnwatchedEvent } from '../events/issue-unwatched.event';
|
||||||
import { IssueWatchedEvent } from '../events/issue-watched.event';
|
import { IssueWatchedEvent } from '../events/issue-watched.event';
|
||||||
import { IssueVotedEvent } from '../events/issue-voted.event';
|
import { IssueVotedEvent } from '../events/issue-voted.event';
|
||||||
import { ProjectWorkflow } from '../project/project-settings';
|
import { ProjectWorkflow, StatusCategory } from '../project/project-settings';
|
||||||
import {
|
import {
|
||||||
IssueAttachment,
|
IssueAttachment,
|
||||||
MAX_ATTACHMENTS_PER_ISSUE,
|
MAX_ATTACHMENTS_PER_ISSUE,
|
||||||
@@ -200,6 +200,8 @@ export class Issue extends VersionedAggregateRoot implements IssueProps {
|
|||||||
to: string,
|
to: string,
|
||||||
reason?: string | null,
|
reason?: string | null,
|
||||||
workflow: ProjectWorkflow = ProjectWorkflow.default(),
|
workflow: ProjectWorkflow = ProjectWorkflow.default(),
|
||||||
|
/** Owners and admins may close a ticket its definition of done holds back. */
|
||||||
|
canOverride = false,
|
||||||
): void {
|
): void {
|
||||||
const from = this.status;
|
const from = this.status;
|
||||||
if (from === to) return;
|
if (from === to) return;
|
||||||
@@ -214,6 +216,16 @@ export class Issue extends VersionedAggregateRoot implements IssueProps {
|
|||||||
}`,
|
}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
const pending = this.checklist.filter((item) => !item.done).length;
|
||||||
|
if (
|
||||||
|
workflow.checklistBeforeDone &&
|
||||||
|
workflow.categoryOf(to) === StatusCategory.DONE &&
|
||||||
|
pending > 0 &&
|
||||||
|
!canOverride
|
||||||
|
)
|
||||||
|
throw new BusinessRuleError(
|
||||||
|
`A ${this.key} le ${pending === 1 ? 'queda 1 paso' : `quedan ${pending} pasos`} de la lista por marcar, y el proyecto pide terminarla antes de darlo por hecho`,
|
||||||
|
);
|
||||||
const trimmedReason = reason?.trim() || null;
|
const trimmedReason = reason?.trim() || null;
|
||||||
if (workflow.requiresReason(to) && !trimmedReason) {
|
if (workflow.requiresReason(to) && !trimmedReason) {
|
||||||
throw new BusinessRuleError(
|
throw new BusinessRuleError(
|
||||||
|
|||||||
@@ -353,4 +353,54 @@ describe('Project settings', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('holds a ticket with an unticked checklist out of DONE, if asked', () => {
|
||||||
|
const project = newProject();
|
||||||
|
const issue = new IssueFactory().create(
|
||||||
|
{
|
||||||
|
projectId: project.id,
|
||||||
|
projectKey: 'WEB',
|
||||||
|
number: 1,
|
||||||
|
title: 'Pago',
|
||||||
|
type: 'TASK',
|
||||||
|
reporterId: 'dev',
|
||||||
|
},
|
||||||
|
project.workflow,
|
||||||
|
);
|
||||||
|
issue.addChecklistItem('dev', 'a', 'Probar en Safari');
|
||||||
|
issue.addChecklistItem('dev', 'b', 'Probar en Chrome');
|
||||||
|
for (const to of [
|
||||||
|
'TODO',
|
||||||
|
'IN_PROGRESS',
|
||||||
|
'IN_REVIEW',
|
||||||
|
'READY_FOR_QA',
|
||||||
|
'IN_QA',
|
||||||
|
])
|
||||||
|
issue.changeStatus('dev', to, null, project.workflow);
|
||||||
|
|
||||||
|
// Off by default: nothing changes for projects that do not ask.
|
||||||
|
const done = () =>
|
||||||
|
issue.changeStatus('dev', 'DONE', null, project.workflow);
|
||||||
|
expect(project.workflow.checklistBeforeDone).toBe(false);
|
||||||
|
|
||||||
|
project.updateSettings('owner', {
|
||||||
|
...defaultProjectSettings(),
|
||||||
|
checklistBeforeDone: true,
|
||||||
|
});
|
||||||
|
expect(done).toThrow(/quedan 2 pasos/);
|
||||||
|
// A client that does not know about it keeps it on.
|
||||||
|
project.updateSettings('owner', defaultProjectSettings());
|
||||||
|
expect(project.workflow.checklistBeforeDone).toBe(true);
|
||||||
|
issue.updateChecklistItem('dev', 'a', { done: true });
|
||||||
|
expect(done).toThrow(/queda 1 paso/);
|
||||||
|
// An owner or admin may close it anyway.
|
||||||
|
issue.changeStatus('owner', 'DONE', null, project.workflow, true);
|
||||||
|
expect(issue.status).toBe('DONE');
|
||||||
|
|
||||||
|
project.updateSettings('owner', {
|
||||||
|
...defaultProjectSettings(),
|
||||||
|
checklistBeforeDone: false,
|
||||||
|
});
|
||||||
|
expect(project.workflow.checklistBeforeDone).toBe(false);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -134,6 +134,12 @@ export interface ProjectSettings {
|
|||||||
* unchanged (clients that predate them keep them).
|
* unchanged (clients that predate them keep them).
|
||||||
*/
|
*/
|
||||||
customFields?: CustomFieldDefinition[];
|
customFields?: CustomFieldDefinition[];
|
||||||
|
/**
|
||||||
|
* Definition of done: a ticket only reaches a DONE status with its
|
||||||
|
* checklist ticked (owners and admins may still close it). Absent when
|
||||||
|
* off; when saving, absent means unchanged.
|
||||||
|
*/
|
||||||
|
checklistBeforeDone?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const status = (
|
const status = (
|
||||||
@@ -323,6 +329,11 @@ export class ProjectWorkflow {
|
|||||||
throw new BusinessRuleError(`Ticket type ${type.name} is archived`);
|
throw new BusinessRuleError(`Ticket type ${type.name} is archived`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Tickets reach DONE only with their checklist ticked (definition of done). */
|
||||||
|
get checklistBeforeDone(): boolean {
|
||||||
|
return !!this.settings.checklistBeforeDone;
|
||||||
|
}
|
||||||
|
|
||||||
/** The project's custom fields, archived ones included. */
|
/** The project's custom fields, archived ones included. */
|
||||||
get customFields(): CustomFieldDefinition[] {
|
get customFields(): CustomFieldDefinition[] {
|
||||||
return this.settings.customFields ?? [];
|
return this.settings.customFields ?? [];
|
||||||
@@ -503,6 +514,9 @@ export function validateProjectSettings(
|
|||||||
areas,
|
areas,
|
||||||
// Only when there are some: settings saved unchanged must compare equal.
|
// Only when there are some: settings saved unchanged must compare equal.
|
||||||
...(customFields.length ? { customFields } : {}),
|
...(customFields.length ? { customFields } : {}),
|
||||||
|
...((next.checklistBeforeDone ?? previous.checklistBeforeDone)
|
||||||
|
? { checklistBeforeDone: true }
|
||||||
|
: {}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -27,6 +27,8 @@ export interface SettingsPayload {
|
|||||||
template?: { description?: string | null; checklist?: string[] } | null;
|
template?: { description?: string | null; checklist?: string[] } | null;
|
||||||
}[];
|
}[];
|
||||||
areas: { id: string; name: string; archived: boolean }[];
|
areas: { id: string; name: string; archived: boolean }[];
|
||||||
|
/** Definition of done. Absent when saving: unchanged. */
|
||||||
|
checklistBeforeDone?: boolean;
|
||||||
/** Absent when saving: unchanged. */
|
/** Absent when saving: unchanged. */
|
||||||
customFields?: {
|
customFields?: {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -41,6 +43,7 @@ export function toSettingsPayload(settings: ProjectSettings): SettingsPayload {
|
|||||||
return {
|
return {
|
||||||
...settings,
|
...settings,
|
||||||
customFields: settings.customFields ?? [],
|
customFields: settings.customFields ?? [],
|
||||||
|
checklistBeforeDone: !!settings.checklistBeforeDone,
|
||||||
transitions: settings.statuses.map((s) => ({
|
transitions: settings.statuses.map((s) => ({
|
||||||
from: s.id,
|
from: s.id,
|
||||||
to: settings.transitions[s.id] ?? [],
|
to: settings.transitions[s.id] ?? [],
|
||||||
@@ -72,6 +75,9 @@ export function fromSettingsPayload(payload: SettingsPayload): ProjectSettings {
|
|||||||
: {}),
|
: {}),
|
||||||
})),
|
})),
|
||||||
areas: payload.areas,
|
areas: payload.areas,
|
||||||
|
...(payload.checklistBeforeDone != null
|
||||||
|
? { checklistBeforeDone: payload.checklistBeforeDone }
|
||||||
|
: {}),
|
||||||
...(payload.customFields
|
...(payload.customFields
|
||||||
? {
|
? {
|
||||||
customFields: payload.customFields.map((f) => ({
|
customFields: payload.customFields.map((f) => ({
|
||||||
|
|||||||
@@ -772,6 +772,15 @@ export class ProjectSettingsInput {
|
|||||||
@ValidateNested({ each: true })
|
@ValidateNested({ each: true })
|
||||||
@Type(() => CustomFieldDefinitionInput)
|
@Type(() => CustomFieldDefinitionInput)
|
||||||
customFields?: CustomFieldDefinitionInput[];
|
customFields?: CustomFieldDefinitionInput[];
|
||||||
|
|
||||||
|
@Field({
|
||||||
|
nullable: true,
|
||||||
|
description:
|
||||||
|
'Definition of done: DONE only with the checklist ticked. Absent: unchanged',
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
checklistBeforeDone?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@InputType({
|
@InputType({
|
||||||
|
|||||||
@@ -115,6 +115,11 @@ export class ProjectSettingsGql {
|
|||||||
@Field(() => [AreaOptionGql]) areas!: AreaOptionGql[];
|
@Field(() => [AreaOptionGql]) areas!: AreaOptionGql[];
|
||||||
@Field(() => [CustomFieldDefinitionGql])
|
@Field(() => [CustomFieldDefinitionGql])
|
||||||
customFields!: CustomFieldDefinitionGql[];
|
customFields!: CustomFieldDefinitionGql[];
|
||||||
|
@Field({
|
||||||
|
description:
|
||||||
|
'Definition of done: tickets reach DONE only with their checklist ticked (owners and admins may override)',
|
||||||
|
})
|
||||||
|
checklistBeforeDone!: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ObjectType('Project')
|
@ObjectType('Project')
|
||||||
|
|||||||
@@ -318,6 +318,14 @@ export class ProjectSettingsBodyDto {
|
|||||||
@ValidateNested({ each: true })
|
@ValidateNested({ each: true })
|
||||||
@Type(() => CustomFieldDefinitionBodyDto)
|
@Type(() => CustomFieldDefinitionBodyDto)
|
||||||
customFields?: CustomFieldDefinitionBodyDto[];
|
customFields?: CustomFieldDefinitionBodyDto[];
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description:
|
||||||
|
'Definition of done: tickets reach a DONE status only with their checklist ticked (owners and admins may override). Absent: unchanged',
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
checklistBeforeDone?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class AnalyticsQueryDto {
|
export class AnalyticsQueryDto {
|
||||||
|
|||||||
@@ -103,6 +103,11 @@ export class ProjectSettingsDto {
|
|||||||
@ApiProperty({ type: [AreaOptionDto] }) areas!: AreaOptionDto[];
|
@ApiProperty({ type: [AreaOptionDto] }) areas!: AreaOptionDto[];
|
||||||
@ApiProperty({ type: [CustomFieldDefinitionDto] })
|
@ApiProperty({ type: [CustomFieldDefinitionDto] })
|
||||||
customFields!: CustomFieldDefinitionDto[];
|
customFields!: CustomFieldDefinitionDto[];
|
||||||
|
@ApiProperty({
|
||||||
|
description:
|
||||||
|
'Definition of done: tickets reach DONE only with their checklist ticked',
|
||||||
|
})
|
||||||
|
checklistBeforeDone!: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class ProjectDto {
|
export class ProjectDto {
|
||||||
|
|||||||
@@ -97,6 +97,8 @@ export interface ProjectSettings {
|
|||||||
issueTypes: IssueTypeOption[];
|
issueTypes: IssueTypeOption[];
|
||||||
areas: AreaOption[];
|
areas: AreaOption[];
|
||||||
customFields: CustomFieldDefinition[];
|
customFields: CustomFieldDefinition[];
|
||||||
|
/** Definition of done: a DONE status only with the checklist ticked. */
|
||||||
|
checklistBeforeDone?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const FINISHED_CATEGORIES: readonly StatusCategory[] = [
|
export const FINISHED_CATEGORIES: readonly StatusCategory[] = [
|
||||||
|
|||||||
@@ -112,5 +112,6 @@ export function toSettingsInput(
|
|||||||
options: f.options,
|
options: f.options,
|
||||||
archived: f.archived,
|
archived: f.archived,
|
||||||
})),
|
})),
|
||||||
|
checklistBeforeDone: !!settings.checklistBeforeDone,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -58,6 +58,7 @@ fragment ProjectFields on Project {
|
|||||||
options
|
options
|
||||||
archived
|
archived
|
||||||
}
|
}
|
||||||
|
checklistBeforeDone
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -273,6 +273,8 @@ export type ProjectRole = 'ADMIN' | 'MEMBER' | 'OWNER' | 'VIEWER';
|
|||||||
/** The whole configuration (replaces the previous one). Statuses go in board order. */
|
/** The whole configuration (replaces the previous one). Statuses go in board order. */
|
||||||
export type ProjectSettingsInput = {
|
export type ProjectSettingsInput = {
|
||||||
areas: Array<AreaOptionInput>;
|
areas: Array<AreaOptionInput>;
|
||||||
|
/** Definition of done: DONE only with the checklist ticked. Absent: unchanged */
|
||||||
|
checklistBeforeDone?: boolean | null | undefined;
|
||||||
/** Absent: unchanged. Fields cannot be removed: archive them. */
|
/** Absent: unchanged. Fields cannot be removed: archive them. */
|
||||||
customFields?: Array<CustomFieldDefinitionInput> | null | undefined;
|
customFields?: Array<CustomFieldDefinitionInput> | null | undefined;
|
||||||
issueTypes: Array<IssueTypeOptionInput>;
|
issueTypes: Array<IssueTypeOptionInput>;
|
||||||
@@ -530,6 +532,7 @@ export type ProjectFieldsFragment = {
|
|||||||
owner: { id: string; name: string };
|
owner: { id: string; name: string };
|
||||||
members: Array<{ role: ProjectRole; user: { id: string; name: string } }>;
|
members: Array<{ role: ProjectRole; user: { id: string; name: string } }>;
|
||||||
settings: {
|
settings: {
|
||||||
|
checklistBeforeDone: boolean;
|
||||||
statuses: Array<{
|
statuses: Array<{
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -1524,6 +1527,7 @@ export type MyProjectsQuery = {
|
|||||||
owner: { id: string; name: string };
|
owner: { id: string; name: string };
|
||||||
members: Array<{ role: ProjectRole; user: { id: string; name: string } }>;
|
members: Array<{ role: ProjectRole; user: { id: string; name: string } }>;
|
||||||
settings: {
|
settings: {
|
||||||
|
checklistBeforeDone: boolean;
|
||||||
statuses: Array<{
|
statuses: Array<{
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -1574,6 +1578,7 @@ export type ProjectQuery = {
|
|||||||
owner: { id: string; name: string };
|
owner: { id: string; name: string };
|
||||||
members: Array<{ role: ProjectRole; user: { id: string; name: string } }>;
|
members: Array<{ role: ProjectRole; user: { id: string; name: string } }>;
|
||||||
settings: {
|
settings: {
|
||||||
|
checklistBeforeDone: boolean;
|
||||||
statuses: Array<{
|
statuses: Array<{
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -1734,6 +1739,7 @@ export type PublicProjectsQuery = {
|
|||||||
owner: { id: string; name: string };
|
owner: { id: string; name: string };
|
||||||
members: Array<{ role: ProjectRole; user: { id: string; name: string } }>;
|
members: Array<{ role: ProjectRole; user: { id: string; name: string } }>;
|
||||||
settings: {
|
settings: {
|
||||||
|
checklistBeforeDone: boolean;
|
||||||
statuses: Array<{
|
statuses: Array<{
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -2409,6 +2415,7 @@ export type ProjectChangedSubscription = {
|
|||||||
owner: { id: string; name: string };
|
owner: { id: string; name: string };
|
||||||
members: Array<{ role: ProjectRole; user: { id: string; name: string } }>;
|
members: Array<{ role: ProjectRole; user: { id: string; name: string } }>;
|
||||||
settings: {
|
settings: {
|
||||||
|
checklistBeforeDone: boolean;
|
||||||
statuses: Array<{
|
statuses: Array<{
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -2462,6 +2469,7 @@ export type MyProjectsChangedSubscription = {
|
|||||||
owner: { id: string; name: string };
|
owner: { id: string; name: string };
|
||||||
members: Array<{ role: ProjectRole; user: { id: string; name: string } }>;
|
members: Array<{ role: ProjectRole; user: { id: string; name: string } }>;
|
||||||
settings: {
|
settings: {
|
||||||
|
checklistBeforeDone: boolean;
|
||||||
statuses: Array<{
|
statuses: Array<{
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -2874,6 +2882,10 @@ export const ProjectFieldsFragmentDoc = {
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
kind: 'Field',
|
||||||
|
name: { kind: 'Name', value: 'checklistBeforeDone' },
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -9615,6 +9627,10 @@ export const MyProjectsDocument = {
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
kind: 'Field',
|
||||||
|
name: { kind: 'Name', value: 'checklistBeforeDone' },
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -9858,6 +9874,10 @@ export const ProjectDocument = {
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
kind: 'Field',
|
||||||
|
name: { kind: 'Name', value: 'checklistBeforeDone' },
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -10952,6 +10972,10 @@ export const PublicProjectsDocument = {
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
kind: 'Field',
|
||||||
|
name: { kind: 'Name', value: 'checklistBeforeDone' },
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -14334,6 +14358,10 @@ export const ProjectChangedDocument = {
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
kind: 'Field',
|
||||||
|
name: { kind: 'Name', value: 'checklistBeforeDone' },
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -14564,6 +14592,10 @@ export const MyProjectsChangedDocument = {
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
kind: 'Field',
|
||||||
|
name: { kind: 'Name', value: 'checklistBeforeDone' },
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -231,6 +231,12 @@ a.ts-key {
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.ts-checklist__rule {
|
||||||
|
margin: 0 0 0.5rem;
|
||||||
|
color: var(--ts-muted);
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
}
|
||||||
|
|
||||||
.ts-checklist,
|
.ts-checklist,
|
||||||
.ts-children {
|
.ts-children {
|
||||||
display: grid;
|
display: grid;
|
||||||
|
|||||||
@@ -268,6 +268,14 @@ const MAX_FILE_BYTES = 25 * 1024 * 1024;
|
|||||||
>
|
>
|
||||||
}
|
}
|
||||||
</h2>
|
</h2>
|
||||||
|
@if (
|
||||||
|
project.workflow().settings.checklistBeforeDone &&
|
||||||
|
facade.checklistProgress().done < facade.checklistProgress().total
|
||||||
|
) {
|
||||||
|
<p class="ts-checklist__rule">
|
||||||
|
Este proyecto pide marcarla entera antes de darlo por hecho.
|
||||||
|
</p>
|
||||||
|
}
|
||||||
@if (issue.checklist.length) {
|
@if (issue.checklist.length) {
|
||||||
<ul class="ts-checklist">
|
<ul class="ts-checklist">
|
||||||
@for (item of issue.checklist; track item.id) {
|
@for (item of issue.checklist; track item.id) {
|
||||||
|
|||||||
@@ -530,6 +530,33 @@ function idFrom(name: string): string {
|
|||||||
</ol>
|
</ol>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<!-- Definition of done -->
|
||||||
|
<section class="ts-panel ts-block" aria-labelledby="dod-title">
|
||||||
|
<header class="ts-block__head">
|
||||||
|
<div>
|
||||||
|
<h3 id="dod-title">Definición de «Hecho»</h3>
|
||||||
|
<p class="ts-muted">
|
||||||
|
Un ticket no pasa a un estado «Hecho» con su checklist a
|
||||||
|
medias. El propietario y los admins sí pueden cerrarlo.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<label class="ts-switch">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
role="switch"
|
||||||
|
[checked]="!!d.checklistBeforeDone"
|
||||||
|
(change)="
|
||||||
|
setChecklistBeforeDone($any($event.target).checked)
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
<span class="ts-switch__track" aria-hidden="true"></span>
|
||||||
|
<span>{{
|
||||||
|
d.checklistBeforeDone ? 'Activada' : 'Desactivada'
|
||||||
|
}}</span>
|
||||||
|
</label>
|
||||||
|
</header>
|
||||||
|
</section>
|
||||||
|
|
||||||
<!-- Custom fields -->
|
<!-- Custom fields -->
|
||||||
<section class="ts-panel ts-block" aria-labelledby="cf-title">
|
<section class="ts-panel ts-block" aria-labelledby="cf-title">
|
||||||
<header class="ts-block__head">
|
<header class="ts-block__head">
|
||||||
@@ -1396,6 +1423,12 @@ export class SettingsPage {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected setChecklistBeforeDone(on: boolean): void {
|
||||||
|
this.patch((d) => {
|
||||||
|
d.checklistBeforeDone = on;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
protected addField(): void {
|
protected addField(): void {
|
||||||
this.patch((d) =>
|
this.patch((d) =>
|
||||||
d.customFields.push({
|
d.customFields.push({
|
||||||
|
|||||||
Reference in New Issue
Block a user