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:
2026-10-15 14:39:24 +02:00
parent 5b0396bc10
commit e598f76e1c
25 changed files with 285 additions and 4 deletions

View File

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

View File

@@ -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": [

View File

@@ -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!]!

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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) => ({

View File

@@ -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({

View File

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

View File

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

View File

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

View File

@@ -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[] = [

View File

@@ -112,5 +112,6 @@ export function toSettingsInput(
options: f.options, options: f.options,
archived: f.archived, archived: f.archived,
})), })),
checklistBeforeDone: !!settings.checklistBeforeDone,
}; };
} }

View File

@@ -58,6 +58,7 @@ fragment ProjectFields on Project {
options options
archived archived
} }
checklistBeforeDone
} }
} }

View File

@@ -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' },
},
], ],
}, },
}, },

View File

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

View File

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

View File

@@ -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({