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:
@@ -14,11 +14,14 @@ export class ChangeIssueStatusCommandHandler
|
||||
command.issueId,
|
||||
command.actorId,
|
||||
);
|
||||
const role = project.roleOf(command.actorId);
|
||||
issue.changeStatus(
|
||||
command.actorId,
|
||||
command.status,
|
||||
command.reason,
|
||||
project.workflow,
|
||||
// The definition of done holds back members, not who runs the project.
|
||||
role === 'OWNER' || role === 'ADMIN',
|
||||
);
|
||||
await issue.commitAndWait();
|
||||
return ack(issue);
|
||||
|
||||
@@ -26,7 +26,7 @@ import { IssueUpdatedEvent } from '../events/issue-updated.event';
|
||||
import { IssueUnwatchedEvent } from '../events/issue-unwatched.event';
|
||||
import { IssueWatchedEvent } from '../events/issue-watched.event';
|
||||
import { IssueVotedEvent } from '../events/issue-voted.event';
|
||||
import { ProjectWorkflow } from '../project/project-settings';
|
||||
import { ProjectWorkflow, StatusCategory } from '../project/project-settings';
|
||||
import {
|
||||
IssueAttachment,
|
||||
MAX_ATTACHMENTS_PER_ISSUE,
|
||||
@@ -200,6 +200,8 @@ export class Issue extends VersionedAggregateRoot implements IssueProps {
|
||||
to: string,
|
||||
reason?: string | null,
|
||||
workflow: ProjectWorkflow = ProjectWorkflow.default(),
|
||||
/** Owners and admins may close a ticket its definition of done holds back. */
|
||||
canOverride = false,
|
||||
): void {
|
||||
const from = this.status;
|
||||
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;
|
||||
if (workflow.requiresReason(to) && !trimmedReason) {
|
||||
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).
|
||||
*/
|
||||
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 = (
|
||||
@@ -323,6 +329,11 @@ export class ProjectWorkflow {
|
||||
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. */
|
||||
get customFields(): CustomFieldDefinition[] {
|
||||
return this.settings.customFields ?? [];
|
||||
@@ -503,6 +514,9 @@ export function validateProjectSettings(
|
||||
areas,
|
||||
// Only when there are some: settings saved unchanged must compare equal.
|
||||
...(customFields.length ? { customFields } : {}),
|
||||
...((next.checklistBeforeDone ?? previous.checklistBeforeDone)
|
||||
? { checklistBeforeDone: true }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -27,6 +27,8 @@ export interface SettingsPayload {
|
||||
template?: { description?: string | null; checklist?: string[] } | null;
|
||||
}[];
|
||||
areas: { id: string; name: string; archived: boolean }[];
|
||||
/** Definition of done. Absent when saving: unchanged. */
|
||||
checklistBeforeDone?: boolean;
|
||||
/** Absent when saving: unchanged. */
|
||||
customFields?: {
|
||||
id: string;
|
||||
@@ -41,6 +43,7 @@ export function toSettingsPayload(settings: ProjectSettings): SettingsPayload {
|
||||
return {
|
||||
...settings,
|
||||
customFields: settings.customFields ?? [],
|
||||
checklistBeforeDone: !!settings.checklistBeforeDone,
|
||||
transitions: settings.statuses.map((s) => ({
|
||||
from: s.id,
|
||||
to: settings.transitions[s.id] ?? [],
|
||||
@@ -72,6 +75,9 @@ export function fromSettingsPayload(payload: SettingsPayload): ProjectSettings {
|
||||
: {}),
|
||||
})),
|
||||
areas: payload.areas,
|
||||
...(payload.checklistBeforeDone != null
|
||||
? { checklistBeforeDone: payload.checklistBeforeDone }
|
||||
: {}),
|
||||
...(payload.customFields
|
||||
? {
|
||||
customFields: payload.customFields.map((f) => ({
|
||||
|
||||
@@ -772,6 +772,15 @@ export class ProjectSettingsInput {
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => CustomFieldDefinitionInput)
|
||||
customFields?: CustomFieldDefinitionInput[];
|
||||
|
||||
@Field({
|
||||
nullable: true,
|
||||
description:
|
||||
'Definition of done: DONE only with the checklist ticked. Absent: unchanged',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
checklistBeforeDone?: boolean;
|
||||
}
|
||||
|
||||
@InputType({
|
||||
|
||||
@@ -115,6 +115,11 @@ export class ProjectSettingsGql {
|
||||
@Field(() => [AreaOptionGql]) areas!: AreaOptionGql[];
|
||||
@Field(() => [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')
|
||||
|
||||
@@ -318,6 +318,14 @@ export class ProjectSettingsBodyDto {
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => 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 {
|
||||
|
||||
@@ -103,6 +103,11 @@ export class ProjectSettingsDto {
|
||||
@ApiProperty({ type: [AreaOptionDto] }) areas!: AreaOptionDto[];
|
||||
@ApiProperty({ type: [CustomFieldDefinitionDto] })
|
||||
customFields!: CustomFieldDefinitionDto[];
|
||||
@ApiProperty({
|
||||
description:
|
||||
'Definition of done: tickets reach DONE only with their checklist ticked',
|
||||
})
|
||||
checklistBeforeDone!: boolean;
|
||||
}
|
||||
|
||||
export class ProjectDto {
|
||||
|
||||
@@ -97,6 +97,8 @@ export interface ProjectSettings {
|
||||
issueTypes: IssueTypeOption[];
|
||||
areas: AreaOption[];
|
||||
customFields: CustomFieldDefinition[];
|
||||
/** Definition of done: a DONE status only with the checklist ticked. */
|
||||
checklistBeforeDone?: boolean;
|
||||
}
|
||||
|
||||
export const FINISHED_CATEGORIES: readonly StatusCategory[] = [
|
||||
|
||||
@@ -112,5 +112,6 @@ export function toSettingsInput(
|
||||
options: f.options,
|
||||
archived: f.archived,
|
||||
})),
|
||||
checklistBeforeDone: !!settings.checklistBeforeDone,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -58,6 +58,7 @@ fragment ProjectFields on Project {
|
||||
options
|
||||
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. */
|
||||
export type ProjectSettingsInput = {
|
||||
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. */
|
||||
customFields?: Array<CustomFieldDefinitionInput> | null | undefined;
|
||||
issueTypes: Array<IssueTypeOptionInput>;
|
||||
@@ -530,6 +532,7 @@ export type ProjectFieldsFragment = {
|
||||
owner: { id: string; name: string };
|
||||
members: Array<{ role: ProjectRole; user: { id: string; name: string } }>;
|
||||
settings: {
|
||||
checklistBeforeDone: boolean;
|
||||
statuses: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -1524,6 +1527,7 @@ export type MyProjectsQuery = {
|
||||
owner: { id: string; name: string };
|
||||
members: Array<{ role: ProjectRole; user: { id: string; name: string } }>;
|
||||
settings: {
|
||||
checklistBeforeDone: boolean;
|
||||
statuses: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -1574,6 +1578,7 @@ export type ProjectQuery = {
|
||||
owner: { id: string; name: string };
|
||||
members: Array<{ role: ProjectRole; user: { id: string; name: string } }>;
|
||||
settings: {
|
||||
checklistBeforeDone: boolean;
|
||||
statuses: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -1734,6 +1739,7 @@ export type PublicProjectsQuery = {
|
||||
owner: { id: string; name: string };
|
||||
members: Array<{ role: ProjectRole; user: { id: string; name: string } }>;
|
||||
settings: {
|
||||
checklistBeforeDone: boolean;
|
||||
statuses: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -2409,6 +2415,7 @@ export type ProjectChangedSubscription = {
|
||||
owner: { id: string; name: string };
|
||||
members: Array<{ role: ProjectRole; user: { id: string; name: string } }>;
|
||||
settings: {
|
||||
checklistBeforeDone: boolean;
|
||||
statuses: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -2462,6 +2469,7 @@ export type MyProjectsChangedSubscription = {
|
||||
owner: { id: string; name: string };
|
||||
members: Array<{ role: ProjectRole; user: { id: string; name: string } }>;
|
||||
settings: {
|
||||
checklistBeforeDone: boolean;
|
||||
statuses: Array<{
|
||||
id: 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;
|
||||
}
|
||||
|
||||
.ts-checklist__rule {
|
||||
margin: 0 0 0.5rem;
|
||||
color: var(--ts-muted);
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
|
||||
.ts-checklist,
|
||||
.ts-children {
|
||||
display: grid;
|
||||
|
||||
@@ -268,6 +268,14 @@ const MAX_FILE_BYTES = 25 * 1024 * 1024;
|
||||
>
|
||||
}
|
||||
</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) {
|
||||
<ul class="ts-checklist">
|
||||
@for (item of issue.checklist; track item.id) {
|
||||
|
||||
@@ -530,6 +530,33 @@ function idFrom(name: string): string {
|
||||
</ol>
|
||||
</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 -->
|
||||
<section class="ts-panel ts-block" aria-labelledby="cf-title">
|
||||
<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 {
|
||||
this.patch((d) =>
|
||||
d.customFields.push({
|
||||
|
||||
Reference in New Issue
Block a user