diff --git a/apps/api/e2e/tracker.e2e-spec.ts b/apps/api/e2e/tracker.e2e-spec.ts index a4e9223..6b3f2a5 100644 --- a/apps/api/e2e/tracker.e2e-spec.ts +++ b/apps/api/e2e/tracker.e2e-spec.ts @@ -1279,4 +1279,91 @@ describe('task-stream (e2e)', () => { history.body.map((h: { summary: string }) => h.summary).join(' | '), ).toContain('los campos personalizados'); }); + + it('starts a project from another one: its configuration and rules', async () => { + const stamp = Date.now().toString(36).slice(-4).toUpperCase(); + const source = `T${stamp}`; + await rest('token-ana', 'POST', '/projects', { key: source, name: source }); + const created = await eventually( + () => rest('token-ana', 'GET', `/projects/${source}`), + (r) => r.status === 200, + ); + const sourceId = created.body['id']; + await rest('token-ana', 'POST', `/projects/${source}/members`, { + userId: '102', + }); + const settings = created.body['settings']; + settings.areas.push({ id: 'DATA', name: 'Datos', archived: false }); + expect( + ( + await rest('token-ana', 'PUT', `/projects/${source}/settings`, { + ...settings, + customFields: [ + { + id: 'CLIENT', + name: 'Cliente', + type: 'SELECT', + options: ['Acme'], + }, + ], + }) + ).status, + ).toBe(200); + const rule = (name: string, kind: string, value: string) => + gql( + 'token-ana', + `mutation($i: CreateAutomationInput!) { createAutomation(input: $i) { id } }`, + { + i: { + projectId: sourceId, + name, + trigger: { kind: 'ISSUE_CREATED' }, + action: { kind, value }, + }, + }, + ); + const paused = (await rule('Etiquetar', 'ADD_LABEL', 'nuevo'))[ + 'createAutomation' + ].id; + await gql( + 'token-ana', + `mutation($a: ID!) { updateAutomation(automationId: $a, active: false) { id } }`, + { a: paused }, + ); + // Bob is in the source project only: this one stays behind. + await rule('Para Bob', 'ASSIGN', '102'); + + // Only who manages the source can copy it. + expect( + ( + await rest('token-bob', 'POST', '/projects', { + key: `B${stamp}`, + name: 'Copia', + template: source, + }) + ).status, + ).toBe(403); + + const copy = `C${stamp}`; + const { createProject } = await gql( + 'token-ana', + `mutation($i: CreateProjectInput!) { createProject(input: $i) { id key } }`, + { i: { key: copy, name: 'Copia', templateProjectId: sourceId } }, + ); + expect(createProject.key).toBe(copy); + const copied = await eventually( + () => rest('token-ana', 'GET', `/projects/${copy}`), + (r) => r.status === 200 && r.body['settings'].customFields.length === 1, + ); + expect( + copied.body['settings'].areas.map((a: { id: string }) => a.id), + ).toContain('DATA'); + expect(copied.body['settings'].customFields[0].options).toEqual(['Acme']); + const { automations } = await gql( + 'token-ana', + `query($p: ID!) { automations(projectId: $p) { name active } }`, + { p: createProject.id }, + ); + expect(automations).toEqual([{ name: 'Etiquetar', active: false }]); + }); }); diff --git a/apps/api/openapi.json b/apps/api/openapi.json index 7ef4b44..5c27ae0 100644 --- a/apps/api/openapi.json +++ b/apps/api/openapi.json @@ -10656,6 +10656,12 @@ "type": "boolean", "default": false, "description": "Anyone can read it without signing in (board, tickets, comments, releases, activity, statistics)" + }, + "template": { + "type": "string", + "nullable": true, + "example": "API", + "description": "A project you own or administer (id or key) to start from: its statuses, transitions, types, areas, custom fields and automation rules are copied" } }, "required": [ diff --git a/apps/api/schema.gql b/apps/api/schema.gql index e2f3bc8..a01e00b 100644 --- a/apps/api/schema.gql +++ b/apps/api/schema.gql @@ -313,6 +313,11 @@ input CreateProjectInput { """2-10 chars, e.g. "TS" → tickets TS-1, TS-2...""" key: String! name: String! + + """ + A project you own or administer to start from: its statuses, transitions, types, areas, custom fields and automation rules are copied + """ + templateProjectId: ID } input CreateReleaseInput { diff --git a/apps/mcp/src/app/api/taskstream.schema.ts b/apps/mcp/src/app/api/taskstream.schema.ts index 23fd92e..7426471 100644 --- a/apps/mcp/src/app/api/taskstream.schema.ts +++ b/apps/mcp/src/app/api/taskstream.schema.ts @@ -1729,6 +1729,11 @@ export interface components { * @default false */ isPublic: boolean; + /** + * @description A project you own or administer (id or key) to start from: its statuses, transitions, types, areas, custom fields and automation rules are copied + * @example API + */ + template?: string | null; }; CommandResultDto: { /** @description Id of the aggregate written */ diff --git a/apps/mcp/src/app/tools/projects.resolver.ts b/apps/mcp/src/app/tools/projects.resolver.ts index 7b456d3..9bfc957 100644 --- a/apps/mcp/src/app/tools/projects.resolver.ts +++ b/apps/mcp/src/app/tools/projects.resolver.ts @@ -40,7 +40,7 @@ export class ProjectsResolver { @Tool({ name: 'create_project', description: - 'Create a project; the user becomes its owner. The key prefixes its tickets (WEB → WEB-1) and cannot change later.', + 'Create a project; the user becomes its owner. The key prefixes its tickets (WEB → WEB-1) and cannot change later. With template, it starts with the statuses, transitions, types, areas, custom fields and automation rules of that project.', paramsSchema: z.object({ key: z .string() @@ -52,6 +52,12 @@ export class ProjectsResolver { .boolean() .default(false) .describe('Anyone can read it, also without signing in'), + template: z + .string() + .optional() + .describe( + 'Key of a project the user owns or administers to copy its configuration and automation rules from', + ), }), annotations: { readOnlyHint: false, destructiveHint: false }, }) @@ -61,6 +67,7 @@ export class ProjectsResolver { name: string; description?: string; isPublic: boolean; + template?: string; }, ctx: McpContext, ) { diff --git a/docs/03-api.md b/docs/03-api.md index 317a0d6..408e089 100644 --- a/docs/03-api.md +++ b/docs/03-api.md @@ -110,6 +110,11 @@ Reglas: - Colores: `slate`, `stone`, `violet`, `blue`, `sky`, `teal`, `green`, `amber`, `orange`, `red`, `pink`. Iconos: `bug`, `feature`, `palette`, `task`, `qa`, `flag`, `star-fill`, `rocket`, `document`, `light-bulb`, `key`, `support`. +Un proyecto nuevo puede **empezar con la configuración de otro** que administres: `templateProjectId` en +`createProject` o `template` (id o clave) en `POST /api/projects`. Se copian una vez sus estados, transiciones, tipos, +áreas, campos personalizados y automatizaciones (las pausadas, pausadas; las que asignan a alguien que no está en el +nuevo proyecto se quedan fuera). Las etiquetas no tienen catálogo, así que no hay nada que copiar. + Los proyectos creados antes de que existiera la configuración usan los valores por defecto (sus eventos no la llevan). Al arrancar, la API rellena la categoría de los tickets del read model que no la tienen. diff --git a/docs/09-mcp.md b/docs/09-mcp.md index 185cf56..5d6483f 100644 --- a/docs/09-mcp.md +++ b/docs/09-mcp.md @@ -34,53 +34,53 @@ flowchart LR 57 herramientas, agrupadas por ámbito: -| Ámbito | Herramienta | Qué hace | -| ------------ | ------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------- | -| Usuario | `whoami` | El usuario del token | -| | `search_users` | Buscar personas de Pireon (para asignar) | -| | `my_notifications` | Mis notificaciones (por defecto, las no leídas) | -| | `mark_notifications_read` | Marcar como leídas unas o todas | -| Proyectos | `list_my_projects` | Mis proyectos | -| | `list_public_projects` | Proyectos públicos | -| | `create_project` | Crear un proyecto (quien lo crea es su dueño) | -| | `get_project` | Equipo y configuración: ids de estados, tipos y áreas que aceptan las demás herramientas | -| | `get_project_showcase` | El escaparate público: hoja de ruta, releases, sprint en curso y tickets para empezar | -| | `get_board` | Tablero por columnas (filtros por persona, release, área, sprint) | -| | `get_project_stats` | Cifras de seguimiento | -| | `get_project_analytics` | Estadísticas de los últimos días (creados/resueltos, flujo, tiempos, throughput, QA) | -| | `get_project_activity` | Actividad reciente | -| Tickets | `my_issues` | Mi trabajo en todos los proyectos (o de cualquiera, por categoría; o los que sigo) | -| | `search_issues` | Buscar por texto y filtros (épica, sprint, fechas, campos…); el backlog: `noSprint` y orden `RANK` | -| | `get_issue` | Detalle: épica, lista de comprobación, relaciones, commits, seguidores, transiciones, comentarios | -| | `create_issue` | Crear un ticket (también colgado de una épica) | -| | `update_issue` | Cambiar título, descripción, tipo, área, prioridad, etiquetas, fechas, campos personalizados… | -| | `change_issue_status` | Mover de estado (con motivo cuando el estado lo pide) | -| | `assign_issue` | Asignar o desasignar | -| | `set_issue_release` | Meter en una release o sacar | -| | `add_comment` / `edit_comment` / `delete_comment` | Comentar (o responder a un comentario), corregir o borrar | -| | `link_issues` / `unlink_issues` | Relacionar tickets (`DEPENDS_ON`, `BLOCKS`, `TESTS`…) o quitar la relación | -| | `watch_issue` | Seguir un ticket o dejar de seguirlo | -| | `vote_issue` | Votar un ticket o quitar el voto | -| | `duplicate_issue` | Crear otro ticket con lo mismo (sin estado, persona ni comentarios) | -| | `react_to_comment` | Reaccionar a un comentario (👍 🎉…) o quitar la reacción | -| | `add_checklist_item` / `update_checklist_item` / `remove_checklist_item` | La lista de comprobación: añadir, marcar o reescribir, quitar | -| Organización | `list_epics` | Épicas con el progreso de lo que cuelga de cada una | -| | `set_issue_parent` | Colgar un ticket de una épica u otro ticket, o soltarlo | -| | `rank_issue` | Mover un ticket en el backlog | -| | `bulk_update_issues` | El mismo cambio en hasta 100 tickets (por clave); dice cuáles no se dejaron | -| | `delete_issue` / `restore_issue` / `list_trash` | Papelera: borrar (30 días para recuperar), recuperar y ver | -| Sprints | `list_sprints` | Sprints de un proyecto | -| | `get_sprint` | Progreso, tickets y burndown | -| | `create_sprint` / `update_sprint` / `delete_sprint` | Planificar, cambiar o borrar (si no empezó) un sprint | -| | `start_sprint` / `close_sprint` | Empezarlo; cerrarlo pasando lo pendiente a otro sprint o al backlog | -| | `set_issue_sprint` | Meter un ticket en un sprint o devolverlo al backlog | -| Releases | `list_releases` | Releases de un proyecto | -| | `upcoming_releases` | Próximas releases de mis proyectos | -| | `get_release` | Progreso, notas, tickets y burnup | -| | `create_release` / `update_release` | Planificar una release o cambiar su nombre, descripción o fecha | -| | `draft_release_notes` / `set_release_notes` | Borrador de las notas desde los tickets terminados, y guardarlas | -| | `publish_release` | Publicar (con `force` si quedan tickets sin terminar) | -| | `archive_release` | Archivar una release que no saldrá; lo pendiente pasa a `moveTo` (otra release abierta) o la deja | +| Ámbito | Herramienta | Qué hace | +| ------------ | ------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------- | +| Usuario | `whoami` | El usuario del token | +| | `search_users` | Buscar personas de Pireon (para asignar) | +| | `my_notifications` | Mis notificaciones (por defecto, las no leídas) | +| | `mark_notifications_read` | Marcar como leídas unas o todas | +| Proyectos | `list_my_projects` | Mis proyectos | +| | `list_public_projects` | Proyectos públicos | +| | `create_project` | Crear un proyecto (quien lo crea es su dueño), también copiando la configuración de otro (`template`) | +| | `get_project` | Equipo y configuración: ids de estados, tipos y áreas que aceptan las demás herramientas | +| | `get_project_showcase` | El escaparate público: hoja de ruta, releases, sprint en curso y tickets para empezar | +| | `get_board` | Tablero por columnas (filtros por persona, release, área, sprint) | +| | `get_project_stats` | Cifras de seguimiento | +| | `get_project_analytics` | Estadísticas de los últimos días (creados/resueltos, flujo, tiempos, throughput, QA) | +| | `get_project_activity` | Actividad reciente | +| Tickets | `my_issues` | Mi trabajo en todos los proyectos (o de cualquiera, por categoría; o los que sigo) | +| | `search_issues` | Buscar por texto y filtros (épica, sprint, fechas, campos…); el backlog: `noSprint` y orden `RANK` | +| | `get_issue` | Detalle: épica, lista de comprobación, relaciones, commits, seguidores, transiciones, comentarios | +| | `create_issue` | Crear un ticket (también colgado de una épica) | +| | `update_issue` | Cambiar título, descripción, tipo, área, prioridad, etiquetas, fechas, campos personalizados… | +| | `change_issue_status` | Mover de estado (con motivo cuando el estado lo pide) | +| | `assign_issue` | Asignar o desasignar | +| | `set_issue_release` | Meter en una release o sacar | +| | `add_comment` / `edit_comment` / `delete_comment` | Comentar (o responder a un comentario), corregir o borrar | +| | `link_issues` / `unlink_issues` | Relacionar tickets (`DEPENDS_ON`, `BLOCKS`, `TESTS`…) o quitar la relación | +| | `watch_issue` | Seguir un ticket o dejar de seguirlo | +| | `vote_issue` | Votar un ticket o quitar el voto | +| | `duplicate_issue` | Crear otro ticket con lo mismo (sin estado, persona ni comentarios) | +| | `react_to_comment` | Reaccionar a un comentario (👍 🎉…) o quitar la reacción | +| | `add_checklist_item` / `update_checklist_item` / `remove_checklist_item` | La lista de comprobación: añadir, marcar o reescribir, quitar | +| Organización | `list_epics` | Épicas con el progreso de lo que cuelga de cada una | +| | `set_issue_parent` | Colgar un ticket de una épica u otro ticket, o soltarlo | +| | `rank_issue` | Mover un ticket en el backlog | +| | `bulk_update_issues` | El mismo cambio en hasta 100 tickets (por clave); dice cuáles no se dejaron | +| | `delete_issue` / `restore_issue` / `list_trash` | Papelera: borrar (30 días para recuperar), recuperar y ver | +| Sprints | `list_sprints` | Sprints de un proyecto | +| | `get_sprint` | Progreso, tickets y burndown | +| | `create_sprint` / `update_sprint` / `delete_sprint` | Planificar, cambiar o borrar (si no empezó) un sprint | +| | `start_sprint` / `close_sprint` | Empezarlo; cerrarlo pasando lo pendiente a otro sprint o al backlog | +| | `set_issue_sprint` | Meter un ticket en un sprint o devolverlo al backlog | +| Releases | `list_releases` | Releases de un proyecto | +| | `upcoming_releases` | Próximas releases de mis proyectos | +| | `get_release` | Progreso, notas, tickets y burnup | +| | `create_release` / `update_release` | Planificar una release o cambiar su nombre, descripción o fecha | +| | `draft_release_notes` / `set_release_notes` | Borrador de las notas desde los tickets terminados, y guardarlas | +| | `publish_release` | Publicar (con `force` si quedan tickets sin terminar) | +| | `archive_release` | Archivar una release que no saldrá; lo pendiente pasa a `moveTo` (otra release abierta) o la deja | Las de lectura van marcadas como `readOnlyHint`, para que el cliente no pida confirmación al usarlas, y las que borran o archivan, como `destructiveHint`, para que la pida. Lo que es de administración (miembros, configuración, visibilidad, diff --git a/docs/10-futuro.md b/docs/10-futuro.md index 5c3a6b6..be81b09 100644 --- a/docs/10-futuro.md +++ b/docs/10-futuro.md @@ -30,30 +30,26 @@ cuando hay pistas, por dónde empezar. ### En curso, en este orden -1. **Burndown y velocidad de sprint**: puntos pendientes día a día durante el sprint y puntos cerrados por sprint, para - planificar el siguiente con datos (el historial de estados ya está en `issue_timeline`). -2. **Plantillas de proyecto**: crear un proyecto copiando de otro los estados, transiciones, tipos, áreas, campos - personalizados, automatizaciones y etiquetas. -3. **Exportar e imprimir un sprint o una release**: un PDF con los tickets, su estado y las notas, para un cliente o +1. **Exportar e imprimir un sprint o una release**: un PDF con los tickets, su estado y las notas, para un cliente o para archivar. Y en el panel del proyecto: -4. **Sprint en curso**: días que quedan, puntos hechos y pendientes, y aviso si al ritmo actual no llega. -5. **Tickets en riesgo**: vencidos, que vencen esta semana, bloqueados y atascados (días sin moverse en un estado en +2. **Sprint en curso**: días que quedan, puntos hechos y pendientes, y aviso si al ritmo actual no llega. +3. **Tickets en riesgo**: vencidos, que vencen esta semana, bloqueados y atascados (días sin moverse en un estado en curso), cada grupo con enlace a la lista filtrada. -6. **Antigüedad de lo abierto**: cada ticket en curso por los días que lleva en su estado, con el umbral habitual del +4. **Antigüedad de lo abierto**: cada ticket en curso por los días que lleva en su estado, con el umbral habitual del proyecto (p85 del tiempo de ciclo). -7. **Cuellos de botella**: días medios en cada estado y columnas por encima de su límite WIP. -8. **Próximas releases con previsión**: progreso, fecha objetivo y fecha probable según el ritmo real (Monte Carlo), +5. **Cuellos de botella**: días medios en cada estado y columnas por encima de su límite WIP. +6. **Próximas releases con previsión**: progreso, fecha objetivo y fecha probable según el ritmo real (Monte Carlo), con semáforo. -9. **Qué ha cambiado desde tu última visita**: nuevos, cerrados, devueltos por QA y menciones. -10. **Carga del equipo en puntos**: asignado frente a lo que cada persona suele cerrar por sprint, con aviso de - sobrecarga y sus bloqueados. -11. **Salud del backlog**: sin estimar, sin área, sin asignar y sin tocar en 90 días, con enlace a la lista filtrada. -12. **Calidad**: bugs abiertos por prioridad y su tendencia, devoluciones de QA por área y bugs abiertos en los 14 días +7. **Qué ha cambiado desde tu última visita**: nuevos, cerrados, devueltos por QA y menciones. +8. **Carga del equipo en puntos**: asignado frente a lo que cada persona suele cerrar por sprint, con aviso de + sobrecarga y sus bloqueados. +9. **Salud del backlog**: sin estimar, sin área, sin asignar y sin tocar en 90 días, con enlace a la lista filtrada. +10. **Calidad**: bugs abiertos por prioridad y su tendencia, devoluciones de QA por área y bugs abiertos en los 14 días siguientes a cada release. -13. **Desglose por campo personalizado**: tickets abiertos según un campo de opciones a elegir («bugs por +11. **Desglose por campo personalizado**: tickets abiertos según un campo de opciones a elegir («bugs por navegador»). ### Aceptadas, para después de esta tanda @@ -130,3 +126,6 @@ Mucho trabajo para lo que aportan hoy; se pueden recuperar si alguien los necesi MCP). - Dependencias en la hoja de ruta: flechas entre las épicas que se bloquean o dependen unas de otras, y aviso cuando una empieza antes de que acabe la que la bloquea. +- Plantillas de proyecto: al crear uno, «Empezar con la configuración de» otro que administras copia sus estados, + transiciones, tipos, áreas, campos personalizados y automatizaciones. (El burndown y la velocidad de sprint ya + existían, en Sprints.) diff --git a/docs/TaskStream-Documentacion.docx b/docs/TaskStream-Documentacion.docx index 184a28c..23c8582 100644 Binary files a/docs/TaskStream-Documentacion.docx and b/docs/TaskStream-Documentacion.docx differ diff --git a/docs/TaskStream-Documentacion.pdf b/docs/TaskStream-Documentacion.pdf index 51ae9ca..d377c3d 100644 Binary files a/docs/TaskStream-Documentacion.pdf and b/docs/TaskStream-Documentacion.pdf differ diff --git a/libs/api/tracker/application/src/lib/projects.service.ts b/libs/api/tracker/application/src/lib/projects.service.ts index b8ad70f..b51bdc3 100644 --- a/libs/api/tracker/application/src/lib/projects.service.ts +++ b/libs/api/tracker/application/src/lib/projects.service.ts @@ -2,8 +2,10 @@ import { GetShowcaseQuery, Showcase } from './queries/get-showcase.query'; import { Injectable } from '@nestjs/common'; import { CommandBus, QueryBus } from '@nestjs/cqrs'; import { + BusinessRuleError, ForbiddenError, NotFoundError, + ValidationError, assertThat, } from '@task-stream/api/shared/domain'; import { @@ -36,6 +38,12 @@ import { GetProjectStatsQuery } from './queries/get-project-stats.query'; import { GetPublicProjectsQuery } from './queries/get-public-projects.query'; import { GetProjectQuery } from './queries/get-project.query'; import { GetReleasesQuery } from './queries/get-releases.query'; +import { + CreateAutomationCommand, + GetAutomationsQuery, + UpdateAutomationCommand, +} from './automations/automations'; +import { Automation } from './ports/automations.repository'; @Injectable() export class ProjectsService { @@ -108,16 +116,28 @@ export class ProjectsService { return this.queryBus.execute(new GetShowcaseQuery(project)); } - create( + /** + * A new project. With `templateId`, it starts with a copy of that + * project's configuration (statuses, transitions, types, areas and custom + * fields) and automation rules, taken once: later changes to either one + * stay there. Only from a project the actor manages (rules are only shown + * to managers); a rule that names someone who is not in the new project + * is left out. + */ + async create( actorId: string, input: { key: string; name: string; description?: string | null; isPublic?: boolean; + templateId?: string | null; }, ): Promise { - return this.commandBus.execute( + const template = input.templateId + ? await this.template(actorId, input.templateId) + : null; + const created: CommandResult = await this.commandBus.execute( new CreateProjectCommand( actorId, input.key, @@ -126,6 +146,61 @@ export class ProjectsService { input.isPublic ?? false, ), ); + if (!template) return created; + const configured: CommandResult = await this.commandBus.execute( + new UpdateProjectSettingsCommand( + actorId, + created.id, + structuredClone(template.settings), + ), + ); + for (const rule of template.automations) + await this.copyRule(actorId, created.id, rule); + return { ...configured, key: created.key }; + } + + private async template( + actorId: string, + projectId: string, + ): Promise<{ + settings: ProjectSettings; + automations: Automation[]; + }> { + const project = await this.access.project(projectId); + project.assertCanManage(actorId); + const automations: Automation[] = await this.queryBus.execute( + new GetAutomationsQuery(actorId, projectId), + ); + return { settings: project.settings, automations }; + } + + private async copyRule( + actorId: string, + projectId: string, + rule: Automation, + ): Promise { + try { + const copy: Automation = await this.commandBus.execute( + new CreateAutomationCommand(actorId, projectId, { + name: rule.name, + trigger: rule.trigger, + // Stored without conditions when it had none. + conditions: rule.conditions ?? {}, + action: rule.action, + }), + ); + if (!rule.active) + await this.commandBus.execute( + new UpdateAutomationCommand(actorId, copy.id, { active: false }), + ); + } catch (error) { + // Someone it assigns to is not a member here: that rule stays behind. + if ( + !(error instanceof ValidationError) && + !(error instanceof BusinessRuleError) + ) + throw error; + } } update( diff --git a/libs/api/tracker/presenters/src/lib/graphql/inputs.ts b/libs/api/tracker/presenters/src/lib/graphql/inputs.ts index b360ed7..ead9379 100644 --- a/libs/api/tracker/presenters/src/lib/graphql/inputs.ts +++ b/libs/api/tracker/presenters/src/lib/graphql/inputs.ts @@ -55,6 +55,15 @@ export class CreateProjectInput { @IsOptional() @IsBoolean() isPublic?: boolean; + + @Field(() => ID, { + nullable: true, + description: + 'A project you own or administer to start from: its statuses, transitions, types, areas, custom fields and automation rules are copied', + }) + @IsOptional() + @IsUUID() + templateProjectId?: string | null; } @InputType() diff --git a/libs/api/tracker/presenters/src/lib/graphql/project.resolver.ts b/libs/api/tracker/presenters/src/lib/graphql/project.resolver.ts index 4a3fca1..12a40ec 100644 --- a/libs/api/tracker/presenters/src/lib/graphql/project.resolver.ts +++ b/libs/api/tracker/presenters/src/lib/graphql/project.resolver.ts @@ -151,7 +151,11 @@ export class ProjectResolver { @CurrentUser() me: User, @Args('input') input: CreateProjectInput, ) { - return this.projects.create(me.id, input); + const { templateProjectId, ...fields } = input; + return this.projects.create(me.id, { + ...fields, + templateId: templateProjectId, + }); } @Mutation(() => CommandResultGql) 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 d505c8f..ae37385 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 @@ -87,10 +87,21 @@ export class CreateProjectDto { @IsOptional() @IsBoolean() isPublic?: boolean; + + @ApiPropertyOptional({ + type: String, + nullable: true, + example: 'API', + description: + 'A project you own or administer (id or key) to start from: its statuses, transitions, types, areas, custom fields and automation rules are copied', + }) + @IsOptional() + @IsString() + template?: string | null; } export class UpdateProjectDto extends PartialType( - OmitType(CreateProjectDto, ['key'] as const), + OmitType(CreateProjectDto, ['key', 'template'] as const), ) { @ApiPropertyOptional({ description: diff --git a/libs/api/tracker/presenters/src/lib/http/projects.controller.ts b/libs/api/tracker/presenters/src/lib/http/projects.controller.ts index d98aa52..a0dcd33 100644 --- a/libs/api/tracker/presenters/src/lib/http/projects.controller.ts +++ b/libs/api/tracker/presenters/src/lib/http/projects.controller.ts @@ -83,8 +83,12 @@ export class ProjectsController { @ApiOperation({ summary: 'Create a project (you become its owner)' }) @ApiCreatedResponse({ type: CommandResultDto }) @ApiCommandErrors() - create(@CurrentUser() me: User, @Body() body: CreateProjectDto) { - return this.projects.create(me.id, body); + async create(@CurrentUser() me: User, @Body() body: CreateProjectDto) { + const { template, ...fields } = body; + return this.projects.create(me.id, { + ...fields, + templateId: template ? await this.refs.projectId(me.id, template) : null, + }); } @Get(':project') diff --git a/libs/web/tracker/domain/src/lib/models/inputs.ts b/libs/web/tracker/domain/src/lib/models/inputs.ts index 47463e6..4c81563 100644 --- a/libs/web/tracker/domain/src/lib/models/inputs.ts +++ b/libs/web/tracker/domain/src/lib/models/inputs.ts @@ -11,6 +11,8 @@ export interface CreateProjectInput { name: string; description?: string | null; isPublic?: boolean; + /** A project I manage whose configuration and rules it starts with. */ + templateProjectId?: string | null; } export interface UpdateProjectInput { diff --git a/libs/web/tracker/infrastructure/src/lib/graphql/generated.ts b/libs/web/tracker/infrastructure/src/lib/graphql/generated.ts index 05ea553..bf75d6c 100644 --- a/libs/web/tracker/infrastructure/src/lib/graphql/generated.ts +++ b/libs/web/tracker/infrastructure/src/lib/graphql/generated.ts @@ -116,6 +116,8 @@ export type CreateProjectInput = { /** 2-10 chars, e.g. "TS" → tickets TS-1, TS-2... */ key: string; name: string; + /** A project you own or administer to start from: its statuses, transitions, types, areas, custom fields and automation rules are copied */ + templateProjectId?: string | null | undefined; }; export type CreateReleaseInput = { diff --git a/libs/web/tracker/presenters/src/lib/pages/projects.page.ts b/libs/web/tracker/presenters/src/lib/pages/projects.page.ts index 6cde1ee..6a20b41 100644 --- a/libs/web/tracker/presenters/src/lib/pages/projects.page.ts +++ b/libs/web/tracker/presenters/src/lib/pages/projects.page.ts @@ -20,12 +20,15 @@ import { PiCheckboxComponent, PiEmptyStateComponent, PiLabelDirective, + PiSelectFieldComponent, PiSkeletonComponent, PiTextareaComponent, PiTextFieldComponent, + type PiSelectFieldOption, } from '@pi/ui'; import { DrawerService } from '@task-stream/web/shared/ui'; import { ProjectsFacade } from '@task-stream/web/tracker/application'; +import { ProjectRole } from '@task-stream/web/tracker/domain'; import { AuthFacade } from '@task-stream/web/identity/application'; import { GoToComponent } from '../ui/go-to'; import { ProjectCardComponent } from '../ui/project-card'; @@ -46,6 +49,7 @@ import { MyWorkComponent } from './my-work'; PiTextFieldComponent, PiTextareaComponent, PiLabelDirective, + PiSelectFieldComponent, ], template: `
@@ -125,6 +129,18 @@ import { MyWorkComponent } from './my-work'; > Descripción + @if (templateOptions().length > 1) { + + Empezar con la configuración de + +

+ Copia sus estados, transiciones, tipos, áreas, campos y + automatizaciones. Después, cada proyecto cambia por su cuenta. +

+ }

@@ -164,6 +180,11 @@ import { MyWorkComponent } from './my-work'; font-weight: 400; } } + .ts-hint { + margin: -0.5rem 0 0; + color: var(--ts-muted); + font-size: 0.8125rem; + } .ts-public-check { p { /* Under the checkbox's text, past the box. */ @@ -194,7 +215,28 @@ export class ProjectsPage implements OnInit { name: '', description: '', isPublic: false, + template: '', }); + + /** Projects I own or administer: the ones whose configuration I can copy. */ + protected readonly templateOptions = computed[]>( + () => { + const me = this.auth.user()?.id; + return [ + { value: '', label: 'La de por defecto' }, + ...this.facade + .sorted() + .filter((p) => + p.members.some( + (m) => + m.user.id === me && + (m.role === ProjectRole.OWNER || m.role === ProjectRole.ADMIN), + ), + ) + .map((p) => ({ value: p.id, label: `${p.key} · ${p.name}` })), + ]; + }, + ); protected readonly f = form(this.model, (p) => { required(p.key); pattern(p.key, /^[A-Za-z][A-Za-z0-9]{1,9}$/, { @@ -211,18 +253,25 @@ export class ProjectsPage implements OnInit { } protected openCreate(): void { - this.model.set({ key: '', name: '', description: '', isPublic: false }); + this.model.set({ + key: '', + name: '', + description: '', + isPublic: false, + template: '', + }); this.drawer.open('Nuevo proyecto', this.createForm()); } protected create(): void { if (this.f().invalid()) return; - const { key, name, description, isPublic } = this.model(); + const { key, name, description, isPublic, template } = this.model(); this.facade.create({ key: key.toUpperCase(), name, description: description || null, isPublic, + templateProjectId: template || null, }); this.drawer.closeAll(); }