feat: teams within a project

Projects keep teams of members in their settings (settings.teams; people
outside the project are dropped, teams are archived, not removed; absent
when saving means unchanged). Tickets go to a team (`team`, also filtered
in the list and in MCP search_issues), and a comment mentioning
@[Frontend](team-FRONT) tells each of its members. Settings has the teams
with their members, the ticket a «Equipo» select, the list a team filter,
and «@» offers the teams after the people.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-15 16:20:35 +02:00
parent 4acf629106
commit 869acd3f1f
41 changed files with 895 additions and 13 deletions

View File

@@ -1641,4 +1641,63 @@ describe('task-stream (e2e)', () => {
).status, ).status,
).toBe(200); ).toBe(200);
}); });
it('groups members in teams: tickets go to them and @team tells them', async () => {
const key = 'T' + Date.now().toString(36).slice(-5).toUpperCase();
await rest('token-ana', 'POST', '/projects', { key, name: key });
const project = await eventually(
() => rest('token-ana', 'GET', `/projects/${key}`),
(r) => r.status === 200,
);
await rest('token-ana', 'POST', `/projects/${key}/members`, {
userId: '102',
});
expect(
(
await rest('token-ana', 'PUT', `/projects/${key}/settings`, {
...project.body['settings'],
teams: [
{ id: 'FRONT', name: 'Frontend', memberIds: ['102', 'nobody'] },
],
})
).status,
).toBe(200);
const settings = await eventually(
() => rest('token-ana', 'GET', `/projects/${key}`),
(r) => r.body['settings'].teams.length === 1,
);
expect(settings.body['settings'].teams[0].memberIds).toEqual(['102']);
const created = await rest('token-ana', 'POST', `/projects/${key}/issues`, {
type: 'TASK',
title: 'Para el equipo',
team: 'FRONT',
});
const ticket = created.body['key'];
await rest('token-ana', 'POST', `/projects/${key}/issues`, {
type: 'TASK',
title: 'Sin equipo',
});
const mine = await eventually(
() => rest('token-ana', 'GET', `/projects/${key}/issues?team=FRONT`),
(r) => r.body?.['total'] === 1,
);
expect(mine.body['items'][0]).toMatchObject({ key: ticket, team: 'FRONT' });
await rest('token-ana', 'POST', `/issues/${ticket}/comments`, {
body: '¿Lo miráis, @[Frontend](team-FRONT)?',
});
const told = await eventually(
() => rest('token-bob', 'GET', '/me/notifications'),
(r) =>
r.body?.items?.some(
(n: { kind: string; issueKey: string }) =>
n.kind === 'MENTIONED' && n.issueKey === ticket,
),
);
expect(
told.body.items.find((n: { kind: string }) => n.kind === 'MENTIONED')
.summary,
).toMatch(/tu equipo Frontend/);
});
}); });

View File

@@ -2654,6 +2654,18 @@
} }
} }
}, },
{
"name": "team",
"required": false,
"in": "query",
"description": "Team ids",
"schema": {
"type": "array",
"items": {
"type": "string"
}
}
},
{ {
"name": "priority", "name": "priority",
"required": false, "required": false,
@@ -11152,6 +11164,34 @@
"archived" "archived"
] ]
}, },
"TeamDto": {
"type": "object",
"properties": {
"id": {
"type": "string",
"example": "FRONTEND"
},
"name": {
"type": "string",
"example": "Frontend"
},
"memberIds": {
"type": "array",
"items": {
"type": "string"
}
},
"archived": {
"type": "boolean"
}
},
"required": [
"id",
"name",
"memberIds",
"archived"
]
},
"ProjectSettingsDto": { "ProjectSettingsDto": {
"type": "object", "type": "object",
"properties": { "properties": {
@@ -11189,6 +11229,12 @@
"checklistBeforeDone": { "checklistBeforeDone": {
"type": "boolean", "type": "boolean",
"description": "Definition of done: tickets reach DONE only with their checklist ticked" "description": "Definition of done: tickets reach DONE only with their checklist ticked"
},
"teams": {
"type": "array",
"items": {
"$ref": "#/components/schemas/TeamDto"
}
} }
}, },
"required": [ "required": [
@@ -11197,7 +11243,8 @@
"issueTypes", "issueTypes",
"areas", "areas",
"customFields", "customFields",
"checklistBeforeDone" "checklistBeforeDone",
"teams"
] ]
}, },
"ProjectDto": { "ProjectDto": {
@@ -11560,6 +11607,35 @@
"type" "type"
] ]
}, },
"TeamBodyDto": {
"type": "object",
"properties": {
"id": {
"type": "string",
"example": "FRONTEND"
},
"name": {
"type": "string",
"example": "Frontend",
"maxLength": 40
},
"memberIds": {
"description": "Pireon user ids of project members (others are dropped)",
"type": "array",
"items": {
"type": "string"
}
},
"archived": {
"type": "boolean",
"default": false
}
},
"required": [
"id",
"name"
]
},
"ProjectSettingsBodyDto": { "ProjectSettingsBodyDto": {
"type": "object", "type": "object",
"properties": { "properties": {
@@ -11595,6 +11671,13 @@
"$ref": "#/components/schemas/CustomFieldDefinitionBodyDto" "$ref": "#/components/schemas/CustomFieldDefinitionBodyDto"
} }
}, },
"teams": {
"description": "Absent: unchanged. Teams cannot be removed: archive them.",
"type": "array",
"items": {
"$ref": "#/components/schemas/TeamBodyDto"
}
},
"checklistBeforeDone": { "checklistBeforeDone": {
"type": "boolean", "type": "boolean",
"description": "Definition of done: tickets reach a DONE status only with their checklist ticked (owners and admins may override). Absent: unchanged" "description": "Definition of done: tickets reach a DONE status only with their checklist ticked (owners and admins may override). Absent: unchanged"
@@ -11899,6 +11982,11 @@
"nullable": true, "nullable": true,
"example": "FRONTEND" "example": "FRONTEND"
}, },
"team": {
"type": "string",
"nullable": true,
"description": "A team id"
},
"title": { "title": {
"type": "string" "type": "string"
}, },
@@ -12088,6 +12176,7 @@
"key", "key",
"type", "type",
"area", "area",
"team",
"title", "title",
"description", "description",
"status", "status",
@@ -13199,6 +13288,12 @@
"example": "FRONTEND", "example": "FRONTEND",
"description": "An area id of the project's settings" "description": "An area id of the project's settings"
}, },
"team": {
"type": "string",
"nullable": true,
"example": "FRONTEND",
"description": "A team id of the project's settings"
},
"priority": { "priority": {
"allOf": [ "allOf": [
{ {
@@ -13337,6 +13432,12 @@
"example": "FRONTEND", "example": "FRONTEND",
"description": "An area id of the project's settings" "description": "An area id of the project's settings"
}, },
"team": {
"type": "string",
"nullable": true,
"example": "FRONTEND",
"description": "A team id of the project's settings"
},
"priority": { "priority": {
"allOf": [ "allOf": [
{ {

View File

@@ -339,6 +339,9 @@ input CreateIssueInput {
"""When work should start (roadmap), YYYY-MM-DD""" """When work should start (roadmap), YYYY-MM-DD"""
startDate: String startDate: String
storyPoints: Int storyPoints: Int
"""A team id"""
team: String
title: String! title: String!
"""A ticket type id""" """A ticket type id"""
@@ -607,6 +610,9 @@ type Issue {
status: String! status: String!
statusCategory: StatusCategory! statusCategory: StatusCategory!
storyPoints: Int storyPoints: Int
"""A team id"""
team: String
title: String! title: String!
"""In the trash since then; null when it is not""" """In the trash since then; null when it is not"""
@@ -669,6 +675,9 @@ input IssueFilterInput {
sort: IssueSort = RECENT sort: IssueSort = RECENT
sprintId: ID sprintId: ID
statuses: [String!] statuses: [String!]
"""Team ids"""
teams: [String!]
types: [String!] types: [String!]
unassigned: Boolean unassigned: Boolean
@@ -1204,6 +1213,7 @@ type ProjectSettings {
customFields: [CustomFieldDefinition!]! customFields: [CustomFieldDefinition!]!
issueTypes: [IssueTypeOption!]! issueTypes: [IssueTypeOption!]!
statuses: [WorkflowStatus!]! statuses: [WorkflowStatus!]!
teams: [Team!]!
transitions: [StatusTransitions!]! transitions: [StatusTransitions!]!
} }
@@ -1222,6 +1232,9 @@ input ProjectSettingsInput {
customFields: [CustomFieldDefinitionInput!] customFields: [CustomFieldDefinitionInput!]
issueTypes: [IssueTypeOptionInput!]! issueTypes: [IssueTypeOptionInput!]!
statuses: [WorkflowStatusInput!]! statuses: [WorkflowStatusInput!]!
"""Absent: unchanged. Teams cannot be removed: archive them."""
teams: [TeamInput!]
transitions: [StatusTransitionsInput!]! transitions: [StatusTransitionsInput!]!
} }
@@ -1654,6 +1667,21 @@ type Subscription {
sprintChanged(projectId: ID!): SprintChange! sprintChanged(projectId: ID!): SprintChange!
} }
"""A group of members: tickets go to it; @team mentions tell them all"""
type Team {
archived: Boolean!
id: String!
memberIds: [ID!]!
name: String!
}
input TeamInput {
archived: Boolean! = false
id: String!
memberIds: [ID!]! = []
name: String!
}
type TeamLoad { type TeamLoad {
blocked: Int! blocked: Int!
openPoints: Int! openPoints: Int!
@@ -1696,6 +1724,9 @@ input UpdateIssueInput {
"""When work should start (roadmap), YYYY-MM-DD""" """When work should start (roadmap), YYYY-MM-DD"""
startDate: String startDate: String
storyPoints: Int storyPoints: Int
"""A team id"""
team: String
title: String title: String
type: String type: String
} }

View File

@@ -1777,6 +1777,14 @@ export interface components {
options: string[]; options: string[];
archived: boolean; archived: boolean;
}; };
TeamDto: {
/** @example FRONTEND */
id: string;
/** @example Frontend */
name: string;
memberIds: string[];
archived: boolean;
};
ProjectSettingsDto: { ProjectSettingsDto: {
/** @description In board order */ /** @description In board order */
statuses: components["schemas"]["WorkflowStatusDto"][]; statuses: components["schemas"]["WorkflowStatusDto"][];
@@ -1786,6 +1794,7 @@ export interface components {
customFields: components["schemas"]["CustomFieldDefinitionDto"][]; customFields: components["schemas"]["CustomFieldDefinitionDto"][];
/** @description Definition of done: tickets reach DONE only with their checklist ticked */ /** @description Definition of done: tickets reach DONE only with their checklist ticked */
checklistBeforeDone: boolean; checklistBeforeDone: boolean;
teams: components["schemas"]["TeamDto"][];
}; };
ProjectDto: { ProjectDto: {
/** Format: uuid */ /** Format: uuid */
@@ -1931,6 +1940,16 @@ export interface components {
/** @default false */ /** @default false */
archived: boolean; archived: boolean;
}; };
TeamBodyDto: {
/** @example FRONTEND */
id: string;
/** @example Frontend */
name: string;
/** @description Pireon user ids of project members (others are dropped) */
memberIds?: string[];
/** @default false */
archived: boolean;
};
ProjectSettingsBodyDto: { ProjectSettingsBodyDto: {
/** @description In board order. Values cannot be removed: archive them instead. */ /** @description In board order. Values cannot be removed: archive them instead. */
statuses: components["schemas"]["WorkflowStatusBodyDto"][]; statuses: components["schemas"]["WorkflowStatusBodyDto"][];
@@ -1939,6 +1958,8 @@ export interface components {
areas: components["schemas"]["AreaOptionBodyDto"][]; areas: components["schemas"]["AreaOptionBodyDto"][];
/** @description Absent: unchanged. Fields cannot be removed: archive them. */ /** @description Absent: unchanged. Fields cannot be removed: archive them. */
customFields?: components["schemas"]["CustomFieldDefinitionBodyDto"][]; customFields?: components["schemas"]["CustomFieldDefinitionBodyDto"][];
/** @description Absent: unchanged. Teams cannot be removed: archive them. */
teams?: components["schemas"]["TeamBodyDto"][];
/** @description Definition of done: tickets reach a DONE status only with their checklist ticked (owners and admins may override). Absent: unchanged */ /** @description Definition of done: tickets reach a DONE status only with their checklist ticked (owners and admins may override). Absent: unchanged */
checklistBeforeDone?: boolean; checklistBeforeDone?: boolean;
}; };
@@ -2051,6 +2072,8 @@ export interface components {
type: string; type: string;
/** @example FRONTEND */ /** @example FRONTEND */
area: string | null; area: string | null;
/** @description A team id */
team: string | null;
title: string; title: string;
description: string | null; description: string | null;
/** /**
@@ -2410,6 +2433,11 @@ export interface components {
* @example FRONTEND * @example FRONTEND
*/ */
area?: string | null; area?: string | null;
/**
* @description A team id of the project's settings
* @example FRONTEND
*/
team?: string | null;
priority?: components["schemas"]["IssuePriority"]; priority?: components["schemas"]["IssuePriority"];
labels?: string[]; labels?: string[];
/** @description Figma frame (https) for UI changes */ /** @description Figma frame (https) for UI changes */
@@ -2467,6 +2495,11 @@ export interface components {
* @example FRONTEND * @example FRONTEND
*/ */
area?: string | null; area?: string | null;
/**
* @description A team id of the project's settings
* @example FRONTEND
*/
team?: string | null;
priority?: components["schemas"]["IssuePriority"]; priority?: components["schemas"]["IssuePriority"];
labels?: string[]; labels?: string[];
/** @description Figma frame (https) for UI changes */ /** @description Figma frame (https) for UI changes */
@@ -4946,6 +4979,8 @@ export interface operations {
type?: string[]; type?: string[];
/** @description Area ids */ /** @description Area ids */
area?: string[]; area?: string[];
/** @description Team ids */
team?: string[];
priority?: components["schemas"]["IssuePriority"][]; priority?: components["schemas"]["IssuePriority"][];
/** @description Pireon user id */ /** @description Pireon user id */
assigneeId?: string; assigneeId?: string;

View File

@@ -37,6 +37,10 @@ const ids = (what: string) =>
const issueFields = { const issueFields = {
description: z.string().max(50000).optional(), description: z.string().max(50000).optional(),
area: z.string().optional().describe('Area id (see get_project)'), area: z.string().optional().describe('Area id (see get_project)'),
team: z
.string()
.optional()
.describe('Team id (see get_project settings.teams)'),
priority: PRIORITY.optional(), priority: PRIORITY.optional(),
labels: z.array(z.string().max(40)).max(20).optional(), labels: z.array(z.string().max(40)).max(20).optional(),
figmaUrl: z.string().url().optional().describe('Figma frame (https)'), figmaUrl: z.string().url().optional().describe('Figma frame (https)'),
@@ -57,6 +61,7 @@ const issueFields = {
type IssueFields = { type IssueFields = {
description?: string; description?: string;
area?: string; area?: string;
team?: string;
priority?: z.infer<typeof PRIORITY>; priority?: z.infer<typeof PRIORITY>;
labels?: string[]; labels?: string[];
figmaUrl?: string; figmaUrl?: string;
@@ -144,6 +149,7 @@ export class IssuesResolver {
categories: z.array(CATEGORY).optional(), categories: z.array(CATEGORY).optional(),
types: ids('Ticket type'), types: ids('Ticket type'),
areas: ids('Area'), areas: ids('Area'),
teams: ids('Team'),
priorities: z.array(PRIORITY).optional(), priorities: z.array(PRIORITY).optional(),
assigneeId: z.string().optional().describe('User id'), assigneeId: z.string().optional().describe('User id'),
unassigned: z.boolean().optional(), unassigned: z.boolean().optional(),
@@ -187,6 +193,7 @@ export class IssuesResolver {
categories?: z.infer<typeof CATEGORY>[]; categories?: z.infer<typeof CATEGORY>[];
types?: string[]; types?: string[];
areas?: string[]; areas?: string[];
teams?: string[];
priorities?: z.infer<typeof PRIORITY>[]; priorities?: z.infer<typeof PRIORITY>[];
assigneeId?: string; assigneeId?: string;
unassigned?: boolean; unassigned?: boolean;
@@ -216,6 +223,7 @@ export class IssuesResolver {
category: params.categories, category: params.categories,
type: params.types, type: params.types,
area: params.areas, area: params.areas,
team: params.teams,
priority: params.priorities, priority: params.priorities,
assigneeId: params.assigneeId, assigneeId: params.assigneeId,
unassigned: params.unassigned, unassigned: params.unassigned,

View File

@@ -127,6 +127,14 @@ nuevo proyecto se quedan fuera). Las etiquetas no tienen catálogo, así que no
Los proyectos creados antes de que existiera la configuración usan los valores por defecto (sus eventos no la 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. llevan). Al arrancar, la API rellena la categoría de los tickets del read model que no la tienen.
### Equipos
`settings.teams`: grupos de miembros (`id`, `name`, `memberIds`, `archived`), como mucho 30. Al guardar, quien no es
miembro del proyecto se quita del equipo; un equipo no se borra, se archiva; y si `teams` no viene, se quedan como
estaban. Un ticket va a un equipo con `team` (en `createIssue` / `updateIssue` y por REST), y la lista se filtra con
`teams` (`?team=FRONT`). En un comentario, `@[Frontend](team-FRONT)` avisa a cada miembro del equipo («mencionó a tu
equipo…»).
### Definición de «Hecho» ### Definición de «Hecho»
`settings.checklistBeforeDone` (desactivada por defecto): con ella, un ticket no pasa a un estado de categoría `DONE` `settings.checklistBeforeDone` (desactivada por defecto): con ella, un ticket no pasa a un estado de categoría `DONE`

View File

@@ -294,7 +294,7 @@ visualización de la plataforma:
| `/p/:key/roadmap` | Hoja de ruta: épicas en el tiempo, con sprints y releases; flechas «bloquea» / «depende de» entre épicas y aviso si una empieza antes de que acabe la que la bloquea | | `/p/:key/roadmap` | Hoja de ruta: épicas en el tiempo, con sprints y releases; flechas «bloquea» / «depende de» entre épicas y aviso si una empieza antes de que acabe la que la bloquea |
| `/p/:key/dashboard` | Seguimiento: cifras, flujo por estado, lo que ha cambiado desde tu última visita (guardada en este navegador), sprint en curso, tickets en riesgo, previsión de las próximas releases, antigüedad de lo que está en curso, cuellos de botella, salud del backlog (cada número abre la lista filtrada: `?missing=estimate`, `area`, `assignee` o `?staleDays=90`), calidad (bugs por prioridad y por semana, QA por área, bugs tras cada release), abiertos por un campo de lista a elegir, equipo con sus puntos abiertos y tipos. Se recarga solo con cada cambio | | `/p/:key/dashboard` | Seguimiento: cifras, flujo por estado, lo que ha cambiado desde tu última visita (guardada en este navegador), sprint en curso, tickets en riesgo, previsión de las próximas releases, antigüedad de lo que está en curso, cuellos de botella, salud del backlog (cada número abre la lista filtrada: `?missing=estimate`, `area`, `assignee` o `?staleDays=90`), calidad (bugs por prioridad y por semana, QA por área, bugs tras cada release), abiertos por un campo de lista a elegir, equipo con sus puntos abiertos y tipos. Se recarga solo con cada cambio |
| `/p/:key/analytics` | Estadísticas: creados vs. resueltos, flujo acumulado, burnup de una release, tiempos de ciclo y entrega, QA y resueltos por persona (30 días a 1 año) | | `/p/:key/analytics` | Estadísticas: creados vs. resueltos, flujo acumulado, burnup de una release, tiempos de ciclo y entrega, QA y resueltos por persona (30 días a 1 año) |
| `/p/:key/settings` | Configuración (propietario y admins; pestaña oculta para el resto): estados (orden, categoría, color, motivo), transiciones, tipos, áreas, definición de «Hecho» y campos personalizados; archivar en lugar de borrar | | `/p/:key/settings` | Configuración (propietario y admins; pestaña oculta para el resto): estados (orden, categoría, color, motivo), transiciones, tipos, áreas, equipos (con sus miembros), definición de «Hecho» y campos personalizados; archivar en lugar de borrar |
| `/p/:key/activity` | Actividad en vivo | | `/p/:key/activity` | Actividad en vivo |
| `/p/:key/members` | Miembros | | `/p/:key/members` | Miembros |

View File

@@ -39,7 +39,6 @@ Nada: la tanda está hecha (ver 10.5).
vista. vista.
- **Entornos de despliegue**: en qué entorno está cada ticket (pruebas, producción), desde los avisos de la - **Entornos de despliegue**: en qué entorno está cada ticket (pruebas, producción), desde los avisos de la
integración continua. integración continua.
- **Equipos dentro del proyecto**: asignar a un equipo y mencionarlo (`@frontend`).
- **Presencia**: ver quién está mirando o editando el mismo ticket. - **Presencia**: ver quién está mirando o editando el mismo ticket.
- **Rol «invitado»**: solo ve los tickets en los que participa (clientes, externos). - **Rol «invitado»**: solo ve los tickets en los que participa (clientes, externos).
- **Página pública de novedades**: las notas de versión publicadas en una página por proyecto, con RSS. - **Página pública de novedades**: las notas de versión publicadas en una página por proyecto, con RSS.
@@ -120,3 +119,5 @@ Mucho trabajo para lo que aportan hoy; se pueden recuperar si alguien los necesi
- Definición de «Hecho» por proyecto: con ella activada, un ticket no se cierra con la checklist a medias (el - Definición de «Hecho» por proyecto: con ella activada, un ticket no se cierra con la checklist a medias (el
propietario y los admins sí pueden). propietario y los admins sí pueden).
- Archivar un proyecto: queda en solo lectura (se ve todo, no cambia nada) hasta que su propietario lo desarchiva. - Archivar un proyecto: queda en solo lectura (se ve todo, no cambia nada) hasta que su propietario lo desarchiva.
- Equipos dentro del proyecto: grupos de miembros en Configuración; los tickets se asignan a uno, la lista filtra por
equipo y «@equipo» avisa a todos sus miembros.

Binary file not shown.

Binary file not shown.

View File

@@ -86,6 +86,7 @@ const FIELD: Record<string, string> = {
description: 'la descripción', description: 'la descripción',
type: 'el tipo', type: 'el tipo',
area: 'el área', area: 'el área',
team: 'el equipo',
priority: 'la prioridad', priority: 'la prioridad',
labels: 'las etiquetas', labels: 'las etiquetas',
figmaUrl: 'el enlace de Figma', figmaUrl: 'el enlace de Figma',

View File

@@ -41,6 +41,7 @@ export class IssueCreatedEventHandler
key: issue.key, key: issue.key,
type: issue.type, type: issue.type,
area: issue.area, area: issue.area,
team: issue.team ?? null,
title: issue.title, title: issue.title,
description: issue.description, description: issue.description,
status: issue.status, status: issue.status,

View File

@@ -350,6 +350,7 @@ export class IssuesService {
title: `${issue.title} (copia)`.slice(0, 255), title: `${issue.title} (copia)`.slice(0, 255),
description: issue.description, description: issue.description,
area: issue.area, area: issue.area,
team: issue.team,
priority: issue.priority, priority: issue.priority,
labels: issue.labels, labels: issue.labels,
figmaUrl: issue.figmaUrl, figmaUrl: issue.figmaUrl,

View File

@@ -8,6 +8,9 @@ import {
IssueStatusChangedEvent, IssueStatusChangedEvent,
IssueUpdatedEvent, IssueUpdatedEvent,
mentionedUserIds, mentionedUserIds,
DEFAULT_PROJECT_SETTINGS,
ProjectWorkflow,
TEAM_MENTION_PREFIX,
NotificationKind, NotificationKind,
ProjectMemberAddedEvent, ProjectMemberAddedEvent,
ProjectRole, ProjectRole,
@@ -195,7 +198,12 @@ export class NotificationsEventHandler implements IEventHandler<TrackerEvent> {
if (event instanceof CommentAddedEvent) { if (event instanceof CommentAddedEvent) {
const said = `«${excerpt(event.comment.body)}»`; const said = `«${excerpt(event.comment.body)}»`;
// Only members: a mention cannot reach someone who cannot read the ticket. // Only members: a mention cannot reach someone who cannot read the ticket.
for (const userId of mentionedUserIds(event.comment.body)) { // A team's mention (`team-FRONTEND`) reaches each of its members.
const workflow = new ProjectWorkflow(
project.settings ?? DEFAULT_PROJECT_SETTINGS,
);
const named = mentionedUserIds(event.comment.body);
for (const userId of named) {
if (!memberIds.has(userId)) continue; if (!memberIds.has(userId)) continue;
drafts.push({ drafts.push({
userId, userId,
@@ -203,6 +211,19 @@ export class NotificationsEventHandler implements IEventHandler<TrackerEvent> {
summary: `${actor} te mencionó en ${issue.key}: ${said}`, summary: `${actor} te mencionó en ${issue.key}: ${said}`,
}); });
} }
for (const id of named.filter((n) => n.startsWith(TEAM_MENTION_PREFIX))) {
const team = workflow.teams.find(
(t) => t.id === id.slice(TEAM_MENTION_PREFIX.length),
);
for (const userId of workflow.mentionedPeople([id])) {
if (!memberIds.has(userId)) continue;
drafts.push({
userId,
kind: NotificationKind.MENTIONED,
summary: `${actor} mencionó a tu equipo ${team?.name ?? ''} en ${issue.key}: ${said}`,
});
}
}
const answered = event.comment.replyTo const answered = event.comment.replyTo
? await this.findComments.findById(event.comment.replyTo) ? await this.findComments.findById(event.comment.replyTo)
: null; : null;

View File

@@ -20,6 +20,8 @@ export interface IssueFilter {
categories?: StatusCategory[]; categories?: StatusCategory[];
types?: string[]; types?: string[];
areas?: string[]; areas?: string[];
/** Team ids of the project's settings. */
teams?: string[];
priorities?: IssuePriority[]; priorities?: IssuePriority[];
assigneeId?: string; assigneeId?: string;
unassigned?: boolean; unassigned?: boolean;

View File

@@ -44,6 +44,7 @@ export class IssueFactory {
const details = normalizeIssueDetails(props); const details = normalizeIssueDetails(props);
workflow.assertActiveType(props.type); workflow.assertActiveType(props.type);
workflow.assertActiveArea(props.area); workflow.assertActiveArea(props.area);
workflow.assertActiveTeam(props.team);
const title = details.title ?? ''; const title = details.title ?? '';
assertThat(title.length > 0, 'Issue title is required'); assertThat(title.length > 0, 'Issue title is required');
if (details.startDate && details.dueDate) if (details.startDate && details.dueDate)
@@ -60,6 +61,7 @@ export class IssueFactory {
key: `${props.projectKey}-${props.number}`, key: `${props.projectKey}-${props.number}`,
type: props.type, type: props.type,
area: props.area ?? null, area: props.area ?? null,
team: props.team ?? null,
title, title,
description: details.description ?? null, description: details.description ?? null,
status: startingStatus(props.status, workflow), status: startingStatus(props.status, workflow),

View File

@@ -56,6 +56,8 @@ export interface IssueDetails {
type?: string; type?: string;
/** Id of one of the project's areas. */ /** Id of one of the project's areas. */
area?: string | null; area?: string | null;
/** Id of one of the project's teams. */
team?: string | null;
priority?: IssuePriority; priority?: IssuePriority;
labels?: string[]; labels?: string[];
figmaUrl?: string | null; figmaUrl?: string | null;
@@ -83,6 +85,8 @@ export interface IssueProps {
key: string; key: string;
type: string; type: string;
area: string | null; area: string | null;
/** Absent in tickets created before teams. */
team?: string | null;
title: string; title: string;
description: string | null; description: string | null;
/** Id of a status of the project's workflow. */ /** Id of a status of the project's workflow. */
@@ -113,6 +117,7 @@ export class Issue extends VersionedAggregateRoot implements IssueProps {
public key!: string; public key!: string;
public type!: string; public type!: string;
public area: string | null = null; public area: string | null = null;
public team: string | null = null;
public title!: string; public title!: string;
public description: string | null = null; public description: string | null = null;
public status!: string; public status!: string;
@@ -190,6 +195,7 @@ export class Issue extends VersionedAggregateRoot implements IssueProps {
assertThat(patch.title.length > 0, 'Issue title is required'); assertThat(patch.title.length > 0, 'Issue title is required');
if (patch.type !== undefined) workflow.assertActiveType(patch.type); if (patch.type !== undefined) workflow.assertActiveType(patch.type);
if (patch.area !== undefined) workflow.assertActiveArea(patch.area); if (patch.area !== undefined) workflow.assertActiveArea(patch.area);
if (patch.team !== undefined) workflow.assertActiveTeam(patch.team);
if (Object.keys(patch).length === 0) return; if (Object.keys(patch).length === 0) return;
this.apply(new IssueUpdatedEvent(actorId, this.id, this.projectId, patch)); this.apply(new IssueUpdatedEvent(actorId, this.id, this.projectId, patch));
} }

View File

@@ -403,4 +403,55 @@ describe('Project settings', () => {
}); });
expect(project.workflow.checklistBeforeDone).toBe(false); expect(project.workflow.checklistBeforeDone).toBe(false);
}); });
it('keeps teams of members, assigns tickets to them and expands their mentions', () => {
const project = newProject();
project.updateSettings('owner', {
...defaultProjectSettings(),
teams: [
{
id: 'front',
name: ' Frontend ',
memberIds: ['dev', 'dev', 'stranger'],
archived: false,
},
],
});
// Normalised, and only people of the project.
expect(project.workflow.teams).toEqual([
{ id: 'FRONT', name: 'Frontend', memberIds: ['dev'], archived: false },
]);
// Absent: unchanged; removing one: refused (archive it instead).
project.updateSettings('owner', defaultProjectSettings());
expect(project.workflow.teams).toHaveLength(1);
expect(() =>
project.updateSettings('owner', {
...defaultProjectSettings(),
teams: [],
}),
).toThrow(/archívalo/);
expect(
project.workflow.mentionedPeople(['owner', 'team-FRONT', 'team-NOPE']),
).toEqual(['owner', 'dev']);
const issue = new IssueFactory().create(
{
projectId: project.id,
projectKey: 'WEB',
number: 1,
title: 'Botón',
type: 'TASK',
reporterId: 'dev',
team: 'FRONT',
},
project.workflow,
);
expect(issue.team).toBe('FRONT');
expect(() =>
issue.updateDetails('dev', { team: 'NOPE' }, project.workflow),
).toThrow(ValidationError);
issue.updateDetails('dev', { team: null }, project.workflow);
expect(issue.team).toBeNull();
});
}); });

View File

@@ -94,6 +94,22 @@ export interface AreaOption {
archived: boolean; archived: boolean;
} }
/**
* A group of the project's members (Frontend, QA…): tickets can go to it and
* `@[Frontend](team-FRONTEND)` tells all of them.
*/
export interface TeamDefinition {
id: string;
name: string;
/** User ids; people who leave the project are dropped. */
memberIds: string[];
archived: boolean;
}
/** Prefix of a team's id in a mention: `@[Frontend](team-FRONTEND)`. */
export const TEAM_MENTION_PREFIX = 'team-';
export const MAX_TEAMS = 30;
/** What a custom field holds. */ /** What a custom field holds. */
export enum CustomFieldType { export enum CustomFieldType {
TEXT = 'TEXT', TEXT = 'TEXT',
@@ -140,6 +156,8 @@ export interface ProjectSettings {
* off; when saving, absent means unchanged. * off; when saving, absent means unchanged.
*/ */
checklistBeforeDone?: boolean; checklistBeforeDone?: boolean;
/** Absent in projects that never defined any; when saving, absent means unchanged. */
teams?: TeamDefinition[];
} }
const status = ( const status = (
@@ -369,6 +387,34 @@ export class ProjectWorkflow {
); );
} }
/** The project's teams, archived ones included. */
get teams(): TeamDefinition[] {
return this.settings.teams ?? [];
}
assertActiveTeam(id: string | null | undefined): void {
if (id === null || id === undefined) return;
const team = this.teams.find((t) => t.id === id);
if (!team) throw new ValidationError(`Unknown team ${id}`);
if (team.archived)
throw new BusinessRuleError(`El equipo ${team.name} está archivado`);
}
/** Who a mention id reaches: the person, or every member of the team. */
mentionedPeople(mentionIds: string[]): string[] {
return [
...new Set(
mentionIds.flatMap((id) =>
id.startsWith(TEAM_MENTION_PREFIX)
? (this.teams.find(
(t) => t.id === id.slice(TEAM_MENTION_PREFIX.length),
)?.memberIds ?? [])
: [id],
),
),
];
}
assertActiveArea(id: string | null | undefined): void { assertActiveArea(id: string | null | undefined): void {
if (id === null || id === undefined) return; if (id === null || id === undefined) return;
const area = this.settings.areas.find((a) => a.id === id); const area = this.settings.areas.find((a) => a.id === id);
@@ -414,6 +460,20 @@ export function validateProjectSettings(
name: normName(a.name), name: normName(a.name),
archived: !!a.archived, archived: !!a.archived,
})); }));
const teams = (next.teams ?? previous.teams ?? []).map((t) => ({
id: normId(t.id),
name: normName(t.name),
memberIds: [
...new Set(
(t.memberIds ?? []).map((m) => String(m).trim()).filter(Boolean),
),
].slice(0, 200),
archived: !!t.archived,
}));
assertThat(
teams.length <= MAX_TEAMS,
`Un proyecto tiene como mucho ${MAX_TEAMS} equipos`,
);
const customFields = (next.customFields ?? previous.customFields ?? []).map( const customFields = (next.customFields ?? previous.customFields ?? []).map(
normCustomField, normCustomField,
); );
@@ -434,6 +494,7 @@ export function validateProjectSettings(
['tipo', issueTypes, previous.issueTypes], ['tipo', issueTypes, previous.issueTypes],
['área', areas, previous.areas], ['área', areas, previous.areas],
['campo', customFields, previous.customFields ?? []], ['campo', customFields, previous.customFields ?? []],
['equipo', teams, previous.teams ?? []],
] as const) { ] as const) {
const ids = list.map((item) => item.id); const ids = list.map((item) => item.id);
const duplicate = ids.find((id, i) => ids.indexOf(id) !== i); const duplicate = ids.find((id, i) => ids.indexOf(id) !== i);
@@ -517,6 +578,7 @@ export function validateProjectSettings(
...((next.checklistBeforeDone ?? previous.checklistBeforeDone) ...((next.checklistBeforeDone ?? previous.checklistBeforeDone)
? { checklistBeforeDone: true } ? { checklistBeforeDone: true }
: {}), : {}),
...(teams.length ? { teams } : {}),
}; };
} }

View File

@@ -216,7 +216,17 @@ export class Project extends VersionedAggregateRoot implements ProjectProps {
/** Owners and admins customise statuses, transitions, types and areas. */ /** Owners and admins customise statuses, transitions, types and areas. */
updateSettings(actorId: string, next: ProjectSettings): void { updateSettings(actorId: string, next: ProjectSettings): void {
this.assertCanManage(actorId); this.assertCanManage(actorId);
const settings = validateProjectSettings(next, this.settings); const validated = validateProjectSettings(next, this.settings);
// A team only holds people of the project.
const settings = validated.teams
? {
...validated,
teams: validated.teams.map((t) => ({
...t,
memberIds: t.memberIds.filter((id) => this.isMember(id)),
})),
}
: validated;
if (JSON.stringify(settings) === JSON.stringify(this.settings)) return; if (JSON.stringify(settings) === JSON.stringify(this.settings)) return;
this.apply(new ProjectSettingsChangedEvent(actorId, this.id, settings)); this.apply(new ProjectSettingsChangedEvent(actorId, this.id, settings));
} }

View File

@@ -62,6 +62,8 @@ export class IssueReadModel {
/** Ids from the project's settings. */ /** Ids from the project's settings. */
type!: string; type!: string;
area!: string | null; area!: string | null;
/** A team id of the project's settings. */
team!: string | null;
title!: string; title!: string;
description!: string | null; description!: string | null;
status!: string; status!: string;

View File

@@ -451,6 +451,7 @@ function toMongoFilter(filter: IssueFilter): FilterQuery<IssueDoc> {
query['statusCategory'] = { $in: filter.categories }; query['statusCategory'] = { $in: filter.categories };
if (filter.types?.length) query['type'] = { $in: filter.types }; if (filter.types?.length) query['type'] = { $in: filter.types };
if (filter.areas?.length) query['area'] = { $in: filter.areas }; if (filter.areas?.length) query['area'] = { $in: filter.areas };
if (filter.teams?.length) query['team'] = { $in: filter.teams };
if (filter.priorities?.length) query['priority'] = { $in: filter.priorities }; if (filter.priorities?.length) query['priority'] = { $in: filter.priorities };
if (filter.labels?.length) if (filter.labels?.length)
query.labels = { $all: filter.labels.map((l) => l.toLowerCase()) }; query.labels = { $all: filter.labels.map((l) => l.toLowerCase()) };

View File

@@ -428,6 +428,7 @@ export function issueFromDoc(doc: IssueDoc): IssueReadModel {
parent: rest.parent?.id ? rest.parent : null, parent: rest.parent?.id ? rest.parent : null,
rank: rest.rank ?? implicitRank(rest.number), rank: rest.rank ?? implicitRank(rest.number),
startDate: rest.startDate ?? null, startDate: rest.startDate ?? null,
team: rest.team ?? null,
customFields: rest.customFields ?? {}, customFields: rest.customFields ?? {},
sprint: rest.sprint?.id ? rest.sprint : null, sprint: rest.sprint?.id ? rest.sprint : null,
// Links recorded before projects had several repositories name none. // Links recorded before projects had several repositories name none.

View File

@@ -27,6 +27,13 @@ export interface SettingsPayload {
template?: { description?: string | null; checklist?: string[] } | null; template?: { description?: string | null; checklist?: string[] } | null;
}[]; }[];
areas: { id: string; name: string; archived: boolean }[]; areas: { id: string; name: string; archived: boolean }[];
/** Absent when saving: unchanged. */
teams?: {
id: string;
name: string;
memberIds: string[];
archived: boolean;
}[];
/** Definition of done. Absent when saving: unchanged. */ /** Definition of done. Absent when saving: unchanged. */
checklistBeforeDone?: boolean; checklistBeforeDone?: boolean;
/** Absent when saving: unchanged. */ /** Absent when saving: unchanged. */
@@ -44,6 +51,7 @@ export function toSettingsPayload(settings: ProjectSettings): SettingsPayload {
...settings, ...settings,
customFields: settings.customFields ?? [], customFields: settings.customFields ?? [],
checklistBeforeDone: !!settings.checklistBeforeDone, checklistBeforeDone: !!settings.checklistBeforeDone,
teams: settings.teams ?? [],
transitions: settings.statuses.map((s) => ({ transitions: settings.statuses.map((s) => ({
from: s.id, from: s.id,
to: settings.transitions[s.id] ?? [], to: settings.transitions[s.id] ?? [],
@@ -75,6 +83,7 @@ export function fromSettingsPayload(payload: SettingsPayload): ProjectSettings {
: {}), : {}),
})), })),
areas: payload.areas, areas: payload.areas,
...(payload.teams ? { teams: payload.teams } : {}),
...(payload.checklistBeforeDone != null ...(payload.checklistBeforeDone != null
? { checklistBeforeDone: payload.checklistBeforeDone } ? { checklistBeforeDone: payload.checklistBeforeDone }
: {}), : {}),

View File

@@ -128,6 +128,11 @@ abstract class IssueFieldsInput {
@Matches(SETTING_ID) @Matches(SETTING_ID)
area?: string | null; area?: string | null;
@Field(() => String, { nullable: true, description: 'A team id' })
@IsOptional()
@Matches(SETTING_ID)
team?: string | null;
@Field(() => IssuePriority, { nullable: true }) @Field(() => IssuePriority, { nullable: true })
@IsOptional() @IsOptional()
@IsEnum(IssuePriority) @IsEnum(IssuePriority)
@@ -515,6 +520,10 @@ export class IssueFilterInput {
@IsOptional() @IsOptional()
@IsString({ each: true }) @IsString({ each: true })
areas?: string[]; areas?: string[];
@Field(() => [String], { nullable: true, description: 'Team ids' })
@IsOptional()
@IsString({ each: true })
teams?: string[];
@Field(() => [IssuePriority], { nullable: true }) @Field(() => [IssuePriority], { nullable: true })
@IsOptional() @IsOptional()
@IsEnum(IssuePriority, { each: true }) @IsEnum(IssuePriority, { each: true })
@@ -712,6 +721,18 @@ export class AreaOptionInput {
@Field({ defaultValue: false }) @IsBoolean() archived!: boolean; @Field({ defaultValue: false }) @IsBoolean() archived!: boolean;
} }
@InputType()
export class TeamInput {
@Field() @Matches(SETTING_ID) id!: string;
@Field() @IsString() @MinLength(1) @MaxLength(40) name!: string;
@Field(() => [ID], { defaultValue: [] })
@IsArray()
@ArrayMaxSize(200)
@IsString({ each: true })
memberIds!: string[];
@Field({ defaultValue: false }) @IsBoolean() archived!: boolean;
}
@InputType() @InputType()
export class CustomFieldDefinitionInput { export class CustomFieldDefinitionInput {
@Field() @Matches(SETTING_ID) id!: string; @Field() @Matches(SETTING_ID) id!: string;
@@ -773,6 +794,17 @@ export class ProjectSettingsInput {
@Type(() => CustomFieldDefinitionInput) @Type(() => CustomFieldDefinitionInput)
customFields?: CustomFieldDefinitionInput[]; customFields?: CustomFieldDefinitionInput[];
@Field(() => [TeamInput], {
nullable: true,
description: 'Absent: unchanged. Teams cannot be removed: archive them.',
})
@IsOptional()
@IsArray()
@ArrayMaxSize(30)
@ValidateNested({ each: true })
@Type(() => TeamInput)
teams?: TeamInput[];
@Field({ @Field({
nullable: true, nullable: true,
description: description:

View File

@@ -104,6 +104,17 @@ export class CustomFieldValueGql {
@Field() value!: string; @Field() value!: string;
} }
@ObjectType('Team', {
description:
'A group of members: tickets go to it; @team mentions tell them all',
})
export class TeamGql {
@Field() id!: string;
@Field() name!: string;
@Field(() => [ID]) memberIds!: string[];
@Field() archived!: boolean;
}
@ObjectType('ProjectSettings', { @ObjectType('ProjectSettings', {
description: description:
'Statuses (board order), transitions, ticket types, areas and custom fields', 'Statuses (board order), transitions, ticket types, areas and custom fields',
@@ -120,6 +131,7 @@ export class ProjectSettingsGql {
'Definition of done: tickets reach DONE only with their checklist ticked (owners and admins may override)', 'Definition of done: tickets reach DONE only with their checklist ticked (owners and admins may override)',
}) })
checklistBeforeDone!: boolean; checklistBeforeDone!: boolean;
@Field(() => [TeamGql]) teams!: TeamGql[];
} }
@ObjectType('Project') @ObjectType('Project')
@@ -366,6 +378,8 @@ export class IssueGql {
type!: string; type!: string;
@Field(() => String, { nullable: true, description: 'An area id' }) @Field(() => String, { nullable: true, description: 'An area id' })
area!: string | null; area!: string | null;
@Field(() => String, { nullable: true, description: 'A team id' })
team!: string | null;
@Field() title!: string; @Field() title!: string;
@Field(() => String, { nullable: true }) description!: string | null; @Field(() => String, { nullable: true }) description!: string | null;
@Field({ description: "A status id of the project's settings" }) @Field({ description: "A status id of the project's settings" })

View File

@@ -243,6 +243,28 @@ export class AreaOptionBodyDto {
archived = false; archived = false;
} }
export class TeamBodyDto {
@ApiProperty({ example: 'FRONTEND' }) @Matches(SETTING_ID) id!: string;
@ApiProperty({ example: 'Frontend', maxLength: 40 })
@IsString()
@MinLength(1)
@MaxLength(40)
name!: string;
@ApiPropertyOptional({
type: [String],
description: 'Pireon user ids of project members (others are dropped)',
})
@IsOptional()
@IsArray()
@ArrayMaxSize(200)
@IsString({ each: true })
memberIds: string[] = [];
@ApiPropertyOptional({ type: Boolean, default: false })
@IsOptional()
@IsBoolean()
archived = false;
}
export class CustomFieldDefinitionBodyDto { export class CustomFieldDefinitionBodyDto {
@ApiProperty({ example: 'BROWSER' }) @Matches(SETTING_ID) id!: string; @ApiProperty({ example: 'BROWSER' }) @Matches(SETTING_ID) id!: string;
@ApiProperty({ example: 'Navegador', maxLength: 40 }) @ApiProperty({ example: 'Navegador', maxLength: 40 })
@@ -319,6 +341,17 @@ export class ProjectSettingsBodyDto {
@Type(() => CustomFieldDefinitionBodyDto) @Type(() => CustomFieldDefinitionBodyDto)
customFields?: CustomFieldDefinitionBodyDto[]; customFields?: CustomFieldDefinitionBodyDto[];
@ApiPropertyOptional({
type: [TeamBodyDto],
description: 'Absent: unchanged. Teams cannot be removed: archive them.',
})
@IsOptional()
@IsArray()
@ArrayMaxSize(30)
@ValidateNested({ each: true })
@Type(() => TeamBodyDto)
teams?: TeamBodyDto[];
@ApiPropertyOptional({ @ApiPropertyOptional({
description: description:
'Definition of done: tickets reach a DONE status only with their checklist ticked (owners and admins may override). Absent: unchanged', 'Definition of done: tickets reach a DONE status only with their checklist ticked (owners and admins may override). Absent: unchanged',
@@ -584,6 +617,12 @@ export class IssueSearchQueryDto extends PageQueryDto {
@Matches(SETTING_ID, { each: true }) @Matches(SETTING_ID, { each: true })
area?: string[]; area?: string[];
@ApiPropertyOptional({ type: [String], description: 'Team ids' })
@IsOptional()
@Transform(toList)
@Matches(SETTING_ID, { each: true })
team?: string[];
@ApiPropertyOptional({ @ApiPropertyOptional({
enum: IssuePriority, enum: IssuePriority,
enumName: 'IssuePriority', enumName: 'IssuePriority',
@@ -729,6 +768,16 @@ class IssueFieldsDto {
@Matches(SETTING_ID) @Matches(SETTING_ID)
area?: string | null; area?: string | null;
@ApiPropertyOptional({
type: String,
nullable: true,
example: 'FRONTEND',
description: "A team id of the project's settings",
})
@IsOptional()
@Matches(SETTING_ID)
team?: string | null;
@ApiPropertyOptional({ enum: IssuePriority, enumName: 'IssuePriority' }) @ApiPropertyOptional({ enum: IssuePriority, enumName: 'IssuePriority' })
@IsOptional() @IsOptional()
@IsEnum(IssuePriority) @IsEnum(IssuePriority)

View File

@@ -79,6 +79,13 @@ export class AreaOptionDto {
@ApiProperty() archived!: boolean; @ApiProperty() archived!: boolean;
} }
export class TeamDto {
@ApiProperty({ example: 'FRONTEND' }) id!: string;
@ApiProperty({ example: 'Frontend' }) name!: string;
@ApiProperty({ type: [String] }) memberIds!: string[];
@ApiProperty() archived!: boolean;
}
export class CustomFieldDefinitionDto { export class CustomFieldDefinitionDto {
@ApiProperty({ example: 'BROWSER' }) id!: string; @ApiProperty({ example: 'BROWSER' }) id!: string;
@ApiProperty({ example: 'Navegador' }) name!: string; @ApiProperty({ example: 'Navegador' }) name!: string;
@@ -108,6 +115,7 @@ export class ProjectSettingsDto {
'Definition of done: tickets reach DONE only with their checklist ticked', 'Definition of done: tickets reach DONE only with their checklist ticked',
}) })
checklistBeforeDone!: boolean; checklistBeforeDone!: boolean;
@ApiProperty({ type: [TeamDto] }) teams!: TeamDto[];
} }
export class ProjectDto { export class ProjectDto {
@@ -422,6 +430,8 @@ export class IssueDto {
type!: string; type!: string;
@ApiProperty({ type: String, nullable: true, example: 'FRONTEND' }) @ApiProperty({ type: String, nullable: true, example: 'FRONTEND' })
area!: string | null; area!: string | null;
@ApiProperty({ type: String, nullable: true, description: 'A team id' })
team!: string | null;
@ApiProperty() title!: string; @ApiProperty() title!: string;
@ApiProperty({ type: String, nullable: true }) description!: string | null; @ApiProperty({ type: String, nullable: true }) description!: string | null;
@ApiProperty({ example: 'IN_PROGRESS', description: 'A status id' }) @ApiProperty({ example: 'IN_PROGRESS', description: 'A status id' })

View File

@@ -165,6 +165,7 @@ export class IssuesController {
categories: query.category, categories: query.category,
types: query.type, types: query.type,
areas: query.area, areas: query.area,
teams: query.team,
priorities: query.priority, priorities: query.priority,
assigneeId: query.assigneeId, assigneeId: query.assigneeId,
unassigned: query.unassigned, unassigned: query.unassigned,

View File

@@ -234,6 +234,7 @@ describe('ProjectFacade', () => {
issueTypes: [], issueTypes: [],
areas: [], areas: [],
customFields: [], customFields: [],
teams: [],
}; };
project.saveSettings(settings); project.saveSettings(settings);

View File

@@ -1,6 +1,7 @@
import { computed, inject, Injectable, Provider } from '@angular/core'; import { computed, inject, Injectable, Provider } from '@angular/core';
import { injectDispatch } from '@ngrx/signals/events'; import { injectDispatch } from '@ngrx/signals/events';
import { import {
TEAM_MENTION_PREFIX,
CreateReleaseInput, CreateReleaseInput,
CreateSprintInput, CreateSprintInput,
ProjectRole, ProjectRole,
@@ -25,6 +26,20 @@ export class ProjectFacade {
readonly project = this.store.project; readonly project = this.store.project;
readonly members = this.store.members; readonly members = this.store.members;
/**
* Who `@` offers: the members, then the active teams (a team's mention,
* `team-<id>`, tells each of its members).
*/
readonly mentionable = computed(() => [
...this.store.members().map((m) => m.user),
...this.store
.workflow()
.activeTeams()
.map((t) => ({
id: `${TEAM_MENTION_PREFIX}${t.id}`,
name: `${t.name} · equipo`,
})),
]);
readonly stats = this.store.stats; readonly stats = this.store.stats;
readonly releases = this.store.releases; readonly releases = this.store.releases;
/** Under way, planned, then closed; with their progress. */ /** Under way, planned, then closed; with their progress. */

View File

@@ -46,6 +46,7 @@ export interface CreateIssueInput {
title: string; title: string;
description?: string | null; description?: string | null;
area?: string | null; area?: string | null;
team?: string | null;
priority?: IssuePriority; priority?: IssuePriority;
labels?: string[]; labels?: string[];
figmaUrl?: string | null; figmaUrl?: string | null;
@@ -98,6 +99,7 @@ export interface IssueListFilter {
search?: string; search?: string;
categories?: StatusCategory[]; categories?: StatusCategory[];
types?: string[]; types?: string[];
teams?: string[];
priorities?: IssuePriority[]; priorities?: IssuePriority[];
assigneeId?: string; assigneeId?: string;
unassigned?: boolean; unassigned?: boolean;

View File

@@ -131,6 +131,8 @@ export interface IssueSummary {
/** Ids of the project's settings. */ /** Ids of the project's settings. */
type: string; type: string;
area: string | null; area: string | null;
/** A team id of the project's settings. */
team: string | null;
title: string; title: string;
status: string; status: string;
statusCategory: StatusCategory; statusCategory: StatusCategory;

View File

@@ -84,6 +84,17 @@ export interface CustomFieldDefinition {
archived: boolean; archived: boolean;
} }
/** A group of the project's members; `@[Name](team-ID)` tells them all. */
export interface TeamDefinition {
id: string;
name: string;
memberIds: string[];
archived: boolean;
}
/** Prefix of a team's id in a mention. */
export const TEAM_MENTION_PREFIX = 'team-';
/** A ticket's value of a custom field. */ /** A ticket's value of a custom field. */
export interface CustomFieldValue { export interface CustomFieldValue {
fieldId: string; fieldId: string;
@@ -99,6 +110,7 @@ export interface ProjectSettings {
customFields: CustomFieldDefinition[]; customFields: CustomFieldDefinition[];
/** Definition of done: a DONE status only with the checklist ticked. */ /** Definition of done: a DONE status only with the checklist ticked. */
checklistBeforeDone?: boolean; checklistBeforeDone?: boolean;
teams: TeamDefinition[];
} }
export const FINISHED_CATEGORIES: readonly StatusCategory[] = [ export const FINISHED_CATEGORIES: readonly StatusCategory[] = [
@@ -134,6 +146,7 @@ const EMPTY: ProjectSettings = {
issueTypes: [], issueTypes: [],
areas: [], areas: [],
customFields: [], customFields: [],
teams: [],
}; };
/** /**
@@ -187,6 +200,15 @@ export class Workflow {
return this.settings.areas.filter((a) => !a.archived); return this.settings.areas.filter((a) => !a.archived);
} }
/** Teams to pick (archived ones only show where a ticket still has them). */
activeTeams(): TeamDefinition[] {
return (this.settings.teams ?? []).filter((t) => !t.archived);
}
team(id: string): TeamDefinition | undefined {
return this.settings.teams?.find((t) => t.id === id);
}
/** Custom fields to fill in (archived ones only show where they have a value). */ /** Custom fields to fill in (archived ones only show where they have a value). */
activeCustomFields(): CustomFieldDefinition[] { activeCustomFields(): CustomFieldDefinition[] {
return (this.settings.customFields ?? []).filter((f) => !f.archived); return (this.settings.customFields ?? []).filter((f) => !f.archived);

View File

@@ -113,5 +113,11 @@ export function toSettingsInput(
archived: f.archived, archived: f.archived,
})), })),
checklistBeforeDone: !!settings.checklistBeforeDone, checklistBeforeDone: !!settings.checklistBeforeDone,
teams: (settings.teams ?? []).map((t) => ({
id: t.id,
name: t.name,
memberIds: t.memberIds,
archived: t.archived,
})),
}; };
} }

View File

@@ -60,6 +60,12 @@ fragment ProjectFields on Project {
archived archived
} }
checklistBeforeDone checklistBeforeDone
teams {
id
name
memberIds
archived
}
} }
} }
@@ -69,6 +75,7 @@ fragment IssueSummaryFields on Issue {
key key
type type
area area
team
title title
status status
statusCategory statusCategory

View File

@@ -104,6 +104,8 @@ export type CreateIssueInput = {
/** When work should start (roadmap), YYYY-MM-DD */ /** When work should start (roadmap), YYYY-MM-DD */
startDate?: string | null | undefined; startDate?: string | null | undefined;
storyPoints?: number | null | undefined; storyPoints?: number | null | undefined;
/** A team id */
team?: string | null | undefined;
title: string; title: string;
/** A ticket type id */ /** A ticket type id */
type: string; type: string;
@@ -194,6 +196,8 @@ export type IssueFilterInput = {
sort?: IssueSort | null | undefined; sort?: IssueSort | null | undefined;
sprintId?: string | null | undefined; sprintId?: string | null | undefined;
statuses?: Array<string> | null | undefined; statuses?: Array<string> | null | undefined;
/** Team ids */
teams?: Array<string> | null | undefined;
types?: Array<string> | null | undefined; types?: Array<string> | null | undefined;
unassigned?: boolean | null | undefined; unassigned?: boolean | null | undefined;
/** Only tickets not updated since this instant (ISO) */ /** Only tickets not updated since this instant (ISO) */
@@ -279,6 +283,8 @@ export type ProjectSettingsInput = {
customFields?: Array<CustomFieldDefinitionInput> | null | undefined; customFields?: Array<CustomFieldDefinitionInput> | null | undefined;
issueTypes: Array<IssueTypeOptionInput>; issueTypes: Array<IssueTypeOptionInput>;
statuses: Array<WorkflowStatusInput>; statuses: Array<WorkflowStatusInput>;
/** Absent: unchanged. Teams cannot be removed: archive them. */
teams?: Array<TeamInput> | null | undefined;
transitions: Array<StatusTransitionsInput>; transitions: Array<StatusTransitionsInput>;
}; };
@@ -339,6 +345,13 @@ export type StatusTransitionsInput = {
to: Array<string>; to: Array<string>;
}; };
export type TeamInput = {
archived?: boolean;
id: string;
memberIds?: Array<string>;
name: string;
};
export type UpdateChecklistItemInput = { export type UpdateChecklistItemInput = {
done?: boolean | null | undefined; done?: boolean | null | undefined;
issueId: string; issueId: string;
@@ -361,6 +374,8 @@ export type UpdateIssueInput = {
/** When work should start (roadmap), YYYY-MM-DD */ /** When work should start (roadmap), YYYY-MM-DD */
startDate?: string | null | undefined; startDate?: string | null | undefined;
storyPoints?: number | null | undefined; storyPoints?: number | null | undefined;
/** A team id */
team?: string | null | undefined;
title?: string | null | undefined; title?: string | null | undefined;
type?: string | null | undefined; type?: string | null | undefined;
}; };
@@ -560,6 +575,12 @@ export type ProjectFieldsFragment = {
options: Array<string>; options: Array<string>;
archived: boolean; archived: boolean;
}>; }>;
teams: Array<{
id: string;
name: string;
memberIds: Array<string>;
archived: boolean;
}>;
}; };
}; };
@@ -569,6 +590,7 @@ export type IssueSummaryFieldsFragment = {
key: string; key: string;
type: string; type: string;
area: string | null; area: string | null;
team: string | null;
title: string; title: string;
status: string; status: string;
statusCategory: StatusCategory; statusCategory: StatusCategory;
@@ -602,6 +624,7 @@ export type IssueFieldsFragment = {
key: string; key: string;
type: string; type: string;
area: string | null; area: string | null;
team: string | null;
title: string; title: string;
status: string; status: string;
statusCategory: StatusCategory; statusCategory: StatusCategory;
@@ -780,6 +803,7 @@ export type BoardQuery = {
key: string; key: string;
type: string; type: string;
area: string | null; area: string | null;
team: string | null;
title: string; title: string;
status: string; status: string;
statusCategory: StatusCategory; statusCategory: StatusCategory;
@@ -821,6 +845,7 @@ export type IssueQuery = {
key: string; key: string;
type: string; type: string;
area: string | null; area: string | null;
team: string | null;
title: string; title: string;
status: string; status: string;
statusCategory: StatusCategory; statusCategory: StatusCategory;
@@ -886,6 +911,7 @@ export type SearchIssuesQuery = {
key: string; key: string;
type: string; type: string;
area: string | null; area: string | null;
team: string | null;
title: string; title: string;
status: string; status: string;
statusCategory: StatusCategory; statusCategory: StatusCategory;
@@ -1059,6 +1085,7 @@ export type MyIssuesQuery = {
key: string; key: string;
type: string; type: string;
area: string | null; area: string | null;
team: string | null;
title: string; title: string;
status: string; status: string;
statusCategory: StatusCategory; statusCategory: StatusCategory;
@@ -1096,6 +1123,7 @@ export type IssueChildrenQuery = {
key: string; key: string;
type: string; type: string;
area: string | null; area: string | null;
team: string | null;
title: string; title: string;
status: string; status: string;
statusCategory: StatusCategory; statusCategory: StatusCategory;
@@ -1133,6 +1161,7 @@ export type EpicsQuery = {
key: string; key: string;
type: string; type: string;
area: string | null; area: string | null;
team: string | null;
title: string; title: string;
status: string; status: string;
statusCategory: StatusCategory; statusCategory: StatusCategory;
@@ -1218,6 +1247,7 @@ export type BacklogQuery = {
key: string; key: string;
type: string; type: string;
area: string | null; area: string | null;
team: string | null;
title: string; title: string;
status: string; status: string;
statusCategory: StatusCategory; statusCategory: StatusCategory;
@@ -1289,6 +1319,7 @@ export type TrashQuery = {
key: string; key: string;
type: string; type: string;
area: string | null; area: string | null;
team: string | null;
title: string; title: string;
status: string; status: string;
statusCategory: StatusCategory; statusCategory: StatusCategory;
@@ -1325,6 +1356,7 @@ export type DueIssuesQuery = {
key: string; key: string;
type: string; type: string;
area: string | null; area: string | null;
team: string | null;
title: string; title: string;
status: string; status: string;
statusCategory: StatusCategory; statusCategory: StatusCategory;
@@ -1364,6 +1396,7 @@ export type IssueListQuery = {
key: string; key: string;
type: string; type: string;
area: string | null; area: string | null;
team: string | null;
title: string; title: string;
status: string; status: string;
statusCategory: StatusCategory; statusCategory: StatusCategory;
@@ -1559,6 +1592,12 @@ export type MyProjectsQuery = {
options: Array<string>; options: Array<string>;
archived: boolean; archived: boolean;
}>; }>;
teams: Array<{
id: string;
name: string;
memberIds: Array<string>;
archived: boolean;
}>;
}; };
}>; }>;
}; };
@@ -1611,6 +1650,12 @@ export type ProjectQuery = {
options: Array<string>; options: Array<string>;
archived: boolean; archived: boolean;
}>; }>;
teams: Array<{
id: string;
name: string;
memberIds: Array<string>;
archived: boolean;
}>;
}; };
}; };
}; };
@@ -1773,6 +1818,12 @@ export type PublicProjectsQuery = {
options: Array<string>; options: Array<string>;
archived: boolean; archived: boolean;
}>; }>;
teams: Array<{
id: string;
name: string;
memberIds: Array<string>;
archived: boolean;
}>;
}; };
}>; }>;
}; };
@@ -2331,6 +2382,7 @@ export type IssueChangedSubscription = {
key: string; key: string;
type: string; type: string;
area: string | null; area: string | null;
team: string | null;
title: string; title: string;
status: string; status: string;
statusCategory: StatusCategory; statusCategory: StatusCategory;
@@ -2466,6 +2518,12 @@ export type ProjectChangedSubscription = {
options: Array<string>; options: Array<string>;
archived: boolean; archived: boolean;
}>; }>;
teams: Array<{
id: string;
name: string;
memberIds: Array<string>;
archived: boolean;
}>;
}; };
}; };
}; };
@@ -2521,6 +2579,12 @@ export type MyProjectsChangedSubscription = {
options: Array<string>; options: Array<string>;
archived: boolean; archived: boolean;
}>; }>;
teams: Array<{
id: string;
name: string;
memberIds: Array<string>;
archived: boolean;
}>;
}; };
}; };
}; };
@@ -2909,6 +2973,25 @@ export const ProjectFieldsFragmentDoc = {
kind: 'Field', kind: 'Field',
name: { kind: 'Name', value: 'checklistBeforeDone' }, name: { kind: 'Name', value: 'checklistBeforeDone' },
}, },
{
kind: 'Field',
name: { kind: 'Name', value: 'teams' },
selectionSet: {
kind: 'SelectionSet',
selections: [
{ kind: 'Field', name: { kind: 'Name', value: 'id' } },
{ kind: 'Field', name: { kind: 'Name', value: 'name' } },
{
kind: 'Field',
name: { kind: 'Name', value: 'memberIds' },
},
{
kind: 'Field',
name: { kind: 'Name', value: 'archived' },
},
],
},
},
], ],
}, },
}, },
@@ -2950,6 +3033,7 @@ export const IssueSummaryFieldsFragmentDoc = {
{ kind: 'Field', name: { kind: 'Name', value: 'key' } }, { kind: 'Field', name: { kind: 'Name', value: 'key' } },
{ kind: 'Field', name: { kind: 'Name', value: 'type' } }, { kind: 'Field', name: { kind: 'Name', value: 'type' } },
{ kind: 'Field', name: { kind: 'Name', value: 'area' } }, { kind: 'Field', name: { kind: 'Name', value: 'area' } },
{ kind: 'Field', name: { kind: 'Name', value: 'team' } },
{ kind: 'Field', name: { kind: 'Name', value: 'title' } }, { kind: 'Field', name: { kind: 'Name', value: 'title' } },
{ kind: 'Field', name: { kind: 'Name', value: 'status' } }, { kind: 'Field', name: { kind: 'Name', value: 'status' } },
{ kind: 'Field', name: { kind: 'Name', value: 'statusCategory' } }, { kind: 'Field', name: { kind: 'Name', value: 'statusCategory' } },
@@ -3209,6 +3293,7 @@ export const IssueFieldsFragmentDoc = {
{ kind: 'Field', name: { kind: 'Name', value: 'key' } }, { kind: 'Field', name: { kind: 'Name', value: 'key' } },
{ kind: 'Field', name: { kind: 'Name', value: 'type' } }, { kind: 'Field', name: { kind: 'Name', value: 'type' } },
{ kind: 'Field', name: { kind: 'Name', value: 'area' } }, { kind: 'Field', name: { kind: 'Name', value: 'area' } },
{ kind: 'Field', name: { kind: 'Name', value: 'team' } },
{ kind: 'Field', name: { kind: 'Name', value: 'title' } }, { kind: 'Field', name: { kind: 'Name', value: 'title' } },
{ kind: 'Field', name: { kind: 'Name', value: 'status' } }, { kind: 'Field', name: { kind: 'Name', value: 'status' } },
{ kind: 'Field', name: { kind: 'Name', value: 'statusCategory' } }, { kind: 'Field', name: { kind: 'Name', value: 'statusCategory' } },
@@ -4986,6 +5071,7 @@ export const BoardDocument = {
{ kind: 'Field', name: { kind: 'Name', value: 'key' } }, { kind: 'Field', name: { kind: 'Name', value: 'key' } },
{ kind: 'Field', name: { kind: 'Name', value: 'type' } }, { kind: 'Field', name: { kind: 'Name', value: 'type' } },
{ kind: 'Field', name: { kind: 'Name', value: 'area' } }, { kind: 'Field', name: { kind: 'Name', value: 'area' } },
{ kind: 'Field', name: { kind: 'Name', value: 'team' } },
{ kind: 'Field', name: { kind: 'Name', value: 'title' } }, { kind: 'Field', name: { kind: 'Name', value: 'title' } },
{ kind: 'Field', name: { kind: 'Name', value: 'status' } }, { kind: 'Field', name: { kind: 'Name', value: 'status' } },
{ kind: 'Field', name: { kind: 'Name', value: 'statusCategory' } }, { kind: 'Field', name: { kind: 'Name', value: 'statusCategory' } },
@@ -5158,6 +5244,7 @@ export const IssueDocument = {
{ kind: 'Field', name: { kind: 'Name', value: 'key' } }, { kind: 'Field', name: { kind: 'Name', value: 'key' } },
{ kind: 'Field', name: { kind: 'Name', value: 'type' } }, { kind: 'Field', name: { kind: 'Name', value: 'type' } },
{ kind: 'Field', name: { kind: 'Name', value: 'area' } }, { kind: 'Field', name: { kind: 'Name', value: 'area' } },
{ kind: 'Field', name: { kind: 'Name', value: 'team' } },
{ kind: 'Field', name: { kind: 'Name', value: 'title' } }, { kind: 'Field', name: { kind: 'Name', value: 'title' } },
{ kind: 'Field', name: { kind: 'Name', value: 'status' } }, { kind: 'Field', name: { kind: 'Name', value: 'status' } },
{ kind: 'Field', name: { kind: 'Name', value: 'statusCategory' } }, { kind: 'Field', name: { kind: 'Name', value: 'statusCategory' } },
@@ -5465,6 +5552,7 @@ export const SearchIssuesDocument = {
{ kind: 'Field', name: { kind: 'Name', value: 'key' } }, { kind: 'Field', name: { kind: 'Name', value: 'key' } },
{ kind: 'Field', name: { kind: 'Name', value: 'type' } }, { kind: 'Field', name: { kind: 'Name', value: 'type' } },
{ kind: 'Field', name: { kind: 'Name', value: 'area' } }, { kind: 'Field', name: { kind: 'Name', value: 'area' } },
{ kind: 'Field', name: { kind: 'Name', value: 'team' } },
{ kind: 'Field', name: { kind: 'Name', value: 'title' } }, { kind: 'Field', name: { kind: 'Name', value: 'title' } },
{ kind: 'Field', name: { kind: 'Name', value: 'status' } }, { kind: 'Field', name: { kind: 'Name', value: 'status' } },
{ kind: 'Field', name: { kind: 'Name', value: 'statusCategory' } }, { kind: 'Field', name: { kind: 'Name', value: 'statusCategory' } },
@@ -6873,6 +6961,7 @@ export const MyIssuesDocument = {
{ kind: 'Field', name: { kind: 'Name', value: 'key' } }, { kind: 'Field', name: { kind: 'Name', value: 'key' } },
{ kind: 'Field', name: { kind: 'Name', value: 'type' } }, { kind: 'Field', name: { kind: 'Name', value: 'type' } },
{ kind: 'Field', name: { kind: 'Name', value: 'area' } }, { kind: 'Field', name: { kind: 'Name', value: 'area' } },
{ kind: 'Field', name: { kind: 'Name', value: 'team' } },
{ kind: 'Field', name: { kind: 'Name', value: 'title' } }, { kind: 'Field', name: { kind: 'Name', value: 'title' } },
{ kind: 'Field', name: { kind: 'Name', value: 'status' } }, { kind: 'Field', name: { kind: 'Name', value: 'status' } },
{ kind: 'Field', name: { kind: 'Name', value: 'statusCategory' } }, { kind: 'Field', name: { kind: 'Name', value: 'statusCategory' } },
@@ -7059,6 +7148,7 @@ export const IssueChildrenDocument = {
{ kind: 'Field', name: { kind: 'Name', value: 'key' } }, { kind: 'Field', name: { kind: 'Name', value: 'key' } },
{ kind: 'Field', name: { kind: 'Name', value: 'type' } }, { kind: 'Field', name: { kind: 'Name', value: 'type' } },
{ kind: 'Field', name: { kind: 'Name', value: 'area' } }, { kind: 'Field', name: { kind: 'Name', value: 'area' } },
{ kind: 'Field', name: { kind: 'Name', value: 'team' } },
{ kind: 'Field', name: { kind: 'Name', value: 'title' } }, { kind: 'Field', name: { kind: 'Name', value: 'title' } },
{ kind: 'Field', name: { kind: 'Name', value: 'status' } }, { kind: 'Field', name: { kind: 'Name', value: 'status' } },
{ kind: 'Field', name: { kind: 'Name', value: 'statusCategory' } }, { kind: 'Field', name: { kind: 'Name', value: 'statusCategory' } },
@@ -7288,6 +7378,7 @@ export const EpicsDocument = {
{ kind: 'Field', name: { kind: 'Name', value: 'key' } }, { kind: 'Field', name: { kind: 'Name', value: 'key' } },
{ kind: 'Field', name: { kind: 'Name', value: 'type' } }, { kind: 'Field', name: { kind: 'Name', value: 'type' } },
{ kind: 'Field', name: { kind: 'Name', value: 'area' } }, { kind: 'Field', name: { kind: 'Name', value: 'area' } },
{ kind: 'Field', name: { kind: 'Name', value: 'team' } },
{ kind: 'Field', name: { kind: 'Name', value: 'title' } }, { kind: 'Field', name: { kind: 'Name', value: 'title' } },
{ kind: 'Field', name: { kind: 'Name', value: 'status' } }, { kind: 'Field', name: { kind: 'Name', value: 'status' } },
{ kind: 'Field', name: { kind: 'Name', value: 'statusCategory' } }, { kind: 'Field', name: { kind: 'Name', value: 'statusCategory' } },
@@ -7796,6 +7887,7 @@ export const BacklogDocument = {
{ kind: 'Field', name: { kind: 'Name', value: 'key' } }, { kind: 'Field', name: { kind: 'Name', value: 'key' } },
{ kind: 'Field', name: { kind: 'Name', value: 'type' } }, { kind: 'Field', name: { kind: 'Name', value: 'type' } },
{ kind: 'Field', name: { kind: 'Name', value: 'area' } }, { kind: 'Field', name: { kind: 'Name', value: 'area' } },
{ kind: 'Field', name: { kind: 'Name', value: 'team' } },
{ kind: 'Field', name: { kind: 'Name', value: 'title' } }, { kind: 'Field', name: { kind: 'Name', value: 'title' } },
{ kind: 'Field', name: { kind: 'Name', value: 'status' } }, { kind: 'Field', name: { kind: 'Name', value: 'status' } },
{ kind: 'Field', name: { kind: 'Name', value: 'statusCategory' } }, { kind: 'Field', name: { kind: 'Name', value: 'statusCategory' } },
@@ -8241,6 +8333,7 @@ export const TrashDocument = {
{ kind: 'Field', name: { kind: 'Name', value: 'key' } }, { kind: 'Field', name: { kind: 'Name', value: 'key' } },
{ kind: 'Field', name: { kind: 'Name', value: 'type' } }, { kind: 'Field', name: { kind: 'Name', value: 'type' } },
{ kind: 'Field', name: { kind: 'Name', value: 'area' } }, { kind: 'Field', name: { kind: 'Name', value: 'area' } },
{ kind: 'Field', name: { kind: 'Name', value: 'team' } },
{ kind: 'Field', name: { kind: 'Name', value: 'title' } }, { kind: 'Field', name: { kind: 'Name', value: 'title' } },
{ kind: 'Field', name: { kind: 'Name', value: 'status' } }, { kind: 'Field', name: { kind: 'Name', value: 'status' } },
{ kind: 'Field', name: { kind: 'Name', value: 'statusCategory' } }, { kind: 'Field', name: { kind: 'Name', value: 'statusCategory' } },
@@ -8427,6 +8520,7 @@ export const DueIssuesDocument = {
{ kind: 'Field', name: { kind: 'Name', value: 'key' } }, { kind: 'Field', name: { kind: 'Name', value: 'key' } },
{ kind: 'Field', name: { kind: 'Name', value: 'type' } }, { kind: 'Field', name: { kind: 'Name', value: 'type' } },
{ kind: 'Field', name: { kind: 'Name', value: 'area' } }, { kind: 'Field', name: { kind: 'Name', value: 'area' } },
{ kind: 'Field', name: { kind: 'Name', value: 'team' } },
{ kind: 'Field', name: { kind: 'Name', value: 'title' } }, { kind: 'Field', name: { kind: 'Name', value: 'title' } },
{ kind: 'Field', name: { kind: 'Name', value: 'status' } }, { kind: 'Field', name: { kind: 'Name', value: 'status' } },
{ kind: 'Field', name: { kind: 'Name', value: 'statusCategory' } }, { kind: 'Field', name: { kind: 'Name', value: 'statusCategory' } },
@@ -8628,6 +8722,7 @@ export const IssueListDocument = {
{ kind: 'Field', name: { kind: 'Name', value: 'key' } }, { kind: 'Field', name: { kind: 'Name', value: 'key' } },
{ kind: 'Field', name: { kind: 'Name', value: 'type' } }, { kind: 'Field', name: { kind: 'Name', value: 'type' } },
{ kind: 'Field', name: { kind: 'Name', value: 'area' } }, { kind: 'Field', name: { kind: 'Name', value: 'area' } },
{ kind: 'Field', name: { kind: 'Name', value: 'team' } },
{ kind: 'Field', name: { kind: 'Name', value: 'title' } }, { kind: 'Field', name: { kind: 'Name', value: 'title' } },
{ kind: 'Field', name: { kind: 'Name', value: 'status' } }, { kind: 'Field', name: { kind: 'Name', value: 'status' } },
{ kind: 'Field', name: { kind: 'Name', value: 'statusCategory' } }, { kind: 'Field', name: { kind: 'Name', value: 'statusCategory' } },
@@ -9655,6 +9750,25 @@ export const MyProjectsDocument = {
kind: 'Field', kind: 'Field',
name: { kind: 'Name', value: 'checklistBeforeDone' }, name: { kind: 'Name', value: 'checklistBeforeDone' },
}, },
{
kind: 'Field',
name: { kind: 'Name', value: 'teams' },
selectionSet: {
kind: 'SelectionSet',
selections: [
{ kind: 'Field', name: { kind: 'Name', value: 'id' } },
{ kind: 'Field', name: { kind: 'Name', value: 'name' } },
{
kind: 'Field',
name: { kind: 'Name', value: 'memberIds' },
},
{
kind: 'Field',
name: { kind: 'Name', value: 'archived' },
},
],
},
},
], ],
}, },
}, },
@@ -9903,6 +10017,25 @@ export const ProjectDocument = {
kind: 'Field', kind: 'Field',
name: { kind: 'Name', value: 'checklistBeforeDone' }, name: { kind: 'Name', value: 'checklistBeforeDone' },
}, },
{
kind: 'Field',
name: { kind: 'Name', value: 'teams' },
selectionSet: {
kind: 'SelectionSet',
selections: [
{ kind: 'Field', name: { kind: 'Name', value: 'id' } },
{ kind: 'Field', name: { kind: 'Name', value: 'name' } },
{
kind: 'Field',
name: { kind: 'Name', value: 'memberIds' },
},
{
kind: 'Field',
name: { kind: 'Name', value: 'archived' },
},
],
},
},
], ],
}, },
}, },
@@ -11002,6 +11135,25 @@ export const PublicProjectsDocument = {
kind: 'Field', kind: 'Field',
name: { kind: 'Name', value: 'checklistBeforeDone' }, name: { kind: 'Name', value: 'checklistBeforeDone' },
}, },
{
kind: 'Field',
name: { kind: 'Name', value: 'teams' },
selectionSet: {
kind: 'SelectionSet',
selections: [
{ kind: 'Field', name: { kind: 'Name', value: 'id' } },
{ kind: 'Field', name: { kind: 'Name', value: 'name' } },
{
kind: 'Field',
name: { kind: 'Name', value: 'memberIds' },
},
{
kind: 'Field',
name: { kind: 'Name', value: 'archived' },
},
],
},
},
], ],
}, },
}, },
@@ -13868,6 +14020,7 @@ export const IssueChangedDocument = {
{ kind: 'Field', name: { kind: 'Name', value: 'key' } }, { kind: 'Field', name: { kind: 'Name', value: 'key' } },
{ kind: 'Field', name: { kind: 'Name', value: 'type' } }, { kind: 'Field', name: { kind: 'Name', value: 'type' } },
{ kind: 'Field', name: { kind: 'Name', value: 'area' } }, { kind: 'Field', name: { kind: 'Name', value: 'area' } },
{ kind: 'Field', name: { kind: 'Name', value: 'team' } },
{ kind: 'Field', name: { kind: 'Name', value: 'title' } }, { kind: 'Field', name: { kind: 'Name', value: 'title' } },
{ kind: 'Field', name: { kind: 'Name', value: 'status' } }, { kind: 'Field', name: { kind: 'Name', value: 'status' } },
{ kind: 'Field', name: { kind: 'Name', value: 'statusCategory' } }, { kind: 'Field', name: { kind: 'Name', value: 'statusCategory' } },
@@ -14529,6 +14682,25 @@ export const ProjectChangedDocument = {
kind: 'Field', kind: 'Field',
name: { kind: 'Name', value: 'checklistBeforeDone' }, name: { kind: 'Name', value: 'checklistBeforeDone' },
}, },
{
kind: 'Field',
name: { kind: 'Name', value: 'teams' },
selectionSet: {
kind: 'SelectionSet',
selections: [
{ kind: 'Field', name: { kind: 'Name', value: 'id' } },
{ kind: 'Field', name: { kind: 'Name', value: 'name' } },
{
kind: 'Field',
name: { kind: 'Name', value: 'memberIds' },
},
{
kind: 'Field',
name: { kind: 'Name', value: 'archived' },
},
],
},
},
], ],
}, },
}, },
@@ -14764,6 +14936,25 @@ export const MyProjectsChangedDocument = {
kind: 'Field', kind: 'Field',
name: { kind: 'Name', value: 'checklistBeforeDone' }, name: { kind: 'Name', value: 'checklistBeforeDone' },
}, },
{
kind: 'Field',
name: { kind: 'Name', value: 'teams' },
selectionSet: {
kind: 'SelectionSet',
selections: [
{ kind: 'Field', name: { kind: 'Name', value: 'id' } },
{ kind: 'Field', name: { kind: 'Name', value: 'name' } },
{
kind: 'Field',
name: { kind: 'Name', value: 'memberIds' },
},
{
kind: 'Field',
name: { kind: 'Name', value: 'archived' },
},
],
},
},
], ],
}, },
}, },

View File

@@ -477,9 +477,7 @@ export class BoardPage {
protected readonly board = inject(BoardFacade); protected readonly board = inject(BoardFacade);
protected readonly project = inject(ProjectFacade); protected readonly project = inject(ProjectFacade);
/** Who `@` offers in the new ticket's description. */ /** Who `@` offers in the new ticket's description. */
protected readonly mentionable = computed(() => protected readonly mentionable = this.project.mentionable;
this.project.members().map((m) => m.user),
);
private readonly drawer = inject(DrawerService); private readonly drawer = inject(DrawerService);
private readonly toast = inject(ToastService); private readonly toast = inject(ToastService);
private readonly auth = inject(AuthFacade); private readonly auth = inject(AuthFacade);

View File

@@ -854,6 +854,16 @@ const MAX_FILE_BYTES = 25 * 1024 * 1024;
> >
<pi-label>Área</pi-label> <pi-label>Área</pi-label>
</pi-select-field> </pi-select-field>
@if (teamOptions().length > 1) {
<pi-select-field
[options]="teamOptions()"
[disabled]="!project.canContribute()"
[value]="issue.team ?? ''"
(valueChange)="update({ team: $any($event || null) })"
>
<pi-label>Equipo</pi-label>
</pi-select-field>
}
@for (field of customFields(); track field.id) { @for (field of customFields(); track field.id) {
<ts-custom-field <ts-custom-field
[field]="field" [field]="field"
@@ -1076,6 +1086,18 @@ export class IssuePage {
.map((t) => ({ value: t.id, label: t.name })); .map((t) => ({ value: t.id, label: t.name }));
}, },
); );
protected readonly teamOptions = computed<PiSelectFieldOption<string>[]>(
() => {
const current = this.facade.issue()?.team;
return [
{ value: NONE, label: 'Sin equipo' },
...this.project
.workflow()
.settings.teams.filter((t) => !t.archived || t.id === current)
.map((t) => ({ value: t.id, label: t.name })),
];
},
);
protected readonly areaOptions = computed<PiSelectFieldOption<string>[]>( protected readonly areaOptions = computed<PiSelectFieldOption<string>[]>(
() => { () => {
const workflow = this.project.workflow(); const workflow = this.project.workflow();
@@ -1164,9 +1186,7 @@ export class IssuePage {
protected readonly pasteImage: ImageUploader = (file) => protected readonly pasteImage: ImageUploader = (file) =>
this.facade.uploadImage(file); this.facade.uploadImage(file);
/** Who `@` offers in what is written here. */ /** Who `@` offers in what is written here. */
protected readonly mentionable = computed(() => protected readonly mentionable = this.project.mentionable;
this.project.members().map((m) => m.user),
);
/** Pull requests first, then the newest. */ /** Pull requests first, then the newest. */
protected readonly devLinks = computed(() => protected readonly devLinks = computed(() =>
[...(this.facade.issue()?.devLinks ?? [])].sort( [...(this.facade.issue()?.devLinks ?? [])].sort(
@@ -1332,6 +1352,7 @@ export class IssuePage {
type?: string; type?: string;
priority?: IssuePriority; priority?: IssuePriority;
area?: string | null; area?: string | null;
team?: string | null;
storyPoints?: number | null; storyPoints?: number | null;
startDate?: string | null; startDate?: string | null;
dueDate?: string | null; dueDate?: string | null;

View File

@@ -112,6 +112,9 @@ const numberOf = (key: string) => Number(key.split('-').pop());
[options]="releaseFilterOptions()" [options]="releaseFilterOptions()"
[(value)]="releaseFilter" [(value)]="releaseFilter"
/> />
@if (teamFilterOptions().length > 1) {
<pi-select-field [options]="teamFilterOptions()" [(value)]="team" />
}
@for (field of listFields(); track field.id) { @for (field of listFields(); track field.id) {
<pi-select-field <pi-select-field
[options]="field.options" [options]="field.options"
@@ -527,6 +530,18 @@ export class ListPage {
protected readonly priority = signal<string | null>(ALL); protected readonly priority = signal<string | null>(ALL);
protected readonly assignee = signal<string | null>(ALL); protected readonly assignee = signal<string | null>(ALL);
protected readonly releaseFilter = signal<string | null>(ALL); protected readonly releaseFilter = signal<string | null>(ALL);
protected readonly team = signal<string | null>(ALL);
protected readonly teamFilterOptions = computed<
PiSelectFieldOption<string>[]
>(() => {
const teams = this.project.workflow().activeTeams();
return teams.length
? [
{ value: ALL, label: 'Todos los equipos' },
...teams.map((t) => ({ value: t.id, label: t.name })),
]
: [];
});
private readonly releaseFromUrl = effect(() => { private readonly releaseFromUrl = effect(() => {
const id = this.release(); const id = this.release();
if (id) untracked(() => this.releaseFilter.set(id)); if (id) untracked(() => this.releaseFilter.set(id));
@@ -626,6 +641,7 @@ export class ListPage {
unassigned: assignee === NOBODY || undefined, unassigned: assignee === NOBODY || undefined,
assigneeId: assignee && assignee !== NOBODY ? assignee : undefined, assigneeId: assignee && assignee !== NOBODY ? assignee : undefined,
releaseId: this.releaseFilter() || undefined, releaseId: this.releaseFilter() || undefined,
teams: this.team() ? [this.team() as string] : undefined,
noEstimate: this.gap() === 'estimate' || undefined, noEstimate: this.gap() === 'estimate' || undefined,
noArea: this.gap() === 'area' || undefined, noArea: this.gap() === 'area' || undefined,
updatedBefore: this.stale() updatedBefore: this.stale()

View File

@@ -32,7 +32,7 @@ import {
} from '@task-stream/web/tracker/domain'; } from '@task-stream/web/tracker/domain';
import { CATEGORY, COLOR_LABEL, ICON_LABEL, tone } from '../ui/copy'; import { CATEGORY, COLOR_LABEL, ICON_LABEL, tone } from '../ui/copy';
type Section = 'statuses' | 'issueTypes' | 'areas' | 'customFields'; type Section = 'statuses' | 'issueTypes' | 'areas' | 'customFields' | 'teams';
const FIELD_TYPES: { id: CustomFieldType; label: string }[] = [ const FIELD_TYPES: { id: CustomFieldType; label: string }[] = [
{ id: CustomFieldType.TEXT, label: 'Texto' }, { id: CustomFieldType.TEXT, label: 'Texto' },
@@ -530,6 +530,63 @@ function idFrom(name: string): string {
</ol> </ol>
</section> </section>
<!-- Teams -->
<section class="ts-panel ts-block" aria-labelledby="teams-title">
<header class="ts-block__head">
<div>
<h3 id="teams-title">Equipos</h3>
<p class="ts-muted">
Grupos de miembros: los tickets se pueden asignar a uno y
«@equipo» avisa a todos sus miembros.
</p>
</div>
<button type="button" class="ts-add" (click)="addTeam()">
+ Añadir equipo
</button>
</header>
@if (!d.teams.length) {
<p class="ts-muted ts-empty-fields">Ningún equipo todavía.</p>
}
<ol class="ts-rows">
@for (team of d.teams; track $index; let i = $index) {
<li
class="ts-row ts-field-grid"
[class.is-archived]="team.archived"
>
<span class="ts-name">
<input
class="ts-input"
maxlength="40"
[attr.aria-label]="'Nombre del equipo ' + (i + 1)"
[value]="team.name"
(input)="setName('teams', i, $any($event.target).value)"
/>
<code class="ts-id">{{ team.id }}</code>
</span>
<ng-container
[ngTemplateOutlet]="archive"
[ngTemplateOutletContext]="{
section: 'teams',
index: i,
item: team,
}"
/>
<div class="ts-team-members ts-field-type">
@for (member of facade.members(); track member.user.id) {
<pi-checkbox
[label]="member.user.name"
[checked]="team.memberIds.includes(member.user.id)"
(checkedChange)="
setTeamMember(i, member.user.id, $event)
"
/>
}
</div>
</li>
}
</ol>
</section>
<!-- Definition of done --> <!-- Definition of done -->
<section class="ts-panel ts-block" aria-labelledby="dod-title"> <section class="ts-panel ts-block" aria-labelledby="dod-title">
<header class="ts-block__head"> <header class="ts-block__head">
@@ -1035,6 +1092,11 @@ function idFrom(name: string): string {
resize: vertical; resize: vertical;
} }
} }
.ts-team-members {
display: flex;
flex-wrap: wrap;
gap: 0.25rem 1rem;
}
.ts-empty-fields { .ts-empty-fields {
margin: 0; margin: 0;
} }
@@ -1277,7 +1339,7 @@ export class SettingsPage {
const d = this.draft(); const d = this.draft();
if (!d) return []; if (!d) return [];
const problems: string[] = []; const problems: string[] = [];
const lists = [d.statuses, d.issueTypes, d.areas, d.customFields]; const lists = [d.statuses, d.issueTypes, d.areas, d.customFields, d.teams];
if (lists.some((list) => list.some((item) => !item.name.trim()))) if (lists.some((list) => list.some((item) => !item.name.trim())))
problems.push('Hay nombres vacíos'); problems.push('Hay nombres vacíos');
if ( if (
@@ -1449,6 +1511,26 @@ export class SettingsPage {
); );
} }
protected addTeam(): void {
this.patch((d) =>
d.teams.push({
id: this.uniqueId(d.teams, 'NUEVO_EQUIPO'),
name: 'Nuevo equipo',
memberIds: [],
archived: false,
}),
);
}
protected setTeamMember(index: number, userId: string, on: boolean): void {
this.patch((d) => {
const team = d.teams[index];
team.memberIds = on
? [...new Set([...team.memberIds, userId])]
: team.memberIds.filter((id) => id !== userId);
});
}
protected setChecklistBeforeDone(on: boolean): void { protected setChecklistBeforeDone(on: boolean): void {
this.patch((d) => { this.patch((d) => {
d.checklistBeforeDone = on; d.checklistBeforeDone = on;