feat: start a project from another one's configuration
Creating a project can name one the creator owns or administers: its statuses, transitions, types, areas, custom fields and automation rules are copied once (paused rules stay paused; rules assigning to someone who is not in the new project are left out). GraphQL templateProjectId, REST template (id or key), MCP create_project template, and a select in the web's new-project form. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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 }]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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": [
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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,
|
||||
) {
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.)
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@@ -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<CommandResult> {
|
||||
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<void> {
|
||||
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(
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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: `
|
||||
<header class="ts-page-header">
|
||||
@@ -125,6 +129,18 @@ import { MyWorkComponent } from './my-work';
|
||||
>
|
||||
<pi-label>Descripción</pi-label>
|
||||
</pi-textarea>
|
||||
@if (templateOptions().length > 1) {
|
||||
<pi-select-field
|
||||
[formField]="f.template"
|
||||
[options]="templateOptions()"
|
||||
>
|
||||
<pi-label>Empezar con la configuración de</pi-label>
|
||||
</pi-select-field>
|
||||
<p class="ts-hint">
|
||||
Copia sus estados, transiciones, tipos, áreas, campos y
|
||||
automatizaciones. Después, cada proyecto cambia por su cuenta.
|
||||
</p>
|
||||
}
|
||||
<div class="ts-public-check">
|
||||
<pi-checkbox label="Proyecto público" [formField]="f.isPublic" />
|
||||
<p>
|
||||
@@ -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<PiSelectFieldOption<string>[]>(
|
||||
() => {
|
||||
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();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user