From 5863d773be6e30df742336c9260ae16b35a9199c Mon Sep 17 00:00:00 2001 From: Josep Piguillem Date: Thu, 8 Oct 2026 15:58:37 +0200 Subject: [PATCH] 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 --- apps/api/openapi.json | 54 ++++++++ apps/api/schema.gql | 16 +++ apps/mcp/src/app/api/taskstream.schema.ts | 12 ++ docs/01-vision-general.md | 1 + docs/03-api.md | 3 + .../src/lib/commands/commands.spec.ts | 31 +++++ .../commands/create-issue.command-handler.ts | 8 ++ .../src/lib/commands/create-issue.command.ts | 2 + .../src/lib/transfer/import-export.ts | 1 + .../src/lib/project/project-settings.spec.ts | 23 ++++ .../src/lib/project/project-settings.ts | 58 +++++++- .../presenters/src/lib/common/settings.ts | 11 +- .../presenters/src/lib/graphql/inputs.ts | 20 +++ .../presenters/src/lib/graphql/types.ts | 15 ++ .../src/lib/http/dto/requests.dto.ts | 24 ++++ .../src/lib/http/dto/responses.dto.ts | 10 ++ .../tracker/domain/src/lib/models/workflow.ts | 9 ++ .../src/lib/adapters/mappers.ts | 6 + .../src/lib/graphql/fragments.graphql | 4 + .../src/lib/graphql/generated.ts | 129 ++++++++++++++++++ .../presenters/src/lib/pages/board.page.scss | 5 + .../presenters/src/lib/pages/board.page.ts | 30 +++- .../presenters/src/lib/pages/settings.page.ts | 102 ++++++++++++++ 23 files changed, 565 insertions(+), 9 deletions(-) diff --git a/apps/api/openapi.json b/apps/api/openapi.json index 4ef4d23..7215325 100644 --- a/apps/api/openapi.json +++ b/apps/api/openapi.json @@ -9051,6 +9051,25 @@ "support" ] }, + "IssueTemplateDto": { + "type": "object", + "properties": { + "description": { + "type": "string", + "nullable": true + }, + "checklist": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "description", + "checklist" + ] + }, "IssueTypeOptionDto": { "type": "object", "properties": { @@ -9078,6 +9097,14 @@ }, "archived": { "type": "boolean" + }, + "template": { + "description": "What a new ticket of the type starts with", + "allOf": [ + { + "$ref": "#/components/schemas/IssueTemplateDto" + } + ] } }, "required": [ @@ -9352,6 +9379,23 @@ "to" ] }, + "IssueTemplateBodyDto": { + "type": "object", + "properties": { + "description": { + "type": "string", + "nullable": true, + "maxLength": 5000 + }, + "checklist": { + "maxItems": 20, + "type": "array", + "items": { + "type": "string" + } + } + } + }, "IssueTypeOptionBodyDto": { "type": "object", "properties": { @@ -9381,6 +9425,16 @@ "archived": { "type": "boolean", "default": false + }, + "template": { + "nullable": true, + "description": "What a new ticket of the type starts with", + "type": "object", + "allOf": [ + { + "$ref": "#/components/schemas/IssueTemplateBodyDto" + } + ] } }, "required": [ diff --git a/apps/api/schema.gql b/apps/api/schema.gql index 5a66ebc..a45b45b 100644 --- a/apps/api/schema.gql +++ b/apps/api/schema.gql @@ -491,12 +491,27 @@ enum IssueSort { 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 { archived: Boolean! color: String! icon: String! id: String! name: String! + template: IssueTemplate } input IssueTypeOptionInput { @@ -505,6 +520,7 @@ input IssueTypeOptionInput { icon: String! id: String! name: String! + template: IssueTemplateInput } enum LinkDirection { diff --git a/apps/mcp/src/app/api/taskstream.schema.ts b/apps/mcp/src/app/api/taskstream.schema.ts index 74c0686..bc40548 100644 --- a/apps/mcp/src/app/api/taskstream.schema.ts +++ b/apps/mcp/src/app/api/taskstream.schema.ts @@ -1368,6 +1368,10 @@ export interface components { }; /** @enum {string} */ TypeIcon: "bug" | "feature" | "palette" | "task" | "qa" | "flag" | "star-fill" | "rocket" | "document" | "light-bulb" | "key" | "support"; + IssueTemplateDto: { + description: string | null; + checklist: string[]; + }; IssueTypeOptionDto: { /** @example BUG */ id: string; @@ -1376,6 +1380,8 @@ export interface components { icon: components["schemas"]["TypeIcon"]; color: components["schemas"]["SettingColor"]; archived: boolean; + /** @description What a new ticket of the type starts with */ + template?: components["schemas"]["IssueTemplateDto"]; }; AreaOptionDto: { /** @example FRONTEND */ @@ -1478,6 +1484,10 @@ export interface components { */ to: string[]; }; + IssueTemplateBodyDto: { + description?: string | null; + checklist?: string[]; + }; IssueTypeOptionBodyDto: { /** @example SPIKE */ id: string; @@ -1487,6 +1497,8 @@ export interface components { color: components["schemas"]["SettingColor"]; /** @default false */ archived: boolean; + /** @description What a new ticket of the type starts with */ + template?: components["schemas"]["IssueTemplateBodyDto"] | null; }; AreaOptionBodyDto: { /** @example DATA */ diff --git a/docs/01-vision-general.md b/docs/01-vision-general.md index 1d16f0d..e705e42 100644 --- a/docs/01-vision-general.md +++ b/docs/01-vision-general.md @@ -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 | | šŸ”— 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 | +| 🧾 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 | | šŸ“” 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 | diff --git a/docs/03-api.md b/docs/03-api.md index 33ac1d0..3961613 100644 --- a/docs/03-api.md +++ b/docs/03-api.md @@ -167,6 +167,9 @@ stateDiagram-v2 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 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`, `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 diff --git a/libs/api/tracker/application/src/lib/commands/commands.spec.ts b/libs/api/tracker/application/src/lib/commands/commands.spec.ts index b159451..2012871 100644 --- a/libs/api/tracker/application/src/lib/commands/commands.spec.ts +++ b/libs/api/tracker/application/src/lib/commands/commands.spec.ts @@ -18,6 +18,7 @@ import { IssueType, ProjectRole, TRACKER_FACTORIES, + defaultProjectSettings, } from '@task-stream/api/tracker/domain'; import { FakeUserDirectory, @@ -77,6 +78,7 @@ import { DeleteCommentCommand } from './delete-comment.command'; import { PublishReleaseCommand } from './publish-release.command'; import { SetIssueReleaseCommand } from './set-issue-release.command'; import { WriteAccess } from './write-access'; +import { UpdateProjectSettingsCommand } from './update-project-settings.command'; import { ReadAccess } from '../queries/read-access'; import { FindProjectsRepository } from '../ports/find-projects.repository'; @@ -595,6 +597,35 @@ describe('tracker commands (event sourced)', () => { ).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 () => { const { run } = await setup(); const projectId = await projectWithTeam(run); diff --git a/libs/api/tracker/application/src/lib/commands/create-issue.command-handler.ts b/libs/api/tracker/application/src/lib/commands/create-issue.command-handler.ts index edb1d10..922f543 100644 --- a/libs/api/tracker/application/src/lib/commands/create-issue.command-handler.ts +++ b/libs/api/tracker/application/src/lib/commands/create-issue.command-handler.ts @@ -1,3 +1,4 @@ +import { randomUUID } from 'node:crypto'; import { CommandHandler, EventPublisher, ICommandHandler } from '@nestjs/cqrs'; import { IssueFactory, assertCanNest } from '@task-stream/api/tracker/domain'; import { IssueNumbersRepository } from '../ports/issue-numbers.repository'; @@ -39,9 +40,14 @@ export class CreateIssueCommandHandler 0, ); + const template = command.useTemplate + ? workflow.templateOf(command.type) + : null; const issue = this.issueFactory.create( { ...command.details, + description: + command.details.description?.trim() || template?.description || null, projectId: project.id, projectKey: project.key, number: await this.issueNumbers.next(project.id), @@ -55,6 +61,8 @@ export class CreateIssueCommandHandler ); if (command.releaseId) issue.setRelease(command.actorId, command.releaseId); if (parent) issue.setParent(command.actorId, parent); + for (const step of template?.checklist ?? []) + issue.addChecklistItem(command.actorId, randomUUID(), step); this.eventPublisher.mergeObjectContext(issue); await issue.commitAndWait(); diff --git a/libs/api/tracker/application/src/lib/commands/create-issue.command.ts b/libs/api/tracker/application/src/lib/commands/create-issue.command.ts index bfb22c5..34722a4 100644 --- a/libs/api/tracker/application/src/lib/commands/create-issue.command.ts +++ b/libs/api/tracker/application/src/lib/commands/create-issue.command.ts @@ -16,6 +16,8 @@ export class CreateIssueCommand extends Command { public readonly parentId?: string | null, /** Imports only: start in this (active) status instead of the first one. */ public readonly initialStatus?: string | null, + /** Fill in the type's template (imports bring their own content). */ + public readonly useTemplate = true, ) { super(); } diff --git a/libs/api/tracker/application/src/lib/transfer/import-export.ts b/libs/api/tracker/application/src/lib/transfer/import-export.ts index 91f6636..86c2379 100644 --- a/libs/api/tracker/application/src/lib/transfer/import-export.ts +++ b/libs/api/tracker/application/src/lib/transfer/import-export.ts @@ -115,6 +115,7 @@ export class ImportIssuesCommandHandler null, null, row.status, + false, ), ); report.created++; diff --git a/libs/api/tracker/domain/src/lib/project/project-settings.spec.ts b/libs/api/tracker/domain/src/lib/project/project-settings.spec.ts index 88b7957..048ef5b 100644 --- a/libs/api/tracker/domain/src/lib/project/project-settings.spec.ts +++ b/libs/api/tracker/domain/src/lib/project/project-settings.spec.ts @@ -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', () => { const project = newProject(); const limited = defaultProjectSettings(); diff --git a/libs/api/tracker/domain/src/lib/project/project-settings.ts b/libs/api/tracker/domain/src/lib/project/project-settings.ts index ccd821c..7d4c0be 100644 --- a/libs/api/tracker/domain/src/lib/project/project-settings.ts +++ b/libs/api/tracker/domain/src/lib/project/project-settings.ts @@ -68,14 +68,26 @@ export interface WorkflowStatus { 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 { id: string; name: string; icon: TypeIcon; color: SettingColor; archived: boolean; + template?: IssueTemplate; } +export const MAX_TEMPLATE_DESCRIPTION = 5000; +export const MAX_TEMPLATE_STEPS = 20; + export interface AreaOption { id: string; name: string; @@ -198,6 +210,13 @@ export class ProjectWorkflow { 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). */ wipLimit(id: string): number | null { return this.status(id)?.wipLimit ?? null; @@ -300,13 +319,18 @@ export function validateProjectSettings( // Only when set: settings saved unchanged must compare equal. ...(s.wipLimit != null ? { wipLimit: s.wipLimit } : {}), })); - const issueTypes = next.issueTypes.map((t) => ({ - id: normId(t.id), - name: normName(t.name), - icon: t.icon, - color: t.color, - archived: !!t.archived, - })); + const issueTypes = next.issueTypes.map((t) => { + const template = normTemplate(t.template); + return { + id: normId(t.id), + name: normName(t.name), + 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) => ({ id: normId(a.id), name: normName(a.name), @@ -393,6 +417,26 @@ export function validateProjectSettings( 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 { const value = (id ?? '').trim().toUpperCase(); assertThat( diff --git a/libs/api/tracker/presenters/src/lib/common/settings.ts b/libs/api/tracker/presenters/src/lib/common/settings.ts index cafbd75..3ff3de7 100644 --- a/libs/api/tracker/presenters/src/lib/common/settings.ts +++ b/libs/api/tracker/presenters/src/lib/common/settings.ts @@ -23,6 +23,7 @@ export interface SettingsPayload { icon: string; color: string; archived: boolean; + template?: { description?: string | null; checklist?: string[] } | null; }[]; areas: { id: string; name: string; archived: boolean }[]; } @@ -47,10 +48,18 @@ export function fromSettingsPayload(payload: SettingsPayload): ProjectSettings { transitions: Object.fromEntries( payload.transitions.map((t) => [t.from, t.to]), ), - issueTypes: payload.issueTypes.map((t) => ({ + issueTypes: payload.issueTypes.map(({ template, ...t }) => ({ ...t, icon: t.icon as TypeIcon, color: t.color as SettingColor, + ...(template + ? { + template: { + description: template.description ?? null, + checklist: template.checklist ?? [], + }, + } + : {}), })), areas: payload.areas, }; diff --git a/libs/api/tracker/presenters/src/lib/graphql/inputs.ts b/libs/api/tracker/presenters/src/lib/graphql/inputs.ts index 107e6de..2a64f4b 100644 --- a/libs/api/tracker/presenters/src/lib/graphql/inputs.ts +++ b/libs/api/tracker/presenters/src/lib/graphql/inputs.ts @@ -548,6 +548,21 @@ export class StatusTransitionsInput { 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() export class IssueTypeOptionInput { @Field() @Matches(SETTING_ID) id!: string; @@ -555,6 +570,11 @@ export class IssueTypeOptionInput { @Field() @IsString() icon!: string; @Field() @IsString() color!: string; @Field({ defaultValue: false }) @IsBoolean() archived!: boolean; + @Field(() => IssueTemplateInput, { nullable: true }) + @IsOptional() + @ValidateNested() + @Type(() => IssueTemplateInput) + template?: IssueTemplateInput | null; } @InputType() diff --git a/libs/api/tracker/presenters/src/lib/graphql/types.ts b/libs/api/tracker/presenters/src/lib/graphql/types.ts index 1c0a8db..d891278 100644 --- a/libs/api/tracker/presenters/src/lib/graphql/types.ts +++ b/libs/api/tracker/presenters/src/lib/graphql/types.ts @@ -53,6 +53,19 @@ export class StatusTransitionsGql { @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') export class IssueTypeOptionGql { @Field() id!: string; @@ -60,6 +73,8 @@ export class IssueTypeOptionGql { @Field() icon!: string; @Field() color!: string; @Field() archived!: boolean; + @Field(() => IssueTemplateGql, { nullable: true }) + template?: IssueTemplateGql | null; } @ObjectType('AreaOption') diff --git a/libs/api/tracker/presenters/src/lib/http/dto/requests.dto.ts b/libs/api/tracker/presenters/src/lib/http/dto/requests.dto.ts index 611154e..25e9763 100644 --- a/libs/api/tracker/presenters/src/lib/http/dto/requests.dto.ts +++ b/libs/api/tracker/presenters/src/lib/http/dto/requests.dto.ts @@ -165,6 +165,21 @@ export class StatusTransitionsBodyDto { 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 { @ApiProperty({ example: 'SPIKE' }) @Matches(SETTING_ID) id!: string; @ApiProperty({ example: 'Spike', maxLength: 40 }) @@ -182,6 +197,15 @@ export class IssueTypeOptionBodyDto { @IsOptional() @IsBoolean() 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 { diff --git a/libs/api/tracker/presenters/src/lib/http/dto/responses.dto.ts b/libs/api/tracker/presenters/src/lib/http/dto/responses.dto.ts index 2d23dae..8daf50f 100644 --- a/libs/api/tracker/presenters/src/lib/http/dto/responses.dto.ts +++ b/libs/api/tracker/presenters/src/lib/http/dto/responses.dto.ts @@ -53,6 +53,11 @@ export class StatusTransitionsDto { to!: string[]; } +export class IssueTemplateDto { + @ApiProperty({ type: String, nullable: true }) description!: string | null; + @ApiProperty({ type: [String] }) checklist!: string[]; +} + export class IssueTypeOptionDto { @ApiProperty({ example: 'BUG' }) id!: string; @ApiProperty({ example: 'Bug' }) name!: string; @@ -60,6 +65,11 @@ export class IssueTypeOptionDto { @ApiProperty({ enum: SETTING_COLORS, enumName: 'SettingColor' }) color!: string; @ApiProperty() archived!: boolean; + @ApiPropertyOptional({ + type: IssueTemplateDto, + description: 'What a new ticket of the type starts with', + }) + template?: IssueTemplateDto; } export class AreaOptionDto { diff --git a/libs/web/tracker/domain/src/lib/models/workflow.ts b/libs/web/tracker/domain/src/lib/models/workflow.ts index 2c53d6c..153e3f0 100644 --- a/libs/web/tracker/domain/src/lib/models/workflow.ts +++ b/libs/web/tracker/domain/src/lib/models/workflow.ts @@ -51,12 +51,21 @@ export interface StatusTransitions { 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 { id: string; name: string; icon: string; color: string; archived: boolean; + template?: IssueTemplate | null; } export interface AreaOption { diff --git a/libs/web/tracker/infrastructure/src/lib/adapters/mappers.ts b/libs/web/tracker/infrastructure/src/lib/adapters/mappers.ts index 0281a8b..88077bd 100644 --- a/libs/web/tracker/infrastructure/src/lib/adapters/mappers.ts +++ b/libs/web/tracker/infrastructure/src/lib/adapters/mappers.ts @@ -86,6 +86,12 @@ export function toSettingsInput( icon: t.icon, color: t.color, archived: t.archived, + template: t.template + ? { + description: t.template.description ?? null, + checklist: t.template.checklist, + } + : null, })), areas: settings.areas.map((a) => ({ id: a.id, diff --git a/libs/web/tracker/infrastructure/src/lib/graphql/fragments.graphql b/libs/web/tracker/infrastructure/src/lib/graphql/fragments.graphql index 3d67339..dfb5969 100644 --- a/libs/web/tracker/infrastructure/src/lib/graphql/fragments.graphql +++ b/libs/web/tracker/infrastructure/src/lib/graphql/fragments.graphql @@ -40,6 +40,10 @@ fragment ProjectFields on Project { icon color archived + template { + description + checklist + } } areas { id diff --git a/libs/web/tracker/infrastructure/src/lib/graphql/generated.ts b/libs/web/tracker/infrastructure/src/lib/graphql/generated.ts index 99c8ebf..f18505a 100644 --- a/libs/web/tracker/infrastructure/src/lib/graphql/generated.ts +++ b/libs/web/tracker/infrastructure/src/lib/graphql/generated.ts @@ -162,12 +162,18 @@ export type IssuePriority = 'CRITICAL' | 'HIGH' | 'LOW' | 'LOWEST' | 'MEDIUM'; /** RECENT: last updated first. RANK: backlog order. */ export type IssueSort = 'RANK' | 'RECENT'; +export type IssueTemplateInput = { + checklist?: Array; + description?: string | null | undefined; +}; + export type IssueTypeOptionInput = { archived?: boolean; color: string; icon: string; id: string; name: string; + template?: IssueTemplateInput | null | undefined; }; export type LinkDirection = 'INWARD' | 'OUTWARD'; @@ -435,6 +441,7 @@ export type ProjectFieldsFragment = { icon: string; color: string; archived: boolean; + template: { description: string | null; checklist: Array } | null; }>; areas: Array<{ id: string; name: string; archived: boolean }>; }; @@ -1140,6 +1147,10 @@ export type MyProjectsQuery = { icon: string; color: string; archived: boolean; + template: { + description: string | null; + checklist: Array; + } | null; }>; areas: Array<{ id: string; name: string; archived: boolean }>; }; @@ -1178,6 +1189,10 @@ export type ProjectQuery = { icon: string; color: string; archived: boolean; + template: { + description: string | null; + checklist: Array; + } | null; }>; areas: Array<{ id: string; name: string; archived: boolean }>; }; @@ -1326,6 +1341,10 @@ export type PublicProjectsQuery = { icon: string; color: string; archived: boolean; + template: { + description: string | null; + checklist: Array; + } | null; }>; areas: Array<{ id: string; name: string; archived: boolean }>; }; @@ -1763,6 +1782,10 @@ export type ProjectChangedSubscription = { icon: string; color: string; archived: boolean; + template: { + description: string | null; + checklist: Array; + } | null; }>; areas: Array<{ id: string; name: string; archived: boolean }>; }; @@ -1804,6 +1827,10 @@ export type MyProjectsChangedSubscription = { icon: string; color: string; archived: boolean; + template: { + description: string | null; + checklist: Array; + } | null; }>; areas: Array<{ id: string; name: string; archived: boolean }>; }; @@ -2133,6 +2160,23 @@ export const ProjectFieldsFragmentDoc = { kind: 'Field', 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', 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', 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', 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', 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', 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' }, + }, + ], + }, + }, ], }, }, diff --git a/libs/web/tracker/presenters/src/lib/pages/board.page.scss b/libs/web/tracker/presenters/src/lib/pages/board.page.scss index 7908854..e80eaf4 100644 --- a/libs/web/tracker/presenters/src/lib/pages/board.page.scss +++ b/libs/web/tracker/presenters/src/lib/pages/board.page.scss @@ -182,3 +182,8 @@ .ts-column--lane .ts-column__list { min-height: 3.5rem; } + +.ts-template-hint { + margin: -0.25rem 0 0; + font-size: 0.8125rem; +} diff --git a/libs/web/tracker/presenters/src/lib/pages/board.page.ts b/libs/web/tracker/presenters/src/lib/pages/board.page.ts index ce57c1e..6794d9e 100644 --- a/libs/web/tracker/presenters/src/lib/pages/board.page.ts +++ b/libs/web/tracker/presenters/src/lib/pages/board.page.ts @@ -342,10 +342,27 @@ const NOBODY = '__nobody__'; Descripción + @if (template(); as t) { +

+ 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) + } +

+ } @if (project.workflow().isDesignType(newType())) { (''); + /** 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({ title: '', description: '', diff --git a/libs/web/tracker/presenters/src/lib/pages/settings.page.ts b/libs/web/tracker/presenters/src/lib/pages/settings.page.ts index e813920..3b95eb3 100644 --- a/libs/web/tracker/presenters/src/lib/pages/settings.page.ts +++ b/libs/web/tracker/presenters/src/lib/pages/settings.page.ts @@ -22,6 +22,7 @@ import { TransferComponent } from '../ui/transfer'; import { AutomationsComponent } from '../ui/automations'; import { ProjectFacade } from '@task-stream/web/tracker/application'; import { + IssueTypeOption, ProjectSettings, SETTING_COLORS, StatusCategory, @@ -346,6 +347,16 @@ function idFrom(name: string): string { " /> {{ type.id }} + + + + + } } @@ -733,6 +786,27 @@ function idFrom(name: string): string { align-items: center; 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 { display: grid; 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; } + /** Index of the type whose template is open. */ + protected readonly openTemplate = signal(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 { this.patch((d) => Object.assign(d[section][index], changes)); }