feat: templates per ticket type
Each ticket type can carry a starting description and a checklist, edited in the project settings. New tickets of that type get the description when they come without one and always get the checklist; the new ticket form says what the template will add. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -9051,6 +9051,25 @@
|
|||||||
"support"
|
"support"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"IssueTemplateDto": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"description": {
|
||||||
|
"type": "string",
|
||||||
|
"nullable": true
|
||||||
|
},
|
||||||
|
"checklist": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"description",
|
||||||
|
"checklist"
|
||||||
|
]
|
||||||
|
},
|
||||||
"IssueTypeOptionDto": {
|
"IssueTypeOptionDto": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
@@ -9078,6 +9097,14 @@
|
|||||||
},
|
},
|
||||||
"archived": {
|
"archived": {
|
||||||
"type": "boolean"
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"template": {
|
||||||
|
"description": "What a new ticket of the type starts with",
|
||||||
|
"allOf": [
|
||||||
|
{
|
||||||
|
"$ref": "#/components/schemas/IssueTemplateDto"
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": [
|
"required": [
|
||||||
@@ -9352,6 +9379,23 @@
|
|||||||
"to"
|
"to"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"IssueTemplateBodyDto": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"description": {
|
||||||
|
"type": "string",
|
||||||
|
"nullable": true,
|
||||||
|
"maxLength": 5000
|
||||||
|
},
|
||||||
|
"checklist": {
|
||||||
|
"maxItems": 20,
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"IssueTypeOptionBodyDto": {
|
"IssueTypeOptionBodyDto": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
@@ -9381,6 +9425,16 @@
|
|||||||
"archived": {
|
"archived": {
|
||||||
"type": "boolean",
|
"type": "boolean",
|
||||||
"default": false
|
"default": false
|
||||||
|
},
|
||||||
|
"template": {
|
||||||
|
"nullable": true,
|
||||||
|
"description": "What a new ticket of the type starts with",
|
||||||
|
"type": "object",
|
||||||
|
"allOf": [
|
||||||
|
{
|
||||||
|
"$ref": "#/components/schemas/IssueTemplateBodyDto"
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": [
|
"required": [
|
||||||
|
|||||||
@@ -491,12 +491,27 @@ enum IssueSort {
|
|||||||
RECENT
|
RECENT
|
||||||
}
|
}
|
||||||
|
|
||||||
|
"""What a new ticket of the type starts with"""
|
||||||
|
type IssueTemplate {
|
||||||
|
"""Steps added to every new ticket"""
|
||||||
|
checklist: [String!]!
|
||||||
|
|
||||||
|
"""Used when the ticket comes without a description"""
|
||||||
|
description: String
|
||||||
|
}
|
||||||
|
|
||||||
|
input IssueTemplateInput {
|
||||||
|
checklist: [String!]! = []
|
||||||
|
description: String
|
||||||
|
}
|
||||||
|
|
||||||
type IssueTypeOption {
|
type IssueTypeOption {
|
||||||
archived: Boolean!
|
archived: Boolean!
|
||||||
color: String!
|
color: String!
|
||||||
icon: String!
|
icon: String!
|
||||||
id: String!
|
id: String!
|
||||||
name: String!
|
name: String!
|
||||||
|
template: IssueTemplate
|
||||||
}
|
}
|
||||||
|
|
||||||
input IssueTypeOptionInput {
|
input IssueTypeOptionInput {
|
||||||
@@ -505,6 +520,7 @@ input IssueTypeOptionInput {
|
|||||||
icon: String!
|
icon: String!
|
||||||
id: String!
|
id: String!
|
||||||
name: String!
|
name: String!
|
||||||
|
template: IssueTemplateInput
|
||||||
}
|
}
|
||||||
|
|
||||||
enum LinkDirection {
|
enum LinkDirection {
|
||||||
|
|||||||
@@ -1368,6 +1368,10 @@ export interface components {
|
|||||||
};
|
};
|
||||||
/** @enum {string} */
|
/** @enum {string} */
|
||||||
TypeIcon: "bug" | "feature" | "palette" | "task" | "qa" | "flag" | "star-fill" | "rocket" | "document" | "light-bulb" | "key" | "support";
|
TypeIcon: "bug" | "feature" | "palette" | "task" | "qa" | "flag" | "star-fill" | "rocket" | "document" | "light-bulb" | "key" | "support";
|
||||||
|
IssueTemplateDto: {
|
||||||
|
description: string | null;
|
||||||
|
checklist: string[];
|
||||||
|
};
|
||||||
IssueTypeOptionDto: {
|
IssueTypeOptionDto: {
|
||||||
/** @example BUG */
|
/** @example BUG */
|
||||||
id: string;
|
id: string;
|
||||||
@@ -1376,6 +1380,8 @@ export interface components {
|
|||||||
icon: components["schemas"]["TypeIcon"];
|
icon: components["schemas"]["TypeIcon"];
|
||||||
color: components["schemas"]["SettingColor"];
|
color: components["schemas"]["SettingColor"];
|
||||||
archived: boolean;
|
archived: boolean;
|
||||||
|
/** @description What a new ticket of the type starts with */
|
||||||
|
template?: components["schemas"]["IssueTemplateDto"];
|
||||||
};
|
};
|
||||||
AreaOptionDto: {
|
AreaOptionDto: {
|
||||||
/** @example FRONTEND */
|
/** @example FRONTEND */
|
||||||
@@ -1478,6 +1484,10 @@ export interface components {
|
|||||||
*/
|
*/
|
||||||
to: string[];
|
to: string[];
|
||||||
};
|
};
|
||||||
|
IssueTemplateBodyDto: {
|
||||||
|
description?: string | null;
|
||||||
|
checklist?: string[];
|
||||||
|
};
|
||||||
IssueTypeOptionBodyDto: {
|
IssueTypeOptionBodyDto: {
|
||||||
/** @example SPIKE */
|
/** @example SPIKE */
|
||||||
id: string;
|
id: string;
|
||||||
@@ -1487,6 +1497,8 @@ export interface components {
|
|||||||
color: components["schemas"]["SettingColor"];
|
color: components["schemas"]["SettingColor"];
|
||||||
/** @default false */
|
/** @default false */
|
||||||
archived: boolean;
|
archived: boolean;
|
||||||
|
/** @description What a new ticket of the type starts with */
|
||||||
|
template?: components["schemas"]["IssueTemplateBodyDto"] | null;
|
||||||
};
|
};
|
||||||
AreaOptionBodyDto: {
|
AreaOptionBodyDto: {
|
||||||
/** @example DATA */
|
/** @example DATA */
|
||||||
|
|||||||
@@ -27,6 +27,7 @@
|
|||||||
| 📎 Adjuntos | Capturas, logs y vídeos en el ticket o bajo un comentario; arrastrar, elegir o pegar con Ctrl+V; miniaturas de las imágenes |
|
| 📎 Adjuntos | Capturas, logs y vídeos en el ticket o bajo un comentario; arrastrar, elegir o pegar con Ctrl+V; miniaturas de las imágenes |
|
||||||
| 🔗 Relaciones | `BLOCKS`, `DEPENDS_ON`, `TESTS`, `DUPLICATES`, `RELATES_TO`, también entre proyectos (FE ↔ BE ↔ QA) |
|
| 🔗 Relaciones | `BLOCKS`, `DEPENDS_ON`, `TESTS`, `DUPLICATES`, `RELATES_TO`, también entre proyectos (FE ↔ BE ↔ QA) |
|
||||||
| 🌿 Git | Webhook para GitHub, Gitea o GitLab: commits, ramas y pull requests que nombran un ticket (WEB-12) aparecen en su ficha |
|
| 🌿 Git | Webhook para GitHub, Gitea o GitLab: commits, ramas y pull requests que nombran un ticket (WEB-12) aparecen en su ficha |
|
||||||
|
| 🧾 Plantillas | Cada tipo de ticket puede traer una descripción inicial y una checklist que se aplican al crearlo |
|
||||||
| 🤖 Automatizaciones | Reglas «cuando… si… entonces…»: al crear, mover, asignar, comentar o fusionar una PR, mover, asignar, etiquetar, priorizar, comentar o meter en el sprint |
|
| 🤖 Automatizaciones | Reglas «cuando… si… entonces…»: al crear, mover, asignar, comentar o fusionar una PR, mover, asignar, etiquetar, priorizar, comentar o meter en el sprint |
|
||||||
| 📡 Webhooks | Un POST firmado (HMAC-SHA256) a otro sistema cuando pasa algo: tickets, comentarios, releases, sprints; con prueba y último envío |
|
| 📡 Webhooks | Un POST firmado (HMAC-SHA256) a otro sistema cuando pasa algo: tickets, comentarios, releases, sprints; con prueba y último envío |
|
||||||
| 📦 Importar/exportar | Todos los tickets a CSV; importar un CSV propio o una exportación de Jira, con vista previa de lo que se va a crear |
|
| 📦 Importar/exportar | Todos los tickets a CSV; importar un CSV propio o una exportación de Jira, con vista previa de lo que se va a crear |
|
||||||
|
|||||||
@@ -167,6 +167,9 @@ stateDiagram-v2
|
|||||||
actor `system`); el mismo enlace solo vuelve a contar si cambia (una PR fusionada). Proyecto desconocido,
|
actor `system`); el mismo enlace solo vuelve a contar si cambia (una PR fusionada). Proyecto desconocido,
|
||||||
integración apagada o firma mala responden igual: 403. El secreto vive en MongoDB (`git_integrations`), no en el
|
integración apagada o firma mala responden igual: 403. El secreto vive en MongoDB (`git_integrations`), no en el
|
||||||
event store.
|
event store.
|
||||||
|
- **Plantillas por tipo**: en la configuración, cada tipo de ticket puede llevar `template` con una descripción
|
||||||
|
(hasta 5000 caracteres) y una checklist (hasta 20 pasos). Al crear un ticket de ese tipo, la descripción se usa si
|
||||||
|
llega vacía y los pasos se añaden siempre (las importaciones no aplican plantillas).
|
||||||
- **Automatizaciones**: owners y admins crean reglas (hasta 50 por proyecto, por GraphQL: `automations`,
|
- **Automatizaciones**: owners y admins crean reglas (hasta 50 por proyecto, por GraphQL: `automations`,
|
||||||
`createAutomation`, `updateAutomation`, `deleteAutomation`). Disparador: ticket creado, cambio de estado (opcionalmente
|
`createAutomation`, `updateAutomation`, `deleteAutomation`). Disparador: ticket creado, cambio de estado (opcionalmente
|
||||||
a uno concreto), asignación, comentario o PR fusionada (integración Git). Condiciones: tipo, prioridad, área o
|
a uno concreto), asignación, comentario o PR fusionada (integración Git). Condiciones: tipo, prioridad, área o
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import {
|
|||||||
IssueType,
|
IssueType,
|
||||||
ProjectRole,
|
ProjectRole,
|
||||||
TRACKER_FACTORIES,
|
TRACKER_FACTORIES,
|
||||||
|
defaultProjectSettings,
|
||||||
} from '@task-stream/api/tracker/domain';
|
} from '@task-stream/api/tracker/domain';
|
||||||
import {
|
import {
|
||||||
FakeUserDirectory,
|
FakeUserDirectory,
|
||||||
@@ -77,6 +78,7 @@ import { DeleteCommentCommand } from './delete-comment.command';
|
|||||||
import { PublishReleaseCommand } from './publish-release.command';
|
import { PublishReleaseCommand } from './publish-release.command';
|
||||||
import { SetIssueReleaseCommand } from './set-issue-release.command';
|
import { SetIssueReleaseCommand } from './set-issue-release.command';
|
||||||
import { WriteAccess } from './write-access';
|
import { WriteAccess } from './write-access';
|
||||||
|
import { UpdateProjectSettingsCommand } from './update-project-settings.command';
|
||||||
import { ReadAccess } from '../queries/read-access';
|
import { ReadAccess } from '../queries/read-access';
|
||||||
import { FindProjectsRepository } from '../ports/find-projects.repository';
|
import { FindProjectsRepository } from '../ports/find-projects.repository';
|
||||||
|
|
||||||
@@ -595,6 +597,35 @@ describe('tracker commands (event sourced)', () => {
|
|||||||
).toBe('No sprint is under way');
|
).toBe('No sprint is under way');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("fills a new ticket with its type's template", async () => {
|
||||||
|
const { run, rehydrator } = await setup();
|
||||||
|
const projectId = await projectWithTeam(run);
|
||||||
|
const settings = defaultProjectSettings();
|
||||||
|
settings.issueTypes[0].template = {
|
||||||
|
description: '## Pasos para reproducir',
|
||||||
|
checklist: ['Probar en Safari', 'Adjuntar captura'],
|
||||||
|
};
|
||||||
|
await run(new UpdateProjectSettingsCommand('owner', projectId, settings));
|
||||||
|
const plain = await run(
|
||||||
|
new CreateIssueCommand('qa', projectId, IssueType.BUG, 'Crash', {}),
|
||||||
|
);
|
||||||
|
const written = await run(
|
||||||
|
new CreateIssueCommand('qa', projectId, IssueType.BUG, 'Crash 2', {
|
||||||
|
description: 'Ya lo explico yo',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const first = await rehydrator.rehydrate(plain.id, Issue);
|
||||||
|
expect(first.description).toBe('## Pasos para reproducir');
|
||||||
|
expect(first.checklist.map((i) => i.text)).toEqual([
|
||||||
|
'Probar en Safari',
|
||||||
|
'Adjuntar captura',
|
||||||
|
]);
|
||||||
|
expect((await rehydrator.rehydrate(written.id, Issue)).description).toBe(
|
||||||
|
'Ya lo explico yo',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it('does not let viewers modify tickets', async () => {
|
it('does not let viewers modify tickets', async () => {
|
||||||
const { run } = await setup();
|
const { run } = await setup();
|
||||||
const projectId = await projectWithTeam(run);
|
const projectId = await projectWithTeam(run);
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { randomUUID } from 'node:crypto';
|
||||||
import { CommandHandler, EventPublisher, ICommandHandler } from '@nestjs/cqrs';
|
import { CommandHandler, EventPublisher, ICommandHandler } from '@nestjs/cqrs';
|
||||||
import { IssueFactory, assertCanNest } from '@task-stream/api/tracker/domain';
|
import { IssueFactory, assertCanNest } from '@task-stream/api/tracker/domain';
|
||||||
import { IssueNumbersRepository } from '../ports/issue-numbers.repository';
|
import { IssueNumbersRepository } from '../ports/issue-numbers.repository';
|
||||||
@@ -39,9 +40,14 @@ export class CreateIssueCommandHandler
|
|||||||
0,
|
0,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const template = command.useTemplate
|
||||||
|
? workflow.templateOf(command.type)
|
||||||
|
: null;
|
||||||
const issue = this.issueFactory.create(
|
const issue = this.issueFactory.create(
|
||||||
{
|
{
|
||||||
...command.details,
|
...command.details,
|
||||||
|
description:
|
||||||
|
command.details.description?.trim() || template?.description || null,
|
||||||
projectId: project.id,
|
projectId: project.id,
|
||||||
projectKey: project.key,
|
projectKey: project.key,
|
||||||
number: await this.issueNumbers.next(project.id),
|
number: await this.issueNumbers.next(project.id),
|
||||||
@@ -55,6 +61,8 @@ export class CreateIssueCommandHandler
|
|||||||
);
|
);
|
||||||
if (command.releaseId) issue.setRelease(command.actorId, command.releaseId);
|
if (command.releaseId) issue.setRelease(command.actorId, command.releaseId);
|
||||||
if (parent) issue.setParent(command.actorId, parent);
|
if (parent) issue.setParent(command.actorId, parent);
|
||||||
|
for (const step of template?.checklist ?? [])
|
||||||
|
issue.addChecklistItem(command.actorId, randomUUID(), step);
|
||||||
|
|
||||||
this.eventPublisher.mergeObjectContext(issue);
|
this.eventPublisher.mergeObjectContext(issue);
|
||||||
await issue.commitAndWait();
|
await issue.commitAndWait();
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ export class CreateIssueCommand extends Command<CommandResult> {
|
|||||||
public readonly parentId?: string | null,
|
public readonly parentId?: string | null,
|
||||||
/** Imports only: start in this (active) status instead of the first one. */
|
/** Imports only: start in this (active) status instead of the first one. */
|
||||||
public readonly initialStatus?: string | null,
|
public readonly initialStatus?: string | null,
|
||||||
|
/** Fill in the type's template (imports bring their own content). */
|
||||||
|
public readonly useTemplate = true,
|
||||||
) {
|
) {
|
||||||
super();
|
super();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -115,6 +115,7 @@ export class ImportIssuesCommandHandler
|
|||||||
null,
|
null,
|
||||||
null,
|
null,
|
||||||
row.status,
|
row.status,
|
||||||
|
false,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
report.created++;
|
report.created++;
|
||||||
|
|||||||
@@ -99,6 +99,29 @@ describe('Project settings', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('keeps a template per type, only when it says something', () => {
|
||||||
|
const project = newProject();
|
||||||
|
const settings = defaultProjectSettings();
|
||||||
|
settings.issueTypes[0].template = {
|
||||||
|
description: ' ## Pasos para reproducir ',
|
||||||
|
checklist: [' Probar en Safari ', ''],
|
||||||
|
};
|
||||||
|
settings.issueTypes[1].template = { description: ' ', checklist: [] };
|
||||||
|
project.updateSettings('owner', settings);
|
||||||
|
|
||||||
|
expect(project.workflow.templateOf('BUG')).toEqual({
|
||||||
|
description: '## Pasos para reproducir',
|
||||||
|
checklist: ['Probar en Safari'],
|
||||||
|
});
|
||||||
|
expect(project.workflow.templateOf('FEATURE')).toBeNull();
|
||||||
|
const tooLong = defaultProjectSettings();
|
||||||
|
tooLong.issueTypes[0].template = {
|
||||||
|
description: null,
|
||||||
|
checklist: Array.from({ length: 21 }, (_, i) => `Paso ${i}`),
|
||||||
|
};
|
||||||
|
expect(() => project.updateSettings('owner', tooLong)).toThrow(/plantilla/);
|
||||||
|
});
|
||||||
|
|
||||||
it('keeps work-in-progress limits, from 1 to 999', () => {
|
it('keeps work-in-progress limits, from 1 to 999', () => {
|
||||||
const project = newProject();
|
const project = newProject();
|
||||||
const limited = defaultProjectSettings();
|
const limited = defaultProjectSettings();
|
||||||
|
|||||||
@@ -68,14 +68,26 @@ export interface WorkflowStatus {
|
|||||||
wipLimit?: number | null;
|
wipLimit?: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** What a new ticket of a type starts with. */
|
||||||
|
export interface IssueTemplate {
|
||||||
|
/** Used when the ticket comes without a description. */
|
||||||
|
description: string | null;
|
||||||
|
/** Steps added to every new ticket of the type. */
|
||||||
|
checklist: string[];
|
||||||
|
}
|
||||||
|
|
||||||
export interface IssueTypeOption {
|
export interface IssueTypeOption {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
icon: TypeIcon;
|
icon: TypeIcon;
|
||||||
color: SettingColor;
|
color: SettingColor;
|
||||||
archived: boolean;
|
archived: boolean;
|
||||||
|
template?: IssueTemplate;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const MAX_TEMPLATE_DESCRIPTION = 5000;
|
||||||
|
export const MAX_TEMPLATE_STEPS = 20;
|
||||||
|
|
||||||
export interface AreaOption {
|
export interface AreaOption {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -198,6 +210,13 @@ export class ProjectWorkflow {
|
|||||||
return this.settings.statuses.find((s) => s.id === id);
|
return this.settings.statuses.find((s) => s.id === id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** What a new ticket of the type starts with, if anything. */
|
||||||
|
templateOf(typeId: string): IssueTemplate | null {
|
||||||
|
return (
|
||||||
|
this.settings.issueTypes.find((t) => t.id === typeId)?.template ?? null
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/** Most tickets the status should hold (null: no limit). */
|
/** Most tickets the status should hold (null: no limit). */
|
||||||
wipLimit(id: string): number | null {
|
wipLimit(id: string): number | null {
|
||||||
return this.status(id)?.wipLimit ?? null;
|
return this.status(id)?.wipLimit ?? null;
|
||||||
@@ -300,13 +319,18 @@ export function validateProjectSettings(
|
|||||||
// Only when set: settings saved unchanged must compare equal.
|
// Only when set: settings saved unchanged must compare equal.
|
||||||
...(s.wipLimit != null ? { wipLimit: s.wipLimit } : {}),
|
...(s.wipLimit != null ? { wipLimit: s.wipLimit } : {}),
|
||||||
}));
|
}));
|
||||||
const issueTypes = next.issueTypes.map((t) => ({
|
const issueTypes = next.issueTypes.map((t) => {
|
||||||
id: normId(t.id),
|
const template = normTemplate(t.template);
|
||||||
name: normName(t.name),
|
return {
|
||||||
icon: t.icon,
|
id: normId(t.id),
|
||||||
color: t.color,
|
name: normName(t.name),
|
||||||
archived: !!t.archived,
|
icon: t.icon,
|
||||||
}));
|
color: t.color,
|
||||||
|
archived: !!t.archived,
|
||||||
|
// Only when there is one: settings saved unchanged must compare equal.
|
||||||
|
...(template ? { template } : {}),
|
||||||
|
};
|
||||||
|
});
|
||||||
const areas = next.areas.map((a) => ({
|
const areas = next.areas.map((a) => ({
|
||||||
id: normId(a.id),
|
id: normId(a.id),
|
||||||
name: normName(a.name),
|
name: normName(a.name),
|
||||||
@@ -393,6 +417,26 @@ export function validateProjectSettings(
|
|||||||
return { statuses, transitions, issueTypes, areas };
|
return { statuses, transitions, issueTypes, areas };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normTemplate(
|
||||||
|
template: IssueTemplate | undefined,
|
||||||
|
): IssueTemplate | null {
|
||||||
|
const description = template?.description?.trim() || null;
|
||||||
|
const checklist = (template?.checklist ?? [])
|
||||||
|
.map((step) => step.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
if (!description && !checklist.length) return null;
|
||||||
|
assertThat(
|
||||||
|
(description?.length ?? 0) <= MAX_TEMPLATE_DESCRIPTION,
|
||||||
|
`Una plantilla tiene como mucho ${MAX_TEMPLATE_DESCRIPTION} caracteres`,
|
||||||
|
);
|
||||||
|
assertThat(
|
||||||
|
checklist.length <= MAX_TEMPLATE_STEPS &&
|
||||||
|
checklist.every((step) => step.length <= 300),
|
||||||
|
`Una plantilla tiene como mucho ${MAX_TEMPLATE_STEPS} pasos de 300 caracteres`,
|
||||||
|
);
|
||||||
|
return { description, checklist };
|
||||||
|
}
|
||||||
|
|
||||||
function normId(id: string): string {
|
function normId(id: string): string {
|
||||||
const value = (id ?? '').trim().toUpperCase();
|
const value = (id ?? '').trim().toUpperCase();
|
||||||
assertThat(
|
assertThat(
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ export interface SettingsPayload {
|
|||||||
icon: string;
|
icon: string;
|
||||||
color: string;
|
color: string;
|
||||||
archived: boolean;
|
archived: boolean;
|
||||||
|
template?: { description?: string | null; checklist?: string[] } | null;
|
||||||
}[];
|
}[];
|
||||||
areas: { id: string; name: string; archived: boolean }[];
|
areas: { id: string; name: string; archived: boolean }[];
|
||||||
}
|
}
|
||||||
@@ -47,10 +48,18 @@ export function fromSettingsPayload(payload: SettingsPayload): ProjectSettings {
|
|||||||
transitions: Object.fromEntries(
|
transitions: Object.fromEntries(
|
||||||
payload.transitions.map((t) => [t.from, t.to]),
|
payload.transitions.map((t) => [t.from, t.to]),
|
||||||
),
|
),
|
||||||
issueTypes: payload.issueTypes.map((t) => ({
|
issueTypes: payload.issueTypes.map(({ template, ...t }) => ({
|
||||||
...t,
|
...t,
|
||||||
icon: t.icon as TypeIcon,
|
icon: t.icon as TypeIcon,
|
||||||
color: t.color as SettingColor,
|
color: t.color as SettingColor,
|
||||||
|
...(template
|
||||||
|
? {
|
||||||
|
template: {
|
||||||
|
description: template.description ?? null,
|
||||||
|
checklist: template.checklist ?? [],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
})),
|
})),
|
||||||
areas: payload.areas,
|
areas: payload.areas,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -548,6 +548,21 @@ export class StatusTransitionsInput {
|
|||||||
to!: string[];
|
to!: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@InputType('IssueTemplateInput')
|
||||||
|
export class IssueTemplateInput {
|
||||||
|
@Field(() => String, { nullable: true })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(5000)
|
||||||
|
description?: string | null;
|
||||||
|
@Field(() => [String], { defaultValue: [] })
|
||||||
|
@IsArray()
|
||||||
|
@ArrayMaxSize(20)
|
||||||
|
@IsString({ each: true })
|
||||||
|
@MaxLength(300, { each: true })
|
||||||
|
checklist!: string[];
|
||||||
|
}
|
||||||
|
|
||||||
@InputType()
|
@InputType()
|
||||||
export class IssueTypeOptionInput {
|
export class IssueTypeOptionInput {
|
||||||
@Field() @Matches(SETTING_ID) id!: string;
|
@Field() @Matches(SETTING_ID) id!: string;
|
||||||
@@ -555,6 +570,11 @@ export class IssueTypeOptionInput {
|
|||||||
@Field() @IsString() icon!: string;
|
@Field() @IsString() icon!: string;
|
||||||
@Field() @IsString() color!: string;
|
@Field() @IsString() color!: string;
|
||||||
@Field({ defaultValue: false }) @IsBoolean() archived!: boolean;
|
@Field({ defaultValue: false }) @IsBoolean() archived!: boolean;
|
||||||
|
@Field(() => IssueTemplateInput, { nullable: true })
|
||||||
|
@IsOptional()
|
||||||
|
@ValidateNested()
|
||||||
|
@Type(() => IssueTemplateInput)
|
||||||
|
template?: IssueTemplateInput | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@InputType()
|
@InputType()
|
||||||
|
|||||||
@@ -53,6 +53,19 @@ export class StatusTransitionsGql {
|
|||||||
@Field(() => [String]) to!: string[];
|
@Field(() => [String]) to!: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ObjectType('IssueTemplate', {
|
||||||
|
description: 'What a new ticket of the type starts with',
|
||||||
|
})
|
||||||
|
export class IssueTemplateGql {
|
||||||
|
@Field(() => String, {
|
||||||
|
nullable: true,
|
||||||
|
description: 'Used when the ticket comes without a description',
|
||||||
|
})
|
||||||
|
description!: string | null;
|
||||||
|
@Field(() => [String], { description: 'Steps added to every new ticket' })
|
||||||
|
checklist!: string[];
|
||||||
|
}
|
||||||
|
|
||||||
@ObjectType('IssueTypeOption')
|
@ObjectType('IssueTypeOption')
|
||||||
export class IssueTypeOptionGql {
|
export class IssueTypeOptionGql {
|
||||||
@Field() id!: string;
|
@Field() id!: string;
|
||||||
@@ -60,6 +73,8 @@ export class IssueTypeOptionGql {
|
|||||||
@Field() icon!: string;
|
@Field() icon!: string;
|
||||||
@Field() color!: string;
|
@Field() color!: string;
|
||||||
@Field() archived!: boolean;
|
@Field() archived!: boolean;
|
||||||
|
@Field(() => IssueTemplateGql, { nullable: true })
|
||||||
|
template?: IssueTemplateGql | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ObjectType('AreaOption')
|
@ObjectType('AreaOption')
|
||||||
|
|||||||
@@ -165,6 +165,21 @@ export class StatusTransitionsBodyDto {
|
|||||||
to!: string[];
|
to!: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export class IssueTemplateBodyDto {
|
||||||
|
@ApiPropertyOptional({ type: String, nullable: true, maxLength: 5000 })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(5000)
|
||||||
|
description?: string | null;
|
||||||
|
@ApiPropertyOptional({ type: [String], maxItems: 20 })
|
||||||
|
@IsOptional()
|
||||||
|
@IsArray()
|
||||||
|
@ArrayMaxSize(20)
|
||||||
|
@IsString({ each: true })
|
||||||
|
@MaxLength(300, { each: true })
|
||||||
|
checklist?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
export class IssueTypeOptionBodyDto {
|
export class IssueTypeOptionBodyDto {
|
||||||
@ApiProperty({ example: 'SPIKE' }) @Matches(SETTING_ID) id!: string;
|
@ApiProperty({ example: 'SPIKE' }) @Matches(SETTING_ID) id!: string;
|
||||||
@ApiProperty({ example: 'Spike', maxLength: 40 })
|
@ApiProperty({ example: 'Spike', maxLength: 40 })
|
||||||
@@ -182,6 +197,15 @@ export class IssueTypeOptionBodyDto {
|
|||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsBoolean()
|
@IsBoolean()
|
||||||
archived = false;
|
archived = false;
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
type: IssueTemplateBodyDto,
|
||||||
|
nullable: true,
|
||||||
|
description: 'What a new ticket of the type starts with',
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@ValidateNested()
|
||||||
|
@Type(() => IssueTemplateBodyDto)
|
||||||
|
template?: IssueTemplateBodyDto | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class AreaOptionBodyDto {
|
export class AreaOptionBodyDto {
|
||||||
|
|||||||
@@ -53,6 +53,11 @@ export class StatusTransitionsDto {
|
|||||||
to!: string[];
|
to!: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export class IssueTemplateDto {
|
||||||
|
@ApiProperty({ type: String, nullable: true }) description!: string | null;
|
||||||
|
@ApiProperty({ type: [String] }) checklist!: string[];
|
||||||
|
}
|
||||||
|
|
||||||
export class IssueTypeOptionDto {
|
export class IssueTypeOptionDto {
|
||||||
@ApiProperty({ example: 'BUG' }) id!: string;
|
@ApiProperty({ example: 'BUG' }) id!: string;
|
||||||
@ApiProperty({ example: 'Bug' }) name!: string;
|
@ApiProperty({ example: 'Bug' }) name!: string;
|
||||||
@@ -60,6 +65,11 @@ export class IssueTypeOptionDto {
|
|||||||
@ApiProperty({ enum: SETTING_COLORS, enumName: 'SettingColor' })
|
@ApiProperty({ enum: SETTING_COLORS, enumName: 'SettingColor' })
|
||||||
color!: string;
|
color!: string;
|
||||||
@ApiProperty() archived!: boolean;
|
@ApiProperty() archived!: boolean;
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
type: IssueTemplateDto,
|
||||||
|
description: 'What a new ticket of the type starts with',
|
||||||
|
})
|
||||||
|
template?: IssueTemplateDto;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class AreaOptionDto {
|
export class AreaOptionDto {
|
||||||
|
|||||||
@@ -51,12 +51,21 @@ export interface StatusTransitions {
|
|||||||
to: string[];
|
to: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** What a new ticket of a type starts with. */
|
||||||
|
export interface IssueTemplate {
|
||||||
|
/** Used when the ticket comes without a description. */
|
||||||
|
description: string | null;
|
||||||
|
/** Steps added to every new ticket of the type. */
|
||||||
|
checklist: string[];
|
||||||
|
}
|
||||||
|
|
||||||
export interface IssueTypeOption {
|
export interface IssueTypeOption {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
icon: string;
|
icon: string;
|
||||||
color: string;
|
color: string;
|
||||||
archived: boolean;
|
archived: boolean;
|
||||||
|
template?: IssueTemplate | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AreaOption {
|
export interface AreaOption {
|
||||||
|
|||||||
@@ -86,6 +86,12 @@ export function toSettingsInput(
|
|||||||
icon: t.icon,
|
icon: t.icon,
|
||||||
color: t.color,
|
color: t.color,
|
||||||
archived: t.archived,
|
archived: t.archived,
|
||||||
|
template: t.template
|
||||||
|
? {
|
||||||
|
description: t.template.description ?? null,
|
||||||
|
checklist: t.template.checklist,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
})),
|
})),
|
||||||
areas: settings.areas.map((a) => ({
|
areas: settings.areas.map((a) => ({
|
||||||
id: a.id,
|
id: a.id,
|
||||||
|
|||||||
@@ -40,6 +40,10 @@ fragment ProjectFields on Project {
|
|||||||
icon
|
icon
|
||||||
color
|
color
|
||||||
archived
|
archived
|
||||||
|
template {
|
||||||
|
description
|
||||||
|
checklist
|
||||||
|
}
|
||||||
}
|
}
|
||||||
areas {
|
areas {
|
||||||
id
|
id
|
||||||
|
|||||||
@@ -162,12 +162,18 @@ export type IssuePriority = 'CRITICAL' | 'HIGH' | 'LOW' | 'LOWEST' | 'MEDIUM';
|
|||||||
/** RECENT: last updated first. RANK: backlog order. */
|
/** RECENT: last updated first. RANK: backlog order. */
|
||||||
export type IssueSort = 'RANK' | 'RECENT';
|
export type IssueSort = 'RANK' | 'RECENT';
|
||||||
|
|
||||||
|
export type IssueTemplateInput = {
|
||||||
|
checklist?: Array<string>;
|
||||||
|
description?: string | null | undefined;
|
||||||
|
};
|
||||||
|
|
||||||
export type IssueTypeOptionInput = {
|
export type IssueTypeOptionInput = {
|
||||||
archived?: boolean;
|
archived?: boolean;
|
||||||
color: string;
|
color: string;
|
||||||
icon: string;
|
icon: string;
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
|
template?: IssueTemplateInput | null | undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type LinkDirection = 'INWARD' | 'OUTWARD';
|
export type LinkDirection = 'INWARD' | 'OUTWARD';
|
||||||
@@ -435,6 +441,7 @@ export type ProjectFieldsFragment = {
|
|||||||
icon: string;
|
icon: string;
|
||||||
color: string;
|
color: string;
|
||||||
archived: boolean;
|
archived: boolean;
|
||||||
|
template: { description: string | null; checklist: Array<string> } | null;
|
||||||
}>;
|
}>;
|
||||||
areas: Array<{ id: string; name: string; archived: boolean }>;
|
areas: Array<{ id: string; name: string; archived: boolean }>;
|
||||||
};
|
};
|
||||||
@@ -1140,6 +1147,10 @@ export type MyProjectsQuery = {
|
|||||||
icon: string;
|
icon: string;
|
||||||
color: string;
|
color: string;
|
||||||
archived: boolean;
|
archived: boolean;
|
||||||
|
template: {
|
||||||
|
description: string | null;
|
||||||
|
checklist: Array<string>;
|
||||||
|
} | null;
|
||||||
}>;
|
}>;
|
||||||
areas: Array<{ id: string; name: string; archived: boolean }>;
|
areas: Array<{ id: string; name: string; archived: boolean }>;
|
||||||
};
|
};
|
||||||
@@ -1178,6 +1189,10 @@ export type ProjectQuery = {
|
|||||||
icon: string;
|
icon: string;
|
||||||
color: string;
|
color: string;
|
||||||
archived: boolean;
|
archived: boolean;
|
||||||
|
template: {
|
||||||
|
description: string | null;
|
||||||
|
checklist: Array<string>;
|
||||||
|
} | null;
|
||||||
}>;
|
}>;
|
||||||
areas: Array<{ id: string; name: string; archived: boolean }>;
|
areas: Array<{ id: string; name: string; archived: boolean }>;
|
||||||
};
|
};
|
||||||
@@ -1326,6 +1341,10 @@ export type PublicProjectsQuery = {
|
|||||||
icon: string;
|
icon: string;
|
||||||
color: string;
|
color: string;
|
||||||
archived: boolean;
|
archived: boolean;
|
||||||
|
template: {
|
||||||
|
description: string | null;
|
||||||
|
checklist: Array<string>;
|
||||||
|
} | null;
|
||||||
}>;
|
}>;
|
||||||
areas: Array<{ id: string; name: string; archived: boolean }>;
|
areas: Array<{ id: string; name: string; archived: boolean }>;
|
||||||
};
|
};
|
||||||
@@ -1763,6 +1782,10 @@ export type ProjectChangedSubscription = {
|
|||||||
icon: string;
|
icon: string;
|
||||||
color: string;
|
color: string;
|
||||||
archived: boolean;
|
archived: boolean;
|
||||||
|
template: {
|
||||||
|
description: string | null;
|
||||||
|
checklist: Array<string>;
|
||||||
|
} | null;
|
||||||
}>;
|
}>;
|
||||||
areas: Array<{ id: string; name: string; archived: boolean }>;
|
areas: Array<{ id: string; name: string; archived: boolean }>;
|
||||||
};
|
};
|
||||||
@@ -1804,6 +1827,10 @@ export type MyProjectsChangedSubscription = {
|
|||||||
icon: string;
|
icon: string;
|
||||||
color: string;
|
color: string;
|
||||||
archived: boolean;
|
archived: boolean;
|
||||||
|
template: {
|
||||||
|
description: string | null;
|
||||||
|
checklist: Array<string>;
|
||||||
|
} | null;
|
||||||
}>;
|
}>;
|
||||||
areas: Array<{ id: string; name: string; archived: boolean }>;
|
areas: Array<{ id: string; name: string; archived: boolean }>;
|
||||||
};
|
};
|
||||||
@@ -2133,6 +2160,23 @@ export const ProjectFieldsFragmentDoc = {
|
|||||||
kind: 'Field',
|
kind: 'Field',
|
||||||
name: { kind: 'Name', value: 'archived' },
|
name: { kind: 'Name', value: 'archived' },
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
kind: 'Field',
|
||||||
|
name: { kind: 'Name', value: 'template' },
|
||||||
|
selectionSet: {
|
||||||
|
kind: 'SelectionSet',
|
||||||
|
selections: [
|
||||||
|
{
|
||||||
|
kind: 'Field',
|
||||||
|
name: { kind: 'Name', value: 'description' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: 'Field',
|
||||||
|
name: { kind: 'Name', value: 'checklist' },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -6994,6 +7038,23 @@ export const MyProjectsDocument = {
|
|||||||
kind: 'Field',
|
kind: 'Field',
|
||||||
name: { kind: 'Name', value: 'archived' },
|
name: { kind: 'Name', value: 'archived' },
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
kind: 'Field',
|
||||||
|
name: { kind: 'Name', value: 'template' },
|
||||||
|
selectionSet: {
|
||||||
|
kind: 'SelectionSet',
|
||||||
|
selections: [
|
||||||
|
{
|
||||||
|
kind: 'Field',
|
||||||
|
name: { kind: 'Name', value: 'description' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: 'Field',
|
||||||
|
name: { kind: 'Name', value: 'checklist' },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -7199,6 +7260,23 @@ export const ProjectDocument = {
|
|||||||
kind: 'Field',
|
kind: 'Field',
|
||||||
name: { kind: 'Name', value: 'archived' },
|
name: { kind: 'Name', value: 'archived' },
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
kind: 'Field',
|
||||||
|
name: { kind: 'Name', value: 'template' },
|
||||||
|
selectionSet: {
|
||||||
|
kind: 'SelectionSet',
|
||||||
|
selections: [
|
||||||
|
{
|
||||||
|
kind: 'Field',
|
||||||
|
name: { kind: 'Name', value: 'description' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: 'Field',
|
||||||
|
name: { kind: 'Name', value: 'checklist' },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -8255,6 +8333,23 @@ export const PublicProjectsDocument = {
|
|||||||
kind: 'Field',
|
kind: 'Field',
|
||||||
name: { kind: 'Name', value: 'archived' },
|
name: { kind: 'Name', value: 'archived' },
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
kind: 'Field',
|
||||||
|
name: { kind: 'Name', value: 'template' },
|
||||||
|
selectionSet: {
|
||||||
|
kind: 'SelectionSet',
|
||||||
|
selections: [
|
||||||
|
{
|
||||||
|
kind: 'Field',
|
||||||
|
name: { kind: 'Name', value: 'description' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: 'Field',
|
||||||
|
name: { kind: 'Name', value: 'checklist' },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -10703,6 +10798,23 @@ export const ProjectChangedDocument = {
|
|||||||
kind: 'Field',
|
kind: 'Field',
|
||||||
name: { kind: 'Name', value: 'archived' },
|
name: { kind: 'Name', value: 'archived' },
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
kind: 'Field',
|
||||||
|
name: { kind: 'Name', value: 'template' },
|
||||||
|
selectionSet: {
|
||||||
|
kind: 'SelectionSet',
|
||||||
|
selections: [
|
||||||
|
{
|
||||||
|
kind: 'Field',
|
||||||
|
name: { kind: 'Name', value: 'description' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: 'Field',
|
||||||
|
name: { kind: 'Name', value: 'checklist' },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -10895,6 +11007,23 @@ export const MyProjectsChangedDocument = {
|
|||||||
kind: 'Field',
|
kind: 'Field',
|
||||||
name: { kind: 'Name', value: 'archived' },
|
name: { kind: 'Name', value: 'archived' },
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
kind: 'Field',
|
||||||
|
name: { kind: 'Name', value: 'template' },
|
||||||
|
selectionSet: {
|
||||||
|
kind: 'SelectionSet',
|
||||||
|
selections: [
|
||||||
|
{
|
||||||
|
kind: 'Field',
|
||||||
|
name: { kind: 'Name', value: 'description' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: 'Field',
|
||||||
|
name: { kind: 'Name', value: 'checklist' },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -182,3 +182,8 @@
|
|||||||
.ts-column--lane .ts-column__list {
|
.ts-column--lane .ts-column__list {
|
||||||
min-height: 3.5rem;
|
min-height: 3.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.ts-template-hint {
|
||||||
|
margin: -0.25rem 0 0;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
}
|
||||||
|
|||||||
@@ -342,10 +342,27 @@ const NOBODY = '__nobody__';
|
|||||||
</pi-text-field>
|
</pi-text-field>
|
||||||
<pi-textarea
|
<pi-textarea
|
||||||
[formField]="f.description"
|
[formField]="f.description"
|
||||||
placeholder="Pasos para reproducir, criterios de aceptación…"
|
[placeholder]="
|
||||||
|
template()?.description ??
|
||||||
|
'Pasos para reproducir, criterios de aceptación…'
|
||||||
|
"
|
||||||
>
|
>
|
||||||
<pi-label>Descripción</pi-label>
|
<pi-label>Descripción</pi-label>
|
||||||
</pi-textarea>
|
</pi-textarea>
|
||||||
|
@if (template(); as t) {
|
||||||
|
<p class="ts-muted ts-template-hint">
|
||||||
|
Plantilla de {{ typeLabel() }}:
|
||||||
|
@if (t.description) {
|
||||||
|
si dejas la descripción vacía se usará la de la plantilla
|
||||||
|
}
|
||||||
|
@if (t.description && t.checklist.length) {
|
||||||
|
·
|
||||||
|
}
|
||||||
|
@if (t.checklist.length) {
|
||||||
|
se añadirá su checklist ({{ t.checklist.length }} pasos)
|
||||||
|
}
|
||||||
|
</p>
|
||||||
|
}
|
||||||
@if (project.workflow().isDesignType(newType())) {
|
@if (project.workflow().isDesignType(newType())) {
|
||||||
<pi-text-field
|
<pi-text-field
|
||||||
[formField]="f.figmaUrl"
|
[formField]="f.figmaUrl"
|
||||||
@@ -641,6 +658,17 @@ export class BoardPage {
|
|||||||
|
|
||||||
/* New ticket */
|
/* New ticket */
|
||||||
protected readonly newType = signal<string>('');
|
protected readonly newType = signal<string>('');
|
||||||
|
/** The template the new ticket will get (the API applies it). */
|
||||||
|
protected readonly template = computed(
|
||||||
|
() =>
|
||||||
|
this.project
|
||||||
|
.workflow()
|
||||||
|
.settings.issueTypes.find((t) => t.id === this.newType())?.template ??
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
protected readonly typeLabel = computed(
|
||||||
|
() => typeLook(this.project.workflow(), this.newType()).label,
|
||||||
|
);
|
||||||
private readonly model = signal({
|
private readonly model = signal({
|
||||||
title: '',
|
title: '',
|
||||||
description: '',
|
description: '',
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import { TransferComponent } from '../ui/transfer';
|
|||||||
import { AutomationsComponent } from '../ui/automations';
|
import { AutomationsComponent } from '../ui/automations';
|
||||||
import { ProjectFacade } from '@task-stream/web/tracker/application';
|
import { ProjectFacade } from '@task-stream/web/tracker/application';
|
||||||
import {
|
import {
|
||||||
|
IssueTypeOption,
|
||||||
ProjectSettings,
|
ProjectSettings,
|
||||||
SETTING_COLORS,
|
SETTING_COLORS,
|
||||||
StatusCategory,
|
StatusCategory,
|
||||||
@@ -346,6 +347,16 @@ function idFrom(name: string): string {
|
|||||||
"
|
"
|
||||||
/>
|
/>
|
||||||
<code class="ts-id">{{ type.id }}</code>
|
<code class="ts-id">{{ type.id }}</code>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="ts-plain-link ts-template-toggle"
|
||||||
|
[attr.aria-expanded]="openTemplate() === i"
|
||||||
|
(click)="
|
||||||
|
openTemplate.set(openTemplate() === i ? null : i)
|
||||||
|
"
|
||||||
|
>
|
||||||
|
Plantilla{{ hasTemplate(type) ? ' ●' : '' }}
|
||||||
|
</button>
|
||||||
</span>
|
</span>
|
||||||
<select
|
<select
|
||||||
class="ts-input"
|
class="ts-input"
|
||||||
@@ -385,6 +396,48 @@ function idFrom(name: string): string {
|
|||||||
item: type,
|
item: type,
|
||||||
}"
|
}"
|
||||||
/>
|
/>
|
||||||
|
@if (openTemplate() === i) {
|
||||||
|
<div class="ts-template">
|
||||||
|
<label class="ts-template__field">
|
||||||
|
<span
|
||||||
|
>Descripción inicial (si el ticket llega sin
|
||||||
|
ella)</span
|
||||||
|
>
|
||||||
|
<textarea
|
||||||
|
class="ts-input"
|
||||||
|
rows="5"
|
||||||
|
maxlength="5000"
|
||||||
|
[value]="type.template?.description ?? ''"
|
||||||
|
(input)="
|
||||||
|
setTemplate(i, {
|
||||||
|
description: $any($event.target).value,
|
||||||
|
})
|
||||||
|
"
|
||||||
|
></textarea>
|
||||||
|
</label>
|
||||||
|
<label class="ts-template__field">
|
||||||
|
<span>Checklist (un paso por línea, hasta 20)</span>
|
||||||
|
<textarea
|
||||||
|
class="ts-input"
|
||||||
|
rows="4"
|
||||||
|
[value]="
|
||||||
|
(type.template?.checklist ?? []).join(
|
||||||
|
'
|
||||||
|
'
|
||||||
|
)
|
||||||
|
"
|
||||||
|
(input)="
|
||||||
|
setTemplate(i, {
|
||||||
|
checklist: $any($event.target).value.split(
|
||||||
|
'
|
||||||
|
'
|
||||||
|
),
|
||||||
|
})
|
||||||
|
"
|
||||||
|
></textarea>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
</li>
|
</li>
|
||||||
}
|
}
|
||||||
</ol>
|
</ol>
|
||||||
@@ -733,6 +786,27 @@ function idFrom(name: string): string {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
}
|
}
|
||||||
|
.ts-template {
|
||||||
|
display: grid;
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.5rem 0 0.25rem;
|
||||||
|
}
|
||||||
|
.ts-template__field {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.25rem;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
color: var(--ts-muted);
|
||||||
|
textarea {
|
||||||
|
font: inherit;
|
||||||
|
color: var(--ts-ink);
|
||||||
|
resize: vertical;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.ts-template-toggle {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
.ts-type-grid {
|
.ts-type-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 2rem minmax(8rem, 1fr) 7rem 7rem 6.5rem;
|
grid-template-columns: 2rem minmax(8rem, 1fr) 7rem 7rem 6.5rem;
|
||||||
@@ -1038,6 +1112,34 @@ export class SettingsPage {
|
|||||||
return Number.isFinite(limit) && limit >= 1 ? Math.min(limit, 999) : null;
|
return Number.isFinite(limit) && limit >= 1 ? Math.min(limit, 999) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Index of the type whose template is open. */
|
||||||
|
protected readonly openTemplate = signal<number | null>(null);
|
||||||
|
|
||||||
|
protected hasTemplate(type: IssueTypeOption): boolean {
|
||||||
|
return !!(
|
||||||
|
type.template?.description?.trim() ||
|
||||||
|
type.template?.checklist.some((s) => s.trim())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Edits the template in the draft; empty lines go when saved. */
|
||||||
|
protected setTemplate(
|
||||||
|
index: number,
|
||||||
|
changes: { description?: string; checklist?: string[] },
|
||||||
|
): void {
|
||||||
|
this.patch((d) => {
|
||||||
|
const type = d.issueTypes[index];
|
||||||
|
const current = type.template ?? { description: null, checklist: [] };
|
||||||
|
type.template = {
|
||||||
|
description:
|
||||||
|
changes.description !== undefined
|
||||||
|
? changes.description || null
|
||||||
|
: current.description,
|
||||||
|
checklist: (changes.checklist ?? current.checklist).slice(0, 20),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
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